Add prettier formatting (#214)
* chore: add prettier * chore: format codebase
This commit is contained in:
parent
015f799cf7
commit
5ad4971fc0
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,10 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: ''
|
title: ""
|
||||||
labels: ''
|
labels: ""
|
||||||
assignees: ''
|
assignees: ""
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
**Describe the bug**
|
||||||
@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
|
|||||||
|
|
||||||
**To Reproduce**
|
**To Reproduce**
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
@ -24,15 +24,17 @@ A clear and concise description of what you expected to happen.
|
|||||||
If applicable, add screenshots to help explain your problem.
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
**Desktop (please complete the following information):**
|
||||||
- OS: [e.g. iOS]
|
|
||||||
- Browser [e.g. chrome, safari]
|
- OS: [e.g. iOS]
|
||||||
- Version [e.g. 22]
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
**Smartphone (please complete the following information):**
|
||||||
- Device: [e.g. iPhone6]
|
|
||||||
- OS: [e.g. iOS8.1]
|
- Device: [e.g. iPhone6]
|
||||||
- Browser [e.g. stock browser, safari]
|
- OS: [e.g. iOS8.1]
|
||||||
- Version [e.g. 22]
|
- Browser [e.g. stock browser, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
7
.github/ISSUE_TEMPLATE/feature_request.md
vendored
7
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,10 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: Feature request
|
name: Feature request
|
||||||
about: Suggest an idea for this project
|
about: Suggest an idea for this project
|
||||||
title: ''
|
title: ""
|
||||||
labels: ''
|
labels: ""
|
||||||
assignees: ''
|
assignees: ""
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
1
.prettierrc.json
Normal file
1
.prettierrc.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
14
d.ts
14
d.ts
@ -1,14 +1,14 @@
|
|||||||
declare module "*.jpg" {
|
declare module "*.jpg" {
|
||||||
const value: any
|
const value: any;
|
||||||
export default value
|
export default value;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "*.svg" {
|
declare module "*.svg" {
|
||||||
const value: any
|
const value: any;
|
||||||
export default value
|
export default value;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "*.webp" {
|
declare module "*.webp" {
|
||||||
const value: any
|
const value: any;
|
||||||
export default value
|
export default value;
|
||||||
}
|
}
|
||||||
|
@ -72,5 +72,8 @@
|
|||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prettier": "2.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,26 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta name="description" content="Fast nostr web ui" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src wss://* 'self' https://*; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;"
|
||||||
|
/>
|
||||||
|
|
||||||
<head>
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" />
|
||||||
<meta charset="utf-8" />
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<title>snort.social - Nostr interface</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
</head>
|
||||||
<meta name="theme-color" content="#000000" />
|
|
||||||
<meta name="description" content="Fast nostr web ui" />
|
|
||||||
<meta http-equiv="Content-Security-Policy"
|
|
||||||
content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src wss://* 'self' https://*; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
|
|
||||||
|
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" />
|
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
|
||||||
<title>snort.social - Nostr interface</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
77
src/Const.ts
77
src/Const.ts
@ -18,12 +18,14 @@ export const VoidCatHost = "https://void.cat";
|
|||||||
/**
|
/**
|
||||||
* Kierans pubkey
|
* Kierans pubkey
|
||||||
*/
|
*/
|
||||||
export const KieranPubKey = "npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49";
|
export const KieranPubKey =
|
||||||
|
"npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Official snort account
|
* Official snort account
|
||||||
*/
|
*/
|
||||||
export const SnortPubKey = "npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
|
export const SnortPubKey =
|
||||||
|
"npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Websocket re-connect timeout
|
* Websocket re-connect timeout
|
||||||
@ -33,59 +35,61 @@ export const DefaultConnectTimeout = 2000;
|
|||||||
/**
|
/**
|
||||||
* How long profile cache should be considered valid for
|
* How long profile cache should be considered valid for
|
||||||
*/
|
*/
|
||||||
export const ProfileCacheExpire = (1_000 * 60 * 5);
|
export const ProfileCacheExpire = 1_000 * 60 * 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default bootstrap relays
|
* Default bootstrap relays
|
||||||
*/
|
*/
|
||||||
export const DefaultRelays = new Map<string, RelaySettings>([
|
export const DefaultRelays = new Map<string, RelaySettings>([
|
||||||
["wss://relay.snort.social", { read: true, write: true }],
|
["wss://relay.snort.social", { read: true, write: true }],
|
||||||
["wss://eden.nostr.land", { read: true, write: true }],
|
["wss://eden.nostr.land", { read: true, write: true }],
|
||||||
["wss://atlas.nostr.land", { read: true, write: true }]
|
["wss://atlas.nostr.land", { read: true, write: true }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default search relays
|
* Default search relays
|
||||||
*/
|
*/
|
||||||
export const SearchRelays = new Map<string, RelaySettings>([
|
export const SearchRelays = new Map<string, RelaySettings>([
|
||||||
["wss://relay.nostr.band", { read: true, write: false }],
|
["wss://relay.nostr.band", { read: true, write: false }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of recommended follows for new users
|
* List of recommended follows for new users
|
||||||
*/
|
*/
|
||||||
export const RecommendedFollows = [
|
export const RecommendedFollows = [
|
||||||
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", // jack
|
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", // jack
|
||||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", // fiatjaf
|
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", // fiatjaf
|
||||||
"020f2d21ae09bf35fcdfb65decf1478b846f5f728ab30c5eaabcd6d081a81c3e", // adam3us
|
"020f2d21ae09bf35fcdfb65decf1478b846f5f728ab30c5eaabcd6d081a81c3e", // adam3us
|
||||||
"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", // gigi
|
"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", // gigi
|
||||||
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", // Kieran
|
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", // Kieran
|
||||||
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", // jb55
|
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", // jb55
|
||||||
"e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42", // wiz
|
"e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42", // wiz
|
||||||
"00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", // cameri
|
"00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", // cameri
|
||||||
"A341F45FF9758F570A21B000C17D4E53A3A497C8397F26C0E6D61E5ACFFC7A98", // Saylor
|
"A341F45FF9758F570A21B000C17D4E53A3A497C8397F26C0E6D61E5ACFFC7A98", // Saylor
|
||||||
"E88A691E98D9987C964521DFF60025F60700378A4879180DCBBB4A5027850411", // NVK
|
"E88A691E98D9987C964521DFF60025F60700378A4879180DCBBB4A5027850411", // NVK
|
||||||
"C4EABAE1BE3CF657BC1855EE05E69DE9F059CB7A059227168B80B89761CBC4E0", // jackmallers
|
"C4EABAE1BE3CF657BC1855EE05E69DE9F059CB7A059227168B80B89761CBC4E0", // jackmallers
|
||||||
"85080D3BAD70CCDCD7F74C29A44F55BB85CBCD3DD0CBB957DA1D215BDB931204", // preston
|
"85080D3BAD70CCDCD7F74C29A44F55BB85CBCD3DD0CBB957DA1D215BDB931204", // preston
|
||||||
"C49D52A573366792B9A6E4851587C28042FB24FA5625C6D67B8C95C8751ACA15", // holdonaut
|
"C49D52A573366792B9A6E4851587C28042FB24FA5625C6D67B8C95C8751ACA15", // holdonaut
|
||||||
"83E818DFBECCEA56B0F551576B3FD39A7A50E1D8159343500368FA085CCD964B", // jeffbooth
|
"83E818DFBECCEA56B0F551576B3FD39A7A50E1D8159343500368FA085CCD964B", // jeffbooth
|
||||||
"3F770D65D3A764A9C5CB503AE123E62EC7598AD035D836E2A810F3877A745B24", // DerekRoss
|
"3F770D65D3A764A9C5CB503AE123E62EC7598AD035D836E2A810F3877A745B24", // DerekRoss
|
||||||
"472F440F29EF996E92A186B8D320FF180C855903882E59D50DE1B8BD5669301E", // MartyBent
|
"472F440F29EF996E92A186B8D320FF180C855903882E59D50DE1B8BD5669301E", // MartyBent
|
||||||
"1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", // yegorpetrov
|
"1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", // yegorpetrov
|
||||||
"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", // ODELL
|
"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", // ODELL
|
||||||
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha
|
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha
|
||||||
"52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", // semisol
|
"52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", // semisol
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regex to match email address
|
* Regex to match email address
|
||||||
*/
|
*/
|
||||||
export const EmailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
export const EmailRegex =
|
||||||
|
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic URL regex
|
* Generic URL regex
|
||||||
*/
|
*/
|
||||||
export const UrlRegex = /((?:http|ftp|https):\/\/(?:[\w+?\.\w+])+(?:[a-zA-Z0-9\~\!\@\#\$\%\^\&\*\(\)_\-\=\+\\\/\?\.\:\;\'\,]*)?)/i;
|
export const UrlRegex =
|
||||||
|
/((?:http|ftp|https):\/\/(?:[\w+?\.\w+])+(?:[a-zA-Z0-9\~\!\@\#\$\%\^\&\*\(\)_\-\=\+\\\/\?\.\:\;\'\,]*)?)/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract file extensions regex
|
* Extract file extensions regex
|
||||||
@ -105,12 +109,14 @@ export const InvoiceRegex = /(lnbc\w+)/i;
|
|||||||
/**
|
/**
|
||||||
* YouTube URL regex
|
* YouTube URL regex
|
||||||
*/
|
*/
|
||||||
export const YoutubeUrlRegex = /(?:https?:\/\/)?(?:www|m\.)?(?:youtu\.be\/|youtube\.com\/(?:shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
|
export const YoutubeUrlRegex =
|
||||||
|
/(?:https?:\/\/)?(?:www|m\.)?(?:youtu\.be\/|youtube\.com\/(?:shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tweet Regex
|
* Tweet Regex
|
||||||
*/
|
*/
|
||||||
export const TweetUrlRegex = /https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/
|
export const TweetUrlRegex =
|
||||||
|
/https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hashtag regex
|
* Hashtag regex
|
||||||
@ -125,12 +131,15 @@ export const TidalRegex = /tidal\.com\/(?:browse\/)?(\w+)\/([a-z0-9-]+)/i;
|
|||||||
/**
|
/**
|
||||||
* SoundCloud regex
|
* SoundCloud regex
|
||||||
*/
|
*/
|
||||||
export const SoundCloudRegex = /soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/
|
export const SoundCloudRegex =
|
||||||
|
/soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mixcloud regex
|
* Mixcloud regex
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const MixCloudRegex = /mixcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/
|
export const MixCloudRegex =
|
||||||
|
/mixcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
|
||||||
|
|
||||||
export const SpotifyRegex = /open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/
|
export const SpotifyRegex =
|
||||||
|
/open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/;
|
||||||
|
@ -3,21 +3,21 @@ import { TaggedRawEvent, u256 } from "Nostr";
|
|||||||
import { MetadataCache } from "State/Users";
|
import { MetadataCache } from "State/Users";
|
||||||
import { hexToBech32 } from "Util";
|
import { hexToBech32 } from "Util";
|
||||||
|
|
||||||
export const NAME = 'snortDB'
|
export const NAME = "snortDB";
|
||||||
export const VERSION = 3
|
export const VERSION = 3;
|
||||||
|
|
||||||
export interface SubCache {
|
export interface SubCache {
|
||||||
id: string,
|
id: string;
|
||||||
ids: u256[],
|
ids: u256[];
|
||||||
until?: number,
|
until?: number;
|
||||||
since?: number,
|
since?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORES = {
|
const STORES = {
|
||||||
users: '++pubkey, name, display_name, picture, nip05, npub',
|
users: "++pubkey, name, display_name, picture, nip05, npub",
|
||||||
events: '++id, pubkey, created_at',
|
events: "++id, pubkey, created_at",
|
||||||
feeds: '++id'
|
feeds: "++id",
|
||||||
}
|
};
|
||||||
|
|
||||||
export class SnortDB extends Dexie {
|
export class SnortDB extends Dexie {
|
||||||
users!: Table<MetadataCache>;
|
users!: Table<MetadataCache>;
|
||||||
@ -26,11 +26,16 @@ export class SnortDB extends Dexie {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(NAME);
|
super(NAME);
|
||||||
this.version(VERSION).stores(STORES).upgrade(async tx => {
|
this.version(VERSION)
|
||||||
await tx.table("users").toCollection().modify(user => {
|
.stores(STORES)
|
||||||
user.npub = hexToBech32("npub", user.pubkey)
|
.upgrade(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.table("users")
|
||||||
|
.toCollection()
|
||||||
|
.modify((user) => {
|
||||||
|
user.npub = hexToBech32("npub", user.pubkey);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,27 +1,31 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function AsyncButton(props: any) {
|
export default function AsyncButton(props: any) {
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
async function handle(e : any) {
|
async function handle(e: any) {
|
||||||
if(loading) return;
|
if (loading) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (typeof props.onClick === "function") {
|
if (typeof props.onClick === "function") {
|
||||||
let f = props.onClick(e);
|
let f = props.onClick(e);
|
||||||
if (f instanceof Promise) {
|
if (f instanceof Promise) {
|
||||||
await f;
|
await f;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button type="button" disabled={loading} {...props} onClick={(e) => handle(e)}>
|
<button
|
||||||
{props.children}
|
type="button"
|
||||||
</button>
|
disabled={loading}
|
||||||
)
|
{...props}
|
||||||
}
|
onClick={(e) => handle(e)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -1,19 +1,19 @@
|
|||||||
.avatar {
|
.avatar {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
height: 210px;
|
height: 210px;
|
||||||
width: 210px;
|
width: 210px;
|
||||||
background-image: var(--img-url);
|
background-image: var(--img-url);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
background-origin: border-box;
|
background-origin: border-box;
|
||||||
background-clip: content-box, border-box;
|
background-clip: content-box, border-box;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar[data-domain="snort.social"] {
|
.avatar[data-domain="snort.social"] {
|
||||||
background-image: var(--img-url), var(--snort-gradient);
|
background-image: var(--img-url), var(--snort-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar[data-domain="strike.army"] {
|
.avatar[data-domain="strike.army"] {
|
||||||
background-image: var(--img-url), var(--strike-army-gradient);
|
background-image: var(--img-url), var(--strike-army-gradient);
|
||||||
}
|
}
|
||||||
|
@ -4,30 +4,35 @@ import { CSSProperties, useEffect, useState } from "react";
|
|||||||
import type { UserMetadata } from "Nostr";
|
import type { UserMetadata } from "Nostr";
|
||||||
import useImgProxy from "Feed/ImgProxy";
|
import useImgProxy from "Feed/ImgProxy";
|
||||||
|
|
||||||
const Avatar = ({ user, ...rest }: { user?: UserMetadata, onClick?: () => void }) => {
|
const Avatar = ({
|
||||||
|
user,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
user?: UserMetadata;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) => {
|
||||||
const [url, setUrl] = useState<string>(Nostrich);
|
const [url, setUrl] = useState<string>(Nostrich);
|
||||||
const { proxy } = useImgProxy();
|
const { proxy } = useImgProxy();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.picture) {
|
if (user?.picture) {
|
||||||
proxy(user.picture, 120)
|
proxy(user.picture, 120)
|
||||||
.then(a => setUrl(a))
|
.then((a) => setUrl(a))
|
||||||
.catch(console.warn);
|
.catch(console.warn);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const backgroundImage = `url(${url})`
|
const backgroundImage = `url(${url})`;
|
||||||
const style = { '--img-url': backgroundImage } as CSSProperties
|
const style = { "--img-url": backgroundImage } as CSSProperties;
|
||||||
const domain = user?.nip05 && user.nip05.split('@')[1]
|
const domain = user?.nip05 && user.nip05.split("@")[1];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...rest}
|
{...rest}
|
||||||
style={style}
|
style={style}
|
||||||
className="avatar"
|
className="avatar"
|
||||||
data-domain={domain?.toLowerCase()}
|
data-domain={domain?.toLowerCase()}
|
||||||
>
|
></div>
|
||||||
</div>
|
);
|
||||||
)
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default Avatar
|
export default Avatar;
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back-button svg {
|
.back-button svg {
|
||||||
margin-right: .5em;
|
margin-right: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button:hover {
|
.back-button:hover {
|
||||||
|
@ -1,24 +1,25 @@
|
|||||||
import "./BackButton.css"
|
import "./BackButton.css";
|
||||||
|
|
||||||
import ArrowBack from "Icons/ArrowBack";
|
import ArrowBack from "Icons/ArrowBack";
|
||||||
|
|
||||||
interface BackButtonProps {
|
interface BackButtonProps {
|
||||||
text?: string
|
text?: string;
|
||||||
onClick?(): void
|
onClick?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BackButton = ({ text = "Back", onClick }: BackButtonProps) => {
|
const BackButton = ({ text = "Back", onClick }: BackButtonProps) => {
|
||||||
const onClickHandler = () => {
|
const onClickHandler = () => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick()
|
onClick();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className="back-button" type="button" onClick={onClickHandler}>
|
<button className="back-button" type="button" onClick={onClickHandler}>
|
||||||
<ArrowBack />{text}
|
<ArrowBack />
|
||||||
|
{text}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default BackButton
|
export default BackButton;
|
||||||
|
@ -2,20 +2,20 @@ import { HexKey } from "Nostr";
|
|||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
|
|
||||||
interface BlockButtonProps {
|
interface BlockButtonProps {
|
||||||
pubkey: HexKey
|
pubkey: HexKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlockButton = ({ pubkey }: BlockButtonProps) => {
|
const BlockButton = ({ pubkey }: BlockButtonProps) => {
|
||||||
const { block, unblock, isBlocked } = useModeration()
|
const { block, unblock, isBlocked } = useModeration();
|
||||||
return isBlocked(pubkey) ? (
|
return isBlocked(pubkey) ? (
|
||||||
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
|
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
|
||||||
Unblock
|
Unblock
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="secondary" type="button" onClick={() => block(pubkey)}>
|
<button className="secondary" type="button" onClick={() => block(pubkey)}>
|
||||||
Block
|
Block
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default BlockButton
|
export default BlockButton;
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
import { HexKey } from "Nostr"; import type { RootState } from "State/Store";
|
import { HexKey } from "Nostr";
|
||||||
|
import type { RootState } from "State/Store";
|
||||||
import MuteButton from "Element/MuteButton";
|
import MuteButton from "Element/MuteButton";
|
||||||
import BlockButton from "Element/BlockButton";
|
import BlockButton from "Element/BlockButton";
|
||||||
import ProfilePreview from "Element/ProfilePreview";
|
import ProfilePreview from "Element/ProfilePreview";
|
||||||
@ -9,31 +10,45 @@ import useMutedFeed, { getMuted } from "Feed/MuteList";
|
|||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
|
|
||||||
interface BlockListProps {
|
interface BlockListProps {
|
||||||
variant: "muted" | "blocked"
|
variant: "muted" | "blocked";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BlockList({ variant }: BlockListProps) {
|
export default function BlockList({ variant }: BlockListProps) {
|
||||||
const { publicKey } = useSelector((s: RootState) => s.login)
|
const { publicKey } = useSelector((s: RootState) => s.login);
|
||||||
const { blocked, muted } = useModeration();
|
const { blocked, muted } = useModeration();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
{variant === "muted" && (
|
{variant === "muted" && (
|
||||||
<>
|
<>
|
||||||
<h4>{muted.length} muted</h4>
|
<h4>{muted.length} muted</h4>
|
||||||
{muted.map(a => {
|
{muted.map((a) => {
|
||||||
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
|
return (
|
||||||
})}
|
<ProfilePreview
|
||||||
</>
|
actions={<MuteButton pubkey={a} />}
|
||||||
)}
|
pubkey={a}
|
||||||
{variant === "blocked" && (
|
options={{ about: false }}
|
||||||
<>
|
key={a}
|
||||||
<h4>{blocked.length} blocked</h4>
|
/>
|
||||||
{blocked.map(a => {
|
);
|
||||||
return <ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
|
})}
|
||||||
})}
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
{variant === "blocked" && (
|
||||||
</div>
|
<>
|
||||||
)
|
<h4>{blocked.length} blocked</h4>
|
||||||
|
{blocked.map((a) => {
|
||||||
|
return (
|
||||||
|
<ProfilePreview
|
||||||
|
actions={<BlockButton pubkey={a} />}
|
||||||
|
pubkey={a}
|
||||||
|
options={{ about: false }}
|
||||||
|
key={a}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,22 +3,25 @@ import { useState, ReactNode } from "react";
|
|||||||
import ShowMore from "Element/ShowMore";
|
import ShowMore from "Element/ShowMore";
|
||||||
|
|
||||||
interface CollapsedProps {
|
interface CollapsedProps {
|
||||||
text?: string
|
text?: string;
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
collapsed: boolean
|
collapsed: boolean;
|
||||||
setCollapsed(b: boolean): void
|
setCollapsed(b: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Collapsed = ({ text, children, collapsed, setCollapsed }: CollapsedProps) => {
|
const Collapsed = ({
|
||||||
|
text,
|
||||||
|
children,
|
||||||
|
collapsed,
|
||||||
|
setCollapsed,
|
||||||
|
}: CollapsedProps) => {
|
||||||
return collapsed ? (
|
return collapsed ? (
|
||||||
<div className="collapsed">
|
<div className="collapsed">
|
||||||
<ShowMore text={text} onClick={() => setCollapsed(false)} />
|
<ShowMore text={text} onClick={() => setCollapsed(false)} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="uncollapsed">
|
<div className="uncollapsed">{children}</div>
|
||||||
{children}
|
);
|
||||||
</div>
|
};
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Collapsed
|
export default Collapsed;
|
||||||
|
@ -4,9 +4,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.copy .body {
|
.copy .body {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy .icon {
|
.copy .icon {
|
||||||
|
@ -4,22 +4,30 @@ import CopyIcon from "Icons/Copy";
|
|||||||
import { useCopy } from "useCopy";
|
import { useCopy } from "useCopy";
|
||||||
|
|
||||||
export interface CopyProps {
|
export interface CopyProps {
|
||||||
text: string,
|
text: string;
|
||||||
maxSize?: number
|
maxSize?: number;
|
||||||
}
|
}
|
||||||
export default function Copy({ text, maxSize = 32 }: CopyProps) {
|
export default function Copy({ text, maxSize = 32 }: CopyProps) {
|
||||||
const { copy, copied, error } = useCopy();
|
const { copy, copied, error } = useCopy();
|
||||||
const sliceLength = maxSize / 2
|
const sliceLength = maxSize / 2;
|
||||||
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text
|
const trimmed =
|
||||||
|
text.length > maxSize
|
||||||
|
? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}`
|
||||||
|
: text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row copy" onClick={() => copy(text)}>
|
<div className="flex flex-row copy" onClick={() => copy(text)}>
|
||||||
<span className="body">
|
<span className="body">{trimmed}</span>
|
||||||
{trimmed}
|
<span
|
||||||
</span>
|
className="icon"
|
||||||
<span className="icon" style={{ color: copied ? 'var(--success)' : 'var(--highlight)' }}>
|
style={{ color: copied ? "var(--success)" : "var(--highlight)" }}
|
||||||
{copied ? <Check width={13} height={13} />: <CopyIcon width={13} height={13} />}
|
>
|
||||||
</span>
|
{copied ? (
|
||||||
</div>
|
<Check width={13} height={13} />
|
||||||
)
|
) : (
|
||||||
|
<CopyIcon width={13} height={13} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,23 @@
|
|||||||
.dm {
|
.dm {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background-color: var(--gray);
|
background-color: var(--gray);
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm > div:first-child {
|
.dm > div:first-child {
|
||||||
color: var(--gray-light);
|
color: var(--gray-light);
|
||||||
font-size: small;
|
font-size: small;
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm.me {
|
.dm.me {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
background-color: var(--gray-secondary);
|
background-color: var(--gray-secondary);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import "./DM.css";
|
import "./DM.css";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import Event from "Nostr/Event";
|
import Event from "Nostr/Event";
|
||||||
@ -13,42 +13,53 @@ import { HexKey, TaggedRawEvent } from "Nostr";
|
|||||||
import { incDmInteraction } from "State/Login";
|
import { incDmInteraction } from "State/Login";
|
||||||
|
|
||||||
export type DMProps = {
|
export type DMProps = {
|
||||||
data: TaggedRawEvent
|
data: TaggedRawEvent;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function DM(props: DMProps) {
|
export default function DM(props: DMProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
const pubKey = useSelector<RootState, HexKey | undefined>(
|
||||||
const publisher = useEventPublisher();
|
(s) => s.login.publicKey
|
||||||
const [content, setContent] = useState("Loading...");
|
);
|
||||||
const [decrypted, setDecrypted] = useState(false);
|
const publisher = useEventPublisher();
|
||||||
const { ref, inView } = useInView();
|
const [content, setContent] = useState("Loading...");
|
||||||
const isMe = props.data.pubkey === pubKey;
|
const [decrypted, setDecrypted] = useState(false);
|
||||||
const otherPubkey = isMe ? pubKey : props.data.tags.find(a => a[0] === "p")![1];
|
const { ref, inView } = useInView();
|
||||||
|
const isMe = props.data.pubkey === pubKey;
|
||||||
|
const otherPubkey = isMe
|
||||||
|
? pubKey
|
||||||
|
: props.data.tags.find((a) => a[0] === "p")![1];
|
||||||
|
|
||||||
async function decrypt() {
|
async function decrypt() {
|
||||||
let e = new Event(props.data);
|
let e = new Event(props.data);
|
||||||
let decrypted = await publisher.decryptDm(e);
|
let decrypted = await publisher.decryptDm(e);
|
||||||
setContent(decrypted || "<ERROR>");
|
setContent(decrypted || "<ERROR>");
|
||||||
if (!isMe) {
|
if (!isMe) {
|
||||||
setLastReadDm(e.PubKey);
|
setLastReadDm(e.PubKey);
|
||||||
dispatch(incDmInteraction());
|
dispatch(incDmInteraction());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!decrypted && inView) {
|
if (!decrypted && inView) {
|
||||||
setDecrypted(true);
|
setDecrypted(true);
|
||||||
decrypt().catch(console.error);
|
decrypt().catch(console.error);
|
||||||
}
|
}
|
||||||
}, [inView, props.data]);
|
}, [inView, props.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex dm f-col${isMe ? " me" : ""}`} ref={ref}>
|
<div className={`flex dm f-col${isMe ? " me" : ""}`} ref={ref}>
|
||||||
<div><NoteTime from={props.data.created_at * 1000} fallback={'Just now'} /></div>
|
<div>
|
||||||
<div className="w-max">
|
<NoteTime from={props.data.created_at * 1000} fallback={"Just now"} />
|
||||||
<Text content={content} tags={[]} users={new Map()} creator={otherPubkey} />
|
</div>
|
||||||
</div>
|
<div className="w-max">
|
||||||
</div>
|
<Text
|
||||||
)
|
content={content}
|
||||||
|
tags={[]}
|
||||||
|
users={new Map()}
|
||||||
|
creator={otherPubkey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -8,32 +8,34 @@ import { RootState } from "State/Store";
|
|||||||
import { parseId } from "Util";
|
import { parseId } from "Util";
|
||||||
|
|
||||||
export interface FollowButtonProps {
|
export interface FollowButtonProps {
|
||||||
pubkey: HexKey,
|
pubkey: HexKey;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
export default function FollowButton(props: FollowButtonProps) {
|
export default function FollowButton(props: FollowButtonProps) {
|
||||||
const pubkey = parseId(props.pubkey);
|
const pubkey = parseId(props.pubkey);
|
||||||
const publiser = useEventPublisher();
|
const publiser = useEventPublisher();
|
||||||
const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);
|
const isFollowing = useSelector<RootState, boolean>(
|
||||||
const baseClassname = `${props.className} follow-button`
|
(s) => s.login.follows?.includes(pubkey) ?? false
|
||||||
|
);
|
||||||
|
const baseClassname = `${props.className} follow-button`;
|
||||||
|
|
||||||
async function follow(pubkey: HexKey) {
|
async function follow(pubkey: HexKey) {
|
||||||
let ev = await publiser.addFollow(pubkey);
|
let ev = await publiser.addFollow(pubkey);
|
||||||
publiser.broadcast(ev);
|
publiser.broadcast(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unfollow(pubkey: HexKey) {
|
async function unfollow(pubkey: HexKey) {
|
||||||
let ev = await publiser.removeFollow(pubkey);
|
let ev = await publiser.removeFollow(pubkey);
|
||||||
publiser.broadcast(ev);
|
publiser.broadcast(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={isFollowing ? `${baseClassname} secondary` : baseClassname}
|
className={isFollowing ? `${baseClassname} secondary` : baseClassname}
|
||||||
onClick={() => isFollowing ? unfollow(pubkey) : follow(pubkey)}
|
onClick={() => (isFollowing ? unfollow(pubkey) : follow(pubkey))}
|
||||||
>
|
>
|
||||||
{isFollowing ? 'Unfollow' : 'Follow'}
|
{isFollowing ? "Unfollow" : "Follow"}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,24 +3,35 @@ import { HexKey } from "Nostr";
|
|||||||
import ProfilePreview from "Element/ProfilePreview";
|
import ProfilePreview from "Element/ProfilePreview";
|
||||||
|
|
||||||
export interface FollowListBaseProps {
|
export interface FollowListBaseProps {
|
||||||
pubkeys: HexKey[],
|
pubkeys: HexKey[];
|
||||||
title?: string
|
title?: string;
|
||||||
}
|
}
|
||||||
export default function FollowListBase({ pubkeys, title }: FollowListBaseProps) {
|
export default function FollowListBase({
|
||||||
const publisher = useEventPublisher();
|
pubkeys,
|
||||||
|
title,
|
||||||
|
}: FollowListBaseProps) {
|
||||||
|
const publisher = useEventPublisher();
|
||||||
|
|
||||||
async function followAll() {
|
async function followAll() {
|
||||||
let ev = await publisher.addFollow(pubkeys);
|
let ev = await publisher.addFollow(pubkeys);
|
||||||
publisher.broadcast(ev);
|
publisher.broadcast(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
<div className="flex mt10 mb10">
|
<div className="flex mt10 mb10">
|
||||||
<div className="f-grow bold">{title}</div>
|
<div className="f-grow bold">{title}</div>
|
||||||
<button className="transparent" type="button" onClick={() => followAll()}>Follow All</button>
|
<button
|
||||||
</div>
|
className="transparent"
|
||||||
{pubkeys?.map(a => <ProfilePreview pubkey={a} key={a} />)}
|
type="button"
|
||||||
</div>
|
onClick={() => followAll()}
|
||||||
)
|
>
|
||||||
|
Follow All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{pubkeys?.map((a) => (
|
||||||
|
<ProfilePreview pubkey={a} key={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -5,16 +5,22 @@ import EventKind from "Nostr/EventKind";
|
|||||||
import FollowListBase from "Element/FollowListBase";
|
import FollowListBase from "Element/FollowListBase";
|
||||||
|
|
||||||
export interface FollowersListProps {
|
export interface FollowersListProps {
|
||||||
pubkey: HexKey
|
pubkey: HexKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FollowersList({ pubkey }: FollowersListProps) {
|
export default function FollowersList({ pubkey }: FollowersListProps) {
|
||||||
const feed = useFollowersFeed(pubkey);
|
const feed = useFollowersFeed(pubkey);
|
||||||
|
|
||||||
const pubkeys = useMemo(() => {
|
const pubkeys = useMemo(() => {
|
||||||
let contactLists = feed?.store.notes.filter(a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey));
|
let contactLists = feed?.store.notes.filter(
|
||||||
return [...new Set(contactLists?.map(a => a.pubkey))];
|
(a) =>
|
||||||
}, [feed]);
|
a.kind === EventKind.ContactList &&
|
||||||
|
a.tags.some((b) => b[0] === "p" && b[1] === pubkey)
|
||||||
|
);
|
||||||
|
return [...new Set(contactLists?.map((a) => a.pubkey))];
|
||||||
|
}, [feed]);
|
||||||
|
|
||||||
return <FollowListBase pubkeys={pubkeys} title={`${pubkeys?.length} followers`} />
|
return (
|
||||||
}
|
<FollowListBase pubkeys={pubkeys} title={`${pubkeys?.length} followers`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -2,18 +2,20 @@ import { useMemo } from "react";
|
|||||||
import useFollowsFeed from "Feed/FollowsFeed";
|
import useFollowsFeed from "Feed/FollowsFeed";
|
||||||
import { HexKey } from "Nostr";
|
import { HexKey } from "Nostr";
|
||||||
import FollowListBase from "Element/FollowListBase";
|
import FollowListBase from "Element/FollowListBase";
|
||||||
import { getFollowers} from "Feed/FollowsFeed";
|
import { getFollowers } from "Feed/FollowsFeed";
|
||||||
|
|
||||||
export interface FollowsListProps {
|
export interface FollowsListProps {
|
||||||
pubkey: HexKey
|
pubkey: HexKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FollowsList({ pubkey }: FollowsListProps) {
|
export default function FollowsList({ pubkey }: FollowsListProps) {
|
||||||
const feed = useFollowsFeed(pubkey);
|
const feed = useFollowsFeed(pubkey);
|
||||||
|
|
||||||
const pubkeys = useMemo(() => {
|
const pubkeys = useMemo(() => {
|
||||||
return getFollowers(feed.store, pubkey);
|
return getFollowers(feed.store, pubkey);
|
||||||
}, [feed]);
|
}, [feed]);
|
||||||
|
|
||||||
return <FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} />
|
return (
|
||||||
}
|
<FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
.follows-you {
|
.follows-you {
|
||||||
color: var(--font-secondary-color);
|
color: var(--font-secondary-color);
|
||||||
font-size: var(--font-size-tiny);
|
font-size: var(--font-size-tiny);
|
||||||
margin-left: .2em;
|
margin-left: 0.2em;
|
||||||
font-weight: normal
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
@ -3,26 +3,26 @@ import { useMemo } from "react";
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { HexKey } from "Nostr";
|
import { HexKey } from "Nostr";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import useFollowsFeed from "Feed/FollowsFeed";
|
import useFollowsFeed from "Feed/FollowsFeed";
|
||||||
import { getFollowers } from "Feed/FollowsFeed";
|
import { getFollowers } from "Feed/FollowsFeed";
|
||||||
|
|
||||||
export interface FollowsYouProps {
|
export interface FollowsYouProps {
|
||||||
pubkey: HexKey
|
pubkey: HexKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FollowsYou({ pubkey }: FollowsYouProps ) {
|
export default function FollowsYou({ pubkey }: FollowsYouProps) {
|
||||||
const feed = useFollowsFeed(pubkey);
|
const feed = useFollowsFeed(pubkey);
|
||||||
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
const loginPubKey = useSelector<RootState, HexKey | undefined>(
|
||||||
|
(s) => s.login.publicKey
|
||||||
|
);
|
||||||
|
|
||||||
const pubkeys = useMemo(() => {
|
const pubkeys = useMemo(() => {
|
||||||
return getFollowers(feed.store, pubkey);
|
return getFollowers(feed.store, pubkey);
|
||||||
}, [feed]);
|
}, [feed]);
|
||||||
|
|
||||||
const followsMe = pubkeys.includes(loginPubKey!) ?? false ;
|
const followsMe = pubkeys.includes(loginPubKey!) ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>{followsMe ? <span className="follows-you">follows you</span> : null}</>
|
||||||
{ followsMe ? <span className="follows-you">follows you</span> : null }
|
);
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
.hashtag {
|
.hashtag {
|
||||||
color: var(--highlight);
|
color: var(--highlight);
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from "react-router-dom";
|
||||||
import './Hashtag.css'
|
import "./Hashtag.css";
|
||||||
|
|
||||||
const Hashtag = ({ tag }: { tag: string }) => {
|
const Hashtag = ({ tag }: { tag: string }) => {
|
||||||
return (
|
return (
|
||||||
<span className="hashtag">
|
<span className="hashtag">
|
||||||
<Link to={`/t/${tag}`} onClick={(e) => e.stopPropagation()}>#{tag}</Link>
|
<Link to={`/t/${tag}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
#{tag}
|
||||||
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Hashtag
|
export default Hashtag;
|
||||||
|
@ -1,106 +1,153 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from "react";
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from "react-redux";
|
||||||
import { TwitterTweetEmbed } from "react-twitter-embed";
|
import { TwitterTweetEmbed } from "react-twitter-embed";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FileExtensionRegex,
|
FileExtensionRegex,
|
||||||
YoutubeUrlRegex,
|
YoutubeUrlRegex,
|
||||||
TweetUrlRegex,
|
TweetUrlRegex,
|
||||||
TidalRegex,
|
TidalRegex,
|
||||||
SoundCloudRegex,
|
SoundCloudRegex,
|
||||||
MixCloudRegex,
|
MixCloudRegex,
|
||||||
SpotifyRegex
|
SpotifyRegex,
|
||||||
} from "Const";
|
} from "Const";
|
||||||
import { RootState } from 'State/Store';
|
import { RootState } from "State/Store";
|
||||||
import SoundCloudEmbed from 'Element/SoundCloudEmded'
|
import SoundCloudEmbed from "Element/SoundCloudEmded";
|
||||||
import MixCloudEmbed from 'Element/MixCloudEmbed';
|
import MixCloudEmbed from "Element/MixCloudEmbed";
|
||||||
import SpotifyEmbed from "Element/SpotifyEmbed";
|
import SpotifyEmbed from "Element/SpotifyEmbed";
|
||||||
import TidalEmbed from "Element/TidalEmbed";
|
import TidalEmbed from "Element/TidalEmbed";
|
||||||
import { ProxyImg } from 'Element/ProxyImg';
|
import { ProxyImg } from "Element/ProxyImg";
|
||||||
import { HexKey } from 'Nostr';
|
import { HexKey } from "Nostr";
|
||||||
|
|
||||||
export default function HyperText({ link, creator }: { link: string, creator: HexKey }) {
|
export default function HyperText({
|
||||||
const pref = useSelector((s: RootState) => s.login.preferences);
|
link,
|
||||||
const follows = useSelector((s: RootState) => s.login.follows);
|
creator,
|
||||||
|
}: {
|
||||||
|
link: string;
|
||||||
|
creator: HexKey;
|
||||||
|
}) {
|
||||||
|
const pref = useSelector((s: RootState) => s.login.preferences);
|
||||||
|
const follows = useSelector((s: RootState) => s.login.follows);
|
||||||
|
|
||||||
const render = useCallback(() => {
|
const render = useCallback(() => {
|
||||||
const a = link;
|
const a = link;
|
||||||
try {
|
try {
|
||||||
const hideNonFollows = pref.autoLoadMedia === "follows-only" && !follows.includes(creator);
|
const hideNonFollows =
|
||||||
if (pref.autoLoadMedia === "none" || hideNonFollows) {
|
pref.autoLoadMedia === "follows-only" && !follows.includes(creator);
|
||||||
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
|
if (pref.autoLoadMedia === "none" || hideNonFollows) {
|
||||||
}
|
return (
|
||||||
const url = new URL(a);
|
<a
|
||||||
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
|
href={a}
|
||||||
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
|
onClick={(e) => e.stopPropagation()}
|
||||||
const tidalId = TidalRegex.test(a) && RegExp.$1;
|
target="_blank"
|
||||||
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
|
rel="noreferrer"
|
||||||
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
|
className="ext"
|
||||||
const spotifyId = SpotifyRegex.test(a);
|
>
|
||||||
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
|
{a}
|
||||||
if (extension) {
|
</a>
|
||||||
switch (extension) {
|
);
|
||||||
case "gif":
|
}
|
||||||
case "jpg":
|
const url = new URL(a);
|
||||||
case "jpeg":
|
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
|
||||||
case "png":
|
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
|
||||||
case "bmp":
|
const tidalId = TidalRegex.test(a) && RegExp.$1;
|
||||||
case "webp": {
|
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
|
||||||
return <ProxyImg key={url.toString()} src={url.toString()} />;
|
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
|
||||||
}
|
const spotifyId = SpotifyRegex.test(a);
|
||||||
case "wav":
|
const extension =
|
||||||
case "mp3":
|
FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
|
||||||
case "ogg": {
|
if (extension) {
|
||||||
return <audio key={url.toString()} src={url.toString()} controls />
|
switch (extension) {
|
||||||
}
|
case "gif":
|
||||||
case "mp4":
|
case "jpg":
|
||||||
case "mov":
|
case "jpeg":
|
||||||
case "mkv":
|
case "png":
|
||||||
case "avi":
|
case "bmp":
|
||||||
case "m4v": {
|
case "webp": {
|
||||||
return <video key={url.toString()} src={url.toString()} controls />
|
return <ProxyImg key={url.toString()} src={url.toString()} />;
|
||||||
}
|
}
|
||||||
default:
|
case "wav":
|
||||||
return <a key={url.toString()} href={url.toString()} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{url.toString()}</a>
|
case "mp3":
|
||||||
}
|
case "ogg": {
|
||||||
} else if (tweetId) {
|
return <audio key={url.toString()} src={url.toString()} controls />;
|
||||||
return (
|
}
|
||||||
<div className="tweet" key={tweetId}>
|
case "mp4":
|
||||||
<TwitterTweetEmbed tweetId={tweetId} />
|
case "mov":
|
||||||
</div>
|
case "mkv":
|
||||||
)
|
case "avi":
|
||||||
} else if (youtubeId) {
|
case "m4v": {
|
||||||
return (
|
return <video key={url.toString()} src={url.toString()} controls />;
|
||||||
<>
|
}
|
||||||
<br />
|
default:
|
||||||
<iframe
|
return (
|
||||||
className="w-max"
|
<a
|
||||||
src={`https://www.youtube.com/embed/${youtubeId}`}
|
key={url.toString()}
|
||||||
title="YouTube video player"
|
href={url.toString()}
|
||||||
key={youtubeId}
|
onClick={(e) => e.stopPropagation()}
|
||||||
frameBorder="0"
|
target="_blank"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
rel="noreferrer"
|
||||||
allowFullScreen={true}
|
className="ext"
|
||||||
/>
|
>
|
||||||
<br />
|
{url.toString()}
|
||||||
</>
|
</a>
|
||||||
)
|
);
|
||||||
} else if (tidalId) {
|
|
||||||
return <TidalEmbed link={a} />
|
|
||||||
} else if (soundcloundId) {
|
|
||||||
return <SoundCloudEmbed link={a} />
|
|
||||||
} else if (mixcloudId) {
|
|
||||||
return <MixCloudEmbed link={a} />
|
|
||||||
} else if (spotifyId) {
|
|
||||||
return <SpotifyEmbed link={a} />
|
|
||||||
} else {
|
|
||||||
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
}
|
||||||
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
|
} else if (tweetId) {
|
||||||
|
return (
|
||||||
|
<div className="tweet" key={tweetId}>
|
||||||
|
<TwitterTweetEmbed tweetId={tweetId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (youtubeId) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<iframe
|
||||||
|
className="w-max"
|
||||||
|
src={`https://www.youtube.com/embed/${youtubeId}`}
|
||||||
|
title="YouTube video player"
|
||||||
|
key={youtubeId}
|
||||||
|
frameBorder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowFullScreen={true}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else if (tidalId) {
|
||||||
|
return <TidalEmbed link={a} />;
|
||||||
|
} else if (soundcloundId) {
|
||||||
|
return <SoundCloudEmbed link={a} />;
|
||||||
|
} else if (mixcloudId) {
|
||||||
|
return <MixCloudEmbed link={a} />;
|
||||||
|
} else if (spotifyId) {
|
||||||
|
return <SpotifyEmbed link={a} />;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={a}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="ext"
|
||||||
|
>
|
||||||
|
{a}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={a}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="ext"
|
||||||
|
>
|
||||||
|
{a}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}, [link]);
|
||||||
|
|
||||||
}, [link]);
|
return render();
|
||||||
|
|
||||||
return render();
|
|
||||||
}
|
}
|
||||||
|
@ -1,22 +1,16 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
interface IconButtonProps {
|
interface IconButtonProps {
|
||||||
onClick(): void
|
onClick(): void;
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconButton = ({ onClick, children }: IconButtonProps) => {
|
const IconButton = ({ onClick, children }: IconButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button className="icon" type="button" onClick={onClick}>
|
||||||
className="icon"
|
<div className="icon-wrapper">{children}</div>
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<div className="icon-wrapper">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default IconButton
|
export default IconButton;
|
||||||
|
@ -9,95 +9,107 @@ import ZapCircle from "Icons/ZapCircle";
|
|||||||
import useWebln from "Hooks/useWebln";
|
import useWebln from "Hooks/useWebln";
|
||||||
|
|
||||||
export interface InvoiceProps {
|
export interface InvoiceProps {
|
||||||
invoice: string
|
invoice: string;
|
||||||
}
|
}
|
||||||
export default function Invoice(props: InvoiceProps) {
|
export default function Invoice(props: InvoiceProps) {
|
||||||
const invoice = props.invoice;
|
const invoice = props.invoice;
|
||||||
const webln = useWebln();
|
const webln = useWebln();
|
||||||
const [showInvoice, setShowInvoice] = useState(false);
|
const [showInvoice, setShowInvoice] = useState(false);
|
||||||
|
|
||||||
const info = useMemo(() => {
|
const info = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
let parsed = invoiceDecode(invoice);
|
let parsed = invoiceDecode(invoice);
|
||||||
|
|
||||||
let amount = parseInt(parsed.sections.find((a: any) => a.name === "amount")?.value);
|
let amount = parseInt(
|
||||||
let timestamp = parseInt(parsed.sections.find((a: any) => a.name === "timestamp")?.value);
|
parsed.sections.find((a: any) => a.name === "amount")?.value
|
||||||
let expire = parseInt(parsed.sections.find((a: any) => a.name === "expiry")?.value);
|
);
|
||||||
let description = parsed.sections.find((a: any) => a.name === "description")?.value;
|
let timestamp = parseInt(
|
||||||
let ret = {
|
parsed.sections.find((a: any) => a.name === "timestamp")?.value
|
||||||
amount: !isNaN(amount) ? (amount / 1000) : 0,
|
);
|
||||||
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,
|
let expire = parseInt(
|
||||||
description,
|
parsed.sections.find((a: any) => a.name === "expiry")?.value
|
||||||
expired: false
|
);
|
||||||
};
|
let description = parsed.sections.find(
|
||||||
if (ret.expire) {
|
(a: any) => a.name === "description"
|
||||||
ret.expired = ret.expire < (new Date().getTime() / 1000);
|
)?.value;
|
||||||
}
|
let ret = {
|
||||||
return ret;
|
amount: !isNaN(amount) ? amount / 1000 : 0,
|
||||||
} catch (e) {
|
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,
|
||||||
console.error(e);
|
description,
|
||||||
}
|
expired: false,
|
||||||
}, [invoice]);
|
};
|
||||||
|
if (ret.expire) {
|
||||||
const [isPaid, setIsPaid] = useState(false);
|
ret.expired = ret.expire < new Date().getTime() / 1000;
|
||||||
const isExpired = info?.expired
|
}
|
||||||
const amount = info?.amount ?? 0
|
return ret;
|
||||||
const description = info?.description
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
function header() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h4>Lightning Invoice</h4>
|
|
||||||
<ZapCircle className="zap-circle" />
|
|
||||||
<SendSats title="Pay Invoice" invoice={invoice} show={showInvoice} onClose={() => setShowInvoice(false)} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
}, [invoice]);
|
||||||
|
|
||||||
async function payInvoice(e: any) {
|
const [isPaid, setIsPaid] = useState(false);
|
||||||
e.stopPropagation();
|
const isExpired = info?.expired;
|
||||||
if (webln?.enabled) {
|
const amount = info?.amount ?? 0;
|
||||||
try {
|
const description = info?.description;
|
||||||
await webln.sendPayment(invoice);
|
|
||||||
setIsPaid(true)
|
function header() {
|
||||||
} catch (error) {
|
return (
|
||||||
setShowInvoice(true);
|
<>
|
||||||
}
|
<h4>Lightning Invoice</h4>
|
||||||
} else {
|
<ZapCircle className="zap-circle" />
|
||||||
|
<SendSats
|
||||||
|
title="Pay Invoice"
|
||||||
|
invoice={invoice}
|
||||||
|
show={showInvoice}
|
||||||
|
onClose={() => setShowInvoice(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function payInvoice(e: any) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (webln?.enabled) {
|
||||||
|
try {
|
||||||
|
await webln.sendPayment(invoice);
|
||||||
|
setIsPaid(true);
|
||||||
|
} catch (error) {
|
||||||
setShowInvoice(true);
|
setShowInvoice(true);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setShowInvoice(true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`note-invoice flex ${isExpired ? 'expired' : ''} ${isPaid ? 'paid' : ''}`}>
|
<div
|
||||||
<div className="invoice-header">
|
className={`note-invoice flex ${isExpired ? "expired" : ""} ${
|
||||||
{header()}
|
isPaid ? "paid" : ""
|
||||||
</div>
|
}`}
|
||||||
|
>
|
||||||
|
<div className="invoice-header">{header()}</div>
|
||||||
|
|
||||||
<p className="invoice-amount">
|
<p className="invoice-amount">
|
||||||
{amount > 0 && (
|
{amount > 0 && (
|
||||||
<>
|
<>
|
||||||
{amount.toLocaleString()} <span className="sats">sat{amount === 1 ? '' : 's'}</span>
|
{amount.toLocaleString()}{" "}
|
||||||
</>
|
<span className="sats">sat{amount === 1 ? "" : "s"}</span>
|
||||||
)}
|
</>
|
||||||
</p>
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="invoice-body">
|
<div className="invoice-body">
|
||||||
{description && <p>{description}</p>}
|
{description && <p>{description}</p>}
|
||||||
{isPaid ? (
|
{isPaid ? (
|
||||||
<div className="paid">
|
<div className="paid">Paid</div>
|
||||||
Paid
|
) : (
|
||||||
</div>
|
<button disabled={isExpired} type="button" onClick={payInvoice}>
|
||||||
) : (
|
{isExpired ? "Expired" : "Pay"}
|
||||||
<button disabled={isExpired} type="button" onClick={payInvoice}>
|
</button>
|
||||||
{isExpired ? "Expired" : "Pay"}
|
)}
|
||||||
</button>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
);
|
||||||
</div>
|
|
||||||
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
@ -1,22 +1,34 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
export default function LoadMore({ onLoadMore, shouldLoadMore, children }: { onLoadMore: () => void, shouldLoadMore: boolean, children?: React.ReactNode }) {
|
export default function LoadMore({
|
||||||
const { ref, inView } = useInView();
|
onLoadMore,
|
||||||
const [tick, setTick] = useState<number>(0);
|
shouldLoadMore,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onLoadMore: () => void;
|
||||||
|
shouldLoadMore: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { ref, inView } = useInView();
|
||||||
|
const [tick, setTick] = useState<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inView === true && shouldLoadMore === true) {
|
if (inView === true && shouldLoadMore === true) {
|
||||||
onLoadMore();
|
onLoadMore();
|
||||||
}
|
}
|
||||||
}, [inView, shouldLoadMore, tick]);
|
}, [inView, shouldLoadMore, tick]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let t = setInterval(() => {
|
let t = setInterval(() => {
|
||||||
setTick(x => x += 1);
|
setTick((x) => (x += 1));
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <div ref={ref} className="mb10">{children ?? 'Loading...'}</div>;
|
return (
|
||||||
}
|
<div ref={ref} className="mb10">
|
||||||
|
{children ?? "Loading..."}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -3,12 +3,19 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { logout } from "State/Login";
|
import { logout } from "State/Login";
|
||||||
|
|
||||||
export default function LogoutButton(){
|
export default function LogoutButton() {
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<button className="secondary" type="button" onClick={() => { dispatch(logout()); navigate("/"); }}>
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(logout());
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -5,17 +5,21 @@ import { HexKey } from "Nostr";
|
|||||||
import { hexToBech32, profileLink } from "Util";
|
import { hexToBech32, profileLink } from "Util";
|
||||||
|
|
||||||
export default function Mention({ pubkey }: { pubkey: HexKey }) {
|
export default function Mention({ pubkey }: { pubkey: HexKey }) {
|
||||||
const user = useUserProfile(pubkey)
|
const user = useUserProfile(pubkey);
|
||||||
|
|
||||||
const name = useMemo(() => {
|
const name = useMemo(() => {
|
||||||
let name = hexToBech32("npub", pubkey).substring(0, 12);
|
let name = hexToBech32("npub", pubkey).substring(0, 12);
|
||||||
if ((user?.display_name?.length ?? 0) > 0) {
|
if ((user?.display_name?.length ?? 0) > 0) {
|
||||||
name = user!.display_name!;
|
name = user!.display_name!;
|
||||||
} else if ((user?.name?.length ?? 0) > 0) {
|
} else if ((user?.name?.length ?? 0) > 0) {
|
||||||
name = user!.name!;
|
name = user!.name!;
|
||||||
}
|
}
|
||||||
return name;
|
return name;
|
||||||
}, [user, pubkey]);
|
}, [user, pubkey]);
|
||||||
|
|
||||||
return <Link to={profileLink(pubkey)} onClick={(e) => e.stopPropagation()}>@{name}</Link>
|
return (
|
||||||
|
<Link to={profileLink(pubkey)} onClick={(e) => e.stopPropagation()}>
|
||||||
|
@{name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,26 +2,30 @@ import { MixCloudRegex } from "Const";
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
|
|
||||||
const MixCloudEmbed = ({link}: {link: string}) => {
|
const MixCloudEmbed = ({ link }: { link: string }) => {
|
||||||
|
const feedPath =
|
||||||
|
(MixCloudRegex.test(link) && RegExp.$1) +
|
||||||
|
"%2F" +
|
||||||
|
(MixCloudRegex.test(link) && RegExp.$2);
|
||||||
|
|
||||||
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + ( MixCloudRegex.test(link) && RegExp.$2)
|
const lightTheme = useSelector<RootState, boolean>(
|
||||||
|
(s) => s.login.preferences.theme === "light"
|
||||||
|
);
|
||||||
|
|
||||||
const lightTheme = useSelector<RootState, boolean>(s => s.login.preferences.theme === "light");
|
const lightParams = lightTheme ? "light=1" : "light=0";
|
||||||
|
|
||||||
const lightParams = lightTheme ? "light=1" : "light=0";
|
return (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<iframe
|
||||||
|
title="SoundCloud player"
|
||||||
|
width="100%"
|
||||||
|
height="120"
|
||||||
|
frameBorder="0"
|
||||||
|
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return(
|
export default MixCloudEmbed;
|
||||||
<>
|
|
||||||
<br/>
|
|
||||||
<iframe
|
|
||||||
title="SoundCloud player"
|
|
||||||
width="100%"
|
|
||||||
height="120"
|
|
||||||
frameBorder="0"
|
|
||||||
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MixCloudEmbed;
|
|
||||||
|
@ -1,27 +1,27 @@
|
|||||||
.modal {
|
.modal {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
background-color: var(--modal-bg-color);
|
background-color: var(--modal-bg-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 42;
|
z-index: 42;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
background-color: var(--note-bg);
|
background-color: var(--note-bg);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
width: 500px;
|
width: 500px;
|
||||||
min-height: 10vh;
|
min-height: 10vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.modal-body {
|
.modal-body {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
margin: 0 10px;
|
margin: 0 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import "./Modal.css";
|
import "./Modal.css";
|
||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
export interface ModalProps {
|
export interface ModalProps {
|
||||||
className?: string
|
className?: string;
|
||||||
onClose?: () => void,
|
onClose?: () => void;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useOnClickOutside(ref: any, onClickOutside: () => void) {
|
function useOnClickOutside(ref: any, onClickOutside: () => void) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(ev: any) {
|
function handleClickOutside(ev: any) {
|
||||||
if (ref && ref.current && !ref.current.contains(ev.target)) {
|
if (ref && ref.current && !ref.current.contains(ev.target)) {
|
||||||
onClickOutside()
|
onClickOutside();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
@ -23,21 +23,21 @@ function useOnClickOutside(ref: any, onClickOutside: () => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Modal(props: ModalProps) {
|
export default function Modal(props: ModalProps) {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const onClose = props.onClose || (() => { });
|
const onClose = props.onClose || (() => {});
|
||||||
const className = props.className || ''
|
const className = props.className || "";
|
||||||
useOnClickOutside(ref, onClose)
|
useOnClickOutside(ref, onClose);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.add("scroll-lock");
|
document.body.classList.add("scroll-lock");
|
||||||
return () => document.body.classList.remove("scroll-lock");
|
return () => document.body.classList.remove("scroll-lock");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`modal ${className}`}>
|
<div className={`modal ${className}`}>
|
||||||
<div ref={ref} className="modal-body">
|
<div ref={ref} className="modal-body">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,20 +2,20 @@ import { HexKey } from "Nostr";
|
|||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
|
|
||||||
interface MuteButtonProps {
|
interface MuteButtonProps {
|
||||||
pubkey: HexKey
|
pubkey: HexKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MuteButton = ({ pubkey }: MuteButtonProps) => {
|
const MuteButton = ({ pubkey }: MuteButtonProps) => {
|
||||||
const { mute, unmute, isMuted } = useModeration()
|
const { mute, unmute, isMuted } = useModeration();
|
||||||
return isMuted(pubkey) ? (
|
return isMuted(pubkey) ? (
|
||||||
<button className="secondary" type="button" onClick={() => unmute(pubkey)}>
|
<button className="secondary" type="button" onClick={() => unmute(pubkey)}>
|
||||||
Unmute
|
Unmute
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={() => mute(pubkey)}>
|
<button type="button" onClick={() => mute(pubkey)}>
|
||||||
Mute
|
Mute
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default MuteButton
|
export default MuteButton;
|
||||||
|
@ -1,38 +1,48 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
import { HexKey } from "Nostr"; import type { RootState } from "State/Store";
|
import { HexKey } from "Nostr";
|
||||||
|
import type { RootState } from "State/Store";
|
||||||
import MuteButton from "Element/MuteButton";
|
import MuteButton from "Element/MuteButton";
|
||||||
import ProfilePreview from "Element/ProfilePreview";
|
import ProfilePreview from "Element/ProfilePreview";
|
||||||
import useMutedFeed, { getMuted } from "Feed/MuteList";
|
import useMutedFeed, { getMuted } from "Feed/MuteList";
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
|
|
||||||
export interface MutedListProps {
|
export interface MutedListProps {
|
||||||
pubkey: HexKey
|
pubkey: HexKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MutedList({ pubkey }: MutedListProps) {
|
export default function MutedList({ pubkey }: MutedListProps) {
|
||||||
const { muted, isMuted, mute, unmute, muteAll } = useModeration();
|
const { muted, isMuted, mute, unmute, muteAll } = useModeration();
|
||||||
const feed = useMutedFeed(pubkey)
|
const feed = useMutedFeed(pubkey);
|
||||||
const pubkeys = useMemo(() => {
|
const pubkeys = useMemo(() => {
|
||||||
return getMuted(feed.store, pubkey);
|
return getMuted(feed.store, pubkey);
|
||||||
}, [feed, pubkey]);
|
}, [feed, pubkey]);
|
||||||
const hasAllMuted = pubkeys.every(isMuted)
|
const hasAllMuted = pubkeys.every(isMuted);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
<div className="flex mt10">
|
<div className="flex mt10">
|
||||||
<div className="f-grow bold">{`${pubkeys?.length} muted`}</div>
|
<div className="f-grow bold">{`${pubkeys?.length} muted`}</div>
|
||||||
<button
|
<button
|
||||||
disabled={hasAllMuted || pubkeys.length === 0}
|
disabled={hasAllMuted || pubkeys.length === 0}
|
||||||
className="transparent" type="button" onClick={() => muteAll(pubkeys)}
|
className="transparent"
|
||||||
>
|
type="button"
|
||||||
Mute all
|
onClick={() => muteAll(pubkeys)}
|
||||||
</button>
|
>
|
||||||
</div>
|
Mute all
|
||||||
{pubkeys?.map(a => {
|
</button>
|
||||||
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
|
</div>
|
||||||
})}
|
{pubkeys?.map((a) => {
|
||||||
</div>
|
return (
|
||||||
)
|
<ProfilePreview
|
||||||
|
actions={<MuteButton pubkey={a} />}
|
||||||
|
pubkey={a}
|
||||||
|
options={{ about: false }}
|
||||||
|
key={a}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -47,5 +47,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nip05 .badge {
|
.nip05 .badge {
|
||||||
margin: .1em .2em;
|
margin: 0.1em 0.2em;
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faCircleCheck, faSpinner, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
import {
|
||||||
|
faCircleCheck,
|
||||||
|
faSpinner,
|
||||||
|
faTriangleExclamation,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
import './Nip05.css'
|
import "./Nip05.css";
|
||||||
import { HexKey } from "Nostr";
|
import { HexKey } from "Nostr";
|
||||||
|
|
||||||
interface NostrJson {
|
interface NostrJson {
|
||||||
names: Record<string, string>
|
names: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchNip05Pubkey(name: string, domain: string) {
|
async function fetchNip05Pubkey(name: string, domain: string) {
|
||||||
@ -15,54 +19,60 @@ async function fetchNip05Pubkey(name: string, domain: string) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`);
|
const res = await fetch(
|
||||||
|
`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(
|
||||||
|
name
|
||||||
|
)}`
|
||||||
|
);
|
||||||
const data: NostrJson = await res.json();
|
const data: NostrJson = await res.json();
|
||||||
const match = Object.keys(data.names).find(n => {
|
const match = Object.keys(data.names).find((n) => {
|
||||||
return n.toLowerCase() === name.toLowerCase();
|
return n.toLowerCase() === name.toLowerCase();
|
||||||
});
|
});
|
||||||
return match ? data.names[match] : undefined;
|
return match ? data.names[match] : undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return undefined
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERIFICATION_CACHE_TIME = 24 * 60 * 60 * 1000
|
const VERIFICATION_CACHE_TIME = 24 * 60 * 60 * 1000;
|
||||||
const VERIFICATION_STALE_TIMEOUT = 10 * 60 * 1000
|
const VERIFICATION_STALE_TIMEOUT = 10 * 60 * 1000;
|
||||||
|
|
||||||
export function useIsVerified(pubkey: HexKey, nip05?: string) {
|
export function useIsVerified(pubkey: HexKey, nip05?: string) {
|
||||||
const [name, domain] = nip05 ? nip05.split('@') : []
|
const [name, domain] = nip05 ? nip05.split("@") : [];
|
||||||
const { isError, isSuccess, data } = useQuery(
|
const { isError, isSuccess, data } = useQuery(
|
||||||
['nip05', nip05],
|
["nip05", nip05],
|
||||||
() => fetchNip05Pubkey(name, domain),
|
() => fetchNip05Pubkey(name, domain),
|
||||||
{
|
{
|
||||||
retry: false,
|
retry: false,
|
||||||
retryOnMount: false,
|
retryOnMount: false,
|
||||||
cacheTime: VERIFICATION_CACHE_TIME,
|
cacheTime: VERIFICATION_CACHE_TIME,
|
||||||
staleTime: VERIFICATION_STALE_TIMEOUT,
|
staleTime: VERIFICATION_STALE_TIMEOUT,
|
||||||
},
|
}
|
||||||
)
|
);
|
||||||
const isVerified = isSuccess && data === pubkey
|
const isVerified = isSuccess && data === pubkey;
|
||||||
const cantVerify = isSuccess && data !== pubkey
|
const cantVerify = isSuccess && data !== pubkey;
|
||||||
return { isVerified, couldNotVerify: isError || cantVerify }
|
return { isVerified, couldNotVerify: isError || cantVerify };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Nip05Params {
|
export interface Nip05Params {
|
||||||
nip05?: string,
|
nip05?: string;
|
||||||
pubkey: HexKey
|
pubkey: HexKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Nip05 = (props: Nip05Params) => {
|
const Nip05 = (props: Nip05Params) => {
|
||||||
const [name, domain] = props.nip05 ? props.nip05.split('@') : []
|
const [name, domain] = props.nip05 ? props.nip05.split("@") : [];
|
||||||
const isDefaultUser = name === '_'
|
const isDefaultUser = name === "_";
|
||||||
const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05)
|
const { isVerified, couldNotVerify } = useIsVerified(
|
||||||
|
props.pubkey,
|
||||||
|
props.nip05
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={(ev) => ev.stopPropagation()}>
|
<div
|
||||||
{!isDefaultUser && (
|
className={`flex nip05${couldNotVerify ? " failed" : ""}`}
|
||||||
<div className="nick">
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
{`${name}@`}
|
>
|
||||||
</div>
|
{!isDefaultUser && <div className="nick">{`${name}@`}</div>}
|
||||||
)}
|
|
||||||
<span className="domain" data-domain={domain?.toLowerCase()}>
|
<span className="domain" data-domain={domain?.toLowerCase()}>
|
||||||
{domain}
|
{domain}
|
||||||
</span>
|
</span>
|
||||||
@ -90,7 +100,7 @@ const Nip05 = (props: Nip05Params) => {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Nip05
|
export default Nip05;
|
||||||
|
@ -2,195 +2,260 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
ServiceConfig,
|
ServiceConfig,
|
||||||
ServiceError,
|
ServiceError,
|
||||||
HandleAvailability,
|
HandleAvailability,
|
||||||
ServiceErrorCode,
|
ServiceErrorCode,
|
||||||
HandleRegisterResponse,
|
HandleRegisterResponse,
|
||||||
CheckRegisterResponse
|
CheckRegisterResponse,
|
||||||
} from "Nip05/ServiceProvider";
|
} from "Nip05/ServiceProvider";
|
||||||
import AsyncButton from "Element/AsyncButton";
|
import AsyncButton from "Element/AsyncButton";
|
||||||
import SendSats from "Element/SendSats";
|
import SendSats from "Element/SendSats";
|
||||||
import Copy from "Element/Copy";
|
import Copy from "Element/Copy";
|
||||||
import { useUserProfile }from "Feed/ProfileFeed";
|
import { useUserProfile } from "Feed/ProfileFeed";
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import { debounce, hexToBech32 } from "Util";
|
import { debounce, hexToBech32 } from "Util";
|
||||||
import { UserMetadata } from "Nostr";
|
import { UserMetadata } from "Nostr";
|
||||||
|
|
||||||
type Nip05ServiceProps = {
|
type Nip05ServiceProps = {
|
||||||
name: string,
|
name: string;
|
||||||
service: URL | string,
|
service: URL | string;
|
||||||
about: JSX.Element,
|
about: JSX.Element;
|
||||||
link: string,
|
link: string;
|
||||||
supportLink: string
|
supportLink: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReduxStore = any;
|
type ReduxStore = any;
|
||||||
|
|
||||||
export default function Nip5Service(props: Nip05ServiceProps) {
|
export default function Nip5Service(props: Nip05ServiceProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey);
|
const pubkey = useSelector<ReduxStore, string>((s) => s.login.publicKey);
|
||||||
const user = useUserProfile(pubkey);
|
const user = useUserProfile(pubkey);
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
|
const svc = useMemo(
|
||||||
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
|
() => new ServiceProvider(props.service),
|
||||||
const [error, setError] = useState<ServiceError>();
|
[props.service]
|
||||||
const [handle, setHandle] = useState<string>("");
|
);
|
||||||
const [domain, setDomain] = useState<string>("");
|
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
|
||||||
const [availabilityResponse, setAvailabilityResponse] = useState<HandleAvailability>();
|
const [error, setError] = useState<ServiceError>();
|
||||||
const [registerResponse, setRegisterResponse] = useState<HandleRegisterResponse>();
|
const [handle, setHandle] = useState<string>("");
|
||||||
const [showInvoice, setShowInvoice] = useState<boolean>(false);
|
const [domain, setDomain] = useState<string>("");
|
||||||
const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>();
|
const [availabilityResponse, setAvailabilityResponse] =
|
||||||
|
useState<HandleAvailability>();
|
||||||
|
const [registerResponse, setRegisterResponse] =
|
||||||
|
useState<HandleRegisterResponse>();
|
||||||
|
const [showInvoice, setShowInvoice] = useState<boolean>(false);
|
||||||
|
const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>();
|
||||||
|
|
||||||
const domainConfig = useMemo(() => serviceConfig?.domains.find(a => a.name === domain), [domain, serviceConfig]);
|
const domainConfig = useMemo(
|
||||||
|
() => serviceConfig?.domains.find((a) => a.name === domain),
|
||||||
|
[domain, serviceConfig]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
svc.GetConfig()
|
svc
|
||||||
.then(a => {
|
.GetConfig()
|
||||||
if ('error' in a) {
|
.then((a) => {
|
||||||
setError(a as ServiceError)
|
if ("error" in a) {
|
||||||
} else {
|
setError(a as ServiceError);
|
||||||
let svc = a as ServiceConfig;
|
|
||||||
setServiceConfig(svc);
|
|
||||||
let defaultDomain = svc.domains.find(a => a.default)?.name || svc.domains[0].name;
|
|
||||||
setDomain(defaultDomain);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
}, [props, svc]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setError(undefined);
|
|
||||||
setAvailabilityResponse(undefined);
|
|
||||||
if (handle && domain) {
|
|
||||||
if (handle.length < (domainConfig?.length[0] ?? 2)) {
|
|
||||||
setAvailabilityResponse({ available: false, why: "TOO_SHORT" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (handle.length > (domainConfig?.length[1] ?? 20)) {
|
|
||||||
setAvailabilityResponse({ available: false, why: "TOO_LONG" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let rx = new RegExp(domainConfig?.regex[0] ?? "", domainConfig?.regex[1] ?? "");
|
|
||||||
if (!rx.test(handle)) {
|
|
||||||
setAvailabilityResponse({ available: false, why: "REGEX" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return debounce(500, () => {
|
|
||||||
svc.CheckAvailable(handle, domain)
|
|
||||||
.then(a => {
|
|
||||||
if ('error' in a) {
|
|
||||||
setError(a as ServiceError);
|
|
||||||
} else {
|
|
||||||
setAvailabilityResponse(a as HandleAvailability);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [handle, domain, domainConfig, svc]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (registerResponse && showInvoice) {
|
|
||||||
let t = setInterval(async () => {
|
|
||||||
let status = await svc.CheckRegistration(registerResponse.token);
|
|
||||||
if ('error' in status) {
|
|
||||||
setError(status);
|
|
||||||
setRegisterResponse(undefined);
|
|
||||||
setShowInvoice(false);
|
|
||||||
} else {
|
|
||||||
let result: CheckRegisterResponse = status;
|
|
||||||
if (result.available && result.paid) {
|
|
||||||
setShowInvoice(false);
|
|
||||||
setRegisterStatus(status);
|
|
||||||
setRegisterResponse(undefined);
|
|
||||||
setError(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 2_000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}
|
|
||||||
}, [registerResponse, showInvoice, svc])
|
|
||||||
|
|
||||||
function mapError(e: ServiceErrorCode, t: string | null): string | undefined {
|
|
||||||
let whyMap = new Map([
|
|
||||||
["TOO_SHORT", "name too short"],
|
|
||||||
["TOO_LONG", "name too long"],
|
|
||||||
["REGEX", "name has disallowed characters"],
|
|
||||||
["REGISTERED", "name is registered"],
|
|
||||||
["DISALLOWED_null", "name is blocked"],
|
|
||||||
["DISALLOWED_later", "name will be available later"],
|
|
||||||
]);
|
|
||||||
return whyMap.get(e === "DISALLOWED" ? `${e}_${t}` : e);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startBuy(handle: string, domain: string) {
|
|
||||||
if (registerResponse) {
|
|
||||||
setShowInvoice(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rsp = await svc.RegisterHandle(handle, domain, pubkey);
|
|
||||||
if ('error' in rsp) {
|
|
||||||
setError(rsp);
|
|
||||||
} else {
|
} else {
|
||||||
setRegisterResponse(rsp);
|
let svc = a as ServiceConfig;
|
||||||
setShowInvoice(true);
|
setServiceConfig(svc);
|
||||||
|
let defaultDomain =
|
||||||
|
svc.domains.find((a) => a.default)?.name || svc.domains[0].name;
|
||||||
|
setDomain(defaultDomain);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, [props, svc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(undefined);
|
||||||
|
setAvailabilityResponse(undefined);
|
||||||
|
if (handle && domain) {
|
||||||
|
if (handle.length < (domainConfig?.length[0] ?? 2)) {
|
||||||
|
setAvailabilityResponse({ available: false, why: "TOO_SHORT" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (handle.length > (domainConfig?.length[1] ?? 20)) {
|
||||||
|
setAvailabilityResponse({ available: false, why: "TOO_LONG" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rx = new RegExp(
|
||||||
|
domainConfig?.regex[0] ?? "",
|
||||||
|
domainConfig?.regex[1] ?? ""
|
||||||
|
);
|
||||||
|
if (!rx.test(handle)) {
|
||||||
|
setAvailabilityResponse({ available: false, why: "REGEX" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return debounce(500, () => {
|
||||||
|
svc
|
||||||
|
.CheckAvailable(handle, domain)
|
||||||
|
.then((a) => {
|
||||||
|
if ("error" in a) {
|
||||||
|
setError(a as ServiceError);
|
||||||
|
} else {
|
||||||
|
setAvailabilityResponse(a as HandleAvailability);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [handle, domain, domainConfig, svc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (registerResponse && showInvoice) {
|
||||||
|
let t = setInterval(async () => {
|
||||||
|
let status = await svc.CheckRegistration(registerResponse.token);
|
||||||
|
if ("error" in status) {
|
||||||
|
setError(status);
|
||||||
|
setRegisterResponse(undefined);
|
||||||
|
setShowInvoice(false);
|
||||||
|
} else {
|
||||||
|
let result: CheckRegisterResponse = status;
|
||||||
|
if (result.available && result.paid) {
|
||||||
|
setShowInvoice(false);
|
||||||
|
setRegisterStatus(status);
|
||||||
|
setRegisterResponse(undefined);
|
||||||
|
setError(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 2_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}
|
||||||
|
}, [registerResponse, showInvoice, svc]);
|
||||||
|
|
||||||
|
function mapError(e: ServiceErrorCode, t: string | null): string | undefined {
|
||||||
|
let whyMap = new Map([
|
||||||
|
["TOO_SHORT", "name too short"],
|
||||||
|
["TOO_LONG", "name too long"],
|
||||||
|
["REGEX", "name has disallowed characters"],
|
||||||
|
["REGISTERED", "name is registered"],
|
||||||
|
["DISALLOWED_null", "name is blocked"],
|
||||||
|
["DISALLOWED_later", "name will be available later"],
|
||||||
|
]);
|
||||||
|
return whyMap.get(e === "DISALLOWED" ? `${e}_${t}` : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBuy(handle: string, domain: string) {
|
||||||
|
if (registerResponse) {
|
||||||
|
setShowInvoice(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateProfile(handle: string, domain: string) {
|
let rsp = await svc.RegisterHandle(handle, domain, pubkey);
|
||||||
if (user) {
|
if ("error" in rsp) {
|
||||||
let newProfile = {
|
setError(rsp);
|
||||||
...user,
|
} else {
|
||||||
nip05: `${handle}@${domain}`
|
setRegisterResponse(rsp);
|
||||||
} as UserMetadata;
|
setShowInvoice(true);
|
||||||
let ev = await publisher.metadata(newProfile);
|
|
||||||
publisher.broadcast(ev);
|
|
||||||
navigate("/settings");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
async function updateProfile(handle: string, domain: string) {
|
||||||
<>
|
if (user) {
|
||||||
<h3>{props.name}</h3>
|
let newProfile = {
|
||||||
{props.about}
|
...user,
|
||||||
<p>Find out more info about {props.name} at <a href={props.link} target="_blank" rel="noreferrer">{props.link}</a></p>
|
nip05: `${handle}@${domain}`,
|
||||||
{error && <b className="error">{error.error}</b>}
|
} as UserMetadata;
|
||||||
{!registerStatus && <div className="flex mb10">
|
let ev = await publisher.metadata(newProfile);
|
||||||
<input type="text" placeholder="Handle" value={handle} onChange={(e) => setHandle(e.target.value.toLowerCase())} />
|
publisher.broadcast(ev);
|
||||||
@
|
navigate("/settings");
|
||||||
<select value={domain} onChange={(e) => setDomain(e.target.value)}>
|
}
|
||||||
{serviceConfig?.domains.map(a => <option key={a.name}>{a.name}</option>)}
|
}
|
||||||
</select>
|
|
||||||
</div>}
|
return (
|
||||||
{availabilityResponse?.available && !registerStatus && <div className="flex">
|
<>
|
||||||
<div className="mr10">
|
<h3>{props.name}</h3>
|
||||||
{availabilityResponse.quote?.price.toLocaleString()} sats<br />
|
{props.about}
|
||||||
<small>{availabilityResponse.quote?.data.type}</small>
|
<p>
|
||||||
</div>
|
Find out more info about {props.name} at{" "}
|
||||||
<input type="text" className="f-grow mr10" placeholder="pubkey" value={hexToBech32("npub", pubkey)} disabled />
|
<a href={props.link} target="_blank" rel="noreferrer">
|
||||||
<AsyncButton onClick={() => startBuy(handle, domain)}>Buy Now</AsyncButton>
|
{props.link}
|
||||||
</div>}
|
</a>
|
||||||
{availabilityResponse?.available === false && !registerStatus && <div className="flex">
|
</p>
|
||||||
<b className="error">Not available: {mapError(availabilityResponse.why!, availabilityResponse.reasonTag || null)}</b>
|
{error && <b className="error">{error.error}</b>}
|
||||||
</div>}
|
{!registerStatus && (
|
||||||
<SendSats
|
<div className="flex mb10">
|
||||||
invoice={registerResponse?.invoice}
|
<input
|
||||||
show={showInvoice}
|
type="text"
|
||||||
onClose={() => setShowInvoice(false)}
|
placeholder="Handle"
|
||||||
title={`Buying ${handle}@${domain}`} />
|
value={handle}
|
||||||
{registerStatus?.paid && <div className="flex f-col">
|
onChange={(e) => setHandle(e.target.value.toLowerCase())}
|
||||||
<h4>Order Paid!</h4>
|
/>
|
||||||
<p>Your new NIP-05 handle is: <code>{handle}@{domain}</code></p>
|
@
|
||||||
<h3>Account Support</h3>
|
<select value={domain} onChange={(e) => setDomain(e.target.value)}>
|
||||||
<p>Please make sure to save the following password in order to manage your handle in the future</p>
|
{serviceConfig?.domains.map((a) => (
|
||||||
<Copy text={registerStatus.password} />
|
<option key={a.name}>{a.name}</option>
|
||||||
<p>Go to <a href={props.supportLink} target="_blank" rel="noreferrer">account page</a></p>
|
))}
|
||||||
<h4>Activate Now</h4>
|
</select>
|
||||||
<AsyncButton onClick={() => updateProfile(handle, domain)}>Add to Profile</AsyncButton>
|
</div>
|
||||||
</div>}
|
)}
|
||||||
</>
|
{availabilityResponse?.available && !registerStatus && (
|
||||||
)
|
<div className="flex">
|
||||||
|
<div className="mr10">
|
||||||
|
{availabilityResponse.quote?.price.toLocaleString()} sats
|
||||||
|
<br />
|
||||||
|
<small>{availabilityResponse.quote?.data.type}</small>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="f-grow mr10"
|
||||||
|
placeholder="pubkey"
|
||||||
|
value={hexToBech32("npub", pubkey)}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<AsyncButton onClick={() => startBuy(handle, domain)}>
|
||||||
|
Buy Now
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{availabilityResponse?.available === false && !registerStatus && (
|
||||||
|
<div className="flex">
|
||||||
|
<b className="error">
|
||||||
|
Not available:{" "}
|
||||||
|
{mapError(
|
||||||
|
availabilityResponse.why!,
|
||||||
|
availabilityResponse.reasonTag || null
|
||||||
|
)}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SendSats
|
||||||
|
invoice={registerResponse?.invoice}
|
||||||
|
show={showInvoice}
|
||||||
|
onClose={() => setShowInvoice(false)}
|
||||||
|
title={`Buying ${handle}@${domain}`}
|
||||||
|
/>
|
||||||
|
{registerStatus?.paid && (
|
||||||
|
<div className="flex f-col">
|
||||||
|
<h4>Order Paid!</h4>
|
||||||
|
<p>
|
||||||
|
Your new NIP-05 handle is:{" "}
|
||||||
|
<code>
|
||||||
|
{handle}@{domain}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<h3>Account Support</h3>
|
||||||
|
<p>
|
||||||
|
Please make sure to save the following password in order to manage
|
||||||
|
your handle in the future
|
||||||
|
</p>
|
||||||
|
<Copy text={registerStatus.password} />
|
||||||
|
<p>
|
||||||
|
Go to{" "}
|
||||||
|
<a href={props.supportLink} target="_blank" rel="noreferrer">
|
||||||
|
account page
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<h4>Activate Now</h4>
|
||||||
|
<AsyncButton onClick={() => updateProfile(handle, domain)}>
|
||||||
|
Add to Profile
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,27 +2,27 @@
|
|||||||
min-height: 110px;
|
min-height: 110px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note>.header .reply {
|
.note > .header .reply {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--font-secondary-color);
|
color: var(--font-secondary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note>.header .reply a {
|
.note > .header .reply a {
|
||||||
color: var(--highlight);
|
color: var(--highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note>.header .reply a:hover {
|
.note > .header .reply a:hover {
|
||||||
text-decoration-color: var(--highlight);
|
text-decoration-color: var(--highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note>.header>.info {
|
.note > .header > .info {
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--font-secondary-color);
|
color: var(--font-secondary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note>.body {
|
.note > .body {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
padding-left: 56px;
|
padding-left: 56px;
|
||||||
@ -33,7 +33,7 @@
|
|||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note>.footer {
|
.note > .footer {
|
||||||
padding-left: 46px;
|
padding-left: 46px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.note>.footer .ctx-menu {
|
.note > .footer .ctx-menu {
|
||||||
background-color: var(--note-bg);
|
background-color: var(--note-bg);
|
||||||
color: var(--font-secondary-color);
|
color: var(--font-secondary-color);
|
||||||
border: 1px solid var(--font-secondary-color);
|
border: 1px solid var(--font-secondary-color);
|
||||||
@ -57,7 +57,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note>.footer .ctx-menu li {
|
.note > .footer .ctx-menu li {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2rem auto;
|
grid-template-columns: 2rem auto;
|
||||||
}
|
}
|
||||||
@ -66,11 +66,13 @@
|
|||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note>.header img:hover, .note>.header .name>.reply:hover, .note .body:hover {
|
.note > .header img:hover,
|
||||||
|
.note > .header .name > .reply:hover,
|
||||||
|
.note .body:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note>.note-creator {
|
.note > .note-creator {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
margin-left: 56px;
|
margin-left: 56px;
|
||||||
}
|
}
|
||||||
@ -116,7 +118,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hidden-note button {
|
.hidden-note button {
|
||||||
max-height: 30px;
|
max-height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand-note {
|
.expand-note {
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
import "./Note.css";
|
import "./Note.css";
|
||||||
import { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useLayoutEffect,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
@ -17,49 +23,57 @@ import { TaggedRawEvent, u256 } from "Nostr";
|
|||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
|
|
||||||
export interface NoteProps {
|
export interface NoteProps {
|
||||||
data?: TaggedRawEvent,
|
data?: TaggedRawEvent;
|
||||||
className?: string
|
className?: string;
|
||||||
related: TaggedRawEvent[],
|
related: TaggedRawEvent[];
|
||||||
highlight?: boolean,
|
highlight?: boolean;
|
||||||
ignoreModeration?: boolean,
|
ignoreModeration?: boolean;
|
||||||
options?: {
|
options?: {
|
||||||
showHeader?: boolean,
|
showHeader?: boolean;
|
||||||
showTime?: boolean,
|
showTime?: boolean;
|
||||||
showFooter?: boolean
|
showFooter?: boolean;
|
||||||
},
|
};
|
||||||
["data-ev"]?: NEvent
|
["data-ev"]?: NEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HiddenNote = ({ children }: any) => {
|
const HiddenNote = ({ children }: any) => {
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false);
|
||||||
return show ? children : (
|
return show ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
<div className="card note hidden-note">
|
<div className="card note hidden-note">
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<p>
|
<p>This author has been muted</p>
|
||||||
This author has been muted
|
<button onClick={() => setShow(true)}>Show</button>
|
||||||
</p>
|
|
||||||
<button onClick={() => setShow(true)}>
|
|
||||||
Show
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
export default function Note(props: NoteProps) {
|
export default function Note(props: NoteProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data, className, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false} = props
|
const {
|
||||||
|
data,
|
||||||
|
className,
|
||||||
|
related,
|
||||||
|
highlight,
|
||||||
|
options: opt,
|
||||||
|
["data-ev"]: parsedEvent,
|
||||||
|
ignoreModeration = false,
|
||||||
|
} = props;
|
||||||
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
|
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
|
||||||
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
||||||
const users = useUserProfiles(pubKeys);
|
const users = useUserProfiles(pubKeys);
|
||||||
const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
|
const deletions = useMemo(
|
||||||
const { isMuted } = useModeration()
|
() => getReactions(related, ev.Id, EventKind.Deletion),
|
||||||
const isOpMuted = isMuted(ev.PubKey)
|
[related]
|
||||||
|
);
|
||||||
|
const { isMuted } = useModeration();
|
||||||
|
const isOpMuted = isMuted(ev.PubKey);
|
||||||
const { ref, inView, entry } = useInView({ triggerOnce: true });
|
const { ref, inView, entry } = useInView({ triggerOnce: true });
|
||||||
const [extendable, setExtendable] = useState<boolean>(false);
|
const [extendable, setExtendable] = useState<boolean>(false);
|
||||||
const [showMore, setShowMore] = useState<boolean>(false);
|
const [showMore, setShowMore] = useState<boolean>(false);
|
||||||
const baseClassname = `note card ${props.className ? props.className : ''}`
|
const baseClassname = `note card ${props.className ? props.className : ""}`;
|
||||||
const [translated, setTranslated] = useState<Translation>();
|
const [translated, setTranslated] = useState<Translation>();
|
||||||
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||||
|
|
||||||
@ -67,15 +81,22 @@ export default function Note(props: NoteProps) {
|
|||||||
showHeader: true,
|
showHeader: true,
|
||||||
showTime: true,
|
showTime: true,
|
||||||
showFooter: true,
|
showFooter: true,
|
||||||
...opt
|
...opt,
|
||||||
};
|
};
|
||||||
|
|
||||||
const transformBody = useCallback(() => {
|
const transformBody = useCallback(() => {
|
||||||
let body = ev?.Content ?? "";
|
let body = ev?.Content ?? "";
|
||||||
if (deletions?.length > 0) {
|
if (deletions?.length > 0) {
|
||||||
return (<b className="error">Deleted</b>);
|
return <b className="error">Deleted</b>;
|
||||||
}
|
}
|
||||||
return <Text content={body} tags={ev.Tags} users={users || new Map()} creator={ev.PubKey}/>;
|
return (
|
||||||
|
<Text
|
||||||
|
content={body}
|
||||||
|
tags={ev.Tags}
|
||||||
|
users={users || new Map()}
|
||||||
|
creator={ev.PubKey}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}, [ev]);
|
}, [ev]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@ -99,47 +120,45 @@ export default function Note(props: NoteProps) {
|
|||||||
|
|
||||||
const maxMentions = 2;
|
const maxMentions = 2;
|
||||||
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||||
let mentions: { pk: string, name: string, link: ReactNode }[] = [];
|
let mentions: { pk: string; name: string; link: ReactNode }[] = [];
|
||||||
for (let pk of ev.Thread?.PubKeys) {
|
for (let pk of ev.Thread?.PubKeys) {
|
||||||
const u = users?.get(pk);
|
const u = users?.get(pk);
|
||||||
const npub = hexToBech32("npub", pk)
|
const npub = hexToBech32("npub", pk);
|
||||||
const shortNpub = npub.substring(0, 12);
|
const shortNpub = npub.substring(0, 12);
|
||||||
if (u) {
|
if (u) {
|
||||||
mentions.push({
|
mentions.push({
|
||||||
pk,
|
pk,
|
||||||
name: u.name ?? shortNpub,
|
name: u.name ?? shortNpub,
|
||||||
link: (
|
link: (
|
||||||
<Link to={`/p/${npub}`}>
|
<Link to={`/p/${npub}`}>{u.name ? `@${u.name}` : shortNpub}</Link>
|
||||||
{u.name ? `@${u.name}` : shortNpub}
|
),
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
mentions.push({
|
mentions.push({
|
||||||
pk,
|
pk,
|
||||||
name: shortNpub,
|
name: shortNpub,
|
||||||
link: (
|
link: <Link to={`/p/${npub}`}>{shortNpub}</Link>,
|
||||||
<Link to={`/p/${npub}`}>
|
|
||||||
{shortNpub}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mentions.sort((a, b) => a.name.startsWith("npub") ? 1 : -1);
|
mentions.sort((a, b) => (a.name.startsWith("npub") ? 1 : -1));
|
||||||
let othersLength = mentions.length - maxMentions
|
let othersLength = mentions.length - maxMentions;
|
||||||
const renderMention = (m: any, idx: number) => {
|
const renderMention = (m: any, idx: number) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{idx > 0 && ", "}
|
{idx > 0 && ", "}
|
||||||
{m.link}
|
{m.link}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
const pubMentions = mentions.length > maxMentions ? (
|
const pubMentions =
|
||||||
mentions?.slice(0, maxMentions).map(renderMention)
|
mentions.length > maxMentions
|
||||||
) : mentions?.map(renderMention);
|
? mentions?.slice(0, maxMentions).map(renderMention)
|
||||||
const others = mentions.length > maxMentions ? ` & ${othersLength} other${othersLength > 1 ? 's' : ''}` : ''
|
: mentions?.map(renderMention);
|
||||||
|
const others =
|
||||||
|
mentions.length > maxMentions
|
||||||
|
? ` & ${othersLength} other${othersLength > 1 ? "s" : ""}`
|
||||||
|
: "";
|
||||||
return (
|
return (
|
||||||
<div className="reply">
|
<div className="reply">
|
||||||
re:
|
re:
|
||||||
@ -148,68 +167,95 @@ export default function Note(props: NoteProps) {
|
|||||||
{pubMentions}
|
{pubMentions}
|
||||||
{others}
|
{others}
|
||||||
</>
|
</>
|
||||||
) : replyId && (
|
) : (
|
||||||
<Link to={eventLink(replyId)}>
|
replyId && (
|
||||||
{hexToBech32("note", replyId)?.substring(0, 12)}
|
<Link to={eventLink(replyId)}>
|
||||||
</Link>
|
{hexToBech32("note", replyId)?.substring(0, 12)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ev.Kind !== EventKind.TextNote) {
|
if (ev.Kind !== EventKind.TextNote) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4>Unknown event kind: {ev.Kind}</h4>
|
<h4>Unknown event kind: {ev.Kind}</h4>
|
||||||
<pre>
|
<pre>{JSON.stringify(ev.ToObject(), undefined, " ")}</pre>
|
||||||
{JSON.stringify(ev.ToObject(), undefined, ' ')}
|
|
||||||
</pre>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function translation() {
|
function translation() {
|
||||||
if (translated && translated.confidence > 0.5) {
|
if (translated && translated.confidence > 0.5) {
|
||||||
return <>
|
return (
|
||||||
<p className="highlight">Translated from {translated.fromLanguage}:</p>
|
<>
|
||||||
{translated.text}
|
<p className="highlight">
|
||||||
</>
|
Translated from {translated.fromLanguage}:
|
||||||
|
</p>
|
||||||
|
{translated.text}
|
||||||
|
</>
|
||||||
|
);
|
||||||
} else if (translated) {
|
} else if (translated) {
|
||||||
return <p className="highlight">Translation failed</p>
|
return <p className="highlight">Translation failed</p>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function content() {
|
function content() {
|
||||||
if (!inView) return null;
|
if (!inView) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{options.showHeader ?
|
{options.showHeader ? (
|
||||||
<div className="header flex">
|
<div className="header flex">
|
||||||
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
|
<ProfileImage
|
||||||
{options.showTime ?
|
pubkey={ev.RootPubKey}
|
||||||
<div className="info">
|
subHeader={replyTag() ?? undefined}
|
||||||
<NoteTime from={ev.CreatedAt * 1000} />
|
/>
|
||||||
</div> : null}
|
{options.showTime ? (
|
||||||
</div> : null}
|
<div className="info">
|
||||||
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
|
<NoteTime from={ev.CreatedAt * 1000} />
|
||||||
{transformBody()}
|
</div>
|
||||||
{translation()}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{extendable && !showMore && (
|
) : null}
|
||||||
<span className="expand-note mt10 flex f-center" onClick={() => setShowMore(true)}>
|
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
|
||||||
Show more
|
{transformBody()}
|
||||||
</span>
|
{translation()}
|
||||||
)}
|
</div>
|
||||||
{options.showFooter && <NoteFooter ev={ev} related={related} onTranslated={(t) => setTranslated(t)} />}
|
{extendable && !showMore && (
|
||||||
</>
|
<span
|
||||||
)
|
className="expand-note mt10 flex f-center"
|
||||||
|
onClick={() => setShowMore(true)}
|
||||||
|
>
|
||||||
|
Show more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{options.showFooter && (
|
||||||
|
<NoteFooter
|
||||||
|
ev={ev}
|
||||||
|
related={related}
|
||||||
|
onTranslated={(t) => setTranslated(t)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = (
|
const note = (
|
||||||
<div className={`${baseClassname}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`} ref={ref}>
|
<div
|
||||||
|
className={`${baseClassname}${highlight ? " active " : " "}${
|
||||||
|
extendable && !showMore ? " note-expand" : ""
|
||||||
|
}`}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
{content()}
|
{content()}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
return !ignoreModeration && isOpMuted ? <HiddenNote>{note}</HiddenNote> : note
|
return !ignoreModeration && isOpMuted ? (
|
||||||
|
<HiddenNote>{note}</HiddenNote>
|
||||||
|
) : (
|
||||||
|
note
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,25 @@
|
|||||||
.note-creator {
|
.note-creator {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
background-color: var(--note-bg);
|
background-color: var(--note-bg);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-reply {
|
.note-reply {
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-creator textarea {
|
.note-creator textarea {
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
resize: none;
|
resize: none;
|
||||||
background-color: var(--note-bg);
|
background-color: var(--note-bg);
|
||||||
border-radius: 10px 10px 0 0;
|
border-radius: 10px 10px 0 0;
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
max-width: stretch;
|
max-width: stretch;
|
||||||
min-width: stretch;
|
min-width: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-creator textarea::placeholder {
|
.note-creator textarea::placeholder {
|
||||||
@ -29,20 +29,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 520px) {
|
@media (min-width: 520px) {
|
||||||
.note-creator textarea { min-height: 210px; }
|
.note-creator textarea {
|
||||||
|
min-height: 210px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 720px) {
|
@media (min-width: 720px) {
|
||||||
.note-creator textarea { min-height: 321px; }
|
.note-creator textarea {
|
||||||
|
min-height: 321px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-creator-actions {
|
.note-creator-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-creator .attachment {
|
.note-creator .attachment {
|
||||||
@ -75,24 +79,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note-creator-actions button:not(:last-child) {
|
.note-creator-actions button:not(:last-child) {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-creator .error {
|
.note-creator .error {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
font-color: var(--error);
|
font-color: var(--error);
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-creator .btn {
|
.note-creator .btn {
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-create-button {
|
.note-create-button {
|
||||||
|
@ -11,7 +11,7 @@ import { default as NEvent } from "Nostr/Event";
|
|||||||
import useFileUpload from "Upload";
|
import useFileUpload from "Upload";
|
||||||
|
|
||||||
interface NotePreviewProps {
|
interface NotePreviewProps {
|
||||||
note: NEvent
|
note: NEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotePreview({ note }: NotePreviewProps) {
|
function NotePreview({ note }: NotePreviewProps) {
|
||||||
@ -20,32 +20,34 @@ function NotePreview({ note }: NotePreviewProps) {
|
|||||||
<ProfileImage pubkey={note.PubKey} />
|
<ProfileImage pubkey={note.PubKey} />
|
||||||
<div className="note-preview-body">
|
<div className="note-preview-body">
|
||||||
{note.Content.slice(0, 136)}
|
{note.Content.slice(0, 136)}
|
||||||
{note.Content.length > 140 && '...'}
|
{note.Content.length > 140 && "..."}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoteCreatorProps {
|
export interface NoteCreatorProps {
|
||||||
show: boolean
|
show: boolean;
|
||||||
setShow: (s: boolean) => void
|
setShow: (s: boolean) => void;
|
||||||
replyTo?: NEvent,
|
replyTo?: NEvent;
|
||||||
onSend?: Function,
|
onSend?: Function;
|
||||||
autoFocus: boolean
|
autoFocus: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NoteCreator(props: NoteCreatorProps) {
|
export function NoteCreator(props: NoteCreatorProps) {
|
||||||
const { show, setShow, replyTo, onSend, autoFocus } = props
|
const { show, setShow, replyTo, onSend, autoFocus } = props;
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
const [note, setNote] = useState<string>();
|
const [note, setNote] = useState<string>();
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [active, setActive] = useState<boolean>(false);
|
const [active, setActive] = useState<boolean>(false);
|
||||||
const uploader = useFileUpload();
|
const uploader = useFileUpload();
|
||||||
const hasErrors = (error?.length ?? 0) > 0
|
const hasErrors = (error?.length ?? 0) > 0;
|
||||||
|
|
||||||
async function sendNote() {
|
async function sendNote() {
|
||||||
if (note) {
|
if (note) {
|
||||||
let ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note);
|
let ev = replyTo
|
||||||
|
? await publisher.reply(replyTo, note)
|
||||||
|
: await publisher.note(note);
|
||||||
console.debug("Sending note: ", ev);
|
console.debug("Sending note: ", ev);
|
||||||
publisher.broadcast(ev);
|
publisher.broadcast(ev);
|
||||||
setNote("");
|
setNote("");
|
||||||
@ -63,29 +65,29 @@ export function NoteCreator(props: NoteCreatorProps) {
|
|||||||
if (file) {
|
if (file) {
|
||||||
let rx = await uploader.upload(file, file.name);
|
let rx = await uploader.upload(file, file.name);
|
||||||
if (rx.url) {
|
if (rx.url) {
|
||||||
setNote(n => `${n ? `${n}\n` : ""}${rx.url}`);
|
setNote((n) => `${n ? `${n}\n` : ""}${rx.url}`);
|
||||||
} else if (rx?.error) {
|
} else if (rx?.error) {
|
||||||
setError(rx.error);
|
setError(rx.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setError(error?.message)
|
setError(error?.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChange(ev: any) {
|
function onChange(ev: any) {
|
||||||
const { value } = ev.target
|
const { value } = ev.target;
|
||||||
setNote(value)
|
setNote(value);
|
||||||
if (value) {
|
if (value) {
|
||||||
setActive(true)
|
setActive(true);
|
||||||
} else {
|
} else {
|
||||||
setActive(false)
|
setActive(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel(ev: any) {
|
function cancel(ev: any) {
|
||||||
setShow(false)
|
setShow(false);
|
||||||
setNote("")
|
setNote("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
|
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
|
||||||
@ -96,14 +98,9 @@ export function NoteCreator(props: NoteCreatorProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{show && (
|
{show && (
|
||||||
<Modal
|
<Modal className="note-creator-modal" onClose={() => setShow(false)}>
|
||||||
className="note-creator-modal"
|
{replyTo && <NotePreview note={replyTo} />}
|
||||||
onClose={() => setShow(false)}
|
<div className={`flex note-creator ${replyTo ? "note-reply" : ""}`}>
|
||||||
>
|
|
||||||
{replyTo && (
|
|
||||||
<NotePreview note={replyTo} />
|
|
||||||
)}
|
|
||||||
<div className={`flex note-creator ${replyTo ? 'note-reply' : ''}`}>
|
|
||||||
<div className="flex f-col mr10 f-grow">
|
<div className="flex f-col mr10 f-grow">
|
||||||
<Textarea
|
<Textarea
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
@ -112,7 +109,11 @@ export function NoteCreator(props: NoteCreatorProps) {
|
|||||||
value={note}
|
value={note}
|
||||||
onFocus={() => setActive(true)}
|
onFocus={() => setActive(true)}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="attachment" onClick={(e) => attachFile()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="attachment"
|
||||||
|
onClick={(e) => attachFile()}
|
||||||
|
>
|
||||||
<Attachment />
|
<Attachment />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -123,7 +124,7 @@ export function NoteCreator(props: NoteCreatorProps) {
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onSubmit}>
|
<button type="button" onClick={onSubmit}>
|
||||||
{replyTo ? 'Reply' : 'Send'}
|
{replyTo ? "Reply" : "Send"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -1,8 +1,16 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { faTrash, faRepeat, faShareNodes, faCopy, faCommentSlash, faBan, faLanguage } from "@fortawesome/free-solid-svg-icons";
|
import {
|
||||||
|
faTrash,
|
||||||
|
faRepeat,
|
||||||
|
faShareNodes,
|
||||||
|
faCopy,
|
||||||
|
faCommentSlash,
|
||||||
|
faBan,
|
||||||
|
faLanguage,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||||
|
|
||||||
import Dislike from "Icons/Dislike";
|
import Dislike from "Icons/Dislike";
|
||||||
import Heart from "Icons/Heart";
|
import Heart from "Icons/Heart";
|
||||||
@ -25,55 +33,76 @@ import useModeration from "Hooks/useModeration";
|
|||||||
import { TranslateHost } from "Const";
|
import { TranslateHost } from "Const";
|
||||||
|
|
||||||
export interface Translation {
|
export interface Translation {
|
||||||
text: string,
|
text: string;
|
||||||
fromLanguage: string,
|
fromLanguage: string;
|
||||||
confidence: number
|
confidence: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoteFooterProps {
|
export interface NoteFooterProps {
|
||||||
related: TaggedRawEvent[],
|
related: TaggedRawEvent[];
|
||||||
ev: NEvent,
|
ev: NEvent;
|
||||||
onTranslated?: (content: Translation) => void
|
onTranslated?: (content: Translation) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NoteFooter(props: NoteFooterProps) {
|
export default function NoteFooter(props: NoteFooterProps) {
|
||||||
const { related, ev } = props;
|
const { related, ev } = props;
|
||||||
|
|
||||||
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
const login = useSelector<RootState, HexKey | undefined>(
|
||||||
|
(s) => s.login.publicKey
|
||||||
|
);
|
||||||
const { mute, block } = useModeration();
|
const { mute, block } = useModeration();
|
||||||
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
const prefs = useSelector<RootState, UserPreferences>(
|
||||||
|
(s) => s.login.preferences
|
||||||
|
);
|
||||||
const author = useUserProfile(ev.RootPubKey);
|
const author = useUserProfile(ev.RootPubKey);
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
const [reply, setReply] = useState(false);
|
const [reply, setReply] = useState(false);
|
||||||
const [tip, setTip] = useState(false);
|
const [tip, setTip] = useState(false);
|
||||||
const isMine = ev.RootPubKey === login;
|
const isMine = ev.RootPubKey === login;
|
||||||
const lang = window.navigator.language;
|
const lang = window.navigator.language;
|
||||||
const langNames = new Intl.DisplayNames([...window.navigator.languages], { type: "language" });
|
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
|
||||||
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]);
|
type: "language",
|
||||||
const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related, ev]);
|
});
|
||||||
const zaps = useMemo(() =>
|
const reactions = useMemo(
|
||||||
getReactions(related, ev.Id, EventKind.ZapReceipt).map(parseZap).filter(z => z.valid && z.zapper !== ev.PubKey),
|
() => getReactions(related, ev.Id, EventKind.Reaction),
|
||||||
|
[related, ev]
|
||||||
|
);
|
||||||
|
const reposts = useMemo(
|
||||||
|
() => getReactions(related, ev.Id, EventKind.Repost),
|
||||||
|
[related, ev]
|
||||||
|
);
|
||||||
|
const zaps = useMemo(
|
||||||
|
() =>
|
||||||
|
getReactions(related, ev.Id, EventKind.ZapReceipt)
|
||||||
|
.map(parseZap)
|
||||||
|
.filter((z) => z.valid && z.zapper !== ev.PubKey),
|
||||||
[related]
|
[related]
|
||||||
);
|
);
|
||||||
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0)
|
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
|
||||||
const didZap = zaps.some(a => a.zapper === login);
|
const didZap = zaps.some((a) => a.zapper === login);
|
||||||
const groupReactions = useMemo(() => {
|
const groupReactions = useMemo(() => {
|
||||||
return reactions?.reduce((acc, { content }) => {
|
return reactions?.reduce(
|
||||||
let r = normalizeReaction(content);
|
(acc, { content }) => {
|
||||||
const amount = acc[r] || 0
|
let r = normalizeReaction(content);
|
||||||
return { ...acc, [r]: amount + 1 }
|
const amount = acc[r] || 0;
|
||||||
}, {
|
return { ...acc, [r]: amount + 1 };
|
||||||
[Reaction.Positive]: 0,
|
},
|
||||||
[Reaction.Negative]: 0
|
{
|
||||||
});
|
[Reaction.Positive]: 0,
|
||||||
|
[Reaction.Negative]: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
}, [reactions]);
|
}, [reactions]);
|
||||||
|
|
||||||
function hasReacted(emoji: string) {
|
function hasReacted(emoji: string) {
|
||||||
return reactions?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login)
|
return reactions?.some(
|
||||||
|
({ pubkey, content }) =>
|
||||||
|
normalizeReaction(content) === emoji && pubkey === login
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasReposted() {
|
function hasReposted() {
|
||||||
return reposts.some(a => a.pubkey === login);
|
return reposts.some((a) => a.pubkey === login);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function react(content: string) {
|
async function react(content: string) {
|
||||||
@ -84,7 +113,11 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEvent() {
|
async function deleteEvent() {
|
||||||
if (window.confirm(`Are you sure you want to delete ${ev.Id.substring(0, 8)}?`)) {
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Are you sure you want to delete ${ev.Id.substring(0, 8)}?`
|
||||||
|
)
|
||||||
|
) {
|
||||||
let evDelete = await publisher.delete(ev.Id);
|
let evDelete = await publisher.delete(ev.Id);
|
||||||
publisher.broadcast(evDelete);
|
publisher.broadcast(evDelete);
|
||||||
}
|
}
|
||||||
@ -92,7 +125,10 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
|
|
||||||
async function repost() {
|
async function repost() {
|
||||||
if (!hasReposted()) {
|
if (!hasReposted()) {
|
||||||
if (!prefs.confirmReposts || window.confirm(`Are you sure you want to repost: ${ev.Id}`)) {
|
if (
|
||||||
|
!prefs.confirmReposts ||
|
||||||
|
window.confirm(`Are you sure you want to repost: ${ev.Id}`)
|
||||||
|
) {
|
||||||
let evRepost = await publisher.repost(ev);
|
let evRepost = await publisher.repost(ev);
|
||||||
publisher.broadcast(evRepost);
|
publisher.broadcast(evRepost);
|
||||||
}
|
}
|
||||||
@ -104,21 +140,31 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
if (service) {
|
if (service) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`reaction-pill ${didZap ? 'reacted' : ''}`} onClick={() => setTip(true)}>
|
<div
|
||||||
|
className={`reaction-pill ${didZap ? "reacted" : ""}`}
|
||||||
|
onClick={() => setTip(true)}
|
||||||
|
>
|
||||||
<div className="reaction-pill-icon">
|
<div className="reaction-pill-icon">
|
||||||
<Zap />
|
<Zap />
|
||||||
</div>
|
</div>
|
||||||
{zapTotal > 0 && (<div className="reaction-pill-number">{formatShort(zapTotal)}</div>)}
|
{zapTotal > 0 && (
|
||||||
|
<div className="reaction-pill-number">
|
||||||
|
{formatShort(zapTotal)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function repostIcon() {
|
function repostIcon() {
|
||||||
return (
|
return (
|
||||||
<div className={`reaction-pill ${hasReposted() ? 'reacted' : ''}`} onClick={() => repost()}>
|
<div
|
||||||
|
className={`reaction-pill ${hasReposted() ? "reacted" : ""}`}
|
||||||
|
onClick={() => repost()}
|
||||||
|
>
|
||||||
<div className="reaction-pill-icon">
|
<div className="reaction-pill-icon">
|
||||||
<FontAwesomeIcon icon={faRepeat} />
|
<FontAwesomeIcon icon={faRepeat} />
|
||||||
</div>
|
</div>
|
||||||
@ -128,7 +174,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reactionIcons() {
|
function reactionIcons() {
|
||||||
@ -137,7 +183,10 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`reaction-pill ${hasReacted('+') ? 'reacted' : ''} `} onClick={() => react("+")}>
|
<div
|
||||||
|
className={`reaction-pill ${hasReacted("+") ? "reacted" : ""} `}
|
||||||
|
onClick={() => react("+")}
|
||||||
|
>
|
||||||
<div className="reaction-pill-icon">
|
<div className="reaction-pill-icon">
|
||||||
<Heart />
|
<Heart />
|
||||||
</div>
|
</div>
|
||||||
@ -147,15 +196,17 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
</div>
|
</div>
|
||||||
{repostIcon()}
|
{repostIcon()}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function share() {
|
async function share() {
|
||||||
const url = `${window.location.protocol}//${window.location.host}/e/${hexToBech32("note", ev.Id)}`;
|
const url = `${window.location.protocol}//${
|
||||||
|
window.location.host
|
||||||
|
}/e/${hexToBech32("note", ev.Id)}`;
|
||||||
if ("share" in window.navigator) {
|
if ("share" in window.navigator) {
|
||||||
await window.navigator.share({
|
await window.navigator.share({
|
||||||
title: "Snort",
|
title: "Snort",
|
||||||
url: url
|
url: url,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
@ -170,7 +221,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
source: "auto",
|
source: "auto",
|
||||||
target: lang.split("-")[0],
|
target: lang.split("-")[0],
|
||||||
}),
|
}),
|
||||||
headers: { "Content-Type": "application/json" }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -179,7 +230,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
props.onTranslated({
|
props.onTranslated({
|
||||||
text: result.translatedText,
|
text: result.translatedText,
|
||||||
fromLanguage: langNames.of(result.detectedLanguage.language),
|
fromLanguage: langNames.of(result.detectedLanguage.language),
|
||||||
confidence: result.detectedLanguage.confidence
|
confidence: result.detectedLanguage.confidence,
|
||||||
} as Translation);
|
} as Translation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -190,7 +241,9 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyEvent() {
|
async function copyEvent() {
|
||||||
await navigator.clipboard.writeText(JSON.stringify(ev.Original, undefined, ' '));
|
await navigator.clipboard.writeText(
|
||||||
|
JSON.stringify(ev.Original, undefined, " ")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function menuItems() {
|
function menuItems() {
|
||||||
@ -200,8 +253,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
<MenuItem onClick={() => react("-")}>
|
<MenuItem onClick={() => react("-")}>
|
||||||
<Dislike />
|
<Dislike />
|
||||||
{formatShort(groupReactions[Reaction.Negative])}
|
{formatShort(groupReactions[Reaction.Negative])}
|
||||||
|
Dislike
|
||||||
Dislike
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
<MenuItem onClick={() => share()}>
|
<MenuItem onClick={() => share()}>
|
||||||
@ -237,49 +289,55 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="footer">
|
<div className="footer">
|
||||||
<div className="footer-reactions">
|
<div className="footer-reactions">
|
||||||
{tipButton()}
|
{tipButton()}
|
||||||
{reactionIcons()}
|
{reactionIcons()}
|
||||||
<div className={`reaction-pill ${reply ? 'reacted' : ''}`} onClick={(e) => setReply(s => !s)}>
|
<div
|
||||||
<div className="reaction-pill-icon">
|
className={`reaction-pill ${reply ? "reacted" : ""}`}
|
||||||
<Reply />
|
onClick={(e) => setReply((s) => !s)}
|
||||||
|
>
|
||||||
|
<div className="reaction-pill-icon">
|
||||||
|
<Reply />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Menu
|
||||||
|
menuButton={
|
||||||
|
<div className="reaction-pill">
|
||||||
|
<div className="reaction-pill-icon">
|
||||||
|
<Dots />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
menuClassName="ctx-menu"
|
||||||
|
>
|
||||||
|
{menuItems()}
|
||||||
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
<Menu menuButton={<div className="reaction-pill">
|
<NoteCreator
|
||||||
<div className="reaction-pill-icon">
|
autoFocus={true}
|
||||||
<Dots />
|
replyTo={ev}
|
||||||
</div>
|
onSend={() => setReply(false)}
|
||||||
</div>}
|
show={reply}
|
||||||
menuClassName="ctx-menu"
|
setShow={setReply}
|
||||||
>
|
/>
|
||||||
{menuItems()}
|
<SendSats
|
||||||
</Menu>
|
svc={author?.lud16 || author?.lud06}
|
||||||
|
onClose={() => setTip(false)}
|
||||||
|
show={tip}
|
||||||
|
author={author?.pubkey}
|
||||||
|
target={author?.display_name || author?.name}
|
||||||
|
note={ev.Id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="zaps-container">
|
||||||
|
<ZapsSummary zaps={zaps} />
|
||||||
</div>
|
</div>
|
||||||
<NoteCreator
|
|
||||||
autoFocus={true}
|
|
||||||
replyTo={ev}
|
|
||||||
onSend={() => setReply(false)}
|
|
||||||
show={reply}
|
|
||||||
setShow={setReply}
|
|
||||||
/>
|
|
||||||
<SendSats
|
|
||||||
svc={author?.lud16 || author?.lud06}
|
|
||||||
onClose={() => setTip(false)}
|
|
||||||
show={tip}
|
|
||||||
author={author?.pubkey}
|
|
||||||
target={author?.display_name || author?.name}
|
|
||||||
note={ev.Id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="zaps-container">
|
|
||||||
<ZapsSummary zaps={zaps} />
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,17 +2,14 @@ import "./Note.css";
|
|||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
|
|
||||||
export default function NoteGhost(props: any) {
|
export default function NoteGhost(props: any) {
|
||||||
const className = `note card ${props.className ? props.className : ''}`
|
const className = `note card ${props.className ? props.className : ""}`;
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<ProfileImage pubkey="" />
|
<ProfileImage pubkey="" />
|
||||||
</div>
|
</div>
|
||||||
<div className="body">
|
<div className="body">{props.children}</div>
|
||||||
{props.children}
|
<div className="footer"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer">
|
);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -2,22 +2,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reaction > .note {
|
.reaction > .note {
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction > .header {
|
.reaction > .header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction > .header .reply {
|
.reaction > .header .reply {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction > .header > .info {
|
.reaction > .header > .info {
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--font-secondary-color);
|
color: var(--font-secondary-color);
|
||||||
margin-right: 24px;
|
margin-right: 24px;
|
||||||
}
|
}
|
||||||
|
@ -12,62 +12,72 @@ import { RawEvent, TaggedRawEvent } from "Nostr";
|
|||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
|
|
||||||
export interface NoteReactionProps {
|
export interface NoteReactionProps {
|
||||||
data?: TaggedRawEvent,
|
data?: TaggedRawEvent;
|
||||||
["data-ev"]?: NEvent,
|
["data-ev"]?: NEvent;
|
||||||
root?: TaggedRawEvent
|
root?: TaggedRawEvent;
|
||||||
}
|
}
|
||||||
export default function NoteReaction(props: NoteReactionProps) {
|
export default function NoteReaction(props: NoteReactionProps) {
|
||||||
const { ["data-ev"]: dataEv, data } = props;
|
const { ["data-ev"]: dataEv, data } = props;
|
||||||
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv])
|
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]);
|
||||||
const { isMuted } = useModeration();
|
const { isMuted } = useModeration();
|
||||||
|
|
||||||
const refEvent = useMemo(() => {
|
const refEvent = useMemo(() => {
|
||||||
if (ev) {
|
if (ev) {
|
||||||
let eTags = ev.Tags.filter(a => a.Key === "e");
|
let eTags = ev.Tags.filter((a) => a.Key === "e");
|
||||||
if (eTags.length > 0) {
|
if (eTags.length > 0) {
|
||||||
return eTags[0].Event;
|
return eTags[0].Event;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [ev]);
|
|
||||||
|
|
||||||
if (ev.Kind !== EventKind.Reaction && ev.Kind !== EventKind.Repost) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}, [ev]);
|
||||||
|
|
||||||
/**
|
if (ev.Kind !== EventKind.Reaction && ev.Kind !== EventKind.Repost) {
|
||||||
* Some clients embed the reposted note in the content
|
return null;
|
||||||
*/
|
}
|
||||||
function extractRoot() {
|
|
||||||
if (ev?.Kind === EventKind.Repost && ev.Content.length > 0 && ev.Content !== "#[0]") {
|
/**
|
||||||
try {
|
* Some clients embed the reposted note in the content
|
||||||
let r: RawEvent = JSON.parse(ev.Content);
|
*/
|
||||||
return r as TaggedRawEvent;
|
function extractRoot() {
|
||||||
} catch (e) {
|
if (
|
||||||
console.error("Could not load reposted content", e);
|
ev?.Kind === EventKind.Repost &&
|
||||||
}
|
ev.Content.length > 0 &&
|
||||||
}
|
ev.Content !== "#[0]"
|
||||||
return props.root;
|
) {
|
||||||
|
try {
|
||||||
|
let r: RawEvent = JSON.parse(ev.Content);
|
||||||
|
return r as TaggedRawEvent;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Could not load reposted content", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return props.root;
|
||||||
|
}
|
||||||
|
|
||||||
const root = extractRoot();
|
const root = extractRoot();
|
||||||
const isOpMuted = root && isMuted(root.pubkey)
|
const isOpMuted = root && isMuted(root.pubkey);
|
||||||
const opt = {
|
const opt = {
|
||||||
showHeader: ev?.Kind === EventKind.Repost,
|
showHeader: ev?.Kind === EventKind.Repost,
|
||||||
showFooter: false,
|
showFooter: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
return isOpMuted ? null : (
|
return isOpMuted ? null : (
|
||||||
<div className="reaction">
|
<div className="reaction">
|
||||||
<div className="header flex">
|
<div className="header flex">
|
||||||
<ProfileImage pubkey={ev.RootPubKey} />
|
<ProfileImage pubkey={ev.RootPubKey} />
|
||||||
<div className="info">
|
<div className="info">
|
||||||
<NoteTime from={ev.CreatedAt * 1000} />
|
<NoteTime from={ev.CreatedAt * 1000} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{root ? <Note data={root} options={opt} related={[]}/> : null}
|
|
||||||
{!root && refEvent ? <p><Link to={eventLink(refEvent)}>#{hexToBech32("note", refEvent).substring(0, 12)}</Link></p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
|
||||||
|
{root ? <Note data={root} options={opt} related={[]} /> : null}
|
||||||
|
{!root && refEvent ? (
|
||||||
|
<p>
|
||||||
|
<Link to={eventLink(refEvent)}>
|
||||||
|
#{hexToBech32("note", refEvent).substring(0, 12)}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -5,48 +5,63 @@ const HourInMs = MinuteInMs * 60;
|
|||||||
const DayInMs = HourInMs * 24;
|
const DayInMs = HourInMs * 24;
|
||||||
|
|
||||||
export interface NoteTimeProps {
|
export interface NoteTimeProps {
|
||||||
from: number,
|
from: number;
|
||||||
fallback?: string
|
fallback?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NoteTime(props: NoteTimeProps) {
|
export default function NoteTime(props: NoteTimeProps) {
|
||||||
const [time, setTime] = useState<string>();
|
const [time, setTime] = useState<string>();
|
||||||
const { from, fallback } = props;
|
const { from, fallback } = props;
|
||||||
const absoluteTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'long'}).format(from);
|
const absoluteTime = new Intl.DateTimeFormat(undefined, {
|
||||||
const isoDate = new Date(from).toISOString();
|
dateStyle: "medium",
|
||||||
|
timeStyle: "long",
|
||||||
|
}).format(from);
|
||||||
|
const isoDate = new Date(from).toISOString();
|
||||||
|
|
||||||
function calcTime() {
|
function calcTime() {
|
||||||
let fromDate = new Date(from);
|
let fromDate = new Date(from);
|
||||||
let ago = (new Date().getTime()) - from;
|
let ago = new Date().getTime() - from;
|
||||||
let absAgo = Math.abs(ago);
|
let absAgo = Math.abs(ago);
|
||||||
if (absAgo > DayInMs) {
|
if (absAgo > DayInMs) {
|
||||||
return fromDate.toLocaleDateString(undefined, { year: "2-digit", month: "short", day: "2-digit", weekday: "short" });
|
return fromDate.toLocaleDateString(undefined, {
|
||||||
} else if (absAgo > HourInMs) {
|
year: "2-digit",
|
||||||
return `${fromDate.getHours().toString().padStart(2, '0')}:${fromDate.getMinutes().toString().padStart(2, '0')}`;
|
month: "short",
|
||||||
} else if (absAgo < MinuteInMs) {
|
day: "2-digit",
|
||||||
return fallback
|
weekday: "short",
|
||||||
} else {
|
});
|
||||||
let mins = Math.floor(absAgo / MinuteInMs);
|
} else if (absAgo > HourInMs) {
|
||||||
if(ago < 0) {
|
return `${fromDate.getHours().toString().padStart(2, "0")}:${fromDate
|
||||||
return `in ${mins}m`;
|
.getMinutes()
|
||||||
}
|
.toString()
|
||||||
return `${mins}m`;
|
.padStart(2, "0")}`;
|
||||||
}
|
} else if (absAgo < MinuteInMs) {
|
||||||
|
return fallback;
|
||||||
|
} else {
|
||||||
|
let mins = Math.floor(absAgo / MinuteInMs);
|
||||||
|
if (ago < 0) {
|
||||||
|
return `in ${mins}m`;
|
||||||
|
}
|
||||||
|
return `${mins}m`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTime(calcTime());
|
setTime(calcTime());
|
||||||
let t = setInterval(() => {
|
let t = setInterval(() => {
|
||||||
setTime(s => {
|
setTime((s) => {
|
||||||
let newTime = calcTime();
|
let newTime = calcTime();
|
||||||
if (newTime !== s) {
|
if (newTime !== s) {
|
||||||
return newTime;
|
return newTime;
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
})
|
});
|
||||||
}, MinuteInMs);
|
}, MinuteInMs);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [from]);
|
}, [from]);
|
||||||
|
|
||||||
return <time dateTime={isoDate} title={absoluteTime}>{time}</time>
|
return (
|
||||||
|
<time dateTime={isoDate} title={absoluteTime}>
|
||||||
|
{time}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
.nts {
|
.nts {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-to-self {
|
.note-to-self {
|
||||||
@ -13,20 +13,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nts .avatar {
|
.nts .avatar {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
.nts .avatar.clickable {
|
.nts .avatar.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nts a {
|
.nts a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nts .name {
|
.nts .name {
|
||||||
margin-top: -.2em;
|
margin-top: -0.2em;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@ -34,5 +34,5 @@
|
|||||||
|
|
||||||
.nts .nip05 {
|
.nts .nip05 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-top: -.2em;
|
margin-top: -0.2em;
|
||||||
}
|
}
|
||||||
|
@ -2,55 +2,63 @@ import "./NoteToSelf.css";
|
|||||||
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons"
|
import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Feed/ProfileFeed";
|
||||||
import Nip05 from "Element/Nip05";
|
import Nip05 from "Element/Nip05";
|
||||||
import { profileLink } from "Util";
|
import { profileLink } from "Util";
|
||||||
|
|
||||||
export interface NoteToSelfProps {
|
export interface NoteToSelfProps {
|
||||||
pubkey: string,
|
pubkey: string;
|
||||||
clickable?: boolean
|
clickable?: boolean;
|
||||||
className?: string,
|
className?: string;
|
||||||
link?: string
|
link?: string;
|
||||||
};
|
|
||||||
|
|
||||||
function NoteLabel({pubkey, link}:NoteToSelfProps) {
|
|
||||||
const user = useUserProfile(pubkey);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
Note to Self <FontAwesomeIcon icon={faCertificate} size="xs" />
|
|
||||||
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NoteToSelf({ pubkey, clickable, className, link }: NoteToSelfProps) {
|
function NoteLabel({ pubkey, link }: NoteToSelfProps) {
|
||||||
const navigate = useNavigate();
|
const user = useUserProfile(pubkey);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
Note to Self <FontAwesomeIcon icon={faCertificate} size="xs" />
|
||||||
|
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const clickLink = () => {
|
export default function NoteToSelf({
|
||||||
if(clickable) {
|
pubkey,
|
||||||
navigate(link ?? profileLink(pubkey))
|
clickable,
|
||||||
}
|
className,
|
||||||
|
link,
|
||||||
|
}: NoteToSelfProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const clickLink = () => {
|
||||||
|
if (clickable) {
|
||||||
|
navigate(link ?? profileLink(pubkey));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`nts${className ? ` ${className}` : ""}`}>
|
<div className={`nts${className ? ` ${className}` : ""}`}>
|
||||||
<div className="avatar-wrapper">
|
<div className="avatar-wrapper">
|
||||||
<div className={`avatar${clickable ? " clickable" : ""}`}>
|
<div className={`avatar${clickable ? " clickable" : ""}`}>
|
||||||
<FontAwesomeIcon onClick={clickLink} className="note-to-self" icon={faBook} size="2xl" />
|
<FontAwesomeIcon
|
||||||
</div>
|
onClick={clickLink}
|
||||||
</div>
|
className="note-to-self"
|
||||||
<div className="f-grow">
|
icon={faBook}
|
||||||
<div className="name">
|
size="2xl"
|
||||||
{clickable && (
|
/>
|
||||||
<Link to={link ?? profileLink(pubkey)}>
|
|
||||||
<NoteLabel pubkey={pubkey} />
|
|
||||||
</Link>
|
|
||||||
) || (
|
|
||||||
<NoteLabel pubkey={pubkey} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
|
<div className="f-grow">
|
||||||
|
<div className="name">
|
||||||
|
{(clickable && (
|
||||||
|
<Link to={link ?? profileLink(pubkey)}>
|
||||||
|
<NoteLabel pubkey={pubkey} />
|
||||||
|
</Link>
|
||||||
|
)) || <NoteLabel pubkey={pubkey} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,9 +10,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pfp .avatar {
|
.pfp .avatar {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pfp a {
|
.pfp a {
|
||||||
|
@ -4,55 +4,69 @@ import { useMemo } from "react";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Feed/ProfileFeed";
|
||||||
import { hexToBech32, profileLink } from "Util";
|
import { hexToBech32, profileLink } from "Util";
|
||||||
import Avatar from "Element/Avatar"
|
import Avatar from "Element/Avatar";
|
||||||
import Nip05 from "Element/Nip05";
|
import Nip05 from "Element/Nip05";
|
||||||
import { HexKey } from "Nostr";
|
import { HexKey } from "Nostr";
|
||||||
import { MetadataCache } from "State/Users";
|
import { MetadataCache } from "State/Users";
|
||||||
|
|
||||||
export interface ProfileImageProps {
|
export interface ProfileImageProps {
|
||||||
pubkey: HexKey,
|
pubkey: HexKey;
|
||||||
subHeader?: JSX.Element,
|
subHeader?: JSX.Element;
|
||||||
showUsername?: boolean,
|
showUsername?: boolean;
|
||||||
className?: string,
|
className?: string;
|
||||||
link?: string
|
link?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }: ProfileImageProps) {
|
export default function ProfileImage({
|
||||||
const navigate = useNavigate();
|
pubkey,
|
||||||
const user = useUserProfile(pubkey);
|
subHeader,
|
||||||
|
showUsername = true,
|
||||||
|
className,
|
||||||
|
link,
|
||||||
|
}: ProfileImageProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const user = useUserProfile(pubkey);
|
||||||
|
|
||||||
const name = useMemo(() => {
|
const name = useMemo(() => {
|
||||||
return getDisplayName(user, pubkey);
|
return getDisplayName(user, pubkey);
|
||||||
}, [user, pubkey]);
|
}, [user, pubkey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`pfp${className ? ` ${className}` : ""}`}>
|
<div className={`pfp${className ? ` ${className}` : ""}`}>
|
||||||
<div className="avatar-wrapper">
|
<div className="avatar-wrapper">
|
||||||
<Avatar user={user} onClick={() => navigate(link ?? profileLink(pubkey))} />
|
<Avatar
|
||||||
</div>
|
user={user}
|
||||||
{showUsername && (
|
onClick={() => navigate(link ?? profileLink(pubkey))}
|
||||||
<div className="profile-name f-grow">
|
/>
|
||||||
<div className="username">
|
</div>
|
||||||
<Link className="display-name" key={pubkey} to={link ?? profileLink(pubkey)}>
|
{showUsername && (
|
||||||
{name}
|
<div className="profile-name f-grow">
|
||||||
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
<div className="username">
|
||||||
</Link>
|
<Link
|
||||||
</div>
|
className="display-name"
|
||||||
<div className="subheader">
|
key={pubkey}
|
||||||
{subHeader}
|
to={link ?? profileLink(pubkey)}
|
||||||
</div>
|
>
|
||||||
</div>
|
{name}
|
||||||
)}
|
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="subheader">{subHeader}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDisplayName(user: MetadataCache | undefined, pubkey: HexKey) {
|
export function getDisplayName(
|
||||||
let name = hexToBech32("npub", pubkey).substring(0, 12);
|
user: MetadataCache | undefined,
|
||||||
if ((user?.display_name?.length ?? 0) > 0) {
|
pubkey: HexKey
|
||||||
name = user!.display_name!;
|
) {
|
||||||
} else if ((user?.name?.length ?? 0) > 0) {
|
let name = hexToBech32("npub", pubkey).substring(0, 12);
|
||||||
name = user!.name!;
|
if ((user?.display_name?.length ?? 0) > 0) {
|
||||||
}
|
name = user!.display_name!;
|
||||||
return name;
|
} else if ((user?.name?.length ?? 0) > 0) {
|
||||||
|
name = user!.name!;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
.profile-preview {
|
.profile-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-preview .pfp {
|
.profile-preview .pfp {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-preview .about {
|
.profile-preview .about {
|
||||||
font-size: small;
|
font-size: small;
|
||||||
color: var(--gray-light);
|
color: var(--gray-light);
|
||||||
}
|
}
|
||||||
|
@ -8,35 +8,46 @@ import { HexKey } from "Nostr";
|
|||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
export interface ProfilePreviewProps {
|
export interface ProfilePreviewProps {
|
||||||
pubkey: HexKey,
|
pubkey: HexKey;
|
||||||
options?: {
|
options?: {
|
||||||
about?: boolean
|
about?: boolean;
|
||||||
},
|
};
|
||||||
actions?: ReactNode,
|
actions?: ReactNode;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
export default function ProfilePreview(props: ProfilePreviewProps) {
|
export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||||
const pubkey = props.pubkey;
|
const pubkey = props.pubkey;
|
||||||
const user = useUserProfile(pubkey);
|
const user = useUserProfile(pubkey);
|
||||||
const { ref, inView } = useInView({ triggerOnce: true });
|
const { ref, inView } = useInView({ triggerOnce: true });
|
||||||
const options = {
|
const options = {
|
||||||
about: true,
|
about: true,
|
||||||
...props.options
|
...props.options,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref}>
|
<div
|
||||||
{inView && <>
|
className={`profile-preview${
|
||||||
<ProfileImage pubkey={pubkey} subHeader=
|
props.className ? ` ${props.className}` : ""
|
||||||
{options.about ? <div className="f-ellipsis about">
|
}`}
|
||||||
{user?.about}
|
ref={ref}
|
||||||
</div> : undefined} />
|
>
|
||||||
{props.actions ?? (
|
{inView && (
|
||||||
<div className="follow-button-container">
|
<>
|
||||||
<FollowButton pubkey={pubkey} />
|
<ProfileImage
|
||||||
</div>
|
pubkey={pubkey}
|
||||||
)}
|
subHeader={
|
||||||
</>}
|
options.about ? (
|
||||||
</div>
|
<div className="f-ellipsis about">{user?.about}</div>
|
||||||
)
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{props.actions ?? (
|
||||||
|
<div className="follow-button-container">
|
||||||
|
<FollowButton pubkey={pubkey} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,17 +2,17 @@ import useImgProxy from "Feed/ImgProxy";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export const ProxyImg = (props: any) => {
|
export const ProxyImg = (props: any) => {
|
||||||
const { src, size, ...rest } = props;
|
const { src, size, ...rest } = props;
|
||||||
const [url, setUrl] = useState<string>();
|
const [url, setUrl] = useState<string>();
|
||||||
const { proxy } = useImgProxy();
|
const { proxy } = useImgProxy();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
proxy(src, size)
|
proxy(src, size)
|
||||||
.then(a => setUrl(a))
|
.then((a) => setUrl(a))
|
||||||
.catch(console.warn);
|
.catch(console.warn);
|
||||||
}
|
}
|
||||||
}, [src]);
|
}, [src]);
|
||||||
|
|
||||||
return <img src={url} {...rest} />
|
return <img src={url} {...rest} />;
|
||||||
}
|
};
|
||||||
|
@ -2,51 +2,54 @@ import QRCodeStyling from "qr-code-styling";
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
export interface QrCodeProps {
|
export interface QrCodeProps {
|
||||||
data?: string,
|
data?: string;
|
||||||
link?: string,
|
link?: string;
|
||||||
avatar?: string,
|
avatar?: string;
|
||||||
height?: number,
|
height?: number;
|
||||||
width?: number,
|
width?: number;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QrCode(props: QrCodeProps) {
|
export default function QrCode(props: QrCodeProps) {
|
||||||
const qrRef = useRef<HTMLDivElement>(null);
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
|
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
|
||||||
let qr = new QRCodeStyling({
|
let qr = new QRCodeStyling({
|
||||||
width: props.width || 256,
|
width: props.width || 256,
|
||||||
height: props.height || 256,
|
height: props.height || 256,
|
||||||
data: props.data,
|
data: props.data,
|
||||||
margin: 5,
|
margin: 5,
|
||||||
type: 'canvas',
|
type: "canvas",
|
||||||
image: props.avatar,
|
image: props.avatar,
|
||||||
dotsOptions: {
|
dotsOptions: {
|
||||||
type: 'rounded'
|
type: "rounded",
|
||||||
},
|
},
|
||||||
cornersSquareOptions: {
|
cornersSquareOptions: {
|
||||||
type: 'extra-rounded'
|
type: "extra-rounded",
|
||||||
},
|
},
|
||||||
imageOptions: {
|
imageOptions: {
|
||||||
crossOrigin: "anonymous"
|
crossOrigin: "anonymous",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
qrRef.current.innerHTML = "";
|
qrRef.current.innerHTML = "";
|
||||||
qr.append(qrRef.current);
|
qr.append(qrRef.current);
|
||||||
if (props.link) {
|
if (props.link) {
|
||||||
qrRef.current.onclick = function (e) {
|
qrRef.current.onclick = function (e) {
|
||||||
let elm = document.createElement("a");
|
let elm = document.createElement("a");
|
||||||
elm.href = props.link!;
|
elm.href = props.link!;
|
||||||
elm.click();
|
elm.click();
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
} else if (qrRef.current) {
|
} else if (qrRef.current) {
|
||||||
qrRef.current.innerHTML = "";
|
qrRef.current.innerHTML = "";
|
||||||
}
|
}
|
||||||
}, [props.data, props.link]);
|
}, [props.data, props.link]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`qr${props.className ? ` ${props.className}` : ""}`} ref={qrRef}></div>
|
<div
|
||||||
);
|
className={`qr${props.className ? ` ${props.className}` : ""}`}
|
||||||
}
|
ref={qrRef}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -1,25 +1,25 @@
|
|||||||
.relay {
|
.relay {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
background-color: var(--gray-secondary);
|
background-color: var(--gray-secondary);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: min-content auto;
|
grid-template-columns: min-content auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
}
|
}
|
||||||
|
|
||||||
.relay > div {
|
.relay > div {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.relay-extra {
|
.relay-extra {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
margin: 0 5px;
|
margin: 0 5px;
|
||||||
background-color: var(--gray-tertiary);
|
background-color: var(--gray-tertiary);
|
||||||
border-radius: 0 0 5px 5px;
|
border-radius: 0 0 5px 5px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
@ -35,7 +35,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.checkmark {
|
.checkmark {
|
||||||
margin-left: .5em;
|
margin-left: 0.5em;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
background-color: var(--gray);
|
background-color: var(--gray);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
import "./Relay.css"
|
import "./Relay.css";
|
||||||
|
|
||||||
import { faPlug, faSquareCheck, faSquareXmark, faWifi, faPlugCircleXmark, faGear } from "@fortawesome/free-solid-svg-icons";
|
import {
|
||||||
|
faPlug,
|
||||||
|
faSquareCheck,
|
||||||
|
faSquareXmark,
|
||||||
|
faWifi,
|
||||||
|
faPlugCircleXmark,
|
||||||
|
faGear,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import useRelayState from "Feed/RelayState";
|
import useRelayState from "Feed/RelayState";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@ -11,65 +18,92 @@ import { RelaySettings } from "Nostr/Connection";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export interface RelayProps {
|
export interface RelayProps {
|
||||||
addr: string
|
addr: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Relay(props: RelayProps) {
|
export default function Relay(props: RelayProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
|
const allRelaySettings = useSelector<
|
||||||
const relaySettings = allRelaySettings[props.addr];
|
RootState,
|
||||||
const state = useRelayState(props.addr);
|
Record<string, RelaySettings>
|
||||||
const name = useMemo(() => new URL(props.addr).host, [props.addr]);
|
>((s) => s.login.relays);
|
||||||
|
const relaySettings = allRelaySettings[props.addr];
|
||||||
|
const state = useRelayState(props.addr);
|
||||||
|
const name = useMemo(() => new URL(props.addr).host, [props.addr]);
|
||||||
|
|
||||||
function configure(o: RelaySettings) {
|
function configure(o: RelaySettings) {
|
||||||
dispatch(setRelays({
|
dispatch(
|
||||||
relays: {
|
setRelays({
|
||||||
...allRelaySettings,
|
relays: {
|
||||||
[props.addr]: o
|
...allRelaySettings,
|
||||||
},
|
[props.addr]: o,
|
||||||
createdAt: Math.floor(new Date().getTime() / 1000)
|
},
|
||||||
}));
|
createdAt: Math.floor(new Date().getTime() / 1000),
|
||||||
}
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let latency = Math.floor(state?.avgLatency ?? 0);
|
||||||
let latency = Math.floor(state?.avgLatency ?? 0);
|
return (
|
||||||
return (
|
<>
|
||||||
<>
|
<div className={`relay w-max`}>
|
||||||
<div className={`relay w-max`}>
|
<div className={`flex ${state?.connected ? "bg-success" : "bg-error"}`}>
|
||||||
<div className={`flex ${state?.connected ? "bg-success" : "bg-error"}`}>
|
<FontAwesomeIcon icon={faPlug} />
|
||||||
<FontAwesomeIcon icon={faPlug} />
|
</div>
|
||||||
</div>
|
<div className="f-grow f-col">
|
||||||
<div className="f-grow f-col">
|
<div className="flex mb10">
|
||||||
<div className="flex mb10">
|
<b className="f-2">{name}</b>
|
||||||
<b className="f-2">{name}</b>
|
<div className="f-1">
|
||||||
<div className="f-1">
|
Write
|
||||||
Write
|
<span
|
||||||
<span className="checkmark" onClick={() => configure({ write: !relaySettings.write, read: relaySettings.read })}>
|
className="checkmark"
|
||||||
<FontAwesomeIcon icon={relaySettings.write ? faSquareCheck : faSquareXmark} />
|
onClick={() =>
|
||||||
</span>
|
configure({
|
||||||
</div>
|
write: !relaySettings.write,
|
||||||
<div className="f-1">
|
read: relaySettings.read,
|
||||||
Read
|
})
|
||||||
<span className="checkmark" onClick={() => configure({ write: relaySettings.write, read: !relaySettings.read })}>
|
}
|
||||||
<FontAwesomeIcon icon={relaySettings.read ? faSquareCheck : faSquareXmark} />
|
>
|
||||||
</span>
|
<FontAwesomeIcon
|
||||||
</div>
|
icon={relaySettings.write ? faSquareCheck : faSquareXmark}
|
||||||
</div>
|
/>
|
||||||
<div className="flex">
|
</span>
|
||||||
<div className="f-grow">
|
|
||||||
<FontAwesomeIcon icon={faWifi} /> {latency > 2000 ? `${(latency / 1000).toFixed(0)} secs` : `${latency.toLocaleString()} ms`}
|
|
||||||
|
|
||||||
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="icon-btn" onClick={() => navigate(state!.id)}>
|
|
||||||
<FontAwesomeIcon icon={faGear} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="f-1">
|
||||||
)
|
Read
|
||||||
|
<span
|
||||||
|
className="checkmark"
|
||||||
|
onClick={() =>
|
||||||
|
configure({
|
||||||
|
write: relaySettings.write,
|
||||||
|
read: !relaySettings.read,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={relaySettings.read ? faSquareCheck : faSquareXmark}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="f-grow">
|
||||||
|
<FontAwesomeIcon icon={faWifi} />{" "}
|
||||||
|
{latency > 2000
|
||||||
|
? `${(latency / 1000).toFixed(0)} secs`
|
||||||
|
: `${latency.toLocaleString()} ms`}
|
||||||
|
|
||||||
|
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="icon-btn" onClick={() => navigate(state!.id)}>
|
||||||
|
<FontAwesomeIcon icon={faGear} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
.lnurl-tip {
|
.lnurl-tip {
|
||||||
padding: 24px 32px;
|
padding: 24px 32px;
|
||||||
background-color: #1B1B1B;
|
background-color: #1b1b1b;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@ -28,7 +28,7 @@
|
|||||||
.lnurl-tip h3 {
|
.lnurl-tip h3 {
|
||||||
color: var(--font-secondary-color);
|
color: var(--font-secondary-color);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
letter-spacing: .11em;
|
letter-spacing: 0.11em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 13px;
|
line-height: 13px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@ -62,9 +62,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lnurl-tip .btn {
|
.lnurl-tip .btn {
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
width: 210px;
|
width: 210px;
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lnurl-tip .btn:hover {
|
.lnurl-tip .btn:hover {
|
||||||
@ -86,7 +86,7 @@
|
|||||||
.sat-amount {
|
.sat-amount {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background-color: #2A2A2A;
|
background-color: #2a2a2a;
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
@ -115,21 +115,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lnurl-tip .invoice {
|
.lnurl-tip .invoice {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lnurl-tip .invoice .actions {
|
.lnurl-tip .invoice .actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lnurl-tip .invoice .actions .copy-action {
|
.lnurl-tip .invoice .actions .copy-action {
|
||||||
margin: 10px auto;
|
margin: 10px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lnurl-tip .invoice .actions .wallet-action {
|
.lnurl-tip .invoice .actions .wallet-action {
|
||||||
|
@ -16,307 +16,318 @@ import useWebln from "Hooks/useWebln";
|
|||||||
import useHorizontalScroll from "Hooks/useHorizontalScroll";
|
import useHorizontalScroll from "Hooks/useHorizontalScroll";
|
||||||
|
|
||||||
interface LNURLService {
|
interface LNURLService {
|
||||||
nostrPubkey?: HexKey
|
nostrPubkey?: HexKey;
|
||||||
minSendable?: number,
|
minSendable?: number;
|
||||||
maxSendable?: number,
|
maxSendable?: number;
|
||||||
metadata: string,
|
metadata: string;
|
||||||
callback: string,
|
callback: string;
|
||||||
commentAllowed?: number
|
commentAllowed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LNURLInvoice {
|
interface LNURLInvoice {
|
||||||
pr: string,
|
pr: string;
|
||||||
successAction?: LNURLSuccessAction
|
successAction?: LNURLSuccessAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LNURLSuccessAction {
|
interface LNURLSuccessAction {
|
||||||
description?: string,
|
description?: string;
|
||||||
url?: string
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LNURLTipProps {
|
export interface LNURLTipProps {
|
||||||
onClose?: () => void,
|
onClose?: () => void;
|
||||||
svc?: string,
|
svc?: string;
|
||||||
show?: boolean,
|
show?: boolean;
|
||||||
invoice?: string, // shortcut to invoice qr tab
|
invoice?: string; // shortcut to invoice qr tab
|
||||||
title?: string,
|
title?: string;
|
||||||
notice?: string
|
notice?: string;
|
||||||
target?: string
|
target?: string;
|
||||||
note?: HexKey
|
note?: HexKey;
|
||||||
author?: HexKey
|
author?: HexKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LNURLTip(props: LNURLTipProps) {
|
export default function LNURLTip(props: LNURLTipProps) {
|
||||||
const onClose = props.onClose || (() => { });
|
const onClose = props.onClose || (() => {});
|
||||||
const service = props.svc;
|
const service = props.svc;
|
||||||
const show = props.show || false;
|
const show = props.show || false;
|
||||||
const { note, author, target } = props
|
const { note, author, target } = props;
|
||||||
const amounts = [500, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000];
|
const amounts = [
|
||||||
const emojis: Record<number, string> = {
|
500, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000,
|
||||||
1_000: "👍",
|
];
|
||||||
5_000: "💜",
|
const emojis: Record<number, string> = {
|
||||||
10_000: "😍",
|
1_000: "👍",
|
||||||
20_000: "🤩",
|
5_000: "💜",
|
||||||
50_000: "🔥",
|
10_000: "😍",
|
||||||
100_000: "🚀",
|
20_000: "🤩",
|
||||||
1_000_000: "🤯",
|
50_000: "🔥",
|
||||||
}
|
100_000: "🚀",
|
||||||
const [payService, setPayService] = useState<LNURLService>();
|
1_000_000: "🤯",
|
||||||
const [amount, setAmount] = useState<number>(500);
|
};
|
||||||
const [customAmount, setCustomAmount] = useState<number>();
|
const [payService, setPayService] = useState<LNURLService>();
|
||||||
const [invoice, setInvoice] = useState<LNURLInvoice>();
|
const [amount, setAmount] = useState<number>(500);
|
||||||
const [comment, setComment] = useState<string>();
|
const [customAmount, setCustomAmount] = useState<number>();
|
||||||
const [error, setError] = useState<string>();
|
const [invoice, setInvoice] = useState<LNURLInvoice>();
|
||||||
const [success, setSuccess] = useState<LNURLSuccessAction>();
|
const [comment, setComment] = useState<string>();
|
||||||
const webln = useWebln(show);
|
const [error, setError] = useState<string>();
|
||||||
const publisher = useEventPublisher();
|
const [success, setSuccess] = useState<LNURLSuccessAction>();
|
||||||
const horizontalScroll = useHorizontalScroll();
|
const webln = useWebln(show);
|
||||||
|
const publisher = useEventPublisher();
|
||||||
|
const horizontalScroll = useHorizontalScroll();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (show && !props.invoice) {
|
if (show && !props.invoice) {
|
||||||
loadService()
|
loadService()
|
||||||
.then(a => setPayService(a!))
|
.then((a) => setPayService(a!))
|
||||||
.catch(() => setError("Failed to load LNURL service"));
|
.catch(() => setError("Failed to load LNURL service"));
|
||||||
|
} else {
|
||||||
|
setPayService(undefined);
|
||||||
|
setError(undefined);
|
||||||
|
setInvoice(props.invoice ? { pr: props.invoice } : undefined);
|
||||||
|
setAmount(500);
|
||||||
|
setComment(undefined);
|
||||||
|
setSuccess(undefined);
|
||||||
|
}
|
||||||
|
}, [show, service]);
|
||||||
|
|
||||||
|
const serviceAmounts = useMemo(() => {
|
||||||
|
if (payService) {
|
||||||
|
let min = (payService.minSendable ?? 0) / 1000;
|
||||||
|
let max = (payService.maxSendable ?? 0) / 1000;
|
||||||
|
return amounts.filter((a) => a >= min && a <= max);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [payService]);
|
||||||
|
|
||||||
|
const metadata = useMemo(() => {
|
||||||
|
if (payService) {
|
||||||
|
let meta: string[][] = JSON.parse(payService.metadata);
|
||||||
|
let desc = meta.find((a) => a[0] === "text/plain");
|
||||||
|
let image = meta.find((a) => a[0] === "image/png;base64");
|
||||||
|
return {
|
||||||
|
description: desc ? desc[1] : null,
|
||||||
|
image: image ? image[1] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [payService]);
|
||||||
|
|
||||||
|
const selectAmount = (a: number) => {
|
||||||
|
setError(undefined);
|
||||||
|
setInvoice(undefined);
|
||||||
|
setAmount(a);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string) {
|
||||||
|
let rsp = await fetch(url);
|
||||||
|
if (rsp.ok) {
|
||||||
|
let data: T = await rsp.json();
|
||||||
|
console.log(data);
|
||||||
|
setError(undefined);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadService(): Promise<LNURLService | null> {
|
||||||
|
if (service) {
|
||||||
|
let isServiceUrl = service.toLowerCase().startsWith("lnurl");
|
||||||
|
if (isServiceUrl) {
|
||||||
|
let serviceUrl = bech32ToText(service);
|
||||||
|
return await fetchJson(serviceUrl);
|
||||||
|
} else {
|
||||||
|
let ns = service.split("@");
|
||||||
|
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvoice() {
|
||||||
|
if (!amount || !payService) return null;
|
||||||
|
let url = "";
|
||||||
|
const amountParam = `amount=${Math.floor(amount * 1000)}`;
|
||||||
|
const commentParam = comment
|
||||||
|
? `&comment=${encodeURIComponent(comment)}`
|
||||||
|
: "";
|
||||||
|
if (payService.nostrPubkey && author) {
|
||||||
|
const ev = await publisher.zap(author, note, comment);
|
||||||
|
const nostrParam =
|
||||||
|
ev && `&nostr=${encodeURIComponent(JSON.stringify(ev.ToObject()))}`;
|
||||||
|
url = `${payService.callback}?${amountParam}${commentParam}${nostrParam}`;
|
||||||
|
} else {
|
||||||
|
url = `${payService.callback}?${amountParam}${commentParam}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let rsp = await fetch(url);
|
||||||
|
if (rsp.ok) {
|
||||||
|
let data = await rsp.json();
|
||||||
|
console.log(data);
|
||||||
|
if (data.status === "ERROR") {
|
||||||
|
setError(data.reason);
|
||||||
} else {
|
} else {
|
||||||
setPayService(undefined);
|
setInvoice(data);
|
||||||
setError(undefined);
|
setError("");
|
||||||
setInvoice(props.invoice ? { pr: props.invoice } : undefined);
|
payWebLNIfEnabled(data);
|
||||||
setAmount(500);
|
|
||||||
setComment(undefined);
|
|
||||||
setSuccess(undefined);
|
|
||||||
}
|
}
|
||||||
}, [show, service]);
|
} else {
|
||||||
|
setError("Failed to load invoice");
|
||||||
const serviceAmounts = useMemo(() => {
|
}
|
||||||
if (payService) {
|
} catch (e) {
|
||||||
let min = (payService.minSendable ?? 0) / 1000;
|
setError("Failed to load invoice");
|
||||||
let max = (payService.maxSendable ?? 0) / 1000;
|
|
||||||
return amounts.filter(a => a >= min && a <= max);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [payService]);
|
|
||||||
|
|
||||||
const metadata = useMemo(() => {
|
|
||||||
if (payService) {
|
|
||||||
let meta: string[][] = JSON.parse(payService.metadata);
|
|
||||||
let desc = meta.find(a => a[0] === "text/plain");
|
|
||||||
let image = meta.find(a => a[0] === "image/png;base64");
|
|
||||||
return {
|
|
||||||
description: desc ? desc[1] : null,
|
|
||||||
image: image ? image[1] : null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [payService]);
|
|
||||||
|
|
||||||
const selectAmount = (a: number) => {
|
|
||||||
setError(undefined);
|
|
||||||
setInvoice(undefined);
|
|
||||||
setAmount(a);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function fetchJson<T>(url: string) {
|
|
||||||
let rsp = await fetch(url);
|
|
||||||
if (rsp.ok) {
|
|
||||||
let data: T = await rsp.json();
|
|
||||||
console.log(data);
|
|
||||||
setError(undefined);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadService(): Promise<LNURLService | null> {
|
function custom() {
|
||||||
if (service) {
|
let min = (payService?.minSendable ?? 1000) / 1000;
|
||||||
let isServiceUrl = service.toLowerCase().startsWith("lnurl");
|
let max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
|
||||||
if (isServiceUrl) {
|
|
||||||
let serviceUrl = bech32ToText(service);
|
|
||||||
return await fetchJson(serviceUrl);
|
|
||||||
} else {
|
|
||||||
let ns = service.split("@");
|
|
||||||
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadInvoice() {
|
|
||||||
if (!amount || !payService) return null;
|
|
||||||
let url = ''
|
|
||||||
const amountParam = `amount=${Math.floor(amount * 1000)}`
|
|
||||||
const commentParam = comment ? `&comment=${encodeURIComponent(comment)}` : ""
|
|
||||||
if (payService.nostrPubkey && author) {
|
|
||||||
const ev = await publisher.zap(author, note, comment)
|
|
||||||
const nostrParam = ev && `&nostr=${encodeURIComponent(JSON.stringify(ev.ToObject()))}`
|
|
||||||
url = `${payService.callback}?${amountParam}${commentParam}${nostrParam}`;
|
|
||||||
} else {
|
|
||||||
url = `${payService.callback}?${amountParam}${commentParam}`;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
let rsp = await fetch(url);
|
|
||||||
if (rsp.ok) {
|
|
||||||
let data = await rsp.json();
|
|
||||||
console.log(data);
|
|
||||||
if (data.status === "ERROR") {
|
|
||||||
setError(data.reason);
|
|
||||||
} else {
|
|
||||||
setInvoice(data);
|
|
||||||
setError("");
|
|
||||||
payWebLNIfEnabled(data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError("Failed to load invoice");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError("Failed to load invoice");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function custom() {
|
|
||||||
let min = (payService?.minSendable ?? 1000) / 1000;
|
|
||||||
let max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
|
|
||||||
return (
|
|
||||||
<div className="custom-amount flex">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
className="f-grow mr10"
|
|
||||||
placeholder="Custom"
|
|
||||||
value={customAmount}
|
|
||||||
onChange={(e) => setCustomAmount(parseInt(e.target.value))}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="secondary"
|
|
||||||
type="button"
|
|
||||||
disabled={!Boolean(customAmount)}
|
|
||||||
onClick={() => selectAmount(customAmount!)}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function payWebLNIfEnabled(invoice: LNURLInvoice) {
|
|
||||||
try {
|
|
||||||
if (webln?.enabled) {
|
|
||||||
let res = await webln.sendPayment(invoice!.pr);
|
|
||||||
console.log(res);
|
|
||||||
setSuccess(invoice!.successAction || {});
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.toString());
|
|
||||||
console.warn(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function invoiceForm() {
|
|
||||||
if (invoice) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3>Zap amount in sats</h3>
|
|
||||||
<div className="amounts" ref={horizontalScroll}>
|
|
||||||
{serviceAmounts.map(a =>
|
|
||||||
<span className={`sat-amount ${amount === a ? "active" : ""}`} key={a} onClick={() => selectAmount(a)}>
|
|
||||||
{emojis[a] && <>{emojis[a]} </> }
|
|
||||||
{formatShort(a)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{payService && custom()}
|
|
||||||
<div className="flex">
|
|
||||||
{(payService?.commentAllowed ?? 0) > 0 &&
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Comment"
|
|
||||||
className="f-grow"
|
|
||||||
maxLength={payService?.commentAllowed}
|
|
||||||
onChange={(e) => setComment(e.target.value)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{(amount ?? 0) > 0 && (
|
|
||||||
<button type="button" className="zap-action" onClick={() => loadInvoice()}>
|
|
||||||
<div className="zap-action-container">
|
|
||||||
<Zap /> Zap
|
|
||||||
{target && ` ${target} `}
|
|
||||||
{formatShort(amount)} sats
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function payInvoice() {
|
|
||||||
if (success) return null;
|
|
||||||
const pr = invoice?.pr;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="invoice">
|
|
||||||
{props.notice && <b className="error">{props.notice}</b>}
|
|
||||||
<QrCode data={pr} link={`lightning:${pr}`} />
|
|
||||||
<div className="actions">
|
|
||||||
{pr && (
|
|
||||||
<>
|
|
||||||
<div className="copy-action">
|
|
||||||
<Copy text={pr} maxSize={26} />
|
|
||||||
</div>
|
|
||||||
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${pr}`)}>
|
|
||||||
Open Wallet
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function successAction() {
|
|
||||||
if (!success) return null;
|
|
||||||
return (
|
|
||||||
<div className="success-action">
|
|
||||||
<p className="paid">
|
|
||||||
<Check className="success mr10" />
|
|
||||||
{success?.description ?? "Paid!"}
|
|
||||||
</p>
|
|
||||||
{success.url &&
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
href={success.url}
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
{success.url}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultTitle = payService?.nostrPubkey ? "Send zap" : "Send sats";
|
|
||||||
const title = target ? `${defaultTitle} to ${target}` : defaultTitle
|
|
||||||
if (!show) return null;
|
|
||||||
return (
|
return (
|
||||||
<Modal className="lnurl-modal" onClose={onClose}>
|
<div className="custom-amount flex">
|
||||||
<div className="lnurl-tip" onClick={(e) => e.stopPropagation()}>
|
<input
|
||||||
<div className="close" onClick={onClose}>
|
type="number"
|
||||||
<Close />
|
min={min}
|
||||||
</div>
|
max={max}
|
||||||
<div className="lnurl-header">
|
className="f-grow mr10"
|
||||||
{author && <ProfileImage pubkey={author} showUsername={false} />}
|
placeholder="Custom"
|
||||||
<h2>
|
value={customAmount}
|
||||||
{props.title || title}
|
onChange={(e) => setCustomAmount(parseInt(e.target.value))}
|
||||||
</h2>
|
/>
|
||||||
</div>
|
<button
|
||||||
{invoiceForm()}
|
className="secondary"
|
||||||
{error && <p className="error">{error}</p>}
|
type="button"
|
||||||
{payInvoice()}
|
disabled={!Boolean(customAmount)}
|
||||||
{successAction()}
|
onClick={() => selectAmount(customAmount!)}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function payWebLNIfEnabled(invoice: LNURLInvoice) {
|
||||||
|
try {
|
||||||
|
if (webln?.enabled) {
|
||||||
|
let res = await webln.sendPayment(invoice!.pr);
|
||||||
|
console.log(res);
|
||||||
|
setSuccess(invoice!.successAction || {});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.toString());
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoiceForm() {
|
||||||
|
if (invoice) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>Zap amount in sats</h3>
|
||||||
|
<div className="amounts" ref={horizontalScroll}>
|
||||||
|
{serviceAmounts.map((a) => (
|
||||||
|
<span
|
||||||
|
className={`sat-amount ${amount === a ? "active" : ""}`}
|
||||||
|
key={a}
|
||||||
|
onClick={() => selectAmount(a)}
|
||||||
|
>
|
||||||
|
{emojis[a] && <>{emojis[a]} </>}
|
||||||
|
{formatShort(a)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{payService && custom()}
|
||||||
|
<div className="flex">
|
||||||
|
{(payService?.commentAllowed ?? 0) > 0 && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Comment"
|
||||||
|
className="f-grow"
|
||||||
|
maxLength={payService?.commentAllowed}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(amount ?? 0) > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="zap-action"
|
||||||
|
onClick={() => loadInvoice()}
|
||||||
|
>
|
||||||
|
<div className="zap-action-container">
|
||||||
|
<Zap /> Zap
|
||||||
|
{target && ` ${target} `}
|
||||||
|
{formatShort(amount)} sats
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function payInvoice() {
|
||||||
|
if (success) return null;
|
||||||
|
const pr = invoice?.pr;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="invoice">
|
||||||
|
{props.notice && <b className="error">{props.notice}</b>}
|
||||||
|
<QrCode data={pr} link={`lightning:${pr}`} />
|
||||||
|
<div className="actions">
|
||||||
|
{pr && (
|
||||||
|
<>
|
||||||
|
<div className="copy-action">
|
||||||
|
<Copy text={pr} maxSize={26} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="wallet-action"
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.open(`lightning:${pr}`)}
|
||||||
|
>
|
||||||
|
Open Wallet
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</div>
|
||||||
)
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function successAction() {
|
||||||
|
if (!success) return null;
|
||||||
|
return (
|
||||||
|
<div className="success-action">
|
||||||
|
<p className="paid">
|
||||||
|
<Check className="success mr10" />
|
||||||
|
{success?.description ?? "Paid!"}
|
||||||
|
</p>
|
||||||
|
{success.url && (
|
||||||
|
<p>
|
||||||
|
<a href={success.url} rel="noreferrer" target="_blank">
|
||||||
|
{success.url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTitle = payService?.nostrPubkey ? "Send zap" : "Send sats";
|
||||||
|
const title = target ? `${defaultTitle} to ${target}` : defaultTitle;
|
||||||
|
if (!show) return null;
|
||||||
|
return (
|
||||||
|
<Modal className="lnurl-modal" onClose={onClose}>
|
||||||
|
<div className="lnurl-tip" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="close" onClick={onClose}>
|
||||||
|
<Close />
|
||||||
|
</div>
|
||||||
|
<div className="lnurl-header">
|
||||||
|
{author && <ProfileImage pubkey={author} showUsername={false} />}
|
||||||
|
<h2>{props.title || title}</h2>
|
||||||
|
</div>
|
||||||
|
{invoiceForm()}
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{payInvoice()}
|
||||||
|
{successAction()}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,20 +1,24 @@
|
|||||||
import './ShowMore.css'
|
import "./ShowMore.css";
|
||||||
|
|
||||||
interface ShowMoreProps {
|
interface ShowMoreProps {
|
||||||
text?: string
|
text?: string;
|
||||||
className?: string
|
className?: string;
|
||||||
onClick: () => void
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShowMore = ({ text = "Show more", onClick, className = "" }: ShowMoreProps) => {
|
const ShowMore = ({
|
||||||
const classNames = className ? `show-more ${className}` : "show-more"
|
text = "Show more",
|
||||||
|
onClick,
|
||||||
|
className = "",
|
||||||
|
}: ShowMoreProps) => {
|
||||||
|
const classNames = className ? `show-more ${className}` : "show-more";
|
||||||
return (
|
return (
|
||||||
<div className="show-more-container">
|
<div className="show-more-container">
|
||||||
<button className={classNames} onClick={onClick}>
|
<button className={classNames} onClick={onClick}>
|
||||||
{text}
|
{text}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ShowMore
|
export default ShowMore;
|
||||||
|
@ -1,48 +1,48 @@
|
|||||||
.skeleton {
|
.skeleton {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #dddbdd;
|
background-color: #dddbdd;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton::after {
|
.skeleton::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgba(255, 255, 255, 0) 0,
|
rgba(255, 255, 255, 0) 0,
|
||||||
rgba(255, 255, 255, 0.2) 20%,
|
rgba(255, 255, 255, 0.2) 20%,
|
||||||
rgba(255, 255, 255, 0.5) 60%,
|
rgba(255, 255, 255, 0.5) 60%,
|
||||||
rgba(255, 255, 255, 0)
|
rgba(255, 255, 255, 0)
|
||||||
);
|
);
|
||||||
animation: shimmer 2s infinite;
|
animation: shimmer 2s infinite;
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
100% {
|
100% {
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (prefers-color-scheme: dark) {
|
@media screen and (prefers-color-scheme: dark) {
|
||||||
.skeleton {
|
.skeleton {
|
||||||
background-color: #50535a;
|
background-color: #50535a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton::after {
|
.skeleton::after {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
#50535a 0%,
|
#50535a 0%,
|
||||||
#656871 20%,
|
#656871 20%,
|
||||||
#50535a 40%,
|
#50535a 40%,
|
||||||
#50535a 100%
|
#50535a 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,30 +1,30 @@
|
|||||||
import "./Skeleton.css";
|
import "./Skeleton.css";
|
||||||
|
|
||||||
interface ISkepetonProps {
|
interface ISkepetonProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
width?: string;
|
width?: string;
|
||||||
height?: string;
|
height?: string;
|
||||||
margin?: string;
|
margin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Skeleton({
|
export default function Skeleton({
|
||||||
children,
|
children,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
margin,
|
margin,
|
||||||
loading = true,
|
loading = true,
|
||||||
}: ISkepetonProps) {
|
}: ISkepetonProps) {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="skeleton"
|
className="skeleton"
|
||||||
style={{ width: width, height: height, margin: margin }}
|
style={{ width: width, height: height, margin: margin }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
const SoundCloudEmbed = ({link}: {link: string}) => {
|
const SoundCloudEmbed = ({ link }: { link: string }) => {
|
||||||
|
return (
|
||||||
return(
|
<iframe
|
||||||
<iframe
|
width="100%"
|
||||||
width="100%"
|
height="166"
|
||||||
height="166"
|
scrolling="no"
|
||||||
scrolling="no"
|
allow="autoplay"
|
||||||
allow="autoplay"
|
src={`https://w.soundcloud.com/player/?url=${link}`}
|
||||||
src={`https://w.soundcloud.com/player/?url=${link}`}>
|
></iframe>
|
||||||
</iframe>
|
);
|
||||||
)
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default SoundCloudEmbed;
|
export default SoundCloudEmbed;
|
||||||
|
@ -4,11 +4,11 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
overflow-x: scroll;
|
overflow-x: scroll;
|
||||||
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs::-webkit-scrollbar{
|
.tabs::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,7 +31,6 @@
|
|||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabs > div {
|
||||||
.tabs>div {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
@ -1,39 +1,47 @@
|
|||||||
import './Tabs.css'
|
import "./Tabs.css";
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
text: string, value: number
|
text: string;
|
||||||
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabsProps {
|
interface TabsProps {
|
||||||
tabs: Tab[]
|
tabs: Tab[];
|
||||||
tab: Tab
|
tab: Tab;
|
||||||
setTab: (t: Tab) => void
|
setTab: (t: Tab) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabElementProps extends Omit<TabsProps, 'tabs'> {
|
interface TabElementProps extends Omit<TabsProps, "tabs"> {
|
||||||
t: Tab
|
t: Tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
|
export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={`tab ${tab.value === t.value ? "active" : ""}`} onClick={() => setTab(t)}>
|
<div
|
||||||
|
className={`tab ${tab.value === t.value ? "active" : ""}`}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
>
|
||||||
{t.text}
|
{t.text}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
|
const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
{tabs.map((t) => {
|
{tabs.map((t) => {
|
||||||
return (
|
return (
|
||||||
<div key={t.value} className={`tab ${tab.value === t.value ? "active" : ""}`} onClick={() => setTab(t)}>
|
<div
|
||||||
|
key={t.value}
|
||||||
|
className={`tab ${tab.value === t.value ? "active" : ""}`}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
>
|
||||||
{t.text}
|
{t.text}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Tabs
|
export default Tabs;
|
||||||
|
@ -4,70 +4,74 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text a {
|
.text a {
|
||||||
color: var(--highlight);
|
color: var(--highlight);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.text a:hover {
|
.text a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text h1 {
|
.text h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.text h2 {
|
.text h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.text h3 {
|
.text h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.text h4 {
|
.text h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.text h5 {
|
.text h5 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.text h6 {
|
.text h6 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text p {
|
.text p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text p:last-child {
|
.text p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text pre {
|
.text pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text li {
|
.text li {
|
||||||
margin-top: -1em;
|
margin-top: -1em;
|
||||||
}
|
}
|
||||||
.text li:last-child {
|
.text li:last-child {
|
||||||
margin-bottom: -2em;
|
margin-bottom: -2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text hr {
|
.text hr {
|
||||||
border: 0;
|
border: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background-image: var(--gray-gradient);
|
background-image: var(--gray-gradient);
|
||||||
margin: 20px;
|
margin: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text img, .text video, .text iframe, .text audio {
|
.text img,
|
||||||
max-width: 100%;
|
.text video,
|
||||||
max-height: 500px;
|
.text iframe,
|
||||||
margin: 10px auto;
|
.text audio {
|
||||||
display: block;
|
max-width: 100%;
|
||||||
border-radius: 12px;
|
max-height: 500px;
|
||||||
|
margin: 10px auto;
|
||||||
|
display: block;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text iframe, .text video {
|
.text iframe,
|
||||||
width: -webkit-fill-available;
|
.text video {
|
||||||
aspect-ratio: 16 / 9;
|
width: -webkit-fill-available;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text blockquote {
|
.text blockquote {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import './Text.css'
|
import "./Text.css";
|
||||||
import { useMemo, useCallback } from "react";
|
import { useMemo, useCallback } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
@ -12,154 +12,182 @@ import Hashtag from "Element/Hashtag";
|
|||||||
import Tag from "Nostr/Tag";
|
import Tag from "Nostr/Tag";
|
||||||
import { MetadataCache } from "State/Users";
|
import { MetadataCache } from "State/Users";
|
||||||
import Mention from "Element/Mention";
|
import Mention from "Element/Mention";
|
||||||
import HyperText from 'Element/HyperText';
|
import HyperText from "Element/HyperText";
|
||||||
import { HexKey } from 'Nostr';
|
import { HexKey } from "Nostr";
|
||||||
|
|
||||||
export type Fragment = string | JSX.Element;
|
export type Fragment = string | JSX.Element;
|
||||||
|
|
||||||
export interface TextFragment {
|
export interface TextFragment {
|
||||||
body: Fragment[],
|
body: Fragment[];
|
||||||
tags: Tag[],
|
tags: Tag[];
|
||||||
users: Map<string, MetadataCache>
|
users: Map<string, MetadataCache>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextProps {
|
export interface TextProps {
|
||||||
content: string,
|
content: string;
|
||||||
creator: HexKey,
|
creator: HexKey;
|
||||||
tags: Tag[],
|
tags: Tag[];
|
||||||
users: Map<string, MetadataCache>
|
users: Map<string, MetadataCache>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Text({ content, tags, creator, users }: TextProps) {
|
export default function Text({ content, tags, creator, users }: TextProps) {
|
||||||
|
function extractLinks(fragments: Fragment[]) {
|
||||||
function extractLinks(fragments: Fragment[]) {
|
return fragments
|
||||||
return fragments.map(f => {
|
.map((f) => {
|
||||||
if (typeof f === "string") {
|
if (typeof f === "string") {
|
||||||
return f.split(UrlRegex).map(a => {
|
return f.split(UrlRegex).map((a) => {
|
||||||
if (a.startsWith("http")) {
|
if (a.startsWith("http")) {
|
||||||
return <HyperText link={a} creator={creator} />
|
return <HyperText link={a} creator={creator} />;
|
||||||
}
|
|
||||||
return a;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return f;
|
return a;
|
||||||
}).flat();
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function extractMentions(frag: TextFragment) {
|
|
||||||
return frag.body.map(f => {
|
|
||||||
if (typeof f === "string") {
|
|
||||||
return f.split(MentionRegex).map((match) => {
|
|
||||||
let matchTag = match.match(/#\[(\d+)\]/);
|
|
||||||
if (matchTag && matchTag.length === 2) {
|
|
||||||
let idx = parseInt(matchTag[1]);
|
|
||||||
let ref = frag.tags?.find(a => a.Index === idx);
|
|
||||||
if (ref) {
|
|
||||||
switch (ref.Key) {
|
|
||||||
case "p": {
|
|
||||||
return <Mention pubkey={ref.PubKey!} />
|
|
||||||
}
|
|
||||||
case "e": {
|
|
||||||
let eText = hexToBech32("note", ref.Event!).substring(0, 12);
|
|
||||||
return <Link key={ref.Event} to={eventLink(ref.Event!)} onClick={(e) => e.stopPropagation()}>#{eText}</Link>;
|
|
||||||
}
|
|
||||||
case "t": {
|
|
||||||
return <Hashtag tag={ref.Hashtag!} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return <b style={{ color: "var(--error)" }}>{matchTag[0]}?</b>;
|
|
||||||
} else {
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
}).flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractInvoices(fragments: Fragment[]) {
|
|
||||||
return fragments.map(f => {
|
|
||||||
if (typeof f === "string") {
|
|
||||||
return f.split(InvoiceRegex).map(i => {
|
|
||||||
if (i.toLowerCase().startsWith("lnbc")) {
|
|
||||||
return <Invoice key={i} invoice={i} />
|
|
||||||
} else {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
}).flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractHashtags(fragments: Fragment[]) {
|
|
||||||
return fragments.map(f => {
|
|
||||||
if (typeof f === "string") {
|
|
||||||
return f.split(HashtagRegex).map(i => {
|
|
||||||
if (i.toLowerCase().startsWith("#")) {
|
|
||||||
return <Hashtag tag={i.substring(1)} />
|
|
||||||
} else {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
}).flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformLi(frag: TextFragment) {
|
|
||||||
let fragments = transformText(frag)
|
|
||||||
return <li>{fragments}</li>
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformParagraph(frag: TextFragment) {
|
|
||||||
const fragments = transformText(frag)
|
|
||||||
if (fragments.every(f => typeof f === 'string')) {
|
|
||||||
return <p>{fragments}</p>
|
|
||||||
}
|
}
|
||||||
return <>{fragments}</>
|
return f;
|
||||||
}
|
})
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
function transformText(frag: TextFragment) {
|
function extractMentions(frag: TextFragment) {
|
||||||
if (frag.body === undefined) {
|
return frag.body
|
||||||
debugger;
|
.map((f) => {
|
||||||
}
|
if (typeof f === "string") {
|
||||||
let fragments = extractMentions(frag);
|
return f.split(MentionRegex).map((match) => {
|
||||||
fragments = extractLinks(fragments);
|
let matchTag = match.match(/#\[(\d+)\]/);
|
||||||
fragments = extractInvoices(fragments);
|
if (matchTag && matchTag.length === 2) {
|
||||||
fragments = extractHashtags(fragments);
|
let idx = parseInt(matchTag[1]);
|
||||||
return fragments;
|
let ref = frag.tags?.find((a) => a.Index === idx);
|
||||||
}
|
if (ref) {
|
||||||
|
switch (ref.Key) {
|
||||||
const components = useMemo(() => {
|
case "p": {
|
||||||
return {
|
return <Mention pubkey={ref.PubKey!} />;
|
||||||
p: (x: any) => transformParagraph({ body: x.children ?? [], tags, users }),
|
}
|
||||||
a: (x: any) => <HyperText link={x.href} creator={creator} />,
|
case "e": {
|
||||||
li: (x: any) => transformLi({ body: x.children ?? [], tags, users }),
|
let eText = hexToBech32("note", ref.Event!).substring(
|
||||||
};
|
0,
|
||||||
}, [content]);
|
12
|
||||||
|
);
|
||||||
const disableMarkdownLinks = useCallback(() => (tree: any) => {
|
return (
|
||||||
visit(tree, (node, index, parent) => {
|
<Link
|
||||||
if (
|
key={ref.Event}
|
||||||
parent &&
|
to={eventLink(ref.Event!)}
|
||||||
typeof index === 'number' &&
|
onClick={(e) => e.stopPropagation()}
|
||||||
(node.type === 'link' ||
|
>
|
||||||
node.type === 'linkReference' ||
|
#{eText}
|
||||||
node.type === 'image' ||
|
</Link>
|
||||||
node.type === 'imageReference' ||
|
);
|
||||||
node.type === 'definition')
|
}
|
||||||
) {
|
case "t": {
|
||||||
node.type = 'text';
|
return <Hashtag tag={ref.Hashtag!} />;
|
||||||
node.value = content.slice(node.position.start.offset, node.position.end.offset).replace(/\)$/, ' )');
|
}
|
||||||
return SKIP;
|
}
|
||||||
|
}
|
||||||
|
return <b style={{ color: "var(--error)" }}>{matchTag[0]}?</b>;
|
||||||
|
} else {
|
||||||
|
return match;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}, [content]);
|
}
|
||||||
return <ReactMarkdown
|
return f;
|
||||||
className="text"
|
})
|
||||||
components={components}
|
.flat();
|
||||||
remarkPlugins={[disableMarkdownLinks]}
|
}
|
||||||
>{content}</ReactMarkdown>
|
|
||||||
|
function extractInvoices(fragments: Fragment[]) {
|
||||||
|
return fragments
|
||||||
|
.map((f) => {
|
||||||
|
if (typeof f === "string") {
|
||||||
|
return f.split(InvoiceRegex).map((i) => {
|
||||||
|
if (i.toLowerCase().startsWith("lnbc")) {
|
||||||
|
return <Invoice key={i} invoice={i} />;
|
||||||
|
} else {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHashtags(fragments: Fragment[]) {
|
||||||
|
return fragments
|
||||||
|
.map((f) => {
|
||||||
|
if (typeof f === "string") {
|
||||||
|
return f.split(HashtagRegex).map((i) => {
|
||||||
|
if (i.toLowerCase().startsWith("#")) {
|
||||||
|
return <Hashtag tag={i.substring(1)} />;
|
||||||
|
} else {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLi(frag: TextFragment) {
|
||||||
|
let fragments = transformText(frag);
|
||||||
|
return <li>{fragments}</li>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformParagraph(frag: TextFragment) {
|
||||||
|
const fragments = transformText(frag);
|
||||||
|
if (fragments.every((f) => typeof f === "string")) {
|
||||||
|
return <p>{fragments}</p>;
|
||||||
|
}
|
||||||
|
return <>{fragments}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformText(frag: TextFragment) {
|
||||||
|
if (frag.body === undefined) {
|
||||||
|
debugger;
|
||||||
|
}
|
||||||
|
let fragments = extractMentions(frag);
|
||||||
|
fragments = extractLinks(fragments);
|
||||||
|
fragments = extractInvoices(fragments);
|
||||||
|
fragments = extractHashtags(fragments);
|
||||||
|
return fragments;
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = useMemo(() => {
|
||||||
|
return {
|
||||||
|
p: (x: any) =>
|
||||||
|
transformParagraph({ body: x.children ?? [], tags, users }),
|
||||||
|
a: (x: any) => <HyperText link={x.href} creator={creator} />,
|
||||||
|
li: (x: any) => transformLi({ body: x.children ?? [], tags, users }),
|
||||||
|
};
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const disableMarkdownLinks = useCallback(
|
||||||
|
() => (tree: any) => {
|
||||||
|
visit(tree, (node, index, parent) => {
|
||||||
|
if (
|
||||||
|
parent &&
|
||||||
|
typeof index === "number" &&
|
||||||
|
(node.type === "link" ||
|
||||||
|
node.type === "linkReference" ||
|
||||||
|
node.type === "image" ||
|
||||||
|
node.type === "imageReference" ||
|
||||||
|
node.type === "definition")
|
||||||
|
) {
|
||||||
|
node.type = "text";
|
||||||
|
node.value = content
|
||||||
|
.slice(node.position.start.offset, node.position.end.offset)
|
||||||
|
.replace(/\)$/, " )");
|
||||||
|
return SKIP;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[content]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
className="text"
|
||||||
|
components={components}
|
||||||
|
remarkPlugins={[disableMarkdownLinks]}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,12 +4,14 @@
|
|||||||
.rta__item:not(:last-child) {
|
.rta__item:not(:last-child) {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.rta__entity--selected .user-item, .rta__entity--selected .emoji-item {
|
.rta__entity--selected .user-item,
|
||||||
|
.rta__entity--selected .emoji-item {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background: var(--gray-secondary);
|
background: var(--gray-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-item, .emoji-item {
|
.user-item,
|
||||||
|
.emoji-item {
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
background: var(--note-bg);
|
background: var(--note-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -19,7 +21,8 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-item:hover, .emoji-item:hover {
|
.user-item:hover,
|
||||||
|
.emoji-item:hover {
|
||||||
background: var(--gray-tertiary);
|
background: var(--gray-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,9 +40,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-picture .avatar {
|
.user-picture .avatar {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-details {
|
.user-details {
|
||||||
@ -57,8 +60,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.emoji-item .emoji {
|
.emoji-item .emoji {
|
||||||
margin-right: .2em;
|
margin-right: 0.2em;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-item .emoji-name {
|
.emoji-item .emoji-name {
|
||||||
|
@ -13,8 +13,8 @@ import { MetadataCache } from "State/Users";
|
|||||||
import { useQuery } from "State/Users/Hooks";
|
import { useQuery } from "State/Users/Hooks";
|
||||||
|
|
||||||
interface EmojiItemProps {
|
interface EmojiItemProps {
|
||||||
name: string
|
name: string;
|
||||||
char: string
|
char: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmojiItem = ({ entity: { name, char } }: { entity: EmojiItemProps }) => {
|
const EmojiItem = ({ entity: { name, char } }: { entity: EmojiItemProps }) => {
|
||||||
@ -23,11 +23,11 @@ const EmojiItem = ({ entity: { name, char } }: { entity: EmojiItemProps }) => {
|
|||||||
<div className="emoji">{char}</div>
|
<div className="emoji">{char}</div>
|
||||||
<div className="emoji-name">{name}</div>
|
<div className="emoji-name">{name}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const UserItem = (metadata: MetadataCache) => {
|
const UserItem = (metadata: MetadataCache) => {
|
||||||
const { pubkey, display_name, picture, nip05, ...rest } = metadata
|
const { pubkey, display_name, picture, nip05, ...rest } = metadata;
|
||||||
return (
|
return (
|
||||||
<div key={pubkey} className="user-item">
|
<div key={pubkey} className="user-item">
|
||||||
<div className="user-picture">
|
<div className="user-picture">
|
||||||
@ -38,24 +38,24 @@ const UserItem = (metadata: MetadataCache) => {
|
|||||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
<Nip05 nip05={nip05} pubkey={pubkey} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const Textarea = ({ users, onChange, ...rest }: any) => {
|
const Textarea = ({ users, onChange, ...rest }: any) => {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const allUsers = useQuery(query)
|
const allUsers = useQuery(query);
|
||||||
|
|
||||||
const userDataProvider = (token: string) => {
|
const userDataProvider = (token: string) => {
|
||||||
setQuery(token)
|
setQuery(token);
|
||||||
return allUsers
|
return allUsers;
|
||||||
}
|
};
|
||||||
|
|
||||||
const emojiDataProvider = (token: string) => {
|
const emojiDataProvider = (token: string) => {
|
||||||
return emoji(token)
|
return emoji(token)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(({ name, char }) => ({ name, char }));
|
.map(({ name, char }) => ({ name, char }));
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactTextareaAutocomplete
|
<ReactTextareaAutocomplete
|
||||||
@ -68,17 +68,17 @@ const Textarea = ({ users, onChange, ...rest }: any) => {
|
|||||||
":": {
|
":": {
|
||||||
dataProvider: emojiDataProvider,
|
dataProvider: emojiDataProvider,
|
||||||
component: EmojiItem,
|
component: EmojiItem,
|
||||||
output: (item: EmojiItemProps, trigger) => item.char
|
output: (item: EmojiItemProps, trigger) => item.char,
|
||||||
},
|
},
|
||||||
"@": {
|
"@": {
|
||||||
afterWhitespace: true,
|
afterWhitespace: true,
|
||||||
dataProvider: userDataProvider,
|
dataProvider: userDataProvider,
|
||||||
component: (props: any) => <UserItem {...props.entity} />,
|
component: (props: any) => <UserItem {...props.entity} />,
|
||||||
output: (item: any) => `@${hexToBech32("npub", item.pubkey)}`
|
output: (item: any) => `@${hexToBech32("npub", item.pubkey)}`,
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Textarea
|
export default Textarea;
|
||||||
|
@ -63,7 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subthread-container.subthread-multi .line-container:before {
|
.subthread-container.subthread-multi .line-container:before {
|
||||||
content: '';
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 36px;
|
left: 36px;
|
||||||
top: 48px;
|
top: 48px;
|
||||||
@ -78,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
|
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
|
||||||
content: '';
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 36px;
|
left: 36px;
|
||||||
top: 48px;
|
top: 48px;
|
||||||
@ -87,13 +87,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 720px) {
|
@media (min-width: 720px) {
|
||||||
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
|
.subthread-container.subthread-mid:not(.subthread-last)
|
||||||
|
.line-container:after {
|
||||||
left: 48px;
|
left: 48px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
|
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
|
||||||
content: '';
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-left: 1px solid var(--gray-superdark);
|
border-left: 1px solid var(--gray-superdark);
|
||||||
left: 36px;
|
left: 36px;
|
||||||
@ -102,13 +103,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 720px) {
|
@media (min-width: 720px) {
|
||||||
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
|
.subthread-container.subthread-mid:not(.subthread-last)
|
||||||
|
.line-container:after {
|
||||||
left: 48px;
|
left: 48px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.subthread-container.subthread-last .line-container:before {
|
.subthread-container.subthread-last .line-container:before {
|
||||||
content: '';
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-left: 1px solid var(--gray-superdark);
|
border-left: 1px solid var(--gray-superdark);
|
||||||
left: 36px;
|
left: 36px;
|
||||||
@ -137,7 +139,8 @@
|
|||||||
margin-left: 80px;
|
margin-left: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-container .collapsed, .thread-container .show-more-container {
|
.thread-container .collapsed,
|
||||||
|
.thread-container .show-more-container {
|
||||||
background: var(--note-bg);
|
background: var(--note-bg);
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
@ -147,7 +150,7 @@
|
|||||||
border-bottom-right-radius: 16px;
|
border-bottom-right-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-container .collapsed {
|
.thread-container .collapsed {
|
||||||
background-color: var(--note-bg);
|
background-color: var(--note-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,60 +13,75 @@ import NoteGhost from "Element/NoteGhost";
|
|||||||
import Collapsed from "Element/Collapsed";
|
import Collapsed from "Element/Collapsed";
|
||||||
import type { RootState } from "State/Store";
|
import type { RootState } from "State/Store";
|
||||||
|
|
||||||
function getParent(ev: HexKey, chains: Map<HexKey, NEvent[]>): HexKey | undefined {
|
function getParent(
|
||||||
|
ev: HexKey,
|
||||||
|
chains: Map<HexKey, NEvent[]>
|
||||||
|
): HexKey | undefined {
|
||||||
for (let [k, vs] of chains.entries()) {
|
for (let [k, vs] of chains.entries()) {
|
||||||
const fs = vs.map(a => a.Id)
|
const fs = vs.map((a) => a.Id);
|
||||||
if (fs.includes(ev)) {
|
if (fs.includes(ev)) {
|
||||||
return k
|
return k;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DividerProps {
|
interface DividerProps {
|
||||||
variant?: "regular" | "small"
|
variant?: "regular" | "small";
|
||||||
}
|
}
|
||||||
|
|
||||||
const Divider = ({ variant = "regular" }: DividerProps) => {
|
const Divider = ({ variant = "regular" }: DividerProps) => {
|
||||||
const className = variant === "small" ? "divider divider-small" : "divider"
|
const className = variant === "small" ? "divider divider-small" : "divider";
|
||||||
return (
|
return (
|
||||||
<div className="divider-container">
|
<div className="divider-container">
|
||||||
<div className={className}>
|
<div className={className}></div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
interface SubthreadProps {
|
interface SubthreadProps {
|
||||||
isLastSubthread?: boolean
|
isLastSubthread?: boolean;
|
||||||
from: u256
|
from: u256;
|
||||||
active: u256
|
active: u256;
|
||||||
path: u256[]
|
path: u256[];
|
||||||
notes: NEvent[]
|
notes: NEvent[];
|
||||||
related: TaggedRawEvent[]
|
related: TaggedRawEvent[];
|
||||||
chains: Map<u256, NEvent[]>
|
chains: Map<u256, NEvent[]>;
|
||||||
onNavigate: (e: u256) => void
|
onNavigate: (e: u256) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Subthread = ({ active, path, from, notes, related, chains, onNavigate }: SubthreadProps) => {
|
const Subthread = ({
|
||||||
|
active,
|
||||||
|
path,
|
||||||
|
from,
|
||||||
|
notes,
|
||||||
|
related,
|
||||||
|
chains,
|
||||||
|
onNavigate,
|
||||||
|
}: SubthreadProps) => {
|
||||||
const renderSubthread = (a: NEvent, idx: number) => {
|
const renderSubthread = (a: NEvent, idx: number) => {
|
||||||
const isLastSubthread = idx === notes.length - 1
|
const isLastSubthread = idx === notes.length - 1;
|
||||||
const replies = getReplies(a.Id, chains)
|
const replies = getReplies(a.Id, chains);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`subthread-container ${replies.length > 0 ? 'subthread-multi' : ''}`}>
|
<div
|
||||||
<Divider />
|
className={`subthread-container ${
|
||||||
<Note
|
replies.length > 0 ? "subthread-multi" : ""
|
||||||
highlight={active === a.Id}
|
}`}
|
||||||
className={`thread-note ${isLastSubthread && replies.length === 0 ? 'is-last-note' : ''}`}
|
>
|
||||||
data-ev={a}
|
<Divider />
|
||||||
key={a.Id}
|
<Note
|
||||||
related={related}
|
highlight={active === a.Id}
|
||||||
/>
|
className={`thread-note ${
|
||||||
<div className="line-container">
|
isLastSubthread && replies.length === 0 ? "is-last-note" : ""
|
||||||
</div>
|
}`}
|
||||||
</div>
|
data-ev={a}
|
||||||
{replies.length > 0 && (
|
key={a.Id}
|
||||||
<TierTwo
|
related={related}
|
||||||
|
/>
|
||||||
|
<div className="line-container"></div>
|
||||||
|
</div>
|
||||||
|
{replies.length > 0 && (
|
||||||
|
<TierTwo
|
||||||
active={active}
|
active={active}
|
||||||
isLastSubthread={isLastSubthread}
|
isLastSubthread={isLastSubthread}
|
||||||
path={path}
|
path={path}
|
||||||
@ -75,78 +90,97 @@ const Subthread = ({ active, path, from, notes, related, chains, onNavigate }: S
|
|||||||
related={related}
|
related={related}
|
||||||
chains={chains}
|
chains={chains}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return <div className="subthread">{notes.map(renderSubthread)}</div>;
|
||||||
<div className="subthread">
|
};
|
||||||
{notes.map(renderSubthread)}
|
|
||||||
</div>
|
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
|
||||||
)
|
note: NEvent;
|
||||||
|
isLast: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThreadNoteProps extends Omit<SubthreadProps, 'notes'> {
|
const ThreadNote = ({
|
||||||
note: NEvent
|
active,
|
||||||
isLast: boolean
|
note,
|
||||||
}
|
isLast,
|
||||||
|
path,
|
||||||
const ThreadNote = ({ active, note, isLast, path, isLastSubthread, from, related, chains, onNavigate }: ThreadNoteProps) => {
|
isLastSubthread,
|
||||||
const replies = getReplies(note.Id, chains)
|
from,
|
||||||
const activeInReplies = replies.map(r => r.Id).includes(active)
|
related,
|
||||||
const [collapsed, setCollapsed] = useState(!activeInReplies)
|
chains,
|
||||||
const hasMultipleNotes = replies.length > 0
|
onNavigate,
|
||||||
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes
|
}: ThreadNoteProps) => {
|
||||||
const className = `subthread-container ${isLast && collapsed ? 'subthread-last' : 'subthread-multi subthread-mid'}`
|
const replies = getReplies(note.Id, chains);
|
||||||
|
const activeInReplies = replies.map((r) => r.Id).includes(active);
|
||||||
|
const [collapsed, setCollapsed] = useState(!activeInReplies);
|
||||||
|
const hasMultipleNotes = replies.length > 0;
|
||||||
|
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes;
|
||||||
|
const className = `subthread-container ${
|
||||||
|
isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid"
|
||||||
|
}`;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<Divider variant="small" />
|
<Divider variant="small" />
|
||||||
<Note
|
<Note
|
||||||
highlight={active === note.Id}
|
highlight={active === note.Id}
|
||||||
className={`thread-note ${isLastVisibleNote ? 'is-last-note' : ''}`}
|
className={`thread-note ${isLastVisibleNote ? "is-last-note" : ""}`}
|
||||||
data-ev={note}
|
data-ev={note}
|
||||||
key={note.Id}
|
key={note.Id}
|
||||||
related={related}
|
related={related}
|
||||||
/>
|
/>
|
||||||
<div className="line-container">
|
<div className="line-container"></div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{replies.length > 0 && (
|
{replies.length > 0 &&
|
||||||
activeInReplies ? (
|
(activeInReplies ? (
|
||||||
<TierThree
|
<TierThree
|
||||||
active={active}
|
active={active}
|
||||||
path={path}
|
path={path}
|
||||||
isLastSubthread={isLastSubthread}
|
isLastSubthread={isLastSubthread}
|
||||||
from={from}
|
from={from}
|
||||||
notes={replies}
|
notes={replies}
|
||||||
related={related}
|
related={related}
|
||||||
chains={chains}
|
chains={chains}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Collapsed text="Show replies" collapsed={collapsed} setCollapsed={setCollapsed}>
|
<Collapsed
|
||||||
|
text="Show replies"
|
||||||
|
collapsed={collapsed}
|
||||||
|
setCollapsed={setCollapsed}
|
||||||
|
>
|
||||||
<TierThree
|
<TierThree
|
||||||
active={active}
|
active={active}
|
||||||
path={path}
|
path={path}
|
||||||
isLastSubthread={isLastSubthread}
|
isLastSubthread={isLastSubthread}
|
||||||
from={from}
|
from={from}
|
||||||
notes={replies}
|
notes={replies}
|
||||||
related={related}
|
related={related}
|
||||||
chains={chains}
|
chains={chains}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
</Collapsed>
|
</Collapsed>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains, onNavigate }: SubthreadProps) => {
|
const TierTwo = ({
|
||||||
const [first, ...rest] = notes
|
active,
|
||||||
|
isLastSubthread,
|
||||||
|
path,
|
||||||
|
from,
|
||||||
|
notes,
|
||||||
|
related,
|
||||||
|
chains,
|
||||||
|
onNavigate,
|
||||||
|
}: SubthreadProps) => {
|
||||||
|
const [first, ...rest] = notes;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -163,9 +197,9 @@ const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains,
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{rest.map((r: NEvent, idx: number) => {
|
{rest.map((r: NEvent, idx: number) => {
|
||||||
const lastReply = idx === rest.length - 1
|
const lastReply = idx === rest.length - 1;
|
||||||
return (
|
return (
|
||||||
<ThreadNote
|
<ThreadNote
|
||||||
active={active}
|
active={active}
|
||||||
path={path}
|
path={path}
|
||||||
from={from}
|
from={from}
|
||||||
@ -176,218 +210,270 @@ const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains,
|
|||||||
isLastSubthread={isLastSubthread}
|
isLastSubthread={isLastSubthread}
|
||||||
isLast={lastReply}
|
isLast={lastReply}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
})}
|
||||||
}
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains, onNavigate }: SubthreadProps) => {
|
const TierThree = ({
|
||||||
const [first, ...rest] = notes
|
active,
|
||||||
const replies = getReplies(first.Id, chains)
|
path,
|
||||||
const activeInReplies = notes.map(r => r.Id).includes(active) || replies.map(r => r.Id).includes(active)
|
isLastSubthread,
|
||||||
const hasMultipleNotes = rest.length > 0 || replies.length > 0
|
from,
|
||||||
const isLast = replies.length === 0 && rest.length === 0
|
notes,
|
||||||
|
related,
|
||||||
|
chains,
|
||||||
|
onNavigate,
|
||||||
|
}: SubthreadProps) => {
|
||||||
|
const [first, ...rest] = notes;
|
||||||
|
const replies = getReplies(first.Id, chains);
|
||||||
|
const activeInReplies =
|
||||||
|
notes.map((r) => r.Id).includes(active) ||
|
||||||
|
replies.map((r) => r.Id).includes(active);
|
||||||
|
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
|
||||||
|
const isLast = replies.length === 0 && rest.length === 0;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`subthread-container ${hasMultipleNotes ? 'subthread-multi' : ''} ${isLast ? 'subthread-last' : 'subthread-mid'}`}>
|
<div
|
||||||
|
className={`subthread-container ${
|
||||||
|
hasMultipleNotes ? "subthread-multi" : ""
|
||||||
|
} ${isLast ? "subthread-last" : "subthread-mid"}`}
|
||||||
|
>
|
||||||
<Divider variant="small" />
|
<Divider variant="small" />
|
||||||
<Note
|
<Note
|
||||||
highlight={active === first.Id}
|
highlight={active === first.Id}
|
||||||
className={`thread-note ${isLastSubthread && isLast ? 'is-last-note' : ''}`}
|
className={`thread-note ${
|
||||||
|
isLastSubthread && isLast ? "is-last-note" : ""
|
||||||
|
}`}
|
||||||
data-ev={first}
|
data-ev={first}
|
||||||
key={first.Id}
|
key={first.Id}
|
||||||
related={related}
|
related={related}
|
||||||
/>
|
/>
|
||||||
<div className="line-container">
|
<div className="line-container"></div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{path.length <= 1 || !activeInReplies ? (
|
{path.length <= 1 || !activeInReplies
|
||||||
replies.length > 0 && (
|
? replies.length > 0 && (
|
||||||
<div className="show-more-container">
|
<div className="show-more-container">
|
||||||
<button className="show-more" type="button" onClick={() => onNavigate(from)}>
|
<button
|
||||||
Show replies
|
className="show-more"
|
||||||
</button>
|
type="button"
|
||||||
</div>
|
onClick={() => onNavigate(from)}
|
||||||
)
|
>
|
||||||
) : (
|
Show replies
|
||||||
replies.length > 0 && (
|
</button>
|
||||||
<TierThree
|
</div>
|
||||||
active={active}
|
)
|
||||||
path={path.slice(1)}
|
: replies.length > 0 && (
|
||||||
isLastSubthread={isLastSubthread}
|
<TierThree
|
||||||
from={from}
|
active={active}
|
||||||
notes={replies}
|
path={path.slice(1)}
|
||||||
related={related}
|
isLastSubthread={isLastSubthread}
|
||||||
chains={chains}
|
from={from}
|
||||||
onNavigate={onNavigate}
|
notes={replies}
|
||||||
/>
|
related={related}
|
||||||
)
|
chains={chains}
|
||||||
)}
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{rest.map((r: NEvent, idx: number) => {
|
{rest.map((r: NEvent, idx: number) => {
|
||||||
const lastReply = idx === rest.length - 1
|
const lastReply = idx === rest.length - 1;
|
||||||
const lastNote = isLastSubthread && lastReply
|
const lastNote = isLastSubthread && lastReply;
|
||||||
return (
|
return (
|
||||||
<div key={r.Id} className={`subthread-container ${lastReply ? '' : 'subthread-multi'} ${lastReply ? 'subthread-last' : 'subthread-mid'}`}>
|
<div
|
||||||
|
key={r.Id}
|
||||||
|
className={`subthread-container ${
|
||||||
|
lastReply ? "" : "subthread-multi"
|
||||||
|
} ${lastReply ? "subthread-last" : "subthread-mid"}`}
|
||||||
|
>
|
||||||
<Divider variant="small" />
|
<Divider variant="small" />
|
||||||
<Note
|
<Note
|
||||||
className={`thread-note ${lastNote ? 'is-last-note' : ''}`}
|
className={`thread-note ${lastNote ? "is-last-note" : ""}`}
|
||||||
highlight={active === r.Id}
|
highlight={active === r.Id}
|
||||||
data-ev={r}
|
data-ev={r}
|
||||||
key={r.Id}
|
key={r.Id}
|
||||||
related={related}
|
related={related}
|
||||||
/>
|
/>
|
||||||
<div className="line-container">
|
<div className="line-container"></div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})
|
})}
|
||||||
}
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
export interface ThreadProps {
|
export interface ThreadProps {
|
||||||
this?: u256,
|
this?: u256;
|
||||||
notes?: TaggedRawEvent[]
|
notes?: TaggedRawEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Thread(props: ThreadProps) {
|
export default function Thread(props: ThreadProps) {
|
||||||
const notes = props.notes ?? [];
|
const notes = props.notes ?? [];
|
||||||
const parsedNotes = notes.map(a => new NEvent(a));
|
const parsedNotes = notes.map((a) => new NEvent(a));
|
||||||
// root note has no thread info
|
// root note has no thread info
|
||||||
const root = useMemo(() => parsedNotes.find(a => a.Thread === null), [notes]);
|
const root = useMemo(
|
||||||
const [path, setPath] = useState<HexKey[]>([])
|
() => parsedNotes.find((a) => a.Thread === null),
|
||||||
const currentId = path.length > 0 && path[path.length - 1]
|
[notes]
|
||||||
const currentRoot = useMemo(() => parsedNotes.find(a => a.Id === currentId), [notes, currentId]);
|
);
|
||||||
const [navigated, setNavigated] = useState(false)
|
const [path, setPath] = useState<HexKey[]>([]);
|
||||||
const navigate = useNavigate()
|
const currentId = path.length > 0 && path[path.length - 1];
|
||||||
const isSingleNote = parsedNotes.filter(a => a.Kind === EventKind.TextNote).length === 1
|
const currentRoot = useMemo(
|
||||||
const location = useLocation()
|
() => parsedNotes.find((a) => a.Id === currentId),
|
||||||
const urlNoteId = location?.pathname.slice(3)
|
[notes, currentId]
|
||||||
const urlNoteHex = urlNoteId && bech32ToHex(urlNoteId)
|
);
|
||||||
const rootNoteId = root && hexToBech32('note', root.Id)
|
const [navigated, setNavigated] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isSingleNote =
|
||||||
|
parsedNotes.filter((a) => a.Kind === EventKind.TextNote).length === 1;
|
||||||
|
const location = useLocation();
|
||||||
|
const urlNoteId = location?.pathname.slice(3);
|
||||||
|
const urlNoteHex = urlNoteId && bech32ToHex(urlNoteId);
|
||||||
|
const rootNoteId = root && hexToBech32("note", root.Id);
|
||||||
|
|
||||||
const chains = useMemo(() => {
|
const chains = useMemo(() => {
|
||||||
let chains = new Map<u256, NEvent[]>();
|
let chains = new Map<u256, NEvent[]>();
|
||||||
parsedNotes?.filter(a => a.Kind === EventKind.TextNote).sort((a, b) => b.CreatedAt - a.CreatedAt).forEach((v) => {
|
parsedNotes
|
||||||
let replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
|
?.filter((a) => a.Kind === EventKind.TextNote)
|
||||||
if (replyTo) {
|
.sort((a, b) => b.CreatedAt - a.CreatedAt)
|
||||||
if (!chains.has(replyTo)) {
|
.forEach((v) => {
|
||||||
chains.set(replyTo, [v]);
|
let replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
|
||||||
} else {
|
if (replyTo) {
|
||||||
chains.get(replyTo)!.push(v);
|
if (!chains.has(replyTo)) {
|
||||||
}
|
chains.set(replyTo, [v]);
|
||||||
} else if (v.Tags.length > 0) {
|
} else {
|
||||||
console.log("Not replying to anything: ", v);
|
chains.get(replyTo)!.push(v);
|
||||||
}
|
}
|
||||||
});
|
} else if (v.Tags.length > 0) {
|
||||||
|
console.log("Not replying to anything: ", v);
|
||||||
return chains;
|
|
||||||
}, [notes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!root) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (navigated) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (root.Id === urlNoteHex) {
|
|
||||||
setPath([root.Id])
|
|
||||||
setNavigated(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let subthreadPath = []
|
|
||||||
let parent = getParent(urlNoteHex, chains)
|
|
||||||
while (parent) {
|
|
||||||
subthreadPath.unshift(parent)
|
|
||||||
parent = getParent(parent, chains)
|
|
||||||
}
|
|
||||||
setPath(subthreadPath)
|
|
||||||
setNavigated(true)
|
|
||||||
}, [root, navigated, urlNoteHex, chains])
|
|
||||||
|
|
||||||
const brokenChains = useMemo(() => {
|
|
||||||
return Array.from(chains?.keys()).filter(a => !parsedNotes?.some(b => b.Id === a));
|
|
||||||
}, [chains]);
|
|
||||||
|
|
||||||
function renderRoot(note: NEvent) {
|
|
||||||
const className = `thread-root ${isSingleNote ? 'thread-root-single' : ''}`
|
|
||||||
if (note) {
|
|
||||||
return <Note className={className} key={note.Id} data-ev={note} related={notes} />
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<NoteGhost className={className}>
|
|
||||||
Loading thread root.. ({notes?.length} notes loaded)
|
|
||||||
</NoteGhost>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return chains;
|
||||||
|
}, [notes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onNavigate(to: u256) {
|
if (navigated) {
|
||||||
setPath([...path, to])
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChain(from: u256): ReactNode {
|
if (root.Id === urlNoteHex) {
|
||||||
if (!from || !chains) {
|
setPath([root.Id]);
|
||||||
return
|
setNavigated(true);
|
||||||
}
|
return;
|
||||||
let replies = chains.get(from);
|
|
||||||
if (replies) {
|
|
||||||
return <Subthread active={urlNoteHex} path={path} from={from} notes={replies} related={notes} chains={chains} onNavigate={onNavigate} />
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
let subthreadPath = [];
|
||||||
if (path.length > 1) {
|
let parent = getParent(urlNoteHex, chains);
|
||||||
const newPath = path.slice(0, path.length - 1)
|
while (parent) {
|
||||||
setPath(newPath)
|
subthreadPath.unshift(parent);
|
||||||
} else {
|
parent = getParent(parent, chains);
|
||||||
navigate("/")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
setPath(subthreadPath);
|
||||||
|
setNavigated(true);
|
||||||
|
}, [root, navigated, urlNoteHex, chains]);
|
||||||
|
|
||||||
return (
|
const brokenChains = useMemo(() => {
|
||||||
<div className="main-content mt10">
|
return Array.from(chains?.keys()).filter(
|
||||||
<BackButton onClick={goBack} text={path?.length > 1 ? "Parent" : "Back"} />
|
(a) => !parsedNotes?.some((b) => b.Id === a)
|
||||||
<div className="thread-container">
|
|
||||||
{currentRoot && renderRoot(currentRoot)}
|
|
||||||
{currentRoot && renderChain(currentRoot.Id)}
|
|
||||||
{currentRoot === root && (
|
|
||||||
<>
|
|
||||||
{brokenChains.length > 0 && <h3>Other replies</h3>}
|
|
||||||
{brokenChains.map(a => {
|
|
||||||
return (
|
|
||||||
<div className="mb10">
|
|
||||||
<NoteGhost className={`thread-note thread-root ghost-root`} key={a}>
|
|
||||||
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
|
|
||||||
</NoteGhost>
|
|
||||||
{renderChain(a)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
}, [chains]);
|
||||||
|
|
||||||
|
function renderRoot(note: NEvent) {
|
||||||
|
const className = `thread-root ${isSingleNote ? "thread-root-single" : ""}`;
|
||||||
|
if (note) {
|
||||||
|
return (
|
||||||
|
<Note
|
||||||
|
className={className}
|
||||||
|
key={note.Id}
|
||||||
|
data-ev={note}
|
||||||
|
related={notes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<NoteGhost className={className}>
|
||||||
|
Loading thread root.. ({notes?.length} notes loaded)
|
||||||
|
</NoteGhost>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNavigate(to: u256) {
|
||||||
|
setPath([...path, to]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChain(from: u256): ReactNode {
|
||||||
|
if (!from || !chains) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let replies = chains.get(from);
|
||||||
|
if (replies) {
|
||||||
|
return (
|
||||||
|
<Subthread
|
||||||
|
active={urlNoteHex}
|
||||||
|
path={path}
|
||||||
|
from={from}
|
||||||
|
notes={replies}
|
||||||
|
related={notes}
|
||||||
|
chains={chains}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (path.length > 1) {
|
||||||
|
const newPath = path.slice(0, path.length - 1);
|
||||||
|
setPath(newPath);
|
||||||
|
} else {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="main-content mt10">
|
||||||
|
<BackButton
|
||||||
|
onClick={goBack}
|
||||||
|
text={path?.length > 1 ? "Parent" : "Back"}
|
||||||
|
/>
|
||||||
|
<div className="thread-container">
|
||||||
|
{currentRoot && renderRoot(currentRoot)}
|
||||||
|
{currentRoot && renderChain(currentRoot.Id)}
|
||||||
|
{currentRoot === root && (
|
||||||
|
<>
|
||||||
|
{brokenChains.length > 0 && <h3>Other replies</h3>}
|
||||||
|
{brokenChains.map((a) => {
|
||||||
|
return (
|
||||||
|
<div className="mb10">
|
||||||
|
<NoteGhost
|
||||||
|
className={`thread-note thread-root ghost-root`}
|
||||||
|
key={a}
|
||||||
|
>
|
||||||
|
Missing event{" "}
|
||||||
|
<Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
|
||||||
|
</NoteGhost>
|
||||||
|
{renderChain(a)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReplies(from: u256, chains?: Map<u256, NEvent[]>): NEvent[] {
|
function getReplies(from: u256, chains?: Map<u256, NEvent[]>): NEvent[] {
|
||||||
if (!from || !chains) {
|
if (!from || !chains) {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
let replies = chains.get(from);
|
let replies = chains.get(from);
|
||||||
return replies ? replies : []
|
return replies ? replies : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,47 +4,70 @@ import { TidalRegex } from "Const";
|
|||||||
// Re-use dom parser across instances of TidalEmbed
|
// Re-use dom parser across instances of TidalEmbed
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
async function oembedLookup (link: string) {
|
async function oembedLookup(link: string) {
|
||||||
// Regex + re-construct to handle both tidal.com/type/id and tidal.com/browse/type/id links.
|
// Regex + re-construct to handle both tidal.com/type/id and tidal.com/browse/type/id links.
|
||||||
const regexResult = TidalRegex.exec(link);
|
const regexResult = TidalRegex.exec(link);
|
||||||
|
|
||||||
if (!regexResult) {
|
if (!regexResult) {
|
||||||
return Promise.reject('Not a TIDAL link.');
|
return Promise.reject("Not a TIDAL link.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [, productType, productId] = regexResult;
|
const [, productType, productId] = regexResult;
|
||||||
const oembedApi = `https://oembed.tidal.com/?url=https://tidal.com/browse/${productType}/${productId}`;
|
const oembedApi = `https://oembed.tidal.com/?url=https://tidal.com/browse/${productType}/${productId}`;
|
||||||
|
|
||||||
const apiResponse = await fetch(oembedApi);
|
const apiResponse = await fetch(oembedApi);
|
||||||
const json = await apiResponse.json();
|
const json = await apiResponse.json();
|
||||||
|
|
||||||
const doc = domParser.parseFromString(json.html, 'text/html');
|
const doc = domParser.parseFromString(json.html, "text/html");
|
||||||
const iframe = doc.querySelector('iframe');
|
const iframe = doc.querySelector("iframe");
|
||||||
|
|
||||||
if (!iframe) {
|
if (!iframe) {
|
||||||
return Promise.reject('No iframe delivered.');
|
return Promise.reject("No iframe delivered.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: iframe.getAttribute('src'),
|
source: iframe.getAttribute("src"),
|
||||||
height: json.height
|
height: json.height,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const TidalEmbed = ({ link }: { link: string }) => {
|
const TidalEmbed = ({ link }: { link: string }) => {
|
||||||
const [source, setSource] = useState<string>();
|
const [source, setSource] = useState<string>();
|
||||||
const [height, setHeight] = useState<number>();
|
const [height, setHeight] = useState<number>();
|
||||||
const extraStyles = link.includes('video') ? { aspectRatio: "16 / 9" } : { height };
|
const extraStyles = link.includes("video")
|
||||||
|
? { aspectRatio: "16 / 9" }
|
||||||
|
: { height };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
oembedLookup(link).then(data => {
|
oembedLookup(link)
|
||||||
setSource(data.source || undefined);
|
.then((data) => {
|
||||||
setHeight(data.height);
|
setSource(data.source || undefined);
|
||||||
}).catch(console.error);
|
setHeight(data.height);
|
||||||
}, [link]);
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, [link]);
|
||||||
|
|
||||||
if (!source) return <a href={link} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} className="ext">{link}</a>;
|
if (!source)
|
||||||
return <iframe src={source} style={extraStyles} width="100%" title="TIDAL Embed" frameBorder={0} />;
|
return (
|
||||||
}
|
<a
|
||||||
|
href={link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="ext"
|
||||||
|
>
|
||||||
|
{link}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={source}
|
||||||
|
style={extraStyles}
|
||||||
|
width="100%"
|
||||||
|
title="TIDAL Embed"
|
||||||
|
frameBorder={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default TidalEmbed;
|
export default TidalEmbed;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
.latest-notes {
|
.latest-notes {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
@ -15,68 +15,97 @@ import ProfilePreview from "./ProfilePreview";
|
|||||||
import Skeleton from "Element/Skeleton";
|
import Skeleton from "Element/Skeleton";
|
||||||
|
|
||||||
export interface TimelineProps {
|
export interface TimelineProps {
|
||||||
postsOnly: boolean,
|
postsOnly: boolean;
|
||||||
subject: TimelineSubject,
|
subject: TimelineSubject;
|
||||||
method: "TIME_RANGE" | "LIMIT_UNTIL"
|
method: "TIME_RANGE" | "LIMIT_UNTIL";
|
||||||
ignoreModeration?: boolean,
|
ignoreModeration?: boolean;
|
||||||
window?: number
|
window?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of notes by pubkeys
|
* A list of notes by pubkeys
|
||||||
*/
|
*/
|
||||||
export default function Timeline({ subject, postsOnly = false, method, ignoreModeration = false, window }: TimelineProps) {
|
export default function Timeline({
|
||||||
const { muted, isMuted } = useModeration();
|
subject,
|
||||||
const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, {
|
postsOnly = false,
|
||||||
method,
|
method,
|
||||||
window: window
|
ignoreModeration = false,
|
||||||
|
window,
|
||||||
|
}: TimelineProps) {
|
||||||
|
const { muted, isMuted } = useModeration();
|
||||||
|
const { main, related, latest, parent, loadMore, showLatest } =
|
||||||
|
useTimelineFeed(subject, {
|
||||||
|
method,
|
||||||
|
window: window,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filterPosts = useCallback((nts: TaggedRawEvent[]) => {
|
const filterPosts = useCallback(
|
||||||
return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => ignoreModeration || !isMuted(a.pubkey));
|
(nts: TaggedRawEvent[]) => {
|
||||||
}, [postsOnly, muted]);
|
return [...nts]
|
||||||
|
.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
?.filter((a) => (postsOnly ? !a.tags.some((b) => b[0] === "e") : true))
|
||||||
|
.filter((a) => ignoreModeration || !isMuted(a.pubkey));
|
||||||
|
},
|
||||||
|
[postsOnly, muted]
|
||||||
|
);
|
||||||
|
|
||||||
const mainFeed = useMemo(() => {
|
const mainFeed = useMemo(() => {
|
||||||
return filterPosts(main.notes);
|
return filterPosts(main.notes);
|
||||||
}, [main, filterPosts]);
|
}, [main, filterPosts]);
|
||||||
|
|
||||||
const latestFeed = useMemo(() => {
|
const latestFeed = useMemo(() => {
|
||||||
return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id))
|
return filterPosts(latest.notes).filter(
|
||||||
}, [latest, mainFeed, filterPosts]);
|
(a) => !mainFeed.some((b) => b.id === a.id)
|
||||||
|
|
||||||
function eventElement(e: TaggedRawEvent) {
|
|
||||||
switch (e.kind) {
|
|
||||||
case EventKind.SetMetadata: {
|
|
||||||
return <ProfilePreview pubkey={e.pubkey} className="card" />
|
|
||||||
}
|
|
||||||
case EventKind.TextNote: {
|
|
||||||
return <Note key={e.id} data={e} related={related.notes} ignoreModeration={ignoreModeration} />
|
|
||||||
}
|
|
||||||
case EventKind.ZapReceipt: {
|
|
||||||
const zap = parseZap(e)
|
|
||||||
return zap.e ? null : <Zap zap={zap} key={e.id} />
|
|
||||||
}
|
|
||||||
case EventKind.Reaction:
|
|
||||||
case EventKind.Repost: {
|
|
||||||
let eRef = e.tags.find(a => a[0] === "e")?.at(1);
|
|
||||||
return <NoteReaction data={e} key={e.id} root={parent.notes.find(a => a.id === eRef)} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="main-content">
|
|
||||||
{latestFeed.length > 1 && (<div className="card latest-notes pointer" onClick={() => showLatest()}>
|
|
||||||
<FontAwesomeIcon icon={faForward} size="xl" />
|
|
||||||
|
|
||||||
Show latest {latestFeed.length - 1} notes
|
|
||||||
</div>)}
|
|
||||||
{mainFeed.map(eventElement)}
|
|
||||||
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}>
|
|
||||||
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
|
|
||||||
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
|
|
||||||
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
|
|
||||||
</LoadMore>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
}, [latest, mainFeed, filterPosts]);
|
||||||
|
|
||||||
|
function eventElement(e: TaggedRawEvent) {
|
||||||
|
switch (e.kind) {
|
||||||
|
case EventKind.SetMetadata: {
|
||||||
|
return <ProfilePreview pubkey={e.pubkey} className="card" />;
|
||||||
|
}
|
||||||
|
case EventKind.TextNote: {
|
||||||
|
return (
|
||||||
|
<Note
|
||||||
|
key={e.id}
|
||||||
|
data={e}
|
||||||
|
related={related.notes}
|
||||||
|
ignoreModeration={ignoreModeration}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case EventKind.ZapReceipt: {
|
||||||
|
const zap = parseZap(e);
|
||||||
|
return zap.e ? null : <Zap zap={zap} key={e.id} />;
|
||||||
|
}
|
||||||
|
case EventKind.Reaction:
|
||||||
|
case EventKind.Repost: {
|
||||||
|
let eRef = e.tags.find((a) => a[0] === "e")?.at(1);
|
||||||
|
return (
|
||||||
|
<NoteReaction
|
||||||
|
data={e}
|
||||||
|
key={e.id}
|
||||||
|
root={parent.notes.find((a) => a.id === eRef)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="main-content">
|
||||||
|
{latestFeed.length > 1 && (
|
||||||
|
<div className="card latest-notes pointer" onClick={() => showLatest()}>
|
||||||
|
<FontAwesomeIcon icon={faForward} size="xl" />
|
||||||
|
Show latest {latestFeed.length - 1} notes
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mainFeed.map(eventElement)}
|
||||||
|
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}>
|
||||||
|
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
|
||||||
|
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
|
||||||
|
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
|
||||||
|
</LoadMore>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
.pill.unread {
|
.pill.unread {
|
||||||
background-color: var(--gray);
|
background-color: var(--gray);
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill:hover {
|
.pill:hover {
|
||||||
|
@ -1,11 +1,7 @@
|
|||||||
import "./UnreadCount.css"
|
import "./UnreadCount.css";
|
||||||
|
|
||||||
const UnreadCount = ({ unread }: { unread: number }) => {
|
const UnreadCount = ({ unread }: { unread: number }) => {
|
||||||
return (
|
return <span className={`pill ${unread > 0 ? "unread" : ""}`}>{unread}</span>;
|
||||||
<span className={`pill ${unread > 0 ? 'unread' : ''}`}>
|
};
|
||||||
{unread}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UnreadCount
|
export default UnreadCount;
|
||||||
|
@ -41,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-zap .amount:before {
|
.top-zap .amount:before {
|
||||||
content: '';
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-zap .summary {
|
.top-zap .summary {
|
||||||
@ -66,7 +66,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-zap .pfp {
|
.top-zap .pfp {
|
||||||
margin-right: .3em;
|
margin-right: 0.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-zap .avatar {
|
.top-zap .avatar {
|
||||||
|
@ -16,28 +16,32 @@ import { RootState } from "State/Store";
|
|||||||
|
|
||||||
function findTag(e: TaggedRawEvent, tag: string) {
|
function findTag(e: TaggedRawEvent, tag: string) {
|
||||||
const maybeTag = e.tags.find((evTag) => {
|
const maybeTag = e.tags.find((evTag) => {
|
||||||
return evTag[0] === tag
|
return evTag[0] === tag;
|
||||||
})
|
});
|
||||||
return maybeTag && maybeTag[1]
|
return maybeTag && maybeTag[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInvoice(zap: TaggedRawEvent) {
|
function getInvoice(zap: TaggedRawEvent) {
|
||||||
const bolt11 = findTag(zap, 'bolt11')
|
const bolt11 = findTag(zap, "bolt11");
|
||||||
const decoded = invoiceDecode(bolt11)
|
const decoded = invoiceDecode(bolt11);
|
||||||
|
|
||||||
const amount = decoded.sections.find((section: any) => section.name === 'amount')?.value
|
const amount = decoded.sections.find(
|
||||||
const hash = decoded.sections.find((section: any) => section.name === 'description_hash')?.value;
|
(section: any) => section.name === "amount"
|
||||||
|
)?.value;
|
||||||
|
const hash = decoded.sections.find(
|
||||||
|
(section: any) => section.name === "description_hash"
|
||||||
|
)?.value;
|
||||||
|
|
||||||
return { amount, hash: hash ? bytesToHex(hash) : undefined };
|
return { amount, hash: hash ? bytesToHex(hash) : undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Zapper {
|
interface Zapper {
|
||||||
pubkey?: HexKey,
|
pubkey?: HexKey;
|
||||||
isValid: boolean
|
isValid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getZapper(zap: TaggedRawEvent, dhash: string): Zapper {
|
function getZapper(zap: TaggedRawEvent, dhash: string): Zapper {
|
||||||
const zapRequest = findTag(zap, 'description')
|
const zapRequest = findTag(zap, "description");
|
||||||
if (zapRequest) {
|
if (zapRequest) {
|
||||||
const rawEvent: TaggedRawEvent = JSON.parse(zapRequest);
|
const rawEvent: TaggedRawEvent = JSON.parse(zapRequest);
|
||||||
if (Array.isArray(rawEvent)) {
|
if (Array.isArray(rawEvent)) {
|
||||||
@ -45,27 +49,27 @@ function getZapper(zap: TaggedRawEvent, dhash: string): Zapper {
|
|||||||
return { isValid: false };
|
return { isValid: false };
|
||||||
}
|
}
|
||||||
const metaHash = sha256(zapRequest);
|
const metaHash = sha256(zapRequest);
|
||||||
const ev = new Event(rawEvent)
|
const ev = new Event(rawEvent);
|
||||||
return { pubkey: ev.PubKey, isValid: dhash === metaHash };
|
return { pubkey: ev.PubKey, isValid: dhash === metaHash };
|
||||||
}
|
}
|
||||||
return { isValid: false }
|
return { isValid: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedZap {
|
interface ParsedZap {
|
||||||
id: HexKey
|
id: HexKey;
|
||||||
e?: HexKey
|
e?: HexKey;
|
||||||
p: HexKey
|
p: HexKey;
|
||||||
amount: number
|
amount: number;
|
||||||
content: string
|
content: string;
|
||||||
zapper?: HexKey
|
zapper?: HexKey;
|
||||||
valid: boolean
|
valid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseZap(zap: TaggedRawEvent): ParsedZap {
|
export function parseZap(zap: TaggedRawEvent): ParsedZap {
|
||||||
const { amount, hash } = getInvoice(zap)
|
const { amount, hash } = getInvoice(zap);
|
||||||
const zapper = hash ? getZapper(zap, hash) : { isValid: false };
|
const zapper = hash ? getZapper(zap, hash) : { isValid: false };
|
||||||
const e = findTag(zap, 'e')
|
const e = findTag(zap, "e");
|
||||||
const p = findTag(zap, 'p')!
|
const p = findTag(zap, "p")!;
|
||||||
return {
|
return {
|
||||||
id: zap.id,
|
id: zap.id,
|
||||||
e,
|
e,
|
||||||
@ -74,12 +78,18 @@ export function parseZap(zap: TaggedRawEvent): ParsedZap {
|
|||||||
zapper: zapper.pubkey,
|
zapper: zapper.pubkey,
|
||||||
content: zap.content,
|
content: zap.content,
|
||||||
valid: zapper.isValid,
|
valid: zapper.isValid,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap, showZapped?: boolean }) => {
|
const Zap = ({
|
||||||
const { amount, content, zapper, valid, p } = zap
|
zap,
|
||||||
const pubKey = useSelector((s: RootState) => s.login.publicKey)
|
showZapped = true,
|
||||||
|
}: {
|
||||||
|
zap: ParsedZap;
|
||||||
|
showZapped?: boolean;
|
||||||
|
}) => {
|
||||||
|
const { amount, content, zapper, valid, p } = zap;
|
||||||
|
const pubKey = useSelector((s: RootState) => s.login.publicKey);
|
||||||
|
|
||||||
return valid ? (
|
return valid ? (
|
||||||
<div className="zap note card">
|
<div className="zap note card">
|
||||||
@ -99,26 +109,28 @@ const Zap = ({ zap, showZapped = true }: { zap: ParsedZap, showZapped?: boolean
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface ZapsSummaryProps { zaps: ParsedZap[] }
|
interface ZapsSummaryProps {
|
||||||
|
zaps: ParsedZap[];
|
||||||
|
}
|
||||||
|
|
||||||
export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
|
export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
|
||||||
const sortedZaps = useMemo(() => {
|
const sortedZaps = useMemo(() => {
|
||||||
const pub = [...zaps.filter(z => z.zapper)]
|
const pub = [...zaps.filter((z) => z.zapper)];
|
||||||
const priv = [...zaps.filter(z => !z.zapper)]
|
const priv = [...zaps.filter((z) => !z.zapper)];
|
||||||
pub.sort((a, b) => b.amount - a.amount)
|
pub.sort((a, b) => b.amount - a.amount);
|
||||||
return pub.concat(priv)
|
return pub.concat(priv);
|
||||||
}, [zaps])
|
}, [zaps]);
|
||||||
|
|
||||||
if (zaps.length === 0) {
|
if (zaps.length === 0) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [topZap, ...restZaps] = sortedZaps
|
const [topZap, ...restZaps] = sortedZaps;
|
||||||
const restZapsTotal = restZaps.reduce((acc, z) => acc + z.amount, 0)
|
const restZapsTotal = restZaps.reduce((acc, z) => acc + z.amount, 0);
|
||||||
const { zapper, amount, content, valid } = topZap
|
const { zapper, amount, content, valid } = topZap;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="zaps-summary">
|
<div className="zaps-summary">
|
||||||
@ -127,14 +139,16 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
|
|||||||
<div className="summary">
|
<div className="summary">
|
||||||
{zapper && <ProfileImage pubkey={zapper} />}
|
{zapper && <ProfileImage pubkey={zapper} />}
|
||||||
{restZaps.length > 0 && (
|
{restZaps.length > 0 && (
|
||||||
<span>and {restZaps.length} other{restZaps.length > 1 ? 's' : ''}</span>
|
<span>
|
||||||
|
and {restZaps.length} other{restZaps.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span> zapped</span>
|
<span> zapped</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Zap
|
export default Zap;
|
||||||
|
@ -6,22 +6,27 @@ import { useUserProfile } from "Feed/ProfileFeed";
|
|||||||
import { HexKey } from "Nostr";
|
import { HexKey } from "Nostr";
|
||||||
import SendSats from "Element/SendSats";
|
import SendSats from "Element/SendSats";
|
||||||
|
|
||||||
|
const ZapButton = ({ pubkey, svc }: { pubkey?: HexKey; svc?: string }) => {
|
||||||
|
const profile = useUserProfile(pubkey!);
|
||||||
|
const [zap, setZap] = useState(false);
|
||||||
|
const service = svc ?? (profile?.lud16 || profile?.lud06);
|
||||||
|
|
||||||
const ZapButton = ({ pubkey, svc }: { pubkey?: HexKey, svc?: string }) => {
|
if (!service) return null;
|
||||||
const profile = useUserProfile(pubkey!)
|
|
||||||
const [zap, setZap] = useState(false);
|
|
||||||
const service = svc ?? (profile?.lud16 || profile?.lud06);
|
|
||||||
|
|
||||||
if (!service) return null;
|
return (
|
||||||
|
<>
|
||||||
return (
|
<div className="zap-button" onClick={(e) => setZap(true)}>
|
||||||
<>
|
<FontAwesomeIcon icon={faBolt} />
|
||||||
<div className="zap-button" onClick={(e) => setZap(true)}>
|
</div>
|
||||||
<FontAwesomeIcon icon={faBolt} />
|
<SendSats
|
||||||
</div>
|
target={profile?.display_name || profile?.name}
|
||||||
<SendSats target={profile?.display_name || profile?.name} svc={service} show={zap} onClose={() => setZap(false)} author={pubkey} />
|
svc={service}
|
||||||
</>
|
show={zap}
|
||||||
)
|
onClose={() => setZap(false)}
|
||||||
}
|
author={pubkey}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ZapButton;
|
export default ZapButton;
|
||||||
|
@ -6,342 +6,371 @@ import EventKind from "Nostr/EventKind";
|
|||||||
import Tag from "Nostr/Tag";
|
import Tag from "Nostr/Tag";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr";
|
import { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr";
|
||||||
import { bech32ToHex } from "Util"
|
import { bech32ToHex } from "Util";
|
||||||
import { DefaultRelays, HashtagRegex } from "Const";
|
import { DefaultRelays, HashtagRegex } from "Const";
|
||||||
import { RelaySettings } from "Nostr/Connection";
|
import { RelaySettings } from "Nostr/Connection";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
nostr: {
|
nostr: {
|
||||||
getPublicKey: () => Promise<HexKey>,
|
getPublicKey: () => Promise<HexKey>;
|
||||||
signEvent: (event: RawEvent) => Promise<RawEvent>,
|
signEvent: (event: RawEvent) => Promise<RawEvent>;
|
||||||
getRelays: () => Promise<Record<string, { read: boolean, write: boolean }>>,
|
getRelays: () => Promise<
|
||||||
nip04: {
|
Record<string, { read: boolean; write: boolean }>
|
||||||
encrypt: (pubkey: HexKey, content: string) => Promise<string>,
|
>;
|
||||||
decrypt: (pubkey: HexKey, content: string) => Promise<string>
|
nip04: {
|
||||||
}
|
encrypt: (pubkey: HexKey, content: string) => Promise<string>;
|
||||||
}
|
decrypt: (pubkey: HexKey, content: string) => Promise<string>;
|
||||||
}
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useEventPublisher() {
|
export default function useEventPublisher() {
|
||||||
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
const pubKey = useSelector<RootState, HexKey | undefined>(
|
||||||
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
|
(s) => s.login.publicKey
|
||||||
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
|
);
|
||||||
const relays = useSelector((s: RootState) => s.login.relays);
|
const privKey = useSelector<RootState, HexKey | undefined>(
|
||||||
const hasNip07 = 'nostr' in window;
|
(s) => s.login.privateKey
|
||||||
|
);
|
||||||
|
const follows = useSelector<RootState, HexKey[]>((s) => s.login.follows);
|
||||||
|
const relays = useSelector((s: RootState) => s.login.relays);
|
||||||
|
const hasNip07 = "nostr" in window;
|
||||||
|
|
||||||
async function signEvent(ev: NEvent): Promise<NEvent> {
|
async function signEvent(ev: NEvent): Promise<NEvent> {
|
||||||
if (hasNip07 && !privKey) {
|
if (hasNip07 && !privKey) {
|
||||||
ev.Id = await ev.CreateId();
|
ev.Id = await ev.CreateId();
|
||||||
let tmpEv = await barierNip07(() => window.nostr.signEvent(ev.ToObject()));
|
let tmpEv = await barierNip07(() =>
|
||||||
return new NEvent(tmpEv);
|
window.nostr.signEvent(ev.ToObject())
|
||||||
} else if (privKey) {
|
);
|
||||||
await ev.Sign(privKey);
|
return new NEvent(tmpEv);
|
||||||
} else {
|
} else if (privKey) {
|
||||||
console.warn("Count not sign event, no private keys available");
|
await ev.Sign(privKey);
|
||||||
}
|
} else {
|
||||||
return ev;
|
console.warn("Count not sign event, no private keys available");
|
||||||
}
|
}
|
||||||
|
return ev;
|
||||||
|
}
|
||||||
|
|
||||||
function processContent(ev: NEvent, msg: string) {
|
function processContent(ev: NEvent, msg: string) {
|
||||||
const replaceNpub = (match: string) => {
|
const replaceNpub = (match: string) => {
|
||||||
const npub = match.slice(1);
|
const npub = match.slice(1);
|
||||||
try {
|
try {
|
||||||
const hex = bech32ToHex(npub);
|
const hex = bech32ToHex(npub);
|
||||||
const idx = ev.Tags.length;
|
const idx = ev.Tags.length;
|
||||||
ev.Tags.push(new Tag(["p", hex], idx));
|
ev.Tags.push(new Tag(["p", hex], idx));
|
||||||
return `#[${idx}]`
|
return `#[${idx}]`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return match
|
return match;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const replaceNoteId = (match: string) => {
|
||||||
|
try {
|
||||||
|
const hex = bech32ToHex(match);
|
||||||
|
const idx = ev.Tags.length;
|
||||||
|
ev.Tags.push(new Tag(["e", hex, "", "mention"], idx));
|
||||||
|
return `#[${idx}]`;
|
||||||
|
} catch (error) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const replaceHashtag = (match: string) => {
|
||||||
|
const tag = match.slice(1);
|
||||||
|
const idx = ev.Tags.length;
|
||||||
|
ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx));
|
||||||
|
return match;
|
||||||
|
};
|
||||||
|
const content = msg
|
||||||
|
.replace(/@npub[a-z0-9]+/g, replaceNpub)
|
||||||
|
.replace(/note[a-z0-9]+/g, replaceNoteId)
|
||||||
|
.replace(HashtagRegex, replaceHashtag);
|
||||||
|
ev.Content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nip42Auth: async (challenge: string, relay: string) => {
|
||||||
|
if (pubKey) {
|
||||||
|
const ev = NEvent.ForPubKey(pubKey);
|
||||||
|
ev.Kind = EventKind.Auth;
|
||||||
|
ev.Content = "";
|
||||||
|
ev.Tags.push(new Tag(["relay", relay], 0));
|
||||||
|
ev.Tags.push(new Tag(["challenge", challenge], 1));
|
||||||
|
return await signEvent(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
broadcast: (ev: NEvent | undefined) => {
|
||||||
|
if (ev) {
|
||||||
|
console.debug("Sending event: ", ev);
|
||||||
|
System.BroadcastEvent(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Write event to DefaultRelays, this is important for profiles / relay lists to prevent bugs
|
||||||
|
* If a user removes all the DefaultRelays from their relay list and saves that relay list,
|
||||||
|
* When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state
|
||||||
|
*/
|
||||||
|
broadcastForBootstrap: (ev: NEvent | undefined) => {
|
||||||
|
if (ev) {
|
||||||
|
for (let [k, _] of DefaultRelays) {
|
||||||
|
System.WriteOnceToRelay(k, ev);
|
||||||
}
|
}
|
||||||
const replaceNoteId = (match: string) => {
|
}
|
||||||
try {
|
},
|
||||||
const hex = bech32ToHex(match);
|
muted: async (keys: HexKey[], priv: HexKey[]) => {
|
||||||
const idx = ev.Tags.length;
|
if (pubKey) {
|
||||||
ev.Tags.push(new Tag(["e", hex, "", "mention"], idx));
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
return `#[${idx}]`
|
ev.Kind = EventKind.Lists;
|
||||||
} catch (error) {
|
ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length));
|
||||||
return match
|
keys.forEach((p) => {
|
||||||
}
|
ev.Tags.push(new Tag(["p", p], ev.Tags.length));
|
||||||
|
});
|
||||||
|
let content = "";
|
||||||
|
if (priv.length > 0) {
|
||||||
|
const ps = priv.map((p) => ["p", p]);
|
||||||
|
const plaintext = JSON.stringify(ps);
|
||||||
|
if (hasNip07 && !privKey) {
|
||||||
|
content = await barierNip07(() =>
|
||||||
|
window.nostr.nip04.encrypt(pubKey, plaintext)
|
||||||
|
);
|
||||||
|
} else if (privKey) {
|
||||||
|
content = await ev.EncryptData(plaintext, pubKey, privKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const replaceHashtag = (match: string) => {
|
|
||||||
const tag = match.slice(1);
|
|
||||||
const idx = ev.Tags.length;
|
|
||||||
ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx));
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
const content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub)
|
|
||||||
.replace(/note[a-z0-9]+/g, replaceNoteId)
|
|
||||||
.replace(HashtagRegex, replaceHashtag);
|
|
||||||
ev.Content = content;
|
ev.Content = content;
|
||||||
}
|
return await signEvent(ev);
|
||||||
|
}
|
||||||
return {
|
},
|
||||||
nip42Auth: async (challenge: string, relay: string) => {
|
metadata: async (obj: UserMetadata) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
ev.Kind = EventKind.Auth;
|
ev.Kind = EventKind.SetMetadata;
|
||||||
ev.Content = "";
|
ev.Content = JSON.stringify(obj);
|
||||||
ev.Tags.push(new Tag(["relay", relay], 0));
|
return await signEvent(ev);
|
||||||
ev.Tags.push(new Tag(["challenge", challenge], 1));
|
}
|
||||||
return await signEvent(ev);
|
},
|
||||||
}
|
note: async (msg: string) => {
|
||||||
},
|
if (pubKey) {
|
||||||
broadcast: (ev: NEvent | undefined) => {
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
if (ev) {
|
ev.Kind = EventKind.TextNote;
|
||||||
console.debug("Sending event: ", ev);
|
processContent(ev, msg);
|
||||||
System.BroadcastEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
zap: async (author: HexKey, note?: HexKey, msg?: string) => {
|
||||||
* Write event to DefaultRelays, this is important for profiles / relay lists to prevent bugs
|
if (pubKey) {
|
||||||
* If a user removes all the DefaultRelays from their relay list and saves that relay list,
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
* When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state
|
ev.Kind = EventKind.ZapRequest;
|
||||||
*/
|
if (note) {
|
||||||
broadcastForBootstrap: (ev: NEvent | undefined) => {
|
// @ts-ignore
|
||||||
if (ev) {
|
ev.Tags.push(new Tag(["e", note]));
|
||||||
for (let [k, _] of DefaultRelays) {
|
|
||||||
System.WriteOnceToRelay(k, ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
muted: async (keys: HexKey[], priv: HexKey[]) => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.Lists;
|
|
||||||
ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length))
|
|
||||||
keys.forEach(p => {
|
|
||||||
ev.Tags.push(new Tag(["p", p], ev.Tags.length))
|
|
||||||
})
|
|
||||||
let content = ""
|
|
||||||
if (priv.length > 0) {
|
|
||||||
const ps = priv.map(p => ["p", p])
|
|
||||||
const plaintext = JSON.stringify(ps)
|
|
||||||
if (hasNip07 && !privKey) {
|
|
||||||
content = await barierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext));
|
|
||||||
} else if (privKey) {
|
|
||||||
content = await ev.EncryptData(plaintext, pubKey, privKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ev.Content = content;
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
metadata: async (obj: UserMetadata) => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.SetMetadata;
|
|
||||||
ev.Content = JSON.stringify(obj);
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
note: async (msg: string) => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.TextNote;
|
|
||||||
processContent(ev, msg);
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
zap: async (author: HexKey, note?: HexKey, msg?: string) => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.ZapRequest;
|
|
||||||
if (note) {
|
|
||||||
// @ts-ignore
|
|
||||||
ev.Tags.push(new Tag(["e", note]))
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
ev.Tags.push(new Tag(["p", author]))
|
|
||||||
// @ts-ignore
|
|
||||||
const relayTag = ['relays', ...Object.keys(relays).slice(0, 10)]
|
|
||||||
// @ts-ignore
|
|
||||||
ev.Tags.push(new Tag(relayTag))
|
|
||||||
processContent(ev, msg || '');
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Reply to a note
|
|
||||||
*/
|
|
||||||
reply: async (replyTo: NEvent, msg: string) => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.TextNote;
|
|
||||||
|
|
||||||
let thread = replyTo.Thread;
|
|
||||||
if (thread) {
|
|
||||||
if (thread.Root || thread.ReplyTo) {
|
|
||||||
ev.Tags.push(new Tag(["e", thread.Root?.Event ?? thread.ReplyTo?.Event!, "", "root"], ev.Tags.length));
|
|
||||||
}
|
|
||||||
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length));
|
|
||||||
|
|
||||||
// dont tag self in replies
|
|
||||||
if (replyTo.PubKey !== pubKey) {
|
|
||||||
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let pk of thread.PubKeys) {
|
|
||||||
if (pk === pubKey) {
|
|
||||||
continue; // dont tag self in replies
|
|
||||||
}
|
|
||||||
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0));
|
|
||||||
// dont tag self in replies
|
|
||||||
if (replyTo.PubKey !== pubKey) {
|
|
||||||
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
processContent(ev, msg);
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
react: async (evRef: NEvent, content = "+") => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.Reaction;
|
|
||||||
ev.Content = content;
|
|
||||||
ev.Tags.push(new Tag(["e", evRef.Id], 0));
|
|
||||||
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
saveRelays: async () => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.ContactList;
|
|
||||||
ev.Content = JSON.stringify(relays);
|
|
||||||
for (let pk of follows) {
|
|
||||||
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addFollow: async (pkAdd: HexKey | HexKey[], newRelays?: Record<string, RelaySettings>) => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.ContactList;
|
|
||||||
ev.Content = JSON.stringify(newRelays ?? relays);
|
|
||||||
let temp = new Set(follows);
|
|
||||||
if (Array.isArray(pkAdd)) {
|
|
||||||
pkAdd.forEach(a => temp.add(a));
|
|
||||||
} else {
|
|
||||||
temp.add(pkAdd);
|
|
||||||
}
|
|
||||||
for (let pk of temp) {
|
|
||||||
if (pk.length !== 64) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
removeFollow: async (pkRemove: HexKey) => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.ContactList;
|
|
||||||
ev.Content = JSON.stringify(relays);
|
|
||||||
for (let pk of follows) {
|
|
||||||
if (pk === pkRemove || pk.length !== 64) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Delete an event (NIP-09)
|
|
||||||
*/
|
|
||||||
delete: async (id: u256) => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.Deletion;
|
|
||||||
ev.Content = "";
|
|
||||||
ev.Tags.push(new Tag(["e", id], 0));
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Respot a note (NIP-18)
|
|
||||||
*/
|
|
||||||
repost: async (note: NEvent) => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.Repost;
|
|
||||||
ev.Content = JSON.stringify(note.Original);
|
|
||||||
ev.Tags.push(new Tag(["e", note.Id], 0));
|
|
||||||
ev.Tags.push(new Tag(["p", note.PubKey], 1));
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
decryptDm: async (note: NEvent): Promise<string | undefined> => {
|
|
||||||
if (pubKey) {
|
|
||||||
if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === pubKey)) {
|
|
||||||
return "<CANT DECRYPT>";
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
let otherPubKey = note.PubKey === pubKey ? note.Tags.filter(a => a.Key === "p")[0].PubKey! : note.PubKey;
|
|
||||||
if (hasNip07 && !privKey) {
|
|
||||||
return await barierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.Content));
|
|
||||||
} else if (privKey) {
|
|
||||||
await note.DecryptDm(privKey, otherPubKey);
|
|
||||||
return note.Content;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Decyrption failed", e);
|
|
||||||
return "<DECRYPTION FAILED>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sendDm: async (content: string, to: HexKey) => {
|
|
||||||
if (pubKey) {
|
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
|
||||||
ev.Kind = EventKind.DirectMessage;
|
|
||||||
ev.Content = content;
|
|
||||||
ev.Tags.push(new Tag(["p", to], 0));
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (hasNip07 && !privKey) {
|
|
||||||
let cx: string = await barierNip07(() => window.nostr.nip04.encrypt(to, content));
|
|
||||||
ev.Content = cx;
|
|
||||||
return await signEvent(ev);
|
|
||||||
} else if (privKey) {
|
|
||||||
await ev.EncryptDmForPubkey(to, privKey);
|
|
||||||
return await signEvent(ev);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Encryption failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
// @ts-ignore
|
||||||
|
ev.Tags.push(new Tag(["p", author]));
|
||||||
|
// @ts-ignore
|
||||||
|
const relayTag = ["relays", ...Object.keys(relays).slice(0, 10)];
|
||||||
|
// @ts-ignore
|
||||||
|
ev.Tags.push(new Tag(relayTag));
|
||||||
|
processContent(ev, msg || "");
|
||||||
|
return await signEvent(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Reply to a note
|
||||||
|
*/
|
||||||
|
reply: async (replyTo: NEvent, msg: string) => {
|
||||||
|
if (pubKey) {
|
||||||
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
|
ev.Kind = EventKind.TextNote;
|
||||||
|
|
||||||
|
let thread = replyTo.Thread;
|
||||||
|
if (thread) {
|
||||||
|
if (thread.Root || thread.ReplyTo) {
|
||||||
|
ev.Tags.push(
|
||||||
|
new Tag(
|
||||||
|
["e", thread.Root?.Event ?? thread.ReplyTo?.Event!, "", "root"],
|
||||||
|
ev.Tags.length
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length));
|
||||||
|
|
||||||
|
// dont tag self in replies
|
||||||
|
if (replyTo.PubKey !== pubKey) {
|
||||||
|
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let pk of thread.PubKeys) {
|
||||||
|
if (pk === pubKey) {
|
||||||
|
continue; // dont tag self in replies
|
||||||
|
}
|
||||||
|
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0));
|
||||||
|
// dont tag self in replies
|
||||||
|
if (replyTo.PubKey !== pubKey) {
|
||||||
|
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processContent(ev, msg);
|
||||||
|
return await signEvent(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
react: async (evRef: NEvent, content = "+") => {
|
||||||
|
if (pubKey) {
|
||||||
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
|
ev.Kind = EventKind.Reaction;
|
||||||
|
ev.Content = content;
|
||||||
|
ev.Tags.push(new Tag(["e", evRef.Id], 0));
|
||||||
|
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
|
||||||
|
return await signEvent(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveRelays: async () => {
|
||||||
|
if (pubKey) {
|
||||||
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
|
ev.Kind = EventKind.ContactList;
|
||||||
|
ev.Content = JSON.stringify(relays);
|
||||||
|
for (let pk of follows) {
|
||||||
|
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await signEvent(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addFollow: async (
|
||||||
|
pkAdd: HexKey | HexKey[],
|
||||||
|
newRelays?: Record<string, RelaySettings>
|
||||||
|
) => {
|
||||||
|
if (pubKey) {
|
||||||
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
|
ev.Kind = EventKind.ContactList;
|
||||||
|
ev.Content = JSON.stringify(newRelays ?? relays);
|
||||||
|
let temp = new Set(follows);
|
||||||
|
if (Array.isArray(pkAdd)) {
|
||||||
|
pkAdd.forEach((a) => temp.add(a));
|
||||||
|
} else {
|
||||||
|
temp.add(pkAdd);
|
||||||
|
}
|
||||||
|
for (let pk of temp) {
|
||||||
|
if (pk.length !== 64) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await signEvent(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeFollow: async (pkRemove: HexKey) => {
|
||||||
|
if (pubKey) {
|
||||||
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
|
ev.Kind = EventKind.ContactList;
|
||||||
|
ev.Content = JSON.stringify(relays);
|
||||||
|
for (let pk of follows) {
|
||||||
|
if (pk === pkRemove || pk.length !== 64) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await signEvent(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Delete an event (NIP-09)
|
||||||
|
*/
|
||||||
|
delete: async (id: u256) => {
|
||||||
|
if (pubKey) {
|
||||||
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
|
ev.Kind = EventKind.Deletion;
|
||||||
|
ev.Content = "";
|
||||||
|
ev.Tags.push(new Tag(["e", id], 0));
|
||||||
|
return await signEvent(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Respot a note (NIP-18)
|
||||||
|
*/
|
||||||
|
repost: async (note: NEvent) => {
|
||||||
|
if (pubKey) {
|
||||||
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
|
ev.Kind = EventKind.Repost;
|
||||||
|
ev.Content = JSON.stringify(note.Original);
|
||||||
|
ev.Tags.push(new Tag(["e", note.Id], 0));
|
||||||
|
ev.Tags.push(new Tag(["p", note.PubKey], 1));
|
||||||
|
return await signEvent(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decryptDm: async (note: NEvent): Promise<string | undefined> => {
|
||||||
|
if (pubKey) {
|
||||||
|
if (
|
||||||
|
note.PubKey !== pubKey &&
|
||||||
|
!note.Tags.some((a) => a.PubKey === pubKey)
|
||||||
|
) {
|
||||||
|
return "<CANT DECRYPT>";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let otherPubKey =
|
||||||
|
note.PubKey === pubKey
|
||||||
|
? note.Tags.filter((a) => a.Key === "p")[0].PubKey!
|
||||||
|
: note.PubKey;
|
||||||
|
if (hasNip07 && !privKey) {
|
||||||
|
return await barierNip07(() =>
|
||||||
|
window.nostr.nip04.decrypt(otherPubKey, note.Content)
|
||||||
|
);
|
||||||
|
} else if (privKey) {
|
||||||
|
await note.DecryptDm(privKey, otherPubKey);
|
||||||
|
return note.Content;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Decyrption failed", e);
|
||||||
|
return "<DECRYPTION FAILED>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendDm: async (content: string, to: HexKey) => {
|
||||||
|
if (pubKey) {
|
||||||
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
|
ev.Kind = EventKind.DirectMessage;
|
||||||
|
ev.Content = content;
|
||||||
|
ev.Tags.push(new Tag(["p", to], 0));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (hasNip07 && !privKey) {
|
||||||
|
let cx: string = await barierNip07(() =>
|
||||||
|
window.nostr.nip04.encrypt(to, content)
|
||||||
|
);
|
||||||
|
ev.Content = cx;
|
||||||
|
return await signEvent(ev);
|
||||||
|
} else if (privKey) {
|
||||||
|
await ev.EncryptDmForPubkey(to, privKey);
|
||||||
|
return await signEvent(ev);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Encryption failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let isNip07Busy = false;
|
let isNip07Busy = false;
|
||||||
|
|
||||||
const delay = (t: number) => {
|
const delay = (t: number) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
setTimeout(resolve, t);
|
setTimeout(resolve, t);
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
export const barierNip07 = async (then: () => Promise<any>) => {
|
export const barierNip07 = async (then: () => Promise<any>) => {
|
||||||
while (isNip07Busy) {
|
while (isNip07Busy) {
|
||||||
await delay(10);
|
await delay(10);
|
||||||
}
|
}
|
||||||
isNip07Busy = true;
|
isNip07Busy = true;
|
||||||
try {
|
try {
|
||||||
return await then();
|
return await then();
|
||||||
} finally {
|
} finally {
|
||||||
isNip07Busy = false;
|
isNip07Busy = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -5,14 +5,14 @@ import { Subscriptions } from "Nostr/Subscriptions";
|
|||||||
import useSubscription from "Feed/Subscription";
|
import useSubscription from "Feed/Subscription";
|
||||||
|
|
||||||
export default function useFollowersFeed(pubkey: HexKey) {
|
export default function useFollowersFeed(pubkey: HexKey) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
let x = new Subscriptions();
|
let x = new Subscriptions();
|
||||||
x.Id = `followers:${pubkey.slice(0, 12)}`;
|
x.Id = `followers:${pubkey.slice(0, 12)}`;
|
||||||
x.Kinds = new Set([EventKind.ContactList]);
|
x.Kinds = new Set([EventKind.ContactList]);
|
||||||
x.PTags = new Set([pubkey]);
|
x.PTags = new Set([pubkey]);
|
||||||
|
|
||||||
return x;
|
return x;
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
return useSubscription(sub);
|
return useSubscription(sub);
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,28 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { HexKey } from "Nostr";
|
import { HexKey } from "Nostr";
|
||||||
import EventKind from "Nostr/EventKind";
|
import EventKind from "Nostr/EventKind";
|
||||||
import { Subscriptions} from "Nostr/Subscriptions";
|
import { Subscriptions } from "Nostr/Subscriptions";
|
||||||
import useSubscription, { NoteStore } from "Feed/Subscription";
|
import useSubscription, { NoteStore } from "Feed/Subscription";
|
||||||
|
|
||||||
export default function useFollowsFeed(pubkey: HexKey) {
|
export default function useFollowsFeed(pubkey: HexKey) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
let x = new Subscriptions();
|
let x = new Subscriptions();
|
||||||
x.Id = `follows:${pubkey.slice(0, 12)}`;
|
x.Id = `follows:${pubkey.slice(0, 12)}`;
|
||||||
x.Kinds = new Set([EventKind.ContactList]);
|
x.Kinds = new Set([EventKind.ContactList]);
|
||||||
x.Authors = new Set([pubkey]);
|
x.Authors = new Set([pubkey]);
|
||||||
|
|
||||||
return x;
|
return x;
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
return useSubscription(sub);
|
return useSubscription(sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFollowers(feed: NoteStore, pubkey: HexKey) {
|
export function getFollowers(feed: NoteStore, pubkey: HexKey) {
|
||||||
let contactLists = feed?.notes.filter(a => a.kind === EventKind.ContactList && a.pubkey === pubkey);
|
let contactLists = feed?.notes.filter(
|
||||||
let pTags = contactLists?.map(a => a.tags.filter(b => b[0] === "p").map(c => c[1]));
|
(a) => a.kind === EventKind.ContactList && a.pubkey === pubkey
|
||||||
return [...new Set(pTags?.flat())];
|
);
|
||||||
|
let pTags = contactLists?.map((a) =>
|
||||||
|
a.tags.filter((b) => b[0] === "p").map((c) => c[1])
|
||||||
|
);
|
||||||
|
return [...new Set(pTags?.flat())];
|
||||||
}
|
}
|
||||||
|
@ -1,39 +1,44 @@
|
|||||||
import * as secp from "@noble/secp256k1"
|
import * as secp from "@noble/secp256k1";
|
||||||
import * as base64 from "@protobufjs/base64"
|
import * as base64 from "@protobufjs/base64";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
|
|
||||||
export interface ImgProxySettings {
|
export interface ImgProxySettings {
|
||||||
url: string,
|
url: string;
|
||||||
key: string,
|
key: string;
|
||||||
salt: string
|
salt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useImgProxy() {
|
export default function useImgProxy() {
|
||||||
const settings = useSelector((s: RootState) => s.login.preferences.imgProxyConfig);
|
const settings = useSelector(
|
||||||
const te = new TextEncoder();
|
(s: RootState) => s.login.preferences.imgProxyConfig
|
||||||
|
);
|
||||||
|
const te = new TextEncoder();
|
||||||
|
|
||||||
function urlSafe(s: string) {
|
function urlSafe(s: string) {
|
||||||
return s.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
return s.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signUrl(u: string) {
|
async function signUrl(u: string) {
|
||||||
const result = await secp.utils.hmacSha256(
|
const result = await secp.utils.hmacSha256(
|
||||||
secp.utils.hexToBytes(settings!.key),
|
secp.utils.hexToBytes(settings!.key),
|
||||||
secp.utils.hexToBytes(settings!.salt),
|
secp.utils.hexToBytes(settings!.salt),
|
||||||
te.encode(u));
|
te.encode(u)
|
||||||
return urlSafe(base64.encode(result, 0, result.byteLength));
|
);
|
||||||
}
|
return urlSafe(base64.encode(result, 0, result.byteLength));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
proxy: async (url: string, resize?: number) => {
|
proxy: async (url: string, resize?: number) => {
|
||||||
if (!settings) return url;
|
if (!settings) return url;
|
||||||
const opt = resize ? `rs:fit:${resize}:${resize}` : "";
|
const opt = resize ? `rs:fit:${resize}:${resize}` : "";
|
||||||
const urlBytes = te.encode(url);
|
const urlBytes = te.encode(url);
|
||||||
const urlEncoded = urlSafe(base64.encode(urlBytes, 0, urlBytes.byteLength));
|
const urlEncoded = urlSafe(
|
||||||
const path = `/${opt}/${urlEncoded}`;
|
base64.encode(urlBytes, 0, urlBytes.byteLength)
|
||||||
const sig = await signUrl(path);
|
);
|
||||||
return `${new URL(settings.url).toString()}${sig}${path}`;
|
const path = `/${opt}/${urlEncoded}`;
|
||||||
}
|
const sig = await signUrl(path);
|
||||||
}
|
return `${new URL(settings.url).toString()}${sig}${path}`;
|
||||||
}
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -6,7 +6,15 @@ import { TaggedRawEvent, HexKey, Lists } from "Nostr";
|
|||||||
import EventKind from "Nostr/EventKind";
|
import EventKind from "Nostr/EventKind";
|
||||||
import Event from "Nostr/Event";
|
import Event from "Nostr/Event";
|
||||||
import { Subscriptions } from "Nostr/Subscriptions";
|
import { Subscriptions } from "Nostr/Subscriptions";
|
||||||
import { addDirectMessage, setFollows, setRelays, setMuted, setBlocked, sendNotification, setLatestNotifications } from "State/Login";
|
import {
|
||||||
|
addDirectMessage,
|
||||||
|
setFollows,
|
||||||
|
setRelays,
|
||||||
|
setMuted,
|
||||||
|
setBlocked,
|
||||||
|
sendNotification,
|
||||||
|
setLatestNotifications,
|
||||||
|
} from "State/Login";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { mapEventToProfile, MetadataCache } from "State/Users";
|
import { mapEventToProfile, MetadataCache } from "State/Users";
|
||||||
import { useDb } from "State/Users/Db";
|
import { useDb } from "State/Users/Db";
|
||||||
@ -20,7 +28,12 @@ import useModeration from "Hooks/useModeration";
|
|||||||
*/
|
*/
|
||||||
export default function useLoginFeed() {
|
export default function useLoginFeed() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { publicKey: pubKey, privateKey: privKey, latestMuted, readNotifications } = useSelector((s: RootState) => s.login);
|
const {
|
||||||
|
publicKey: pubKey,
|
||||||
|
privateKey: privKey,
|
||||||
|
latestMuted,
|
||||||
|
readNotifications,
|
||||||
|
} = useSelector((s: RootState) => s.login);
|
||||||
const { isMuted } = useModeration();
|
const { isMuted } = useModeration();
|
||||||
const db = useDb();
|
const db = useDb();
|
||||||
|
|
||||||
@ -31,7 +44,7 @@ export default function useLoginFeed() {
|
|||||||
sub.Id = `login:meta`;
|
sub.Id = `login:meta`;
|
||||||
sub.Authors = new Set([pubKey]);
|
sub.Authors = new Set([pubKey]);
|
||||||
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]);
|
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]);
|
||||||
sub.Limit = 2
|
sub.Limit = 2;
|
||||||
|
|
||||||
return sub;
|
return sub;
|
||||||
}, [pubKey]);
|
}, [pubKey]);
|
||||||
@ -77,35 +90,49 @@ export default function useLoginFeed() {
|
|||||||
return dms;
|
return dms;
|
||||||
}, [pubKey]);
|
}, [pubKey]);
|
||||||
|
|
||||||
const metadataFeed = useSubscription(subMetadata, { leaveOpen: true, cache: true });
|
const metadataFeed = useSubscription(subMetadata, {
|
||||||
const notificationFeed = useSubscription(subNotification, { leaveOpen: true, cache: true });
|
leaveOpen: true,
|
||||||
|
cache: true,
|
||||||
|
});
|
||||||
|
const notificationFeed = useSubscription(subNotification, {
|
||||||
|
leaveOpen: true,
|
||||||
|
cache: true,
|
||||||
|
});
|
||||||
const dmsFeed = useSubscription(subDms, { leaveOpen: true, cache: true });
|
const dmsFeed = useSubscription(subDms, { leaveOpen: true, cache: true });
|
||||||
const mutedFeed = useSubscription(subMuted, { leaveOpen: true, cache: true });
|
const mutedFeed = useSubscription(subMuted, { leaveOpen: true, cache: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList);
|
let contactList = metadataFeed.store.notes.filter(
|
||||||
let metadata = metadataFeed.store.notes.filter(a => a.kind === EventKind.SetMetadata);
|
(a) => a.kind === EventKind.ContactList
|
||||||
let profiles = metadata.map(a => mapEventToProfile(a))
|
);
|
||||||
.filter(a => a !== undefined)
|
let metadata = metadataFeed.store.notes.filter(
|
||||||
.map(a => a!);
|
(a) => a.kind === EventKind.SetMetadata
|
||||||
|
);
|
||||||
|
let profiles = metadata
|
||||||
|
.map((a) => mapEventToProfile(a))
|
||||||
|
.filter((a) => a !== undefined)
|
||||||
|
.map((a) => a!);
|
||||||
|
|
||||||
for (let cl of contactList) {
|
for (let cl of contactList) {
|
||||||
if (cl.content !== "" && cl.content !== "{}") {
|
if (cl.content !== "" && cl.content !== "{}") {
|
||||||
let relays = JSON.parse(cl.content);
|
let relays = JSON.parse(cl.content);
|
||||||
dispatch(setRelays({ relays, createdAt: cl.created_at }));
|
dispatch(setRelays({ relays, createdAt: cl.created_at }));
|
||||||
}
|
}
|
||||||
let pTags = cl.tags.filter(a => a[0] === "p").map(a => a[1]);
|
let pTags = cl.tags.filter((a) => a[0] === "p").map((a) => a[1]);
|
||||||
dispatch(setFollows({ keys: pTags, createdAt: cl.created_at }));
|
dispatch(setFollows({ keys: pTags, createdAt: cl.created_at }));
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
let maxProfile = profiles.reduce((acc, v) => {
|
let maxProfile = profiles.reduce(
|
||||||
if (v.created > acc.created) {
|
(acc, v) => {
|
||||||
acc.profile = v;
|
if (v.created > acc.created) {
|
||||||
acc.created = v.created;
|
acc.profile = v;
|
||||||
}
|
acc.created = v.created;
|
||||||
return acc;
|
}
|
||||||
}, { created: 0, profile: null as MetadataCache | null });
|
return acc;
|
||||||
|
},
|
||||||
|
{ created: 0, profile: null as MetadataCache | null }
|
||||||
|
);
|
||||||
if (maxProfile.profile) {
|
if (maxProfile.profile) {
|
||||||
let existing = await db.find(maxProfile.profile.pubkey);
|
let existing = await db.find(maxProfile.profile.pubkey);
|
||||||
if ((existing?.created ?? 0) < maxProfile.created) {
|
if ((existing?.created ?? 0) < maxProfile.created) {
|
||||||
@ -116,52 +143,74 @@ export default function useLoginFeed() {
|
|||||||
}, [dispatch, metadataFeed.store, db]);
|
}, [dispatch, metadataFeed.store, db]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const replies = notificationFeed.store.notes.
|
const replies = notificationFeed.store.notes.filter(
|
||||||
filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey) && a.created_at > readNotifications)
|
(a) =>
|
||||||
replies.forEach(nx => {
|
a.kind === EventKind.TextNote &&
|
||||||
|
!isMuted(a.pubkey) &&
|
||||||
|
a.created_at > readNotifications
|
||||||
|
);
|
||||||
|
replies.forEach((nx) => {
|
||||||
dispatch(setLatestNotifications(nx.created_at));
|
dispatch(setLatestNotifications(nx.created_at));
|
||||||
makeNotification(db, nx).then(notification => {
|
makeNotification(db, nx).then((notification) => {
|
||||||
if (notification) {
|
if (notification) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
dispatch(sendNotification(notification))
|
dispatch(sendNotification(notification));
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
}, [dispatch, notificationFeed.store, db, readNotifications]);
|
}, [dispatch, notificationFeed.store, db, readNotifications]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const muted = getMutedKeys(mutedFeed.store.notes)
|
const muted = getMutedKeys(mutedFeed.store.notes);
|
||||||
dispatch(setMuted(muted))
|
dispatch(setMuted(muted));
|
||||||
|
|
||||||
const newest = getNewest(mutedFeed.store.notes)
|
const newest = getNewest(mutedFeed.store.notes);
|
||||||
if (newest && newest.content.length > 0 && pubKey && newest.created_at > latestMuted) {
|
if (
|
||||||
decryptBlocked(newest, pubKey, privKey).then((plaintext) => {
|
newest &&
|
||||||
try {
|
newest.content.length > 0 &&
|
||||||
const blocked = JSON.parse(plaintext)
|
pubKey &&
|
||||||
const keys = blocked.filter((p: any) => p && p.length === 2 && p[0] === "p").map((p: any) => p[1])
|
newest.created_at > latestMuted
|
||||||
dispatch(setBlocked({
|
) {
|
||||||
keys,
|
decryptBlocked(newest, pubKey, privKey)
|
||||||
createdAt: newest.created_at,
|
.then((plaintext) => {
|
||||||
}))
|
try {
|
||||||
} catch (error) {
|
const blocked = JSON.parse(plaintext);
|
||||||
console.debug("Couldn't parse JSON")
|
const keys = blocked
|
||||||
}
|
.filter((p: any) => p && p.length === 2 && p[0] === "p")
|
||||||
}).catch((error) => console.warn(error))
|
.map((p: any) => p[1]);
|
||||||
|
dispatch(
|
||||||
|
setBlocked({
|
||||||
|
keys,
|
||||||
|
createdAt: newest.created_at,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.debug("Couldn't parse JSON");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => console.warn(error));
|
||||||
}
|
}
|
||||||
}, [dispatch, mutedFeed.store])
|
}, [dispatch, mutedFeed.store]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage);
|
let dms = dmsFeed.store.notes.filter(
|
||||||
|
(a) => a.kind === EventKind.DirectMessage
|
||||||
|
);
|
||||||
dispatch(addDirectMessage(dms));
|
dispatch(addDirectMessage(dms));
|
||||||
}, [dispatch, dmsFeed.store]);
|
}, [dispatch, dmsFeed.store]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function decryptBlocked(
|
||||||
async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) {
|
raw: TaggedRawEvent,
|
||||||
const ev = new Event(raw)
|
pubKey: HexKey,
|
||||||
|
privKey?: HexKey
|
||||||
|
) {
|
||||||
|
const ev = new Event(raw);
|
||||||
if (pubKey && privKey) {
|
if (pubKey && privKey) {
|
||||||
return await ev.DecryptData(raw.content, privKey, pubKey)
|
return await ev.DecryptData(raw.content, privKey, pubKey);
|
||||||
} else {
|
} else {
|
||||||
return await barierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content));
|
return await barierNip07(() =>
|
||||||
|
window.nostr.nip04.decrypt(pubKey, raw.content)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,41 +6,46 @@ import { Subscriptions } from "Nostr/Subscriptions";
|
|||||||
import useSubscription, { NoteStore } from "Feed/Subscription";
|
import useSubscription, { NoteStore } from "Feed/Subscription";
|
||||||
|
|
||||||
export default function useMutedFeed(pubkey: HexKey) {
|
export default function useMutedFeed(pubkey: HexKey) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
let sub = new Subscriptions();
|
let sub = new Subscriptions();
|
||||||
sub.Id = `muted:${pubkey.slice(0, 12)}`;
|
sub.Id = `muted:${pubkey.slice(0, 12)}`;
|
||||||
sub.Kinds = new Set([EventKind.Lists]);
|
sub.Kinds = new Set([EventKind.Lists]);
|
||||||
sub.Authors = new Set([pubkey]);
|
sub.Authors = new Set([pubkey]);
|
||||||
sub.DTags = new Set([Lists.Muted]);
|
sub.DTags = new Set([Lists.Muted]);
|
||||||
sub.Limit = 1;
|
sub.Limit = 1;
|
||||||
return sub;
|
return sub;
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
return useSubscription(sub);
|
return useSubscription(sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNewest(rawNotes: TaggedRawEvent[]){
|
export function getNewest(rawNotes: TaggedRawEvent[]) {
|
||||||
const notes = [...rawNotes]
|
const notes = [...rawNotes];
|
||||||
notes.sort((a, b) => a.created_at - b.created_at)
|
notes.sort((a, b) => a.created_at - b.created_at);
|
||||||
if (notes.length > 0) {
|
if (notes.length > 0) {
|
||||||
return notes[0]
|
return notes[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } {
|
export function getMutedKeys(rawNotes: TaggedRawEvent[]): {
|
||||||
const newest = getNewest(rawNotes)
|
createdAt: number;
|
||||||
if (newest) {
|
keys: HexKey[];
|
||||||
const { created_at, tags } = newest
|
} {
|
||||||
const keys = tags.filter(t => t[0] === "p").map(t => t[1])
|
const newest = getNewest(rawNotes);
|
||||||
return {
|
if (newest) {
|
||||||
keys,
|
const { created_at, tags } = newest;
|
||||||
createdAt: created_at,
|
const keys = tags.filter((t) => t[0] === "p").map((t) => t[1]);
|
||||||
}
|
return {
|
||||||
}
|
keys,
|
||||||
return { createdAt: 0, keys: [] }
|
createdAt: created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { createdAt: 0, keys: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] {
|
export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] {
|
||||||
let lists = feed?.notes.filter(a => a.kind === EventKind.Lists && a.pubkey === pubkey);
|
let lists = feed?.notes.filter(
|
||||||
return getMutedKeys(lists).keys;
|
(a) => a.kind === EventKind.Lists && a.pubkey === pubkey
|
||||||
|
);
|
||||||
|
return getMutedKeys(lists).keys;
|
||||||
}
|
}
|
||||||
|
@ -5,28 +5,29 @@ import { HexKey } from "Nostr";
|
|||||||
import { System } from "Nostr/System";
|
import { System } from "Nostr/System";
|
||||||
|
|
||||||
export function useUserProfile(pubKey: HexKey): MetadataCache | undefined {
|
export function useUserProfile(pubKey: HexKey): MetadataCache | undefined {
|
||||||
const users = useKey(pubKey);
|
const users = useKey(pubKey);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
System.TrackMetadata(pubKey);
|
System.TrackMetadata(pubKey);
|
||||||
return () => System.UntrackMetadata(pubKey);
|
return () => System.UntrackMetadata(pubKey);
|
||||||
}
|
}
|
||||||
}, [pubKey]);
|
}, [pubKey]);
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUserProfiles(
|
||||||
|
pubKeys: Array<HexKey>
|
||||||
|
): Map<HexKey, MetadataCache> | undefined {
|
||||||
|
const users = useKeys(pubKeys);
|
||||||
|
|
||||||
export function useUserProfiles(pubKeys: Array<HexKey>): Map<HexKey, MetadataCache> | undefined {
|
useEffect(() => {
|
||||||
const users = useKeys(pubKeys);
|
if (pubKeys) {
|
||||||
|
System.TrackMetadata(pubKeys);
|
||||||
|
return () => System.UntrackMetadata(pubKeys);
|
||||||
|
}
|
||||||
|
}, [pubKeys]);
|
||||||
|
|
||||||
useEffect(() => {
|
return users;
|
||||||
if (pubKeys) {
|
|
||||||
System.TrackMetadata(pubKeys);
|
|
||||||
return () => System.UntrackMetadata(pubKeys);
|
|
||||||
}
|
|
||||||
}, [pubKeys]);
|
|
||||||
|
|
||||||
return users;
|
|
||||||
}
|
}
|
||||||
|
@ -2,12 +2,17 @@ import { useSyncExternalStore } from "react";
|
|||||||
import { System } from "Nostr/System";
|
import { System } from "Nostr/System";
|
||||||
import { CustomHook, StateSnapshot } from "Nostr/Connection";
|
import { CustomHook, StateSnapshot } from "Nostr/Connection";
|
||||||
|
|
||||||
const noop = (f: CustomHook) => { return () => { }; };
|
const noop = (f: CustomHook) => {
|
||||||
|
return () => {};
|
||||||
|
};
|
||||||
const noopState = (): StateSnapshot | undefined => {
|
const noopState = (): StateSnapshot | undefined => {
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function useRelayState(addr: string) {
|
export default function useRelayState(addr: string) {
|
||||||
let c = System.Sockets.get(addr);
|
let c = System.Sockets.get(addr);
|
||||||
return useSyncExternalStore<StateSnapshot | undefined>(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noopState);
|
return useSyncExternalStore<StateSnapshot | undefined>(
|
||||||
}
|
c?.StatusHook.bind(c) ?? noop,
|
||||||
|
c?.GetState.bind(c) ?? noopState
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -6,62 +6,59 @@ import { debounce } from "Util";
|
|||||||
import { db } from "Db";
|
import { db } from "Db";
|
||||||
|
|
||||||
export type NoteStore = {
|
export type NoteStore = {
|
||||||
notes: Array<TaggedRawEvent>,
|
notes: Array<TaggedRawEvent>;
|
||||||
end: boolean
|
end: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UseSubscriptionOptions = {
|
export type UseSubscriptionOptions = {
|
||||||
leaveOpen: boolean,
|
leaveOpen: boolean;
|
||||||
cache: boolean
|
cache: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface ReducerArg {
|
interface ReducerArg {
|
||||||
type: "END" | "EVENT" | "CLEAR",
|
type: "END" | "EVENT" | "CLEAR";
|
||||||
ev?: TaggedRawEvent | Array<TaggedRawEvent>,
|
ev?: TaggedRawEvent | Array<TaggedRawEvent>;
|
||||||
end?: boolean
|
end?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function notesReducer(state: NoteStore, arg: ReducerArg) {
|
function notesReducer(state: NoteStore, arg: ReducerArg) {
|
||||||
if (arg.type === "END") {
|
if (arg.type === "END") {
|
||||||
return {
|
|
||||||
notes: state.notes,
|
|
||||||
end: arg.end!
|
|
||||||
} as NoteStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (arg.type === "CLEAR") {
|
|
||||||
return {
|
|
||||||
notes: [],
|
|
||||||
end: state.end,
|
|
||||||
} as NoteStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
let evs = arg.ev!;
|
|
||||||
if (!Array.isArray(evs)) {
|
|
||||||
evs = [evs];
|
|
||||||
}
|
|
||||||
let existingIds = new Set(state.notes.map(a => a.id));
|
|
||||||
evs = evs.filter(a => !existingIds.has(a.id));
|
|
||||||
if (evs.length === 0) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
notes: [
|
notes: state.notes,
|
||||||
...state.notes,
|
end: arg.end!,
|
||||||
...evs
|
|
||||||
]
|
|
||||||
} as NoteStore;
|
} as NoteStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.type === "CLEAR") {
|
||||||
|
return {
|
||||||
|
notes: [],
|
||||||
|
end: state.end,
|
||||||
|
} as NoteStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
let evs = arg.ev!;
|
||||||
|
if (!Array.isArray(evs)) {
|
||||||
|
evs = [evs];
|
||||||
|
}
|
||||||
|
let existingIds = new Set(state.notes.map((a) => a.id));
|
||||||
|
evs = evs.filter((a) => !existingIds.has(a.id));
|
||||||
|
if (evs.length === 0) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
notes: [...state.notes, ...evs],
|
||||||
|
} as NoteStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initStore: NoteStore = {
|
const initStore: NoteStore = {
|
||||||
notes: [],
|
notes: [],
|
||||||
end: false
|
end: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UseSubscriptionState {
|
export interface UseSubscriptionState {
|
||||||
store: NoteStore,
|
store: NoteStore;
|
||||||
clear: () => void,
|
clear: () => void;
|
||||||
append: (notes: TaggedRawEvent[]) => void
|
append: (notes: TaggedRawEvent[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -70,121 +67,131 @@ export interface UseSubscriptionState {
|
|||||||
const DebounceMs = 200;
|
const DebounceMs = 200;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {Subscriptions} sub
|
* @param {Subscriptions} sub
|
||||||
* @param {any} opt
|
* @param {any} opt
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions): UseSubscriptionState {
|
export default function useSubscription(
|
||||||
const [state, dispatch] = useReducer(notesReducer, initStore);
|
sub: Subscriptions | null,
|
||||||
const [debounceOutput, setDebounceOutput] = useState<number>(0);
|
options?: UseSubscriptionOptions
|
||||||
const [subDebounce, setSubDebounced] = useState<Subscriptions>();
|
): UseSubscriptionState {
|
||||||
const useCache = useMemo(() => options?.cache === true, [options]);
|
const [state, dispatch] = useReducer(notesReducer, initStore);
|
||||||
|
const [debounceOutput, setDebounceOutput] = useState<number>(0);
|
||||||
|
const [subDebounce, setSubDebounced] = useState<Subscriptions>();
|
||||||
|
const useCache = useMemo(() => options?.cache === true, [options]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sub) {
|
if (sub) {
|
||||||
return debounce(DebounceMs, () => {
|
return debounce(DebounceMs, () => {
|
||||||
setSubDebounced(sub);
|
setSubDebounced(sub);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}, [sub, options]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (subDebounce) {
|
|
||||||
dispatch({
|
|
||||||
type: "END",
|
|
||||||
end: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useCache) {
|
|
||||||
// preload notes from db
|
|
||||||
PreloadNotes(subDebounce.Id)
|
|
||||||
.then(ev => {
|
|
||||||
dispatch({
|
|
||||||
type: "EVENT",
|
|
||||||
ev: ev
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(console.warn);
|
|
||||||
}
|
|
||||||
subDebounce.OnEvent = (e) => {
|
|
||||||
dispatch({
|
|
||||||
type: "EVENT",
|
|
||||||
ev: e
|
|
||||||
});
|
|
||||||
if (useCache) {
|
|
||||||
db.events.put(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
subDebounce.OnEnd = (c) => {
|
|
||||||
if (!(options?.leaveOpen ?? false)) {
|
|
||||||
c.RemoveSubscription(subDebounce.Id);
|
|
||||||
if (subDebounce.IsFinished()) {
|
|
||||||
System.RemoveSubscription(subDebounce.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dispatch({
|
|
||||||
type: "END",
|
|
||||||
end: true
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
console.debug("Adding sub: ", subDebounce.ToObject());
|
|
||||||
System.AddSubscription(subDebounce);
|
|
||||||
return () => {
|
|
||||||
console.debug("Removing sub: ", subDebounce.ToObject());
|
|
||||||
System.RemoveSubscription(subDebounce.Id);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [subDebounce, useCache]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (subDebounce && useCache) {
|
|
||||||
return debounce(500, () => {
|
|
||||||
TrackNotesInFeed(subDebounce.Id, state.notes)
|
|
||||||
.catch(console.warn);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [state, useCache]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return debounce(DebounceMs, () => {
|
|
||||||
setDebounceOutput(s => s += 1);
|
|
||||||
});
|
|
||||||
}, [state]);
|
|
||||||
|
|
||||||
const stateDebounced = useMemo(() => state, [debounceOutput]);
|
|
||||||
return {
|
|
||||||
store: stateDebounced,
|
|
||||||
clear: () => {
|
|
||||||
dispatch({ type: "CLEAR" });
|
|
||||||
},
|
|
||||||
append: (n: TaggedRawEvent[]) => {
|
|
||||||
dispatch({
|
|
||||||
type: "EVENT",
|
|
||||||
ev: n
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [sub, options]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (subDebounce) {
|
||||||
|
dispatch({
|
||||||
|
type: "END",
|
||||||
|
end: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (useCache) {
|
||||||
|
// preload notes from db
|
||||||
|
PreloadNotes(subDebounce.Id)
|
||||||
|
.then((ev) => {
|
||||||
|
dispatch({
|
||||||
|
type: "EVENT",
|
||||||
|
ev: ev,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(console.warn);
|
||||||
|
}
|
||||||
|
subDebounce.OnEvent = (e) => {
|
||||||
|
dispatch({
|
||||||
|
type: "EVENT",
|
||||||
|
ev: e,
|
||||||
|
});
|
||||||
|
if (useCache) {
|
||||||
|
db.events.put(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
subDebounce.OnEnd = (c) => {
|
||||||
|
if (!(options?.leaveOpen ?? false)) {
|
||||||
|
c.RemoveSubscription(subDebounce.Id);
|
||||||
|
if (subDebounce.IsFinished()) {
|
||||||
|
System.RemoveSubscription(subDebounce.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dispatch({
|
||||||
|
type: "END",
|
||||||
|
end: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
console.debug("Adding sub: ", subDebounce.ToObject());
|
||||||
|
System.AddSubscription(subDebounce);
|
||||||
|
return () => {
|
||||||
|
console.debug("Removing sub: ", subDebounce.ToObject());
|
||||||
|
System.RemoveSubscription(subDebounce.Id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [subDebounce, useCache]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (subDebounce && useCache) {
|
||||||
|
return debounce(500, () => {
|
||||||
|
TrackNotesInFeed(subDebounce.Id, state.notes).catch(console.warn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [state, useCache]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return debounce(DebounceMs, () => {
|
||||||
|
setDebounceOutput((s) => (s += 1));
|
||||||
|
});
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const stateDebounced = useMemo(() => state, [debounceOutput]);
|
||||||
|
return {
|
||||||
|
store: stateDebounced,
|
||||||
|
clear: () => {
|
||||||
|
dispatch({ type: "CLEAR" });
|
||||||
|
},
|
||||||
|
append: (n: TaggedRawEvent[]) => {
|
||||||
|
dispatch({
|
||||||
|
type: "EVENT",
|
||||||
|
ev: n,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lookup cached copy of feed
|
* Lookup cached copy of feed
|
||||||
*/
|
*/
|
||||||
const PreloadNotes = async (id: string): Promise<TaggedRawEvent[]> => {
|
const PreloadNotes = async (id: string): Promise<TaggedRawEvent[]> => {
|
||||||
const feed = await db.feeds.get(id);
|
const feed = await db.feeds.get(id);
|
||||||
if (feed) {
|
if (feed) {
|
||||||
const events = await db.events.bulkGet(feed.ids);
|
const events = await db.events.bulkGet(feed.ids);
|
||||||
return events.filter(a => a !== undefined).map(a => a!);
|
return events.filter((a) => a !== undefined).map((a) => a!);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
};
|
||||||
|
|
||||||
const TrackNotesInFeed = async (id: string, notes: TaggedRawEvent[]) => {
|
const TrackNotesInFeed = async (id: string, notes: TaggedRawEvent[]) => {
|
||||||
const existing = await db.feeds.get(id);
|
const existing = await db.feeds.get(id);
|
||||||
const ids = Array.from(new Set([...(existing?.ids || []), ...notes.map(a => a.id)]));
|
const ids = Array.from(
|
||||||
const since = notes.reduce((acc, v) => acc > v.created_at ? v.created_at : acc, +Infinity);
|
new Set([...(existing?.ids || []), ...notes.map((a) => a.id)])
|
||||||
const until = notes.reduce((acc, v) => acc < v.created_at ? v.created_at : acc, -Infinity);
|
);
|
||||||
await db.feeds.put({ id, ids, since, until });
|
const since = notes.reduce(
|
||||||
}
|
(acc, v) => (acc > v.created_at ? v.created_at : acc),
|
||||||
|
+Infinity
|
||||||
|
);
|
||||||
|
const until = notes.reduce(
|
||||||
|
(acc, v) => (acc < v.created_at ? v.created_at : acc),
|
||||||
|
-Infinity
|
||||||
|
);
|
||||||
|
await db.feeds.put({ id, ids, since, until });
|
||||||
|
};
|
||||||
|
@ -9,51 +9,66 @@ import { UserPreferences } from "State/Login";
|
|||||||
import { debounce } from "Util";
|
import { debounce } from "Util";
|
||||||
|
|
||||||
export default function useThreadFeed(id: u256) {
|
export default function useThreadFeed(id: u256) {
|
||||||
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
|
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
|
||||||
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
const pref = useSelector<RootState, UserPreferences>(
|
||||||
|
(s) => s.login.preferences
|
||||||
|
);
|
||||||
|
|
||||||
function addId(id: u256[]) {
|
function addId(id: u256[]) {
|
||||||
setTrackingEvent((s) => {
|
setTrackingEvent((s) => {
|
||||||
let orig = new Set(s);
|
let orig = new Set(s);
|
||||||
if (id.some(a => !orig.has(a))) {
|
if (id.some((a) => !orig.has(a))) {
|
||||||
let tmp = new Set([...s, ...id]);
|
let tmp = new Set([...s, ...id]);
|
||||||
return Array.from(tmp);
|
return Array.from(tmp);
|
||||||
} else {
|
} else {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = useMemo(() => {
|
||||||
|
const thisSub = new Subscriptions();
|
||||||
|
thisSub.Id = `thread:${id.substring(0, 8)}`;
|
||||||
|
thisSub.Ids = new Set(trackingEvents);
|
||||||
|
|
||||||
|
// get replies to this event
|
||||||
|
const subRelated = new Subscriptions();
|
||||||
|
subRelated.Kinds = new Set(
|
||||||
|
pref.enableReactions
|
||||||
|
? [
|
||||||
|
EventKind.Reaction,
|
||||||
|
EventKind.TextNote,
|
||||||
|
EventKind.Deletion,
|
||||||
|
EventKind.Repost,
|
||||||
|
EventKind.ZapReceipt,
|
||||||
|
]
|
||||||
|
: [EventKind.TextNote]
|
||||||
|
);
|
||||||
|
subRelated.ETags = thisSub.Ids;
|
||||||
|
thisSub.AddSubscription(subRelated);
|
||||||
|
|
||||||
|
return thisSub;
|
||||||
|
}, [trackingEvents, pref, id]);
|
||||||
|
|
||||||
|
const main = useSubscription(sub, { leaveOpen: true, cache: true });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (main.store) {
|
||||||
|
return debounce(200, () => {
|
||||||
|
let mainNotes = main.store.notes.filter(
|
||||||
|
(a) => a.kind === EventKind.TextNote
|
||||||
|
);
|
||||||
|
|
||||||
|
let eTags = mainNotes
|
||||||
|
.filter((a) => a.kind === EventKind.TextNote)
|
||||||
|
.map((a) => a.tags.filter((b) => b[0] === "e").map((b) => b[1]))
|
||||||
|
.flat();
|
||||||
|
let ids = mainNotes.map((a) => a.id);
|
||||||
|
let allEvents = new Set([...eTags, ...ids]);
|
||||||
|
addId(Array.from(allEvents));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}, [main.store]);
|
||||||
|
|
||||||
const sub = useMemo(() => {
|
return main.store;
|
||||||
const thisSub = new Subscriptions();
|
|
||||||
thisSub.Id = `thread:${id.substring(0, 8)}`;
|
|
||||||
thisSub.Ids = new Set(trackingEvents);
|
|
||||||
|
|
||||||
// get replies to this event
|
|
||||||
const subRelated = new Subscriptions();
|
|
||||||
subRelated.Kinds = new Set(pref.enableReactions ? [EventKind.Reaction, EventKind.TextNote, EventKind.Deletion, EventKind.Repost, EventKind.ZapReceipt] : [EventKind.TextNote]);
|
|
||||||
subRelated.ETags = thisSub.Ids;
|
|
||||||
thisSub.AddSubscription(subRelated);
|
|
||||||
|
|
||||||
return thisSub;
|
|
||||||
}, [trackingEvents, pref, id]);
|
|
||||||
|
|
||||||
const main = useSubscription(sub, { leaveOpen: true, cache: true });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (main.store) {
|
|
||||||
return debounce(200, () => {
|
|
||||||
let mainNotes = main.store.notes.filter(a => a.kind === EventKind.TextNote);
|
|
||||||
|
|
||||||
let eTags = mainNotes
|
|
||||||
.filter(a => a.kind === EventKind.TextNote)
|
|
||||||
.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat();
|
|
||||||
let ids = mainNotes.map(a => a.id);
|
|
||||||
let allEvents = new Set([...eTags, ...ids]);
|
|
||||||
addId(Array.from(allEvents));
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [main.store]);
|
|
||||||
|
|
||||||
return main.store;
|
|
||||||
}
|
}
|
||||||
|
@ -9,169 +9,184 @@ import { RootState } from "State/Store";
|
|||||||
import { UserPreferences } from "State/Login";
|
import { UserPreferences } from "State/Login";
|
||||||
|
|
||||||
export interface TimelineFeedOptions {
|
export interface TimelineFeedOptions {
|
||||||
method: "TIME_RANGE" | "LIMIT_UNTIL",
|
method: "TIME_RANGE" | "LIMIT_UNTIL";
|
||||||
window?: number
|
window?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelineSubject {
|
export interface TimelineSubject {
|
||||||
type: "pubkey" | "hashtag" | "global" | "ptag" | "keyword",
|
type: "pubkey" | "hashtag" | "global" | "ptag" | "keyword";
|
||||||
discriminator: string,
|
discriminator: string;
|
||||||
items: string[]
|
items: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
|
export default function useTimelineFeed(
|
||||||
const now = unixNow();
|
subject: TimelineSubject,
|
||||||
const [window] = useState<number>(options.window ?? 60 * 60);
|
options: TimelineFeedOptions
|
||||||
const [until, setUntil] = useState<number>(now);
|
) {
|
||||||
const [since, setSince] = useState<number>(now - window);
|
const now = unixNow();
|
||||||
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
|
const [window] = useState<number>(options.window ?? 60 * 60);
|
||||||
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
|
const [until, setUntil] = useState<number>(now);
|
||||||
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
const [since, setSince] = useState<number>(now - window);
|
||||||
|
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
|
||||||
|
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
|
||||||
|
const pref = useSelector<RootState, UserPreferences>(
|
||||||
|
(s) => s.login.preferences
|
||||||
|
);
|
||||||
|
|
||||||
const createSub = useCallback(() => {
|
const createSub = useCallback(() => {
|
||||||
if (subject.type !== "global" && subject.items.length === 0) {
|
if (subject.type !== "global" && subject.items.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sub = new Subscriptions();
|
||||||
|
sub.Id = `timeline:${subject.type}:${subject.discriminator}`;
|
||||||
|
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
|
||||||
|
switch (subject.type) {
|
||||||
|
case "pubkey": {
|
||||||
|
sub.Authors = new Set(subject.items);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "hashtag": {
|
||||||
|
sub.HashTags = new Set(subject.items);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ptag": {
|
||||||
|
sub.PTags = new Set(subject.items);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "keyword": {
|
||||||
|
sub.Kinds.add(EventKind.SetMetadata);
|
||||||
|
sub.Search = subject.items[0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sub;
|
||||||
|
}, [subject.type, subject.items, subject.discriminator]);
|
||||||
|
|
||||||
|
const sub = useMemo(() => {
|
||||||
|
let sub = createSub();
|
||||||
|
if (sub) {
|
||||||
|
if (options.method === "LIMIT_UNTIL") {
|
||||||
|
sub.Until = until;
|
||||||
|
sub.Limit = 10;
|
||||||
|
} else {
|
||||||
|
sub.Since = since;
|
||||||
|
sub.Until = until;
|
||||||
|
if (since === undefined) {
|
||||||
|
sub.Limit = 50;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let sub = new Subscriptions();
|
if (pref.autoShowLatest) {
|
||||||
sub.Id = `timeline:${subject.type}:${subject.discriminator}`;
|
// copy properties of main sub but with limit 0
|
||||||
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
|
// this will put latest directly into main feed
|
||||||
switch (subject.type) {
|
let latestSub = new Subscriptions();
|
||||||
case "pubkey": {
|
latestSub.Authors = sub.Authors;
|
||||||
sub.Authors = new Set(subject.items);
|
latestSub.HashTags = sub.HashTags;
|
||||||
break;
|
latestSub.PTags = sub.PTags;
|
||||||
}
|
latestSub.Kinds = sub.Kinds;
|
||||||
case "hashtag": {
|
latestSub.Search = sub.Search;
|
||||||
sub.HashTags = new Set(subject.items);
|
latestSub.Limit = 1;
|
||||||
break;
|
latestSub.Since = Math.floor(new Date().getTime() / 1000);
|
||||||
}
|
sub.AddSubscription(latestSub);
|
||||||
case "ptag": {
|
}
|
||||||
sub.PTags = new Set(subject.items);
|
}
|
||||||
break;
|
return sub;
|
||||||
}
|
}, [until, since, options.method, pref, createSub]);
|
||||||
case "keyword": {
|
|
||||||
sub.Kinds.add(EventKind.SetMetadata);
|
const main = useSubscription(sub, { leaveOpen: true, cache: true });
|
||||||
sub.Search = subject.items[0];
|
|
||||||
break;
|
const subRealtime = useMemo(() => {
|
||||||
}
|
let subLatest = createSub();
|
||||||
|
if (subLatest && !pref.autoShowLatest) {
|
||||||
|
subLatest.Id = `${subLatest.Id}:latest`;
|
||||||
|
subLatest.Limit = 1;
|
||||||
|
subLatest.Since = Math.floor(new Date().getTime() / 1000);
|
||||||
|
}
|
||||||
|
return subLatest;
|
||||||
|
}, [pref, createSub]);
|
||||||
|
|
||||||
|
const latest = useSubscription(subRealtime, {
|
||||||
|
leaveOpen: true,
|
||||||
|
cache: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subNext = useMemo(() => {
|
||||||
|
let sub: Subscriptions | undefined;
|
||||||
|
if (trackingEvents.length > 0 && pref.enableReactions) {
|
||||||
|
sub = new Subscriptions();
|
||||||
|
sub.Id = `timeline-related:${subject.type}`;
|
||||||
|
sub.Kinds = new Set([
|
||||||
|
EventKind.Reaction,
|
||||||
|
EventKind.Deletion,
|
||||||
|
EventKind.ZapReceipt,
|
||||||
|
]);
|
||||||
|
sub.ETags = new Set(trackingEvents);
|
||||||
|
}
|
||||||
|
return sub ?? null;
|
||||||
|
}, [trackingEvents, pref, subject.type]);
|
||||||
|
|
||||||
|
const others = useSubscription(subNext, { leaveOpen: true, cache: true });
|
||||||
|
|
||||||
|
const subParents = useMemo(() => {
|
||||||
|
if (trackingParentEvents.length > 0) {
|
||||||
|
let parents = new Subscriptions();
|
||||||
|
parents.Id = `timeline-parent:${subject.type}`;
|
||||||
|
parents.Ids = new Set(trackingParentEvents);
|
||||||
|
return parents;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [trackingParentEvents, subject.type]);
|
||||||
|
|
||||||
|
const parent = useSubscription(subParents);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (main.store.notes.length > 0) {
|
||||||
|
setTrackingEvent((s) => {
|
||||||
|
let ids = main.store.notes.map((a) => a.id);
|
||||||
|
if (ids.some((a) => !s.includes(a))) {
|
||||||
|
return Array.from(new Set([...s, ...ids]));
|
||||||
}
|
}
|
||||||
return sub;
|
return s;
|
||||||
}, [subject.type, subject.items, subject.discriminator]);
|
});
|
||||||
|
let reposts = main.store.notes
|
||||||
|
.filter((a) => a.kind === EventKind.Repost && a.content === "")
|
||||||
|
.map((a) => a.tags.find((b) => b[0] === "e"))
|
||||||
|
.filter((a) => a)
|
||||||
|
.map((a) => a![1]);
|
||||||
|
if (reposts.length > 0) {
|
||||||
|
setTrackingParentEvents((s) => {
|
||||||
|
if (reposts.some((a) => !s.includes(a))) {
|
||||||
|
let temp = new Set([...s, ...reposts]);
|
||||||
|
return Array.from(temp);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [main.store]);
|
||||||
|
|
||||||
const sub = useMemo(() => {
|
return {
|
||||||
let sub = createSub();
|
main: main.store,
|
||||||
if (sub) {
|
related: others.store,
|
||||||
if (options.method === "LIMIT_UNTIL") {
|
latest: latest.store,
|
||||||
sub.Until = until;
|
parent: parent.store,
|
||||||
sub.Limit = 10;
|
loadMore: () => {
|
||||||
} else {
|
console.debug("Timeline load more!");
|
||||||
sub.Since = since;
|
if (options.method === "LIMIT_UNTIL") {
|
||||||
sub.Until = until;
|
let oldest = main.store.notes.reduce(
|
||||||
if (since === undefined) {
|
(acc, v) => (acc = v.created_at < acc ? v.created_at : acc),
|
||||||
sub.Limit = 50;
|
unixNow()
|
||||||
}
|
);
|
||||||
}
|
setUntil(oldest);
|
||||||
|
} else {
|
||||||
if (pref.autoShowLatest) {
|
setUntil((s) => s - window);
|
||||||
// copy properties of main sub but with limit 0
|
setSince((s) => s - window);
|
||||||
// this will put latest directly into main feed
|
}
|
||||||
let latestSub = new Subscriptions();
|
},
|
||||||
latestSub.Authors = sub.Authors;
|
showLatest: () => {
|
||||||
latestSub.HashTags = sub.HashTags;
|
main.append(latest.store.notes);
|
||||||
latestSub.PTags = sub.PTags;
|
latest.clear();
|
||||||
latestSub.Kinds = sub.Kinds;
|
},
|
||||||
latestSub.Search = sub.Search;
|
};
|
||||||
latestSub.Limit = 1;
|
|
||||||
latestSub.Since = Math.floor(new Date().getTime() / 1000);
|
|
||||||
sub.AddSubscription(latestSub);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sub;
|
|
||||||
}, [until, since, options.method, pref, createSub]);
|
|
||||||
|
|
||||||
const main = useSubscription(sub, { leaveOpen: true, cache: true });
|
|
||||||
|
|
||||||
const subRealtime = useMemo(() => {
|
|
||||||
let subLatest = createSub();
|
|
||||||
if (subLatest && !pref.autoShowLatest) {
|
|
||||||
subLatest.Id = `${subLatest.Id}:latest`;
|
|
||||||
subLatest.Limit = 1;
|
|
||||||
subLatest.Since = Math.floor(new Date().getTime() / 1000);
|
|
||||||
}
|
|
||||||
return subLatest;
|
|
||||||
}, [pref, createSub]);
|
|
||||||
|
|
||||||
const latest = useSubscription(subRealtime, { leaveOpen: true, cache: false });
|
|
||||||
|
|
||||||
const subNext = useMemo(() => {
|
|
||||||
let sub: Subscriptions | undefined;
|
|
||||||
if (trackingEvents.length > 0 && pref.enableReactions) {
|
|
||||||
sub = new Subscriptions();
|
|
||||||
sub.Id = `timeline-related:${subject.type}`;
|
|
||||||
sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion, EventKind.ZapReceipt]);
|
|
||||||
sub.ETags = new Set(trackingEvents);
|
|
||||||
}
|
|
||||||
return sub ?? null;
|
|
||||||
}, [trackingEvents, pref, subject.type]);
|
|
||||||
|
|
||||||
const others = useSubscription(subNext, { leaveOpen: true, cache: true });
|
|
||||||
|
|
||||||
const subParents = useMemo(() => {
|
|
||||||
if (trackingParentEvents.length > 0) {
|
|
||||||
let parents = new Subscriptions();
|
|
||||||
parents.Id = `timeline-parent:${subject.type}`;
|
|
||||||
parents.Ids = new Set(trackingParentEvents);
|
|
||||||
return parents;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [trackingParentEvents, subject.type]);
|
|
||||||
|
|
||||||
const parent = useSubscription(subParents);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (main.store.notes.length > 0) {
|
|
||||||
setTrackingEvent(s => {
|
|
||||||
let ids = main.store.notes.map(a => a.id);
|
|
||||||
if (ids.some(a => !s.includes(a))) {
|
|
||||||
return Array.from(new Set([...s, ...ids]));
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
let reposts = main.store.notes
|
|
||||||
.filter(a => a.kind === EventKind.Repost && a.content === "")
|
|
||||||
.map(a => a.tags.find(b => b[0] === "e"))
|
|
||||||
.filter(a => a)
|
|
||||||
.map(a => a![1]);
|
|
||||||
if (reposts.length > 0) {
|
|
||||||
setTrackingParentEvents(s => {
|
|
||||||
if (reposts.some(a => !s.includes(a))) {
|
|
||||||
let temp = new Set([...s, ...reposts]);
|
|
||||||
return Array.from(temp);
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [main.store]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
main: main.store,
|
|
||||||
related: others.store,
|
|
||||||
latest: latest.store,
|
|
||||||
parent: parent.store,
|
|
||||||
loadMore: () => {
|
|
||||||
console.debug("Timeline load more!")
|
|
||||||
if (options.method === "LIMIT_UNTIL") {
|
|
||||||
let oldest = main.store.notes.reduce((acc, v) => acc = v.created_at < acc ? v.created_at : acc, unixNow());
|
|
||||||
setUntil(oldest);
|
|
||||||
} else {
|
|
||||||
setUntil(s => s - window);
|
|
||||||
setSince(s => s - window);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
showLatest: () => {
|
|
||||||
main.append(latest.store.notes);
|
|
||||||
latest.clear();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -5,13 +5,13 @@ import { Subscriptions } from "Nostr/Subscriptions";
|
|||||||
import useSubscription from "./Subscription";
|
import useSubscription from "./Subscription";
|
||||||
|
|
||||||
export default function useZapsFeed(pubkey: HexKey) {
|
export default function useZapsFeed(pubkey: HexKey) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
let x = new Subscriptions();
|
let x = new Subscriptions();
|
||||||
x.Id = `zaps:${pubkey.slice(0, 12)}`;
|
x.Id = `zaps:${pubkey.slice(0, 12)}`;
|
||||||
x.Kinds = new Set([EventKind.ZapReceipt]);
|
x.Kinds = new Set([EventKind.ZapReceipt]);
|
||||||
x.PTags = new Set([pubkey]);
|
x.PTags = new Set([pubkey]);
|
||||||
return x;
|
return x;
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
return useSubscription(sub, { leaveOpen: true, cache: true });
|
return useSubscription(sub, { leaveOpen: true, cache: true });
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ function useHorizontalScroll() {
|
|||||||
return () => el.removeEventListener("wheel", onWheel);
|
return () => el.removeEventListener("wheel", onWheel);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
return elRef as LegacyRef<HTMLDivElement> | undefined
|
return elRef as LegacyRef<HTMLDivElement> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useHorizontalScroll;
|
export default useHorizontalScroll;
|
||||||
|
@ -5,74 +5,93 @@ import { HexKey } from "Nostr";
|
|||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import { setMuted, setBlocked } from "State/Login";
|
import { setMuted, setBlocked } from "State/Login";
|
||||||
|
|
||||||
|
|
||||||
export default function useModeration() {
|
export default function useModeration() {
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch();
|
||||||
const { blocked, muted } = useSelector((s: RootState) => s.login)
|
const { blocked, muted } = useSelector((s: RootState) => s.login);
|
||||||
const publisher = useEventPublisher()
|
const publisher = useEventPublisher();
|
||||||
|
|
||||||
async function setMutedList(pub: HexKey[], priv: HexKey[]) {
|
async function setMutedList(pub: HexKey[], priv: HexKey[]) {
|
||||||
try {
|
try {
|
||||||
const ev = await publisher.muted(pub, priv)
|
const ev = await publisher.muted(pub, priv);
|
||||||
console.debug(ev);
|
console.debug(ev);
|
||||||
publisher.broadcast(ev)
|
publisher.broadcast(ev);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.debug("Couldn't change mute list")
|
console.debug("Couldn't change mute list");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMuted(id: HexKey) {
|
function isMuted(id: HexKey) {
|
||||||
return muted.includes(id) || blocked.includes(id)
|
return muted.includes(id) || blocked.includes(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBlocked(id: HexKey) {
|
function isBlocked(id: HexKey) {
|
||||||
return blocked.includes(id)
|
return blocked.includes(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unmute(id: HexKey) {
|
function unmute(id: HexKey) {
|
||||||
const newMuted = muted.filter(p => p !== id)
|
const newMuted = muted.filter((p) => p !== id);
|
||||||
dispatch(setMuted({
|
dispatch(
|
||||||
createdAt: new Date().getTime(),
|
setMuted({
|
||||||
keys: newMuted
|
createdAt: new Date().getTime(),
|
||||||
}))
|
keys: newMuted,
|
||||||
setMutedList(newMuted, blocked)
|
})
|
||||||
|
);
|
||||||
|
setMutedList(newMuted, blocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unblock(id: HexKey) {
|
function unblock(id: HexKey) {
|
||||||
const newBlocked = blocked.filter(p => p !== id)
|
const newBlocked = blocked.filter((p) => p !== id);
|
||||||
dispatch(setBlocked({
|
dispatch(
|
||||||
createdAt: new Date().getTime(),
|
setBlocked({
|
||||||
keys: newBlocked
|
createdAt: new Date().getTime(),
|
||||||
}))
|
keys: newBlocked,
|
||||||
setMutedList(muted, newBlocked)
|
})
|
||||||
|
);
|
||||||
|
setMutedList(muted, newBlocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mute(id: HexKey) {
|
function mute(id: HexKey) {
|
||||||
const newMuted = muted.includes(id) ? muted : muted.concat([id])
|
const newMuted = muted.includes(id) ? muted : muted.concat([id]);
|
||||||
setMutedList(newMuted, blocked)
|
setMutedList(newMuted, blocked);
|
||||||
dispatch(setMuted({
|
dispatch(
|
||||||
createdAt: new Date().getTime(),
|
setMuted({
|
||||||
keys: newMuted
|
createdAt: new Date().getTime(),
|
||||||
}))
|
keys: newMuted,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function block(id: HexKey) {
|
function block(id: HexKey) {
|
||||||
const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id])
|
const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id]);
|
||||||
setMutedList(muted, newBlocked)
|
setMutedList(muted, newBlocked);
|
||||||
dispatch(setBlocked({
|
dispatch(
|
||||||
createdAt: new Date().getTime(),
|
setBlocked({
|
||||||
keys: newBlocked
|
createdAt: new Date().getTime(),
|
||||||
}))
|
keys: newBlocked,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function muteAll(ids: HexKey[]) {
|
function muteAll(ids: HexKey[]) {
|
||||||
const newMuted = Array.from(new Set(muted.concat(ids)))
|
const newMuted = Array.from(new Set(muted.concat(ids)));
|
||||||
setMutedList(newMuted, blocked)
|
setMutedList(newMuted, blocked);
|
||||||
dispatch(setMuted({
|
dispatch(
|
||||||
createdAt: new Date().getTime(),
|
setMuted({
|
||||||
keys: newMuted
|
createdAt: new Date().getTime(),
|
||||||
}))
|
keys: newMuted,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { muted, mute, muteAll, unmute, isMuted, blocked, block, unblock, isBlocked }
|
return {
|
||||||
|
muted,
|
||||||
|
mute,
|
||||||
|
muteAll,
|
||||||
|
unmute,
|
||||||
|
isMuted,
|
||||||
|
blocked,
|
||||||
|
block,
|
||||||
|
unblock,
|
||||||
|
isBlocked,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,25 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
webln?: {
|
webln?: {
|
||||||
enabled: boolean,
|
enabled: boolean;
|
||||||
enable: () => Promise<void>,
|
enable: () => Promise<void>;
|
||||||
sendPayment: (pr: string) => Promise<any>
|
sendPayment: (pr: string) => Promise<any>;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useWebln(enable = true) {
|
export default function useWebln(enable = true) {
|
||||||
const maybeWebLn = "webln" in window ? window.webln : null
|
const maybeWebLn = "webln" in window ? window.webln : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (maybeWebLn && !maybeWebLn.enabled && enable) {
|
if (maybeWebLn && !maybeWebLn.enabled && enable) {
|
||||||
maybeWebLn.enable().catch((error) => {
|
maybeWebLn.enable().catch((error) => {
|
||||||
console.debug("Couldn't enable WebLN")
|
console.debug("Couldn't enable WebLN");
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [maybeWebLn, enable])
|
}, [maybeWebLn, enable]);
|
||||||
|
|
||||||
return maybeWebLn
|
return maybeWebLn;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,21 @@
|
|||||||
const ArrowBack = () => {
|
const ArrowBack = () => {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="13" viewBox="0 0 16 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<path d="M14.6667 6.5H1.33334M1.33334 6.5L6.33334 11.5M1.33334 6.5L6.33334 1.5" stroke="currentColor" strokeWidth="1.66667" strokeLinecap="round" strokeLinejoin="round"/>
|
width="16"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 16 13"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14.6667 6.5H1.33334M1.33334 6.5L6.33334 11.5M1.33334 6.5L6.33334 1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.66667"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ArrowBack
|
export default ArrowBack;
|
||||||
|
@ -1,9 +1,21 @@
|
|||||||
const ArrowFront = () => {
|
const ArrowFront = () => {
|
||||||
return (
|
return (
|
||||||
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<path d="M1 13L7 7L1 1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
width="8"
|
||||||
</svg>
|
height="14"
|
||||||
)
|
viewBox="0 0 8 14"
|
||||||
}
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M1 13L7 7L1 1"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ArrowFront
|
export default ArrowFront;
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user