Merge branch 'master' into production

This commit is contained in:
Martti Malmi 2023-06-12 10:50:45 +03:00
commit 08fc09a528
203 changed files with 2898 additions and 20552 deletions

View File

@ -1,5 +0,0 @@
---
deployment:
tasks:
- export DEPLOYPATH=/home/iriscx/public_html/
- /bin/cp -R src/* $DEPLOYPATH

30
.gitignore vendored
View File

@ -1,9 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
docs
build
size-plugin.json
translations.csv
.DS_Store
dist
dev-dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.netlify
yarn-error.log
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1 @@
{"imports":{"netlify:edge":"https://edge.netlify.com/v1/index.ts"}}

19
.netlify/state.json Normal file
View File

@ -0,0 +1,19 @@
{
"geolocation": {
"data": {
"city": "Espoo",
"country": {
"code": "FI",
"name": "Finland"
},
"subdivision": {
"code": "18",
"name": "Uusimaa"
},
"timezone": "Europe/Helsinki",
"latitude": 60.205,
"longitude": 24.6455
},
"timestamp": 1676549567324
}
}

View File

@ -15,7 +15,6 @@ WORKDIR /build
COPY package.json yarn.lock ./
# Install dependencies
ENV NODE_OPTIONS=--openssl-legacy-provider
RUN yarn
# Copy project files and folders to the current working directory (i.e. '/app')
@ -36,4 +35,4 @@ COPY --from=build-stage /build .
EXPOSE 8080
CMD [ "yarn", "serve" ]
CMD [ "yarn", "preview" ]

View File

@ -1,6 +1,5 @@
FROM node:19-buster-slim
WORKDIR /iris-messenger/
ENV NODE_OPTIONS=--openssl-legacy-provider
RUN apt-get update && apt-get install -y python3 build-essential
COPY package.json .
@ -10,4 +9,4 @@ RUN yarn
COPY . .
CMD [ "yarn", "dev-docker" ]
CMD [ "yarn", "dev" ]

View File

@ -9,4 +9,4 @@ services:
- .:/iris-messenger
- /iris-messenger/node_modules
ports:
- 8080:8080
- 5173:5173

View File

@ -1,12 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script> window.prerenderReady = false; </script>
<meta charset="utf-8" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Iris The nostr client for better social networks</title>
<meta name="fragment" content="!">
<meta name="prerender-status-code" content="200">
<meta name="description" content="Iris nostr client is the social networking app that is accessible and secure, giving you complete control over your data and profile." data-react-helmet="true" />
<meta
name="viewport"
@ -16,29 +13,25 @@
<meta property="og:description" content="Iris nostr client is the social networking app that is accessible and secure, giving you complete control over your data and profile." data-react-helmet="true" />
<meta
property="og:image"
content="https://iris.to/assets/img/irisconnects.png"
content="https://iris.to/img/irisconnects.png"
data-react-helmet="true"
/>
<meta name="twitter:card" content="summary_large_image" data-react-helmet="true" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/img/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/img/favicon-16x16.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="mask-icon" href="/assets/img/safari-pinned-tab.svg" color="#000000" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="mask-icon" href="/img/safari-pinned-tab.svg" color="#000000" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="msapplication-TileColor" content="#000000" />
<meta pname="msapplication-config" content="/browserconfig.xml" />
<meta name="theme-color" content="#000000" />
<!-- The main stylesheet -->
<% preact.headEnd %>
</head>
<body>
<% preact.bodyEnd %>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,20 +0,0 @@
[build]
environment = { NETLIFY_USE_YARN = "true" }
[dev]
publish = "build"
command = "yarn build"
environment = { NETLIFY_USE_YARN = "true" }
[[headers]]
for = "/.well-known/nostr.json"
[headers.values]
Content-Type = "application/json"
Access-Control-Allow-Origin = "*"
Access-Control-Allow-Methods = "GET"
Access-Control-Allow-Headers = "Content-Type,x-prerender"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

View File

@ -1,108 +1,61 @@
{
"name": "iris-messenger",
"version": "2.3.3",
"license": "MIT",
"name": "iris-vite-preact",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "preact build --no-prerender",
"serve": "sirv build --cors --single",
"dev": "preact watch --host localhost --sw",
"dev-docker": "preact watch --host 0.0.0.0 --sw",
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint 'src/**/*.{js,ts,tsx}'",
"lint:fix": "eslint --fix --quiet 'src/**/*.{js,ts,tsx}'",
"format": "prettier --plugin-search-dir . --write .",
"test": "echo 'jest disabled for now'"
},
"eslintConfig": {
"extends": "preact",
"ignorePatterns": [
"build/",
"src/js/lib/",
"src/assets",
"src/static"
],
"overrides": [
{
"files": [
"*"
],
"rules": {
"react/no-did-mount-set-state": "off",
"react/no-did-update-set-state": "off",
"no-useless-escape": "off",
"radix": "off"
}
}
]
},
"devDependencies": {
"@tauri-apps/cli": "^1.2.3",
"@types/jquery": "3.5.16",
"@types/lodash": "4.14.191",
"@types/react-helmet": "6.1.6",
"@types/webtorrent": "0.109.3",
"@typescript-eslint/eslint-plugin": "^5.54.1",
"@typescript-eslint/parser": "^5.54.1",
"csv-parse": "^5.3.6",
"enzyme": "^3.11.0",
"enzyme-adapter-preact-pure": "^4.1.0",
"eslint": "^8.35.0",
"eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^8.7.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-simple-import-sort": "^10.0.0",
"glob": "^10.1.0",
"jest": "^29.5.0",
"jest-preset-preact": "^4.0.5",
"preact-cli": "^3.4.5",
"prettier": "^2.8.4",
"sirv-cli": "2.0.2",
"webpack-build-notifier": "^2.3.0",
"websocket-polyfill": "^0.0.3"
"lint:fix": "eslint --fix --quiet 'src/**/*.{js,ts,tsx}'"
},
"dependencies": {
"@fontsource/lato": "^4.5.10",
"@heroicons/react": "^2.0.17",
"@noble/hashes": "^1.2.0",
"@noble/secp256k1": "^1.7.1",
"@scure/bip32": "^1.1.5",
"@scure/bip39": "^1.1.1",
"@fontsource/lato": "^5.0.2",
"@heroicons/react": "^2.0.18",
"@noble/hashes": "^1.3.1",
"@noble/secp256k1": "^2.0.0",
"@scure/bip32": "^1.3.0",
"@scure/bip39": "^1.2.0",
"@types/lodash": "^4.14.195",
"aether-torrent": "^0.3.0",
"bech32": "^2.0.0",
"bech32-buffer": "^0.2.1",
"browserify-cipher": ">=1",
"buffer": "^6.0.3",
"dexie": "^3.2.3",
"dexie": "^3.2.4",
"fuse.js": "^6.6.2",
"history": "5.3.0",
"htm": "^3.1.1",
"identicon.js": "^2.3.3",
"jquery": "^3.6.4",
"light-bolt11-decoder": "^2.1.0",
"jquery": "^3.7.0",
"light-bolt11-decoder": "^3.0.0",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"lokijs": "^1.5.12",
"nostr-relaypool": "^0.6.27",
"preact": "^10.13.0",
"preact-router": "^4.1.0",
"nostr-relaypool": "^0.6.28",
"nostr-tools": "^1.11.2",
"preact": "^10.15.1",
"preact-router": "^4.1.1",
"preact-scroll-viewport": "^0.2.0",
"react-helmet": "^6.1.0",
"react-string-replace": "^1.1.0",
"react-virtualized": "^9.22.3",
"styled-components": "^5.3.8",
"workbox-background-sync": "^6.5.4",
"workbox-cacheable-response": "^6.5.4",
"workbox-expiration": "^6.5.4",
"workbox-routing": "^6.5.4",
"workbox-strategies": "^6.5.4"
"react-virtualized": "^9.22.5",
"styled-components": "^5.3.11"
},
"jest": {
"preset": "jest-preset-preact",
"setupFiles": [
"<rootDir>/tests/__mocks__/browserMocks.js",
"<rootDir>/tests/__mocks__/setupTests.js"
]
},
"resolutions": {
"styled-components": "^5"
"devDependencies": {
"@tauri-apps/cli": "^1.3.1",
"@preact/preset-vite": "^2.5.0",
"@typescript-eslint/eslint-plugin": "^5.59.9",
"@typescript-eslint/parser": "^5.59.9",
"eslint": "^8.42.0",
"eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-simple-import-sort": "^10.0.0",
"prettier": "^2.8.8",
"typescript": "^5.1.3",
"vite": "^4.3.9",
"vite-plugin-pwa": "^0.16.3"
}
}

View File

@ -1,20 +0,0 @@
const WebpackBuildNotifierPlugin = require('webpack-build-notifier');
const path = require('path');
export default {
webpack(config, env, helpers, options) {
config.node = { fs: 'empty' };
config.output = config.output || {};
config.output.publicPath = '/';
config.plugins = config.plugins || [];
config.plugins.push(
new WebpackBuildNotifierPlugin({
title: 'Iris Webpack Build',
logo: path.resolve('./src/assets/img/icon128.png'),
suppressSuccess: true, // don't spam success notifications
warningSound: false,
suppressWarning: true,
}),
);
},
};

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

2
src-tauri/Cargo.lock generated
View File

@ -40,7 +40,7 @@ checksum = "224afbd727c3d6e4b90103ece64b8d1b67fbb1973b1046c2281eed3f3803f800"
[[package]]
name = "app"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"serde",
"serde_json",

View File

@ -17,7 +17,7 @@ tauri-build = { version = "1.2.1", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2.5", features = ["updater"] }
tauri = { version = "1.2.5", features = [] }
[features]
# by default Tauri runs in production mode

View File

@ -3,8 +3,8 @@
"build": {
"beforeBuildCommand": "yarn build",
"beforeDevCommand": "yarn dev",
"devPath": "http://localhost:8080",
"distDir": "../build"
"devPath": "http://localhost:5173",
"distDir": "../dist"
},
"package": {
"productName": "iris",

View File

@ -17,9 +17,6 @@
--day-separator-bg: rgba(30, 32, 37, 0.85);
--day-separator-color: rgba(255, 255, 255, 0.88);
--dropdown-bg: rgba(0, 0, 0, 0.9);
--emoji-picker-bg: #35383f;
--emoji-picker-border-color: #1f2125;
--emoji-picker-color: #fff;
--gallery-background: rgba(0, 0, 0, 0.8);
--green: #34ba7c;
--header-color: #000000;
@ -68,9 +65,6 @@
--day-separator-bg: rgba(255, 255, 255, 0.85);
--day-separator-color: rgba(0, 0, 0, 0.88);
--dropdown-bg: rgba(255, 255, 255, 0.8);
--emoji-picker-bg: #f5f5f5;
--emoji-picker-border-color: #e5e5e5;
--emoji-picker-color: #000000;
--gallery-background: rgba(255, 255, 255, 0.8);
--green: #53B781;
--header-color: #ffffff;
@ -693,7 +687,7 @@ header.footer .header-content {
max-height: 55px;
justify-content: center;
background-color: var(--main-color);
border-top: 1px solid var(--emoji-picker-border-color);
border-top: 1px solid var(--border-color);
}
.media-player .info p {
@ -1761,25 +1755,6 @@ a.msgSenderName:hover .user-name {
text-align: center;
}
.emoji-picker-btn {
outline: 1px;
margin-right: 5px;
}
.emoji-picker {
background: var(--emoji-picker-bg) !important;
border-color: var(--emoji-picker-border-color) !important;
}
.emoji-picker * {
color: var(--emoji-picker-color) !important;
border-color: var(--emoji-picker-border-color) !important;
}
.emoji-picker__tab {
font-size: 35px !important;
}
.attachment-preview {
display: flex;
flex-direction: column;

7
src/index.css Normal file
View File

@ -0,0 +1,7 @@
:root {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}

View File

@ -1,3 +0,0 @@
import Main from './js/Main';
export default Main;

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { PureComponent } from 'react';
import { PureComponent } from 'preact/compat';
import { Callback, EventListener } from './LocalState';
import { Callback, Unsubscribe } from './LocalState';
type OwnState = {
ogImageUrl?: any;
@ -14,16 +14,16 @@ export default abstract class BaseComponent<Props = any, State = any> extends Pu
unmounted?: boolean;
// TODO: make this use Subscriptions instead of LocalState eventlisteners? or both?
eventListeners: Record<string, EventListener | undefined> = {};
unsubscribes: Record<string, Unsubscribe | undefined> = {};
sub(callback: CallableFunction, path?: string): Callback {
const cb = (data, key, message, eventListener, f): void => {
const cb = (data, key, unsubscribe, f): void => {
if (this.unmounted) {
eventListener && eventListener.off();
unsubscribe?.();
return;
}
this.eventListeners[path ?? key] = eventListener;
callback(data, key, message, eventListener, f);
this.unsubscribes[path ?? key] = unsubscribe;
callback(data, key, unsubscribe, f);
};
return cb as any;
@ -38,10 +38,10 @@ export default abstract class BaseComponent<Props = any, State = any> extends Pu
}
unsubscribe() {
Object.keys(this.eventListeners).forEach((k) => {
const l = this.eventListeners[k];
l && l.off();
delete this.eventListeners[k];
Object.keys(this.unsubscribes).forEach((k) => {
const unsub = this.unsubscribes[k];
unsub?.();
delete this.unsubscribes[k];
});
}

View File

@ -15,7 +15,7 @@ const notifyUpdate = _.throttle(() => {
}, 2000);
const FuzzySearch = {
index: new Fuse([], options),
index: new Fuse([] as any[], options),
keys: new Set<string>(),
add(doc: any) {
if (this.keys.has(doc.key)) {

View File

@ -9,10 +9,10 @@ import { route } from 'preact-router';
import EventComponent from './components/events/EventComponent';
import Name from './components/Name';
import SafeImg, { isSafeOrigin } from './components/SafeImg';
import SafeImg from './components/SafeImg';
import Torrent from './components/Torrent';
import Key from './nostr/Key';
import { language, translate as t } from './translations/Translation';
import { language, translate as t } from './translations/Translation.mjs';
import localState from './LocalState';
const emojiRegex =
@ -31,18 +31,6 @@ let existingIrisToAddress: any = {};
localState.get('settings').put({}); // ?
localState.get('existingIrisToAddress').on((a) => (existingIrisToAddress = a));
function setImgSrc(el: JQuery<HTMLElement>, src: string): JQuery<HTMLElement> {
if (src) {
// parse src as url safely
src = new URL(src).href;
if (!isSafeOrigin(src)) {
src = `https://imgproxy.iris.to/insecure/plain/${src}`;
}
el.attr('src', src);
}
return el;
}
const userAgent = navigator.userAgent.toLowerCase();
const isElectron = userAgent.indexOf(' electron/') > -1;
@ -147,7 +135,11 @@ export default {
if (opts.showMentionedMessages) {
replacedText = reactStringReplace(replacedText, noteRegex, (match, i) => {
return (
<EventComponent key={match + i} id={Key.toNostrHexAddress(match)} asInlineQuote={true} />
<EventComponent
key={match + i}
id={Key.toNostrHexAddress(match) || ''}
asInlineQuote={true}
/>
);
});
}
@ -556,7 +548,7 @@ export default {
})(),
// hashtags, usernames, links
highlightText(s: string, event: any, opts: any = {}) {
highlightText(s: string, event?: any, opts: any = {}) {
s = reactStringReplace(s, pubKeyRegex, (match, i) => {
match = match.replace(/@/g, '');
const link = `/${match}`;
@ -628,7 +620,7 @@ export default {
},
);
if (event && event.tags) {
if (event?.tags) {
// replace "#[n]" tags with links to the user: event.tags[n][1]
s = reactStringReplace(s, /#\[(\d+)\]/g, (match, i) => {
const tag = event.tags[parseInt(match, 10)];
@ -682,6 +674,7 @@ export default {
document.body.removeChild(textarea);
}
}
return false;
},
showConsoleWarning(): void {
@ -712,7 +705,10 @@ export default {
},
formatDate(date: Date) {
const t = date.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
const t = date.toLocaleString(undefined, {
dateStyle: 'short',
timeStyle: 'short',
});
const s = t.split(':');
if (s.length === 3) {
// safari tries to display seconds
@ -780,7 +776,10 @@ export default {
return Math.floor(timeDifference / secondsInAnHour) + 'h';
} else {
if (date.getFullYear() === currentTime.getFullYear()) {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
} else {
return date.toLocaleDateString(undefined, {
year: 'numeric',
@ -835,8 +834,6 @@ export default {
}
}, 100),
setImgSrc,
animateScrollTop: (selector: string): void => {
const el = $(selector);
el.css({ overflow: 'hidden' });

View File

@ -1,4 +1,3 @@
import { Event } from './lib/nostr-tools';
import Events from './nostr/Events';
import Key from './nostr/Key';
import SocialNetwork from './nostr/SocialNetwork';
@ -50,13 +49,12 @@ export default {
SocialNetwork.setMetadata(p);
}
}
this.setState({ profile: p, irisToActive: true });
});
},
async enableReserved(name) {
const pubkey = Key.getPubKey();
const event: Event = {
const event: any = {
content: `iris.to/${name}`,
kind: 1,
tags: [],
@ -95,7 +93,7 @@ export default {
return;
}
const pubkey = Key.getPubKey();
const event: Event = {
const event: any = {
content: `decline iris.to/${name}`,
kind: 1,
tags: [],

View File

@ -1,4 +1,5 @@
import { Event } from './lib/nostr-tools';
import { Event } from 'nostr-tools';
import Helpers from './Helpers';
// Code kindly contributed by @Kieran from Snort

View File

@ -98,10 +98,12 @@ export function decodeInvoice(pr: string): InvoiceDetails | undefined {
const expirySection = parsed.sections.find((a) => a.name === 'expiry');
const expire = expirySection ? Number(expirySection.value as number | string) : undefined;
const descriptionSection = parsed.sections.find((a) => a.name === 'description')?.value;
const descriptionHashSection = parsed.sections.find(
(a) => a.name === 'description_hash',
)?.value;
const paymentHashSection = parsed.sections.find((a) => a.name === 'payment_hash')?.value;
const descriptionHashSection = new Uint8Array(
parsed.sections.find((a) => a.name === 'description_hash')?.value,
);
const paymentHashSection = new Uint8Array(
parsed.sections.find((a) => a.name === 'payment_hash')?.value,
);
const ret = {
amount: amount,
expire: timestamp && expire ? timestamp + expire : undefined,

View File

@ -1,11 +1,9 @@
import localForage from 'localforage';
import _ from 'lodash';
type EventListener = {
off: () => void;
};
export type Unsubscribe = () => void;
type Callback = (data: any, path: string, something: any, event: EventListener) => void;
export type Callback = (data: any, path: string, unsubscribe: Unsubscribe) => void;
// Localforage returns null if an item is not found, so we represent null with this uuid instead.
// not foolproof, but good enough for now.
@ -80,17 +78,17 @@ class Node {
doCallbacks = _.throttle(() => {
for (const [id, callback] of this.on_subscriptions) {
const event = { off: () => this.on_subscriptions.delete(id) };
this.once(callback, event, false);
const unsubscribe = () => this.on_subscriptions.delete(id);
this.once(callback, unsubscribe, false);
}
if (this.parent) {
for (const [id, callback] of this.parent.on_subscriptions) {
const event = { off: () => this.parent.on_subscriptions.delete(id) };
this.parent.once(callback, event, false);
const unsubscribe = () => this.parent?.on_subscriptions.delete(id);
this.parent.once(callback, unsubscribe, false);
}
for (const [id, callback] of this.parent.map_subscriptions) {
const event = { off: () => this.parent.map_subscriptions.delete(id) };
this.once(callback, event, false);
const unsubscribe = () => this.parent?.map_subscriptions.delete(id);
this.once(callback, unsubscribe, false);
}
}
}, 40);
@ -144,14 +142,18 @@ class Node {
* @param returnIfUndefined
* @returns {Promise<*>}
*/
async once(callback?: Callback, event?: EventListener, returnIfUndefined = true): Promise<any> {
async once(
callback?: Callback,
unsubscribe?: Unsubscribe,
returnIfUndefined = true,
): Promise<any> {
let result: any;
if (this.children.size) {
// return an object containing all children
result = {};
await Promise.all(
Array.from(this.children.keys()).map(async (key) => {
result[key] = await this.get(key).once(undefined, event);
result[key] = await this.get(key).once(undefined, unsubscribe);
}),
);
} else if (this.value !== undefined) {
@ -160,7 +162,15 @@ class Node {
result = await this.loadLocalForage();
}
if (result !== undefined || returnIfUndefined) {
callback && callback(result, this.id.slice(this.id.lastIndexOf('/') + 1), null, event);
callback &&
callback(
result,
this.id.slice(this.id.lastIndexOf('/') + 1),
unsubscribe ||
(() => {
/* do nothing */
}),
);
return result;
}
}
@ -169,11 +179,12 @@ class Node {
* Subscribe to a value
* @param callback
*/
on(callback: Callback): void {
on(callback: Callback): Unsubscribe {
const id = this.counter++;
this.on_subscriptions.set(id, callback);
const event = { off: () => this.on_subscriptions.delete(id) };
this.once(callback, event, false);
const unsubscribe = () => this.on_subscriptions.delete(id);
this.once(callback, unsubscribe, false);
return unsubscribe;
}
/**
@ -181,21 +192,25 @@ class Node {
* @param callback
* @returns {Promise<void>}
*/
async map(callback: Callback) {
map(callback: Callback): Unsubscribe {
const id = this.counter++;
this.map_subscriptions.set(id, callback);
const event = { off: () => this.map_subscriptions.delete(id) };
if (!this.loaded) {
const unsubscribe = () => this.map_subscriptions.delete(id);
const go = () => {
for (const child of this.children.values()) {
child.once(callback, unsubscribe, false);
}
};
if (this.loaded) {
go();
} else {
// ensure that the list of children is loaded
await this.loadLocalForage();
}
for (const child of this.children.values()) {
child.once(callback, event, false);
this.loadLocalForage()?.then(go);
}
return unsubscribe;
}
}
const localState = new Node();
export default localState;
export { Callback, EventListener };

View File

@ -7,7 +7,7 @@ import MediaPlayer from './components/MediaPlayer';
import Menu from './components/Menu';
import Modal from './components/modal/Modal';
import Session from './nostr/Session';
import { translationLoaded } from './translations/Translation';
import { translationLoaded } from './translations/Translation.mjs';
import About from './views/About';
import Chat from './views/chat/Chat';
import EditProfile from './views/EditProfile';
@ -110,7 +110,7 @@ class Main extends Component<Props, ReactState> {
// if id begins with "note", it's a post. otherwise it's a profile.
const NoteOrProfile = (params: { id?: string; path: string }) => {
if (params.id.startsWith('note')) {
if (params.id?.startsWith('note')) {
return <Note id={params.id} />;
}
return <Profile id={params.id} tab="posts" path={params.path} />;

View File

@ -1,9 +1,11 @@
import { JSX } from 'preact';
import Icons from '../Icons';
import Key from '../nostr/Key';
import SocialNetwork from '../nostr/SocialNetwork';
import { translate as t } from '../translations/Translation';
import { translate as t } from '../translations/Translation.mjs';
export default function Badge(props) {
export default function Badge(props): JSX.Element | null {
const myPub = Key.getPubKey();
const hexAddress = Key.toNostrHexAddress(props.pub);
if (hexAddress === myPub) {
@ -37,6 +39,8 @@ export default function Badge(props) {
</span>
</span>
);
} else {
return null;
}
}
}

View File

@ -1,3 +1,4 @@
import { JSX } from 'preact';
import { useEffect, useState } from 'preact/hooks';
type Props = {

View File

@ -3,14 +3,14 @@ import { HeartIcon as HeartIconFull } from '@heroicons/react/24/solid';
import { route } from 'preact-router';
import { Link } from 'preact-router/match';
import logo from '../../assets/img/icon128.png';
import logo from '../../../public/img/icon128.png';
import Component from '../BaseComponent';
import Helpers from '../Helpers';
import Icons from '../Icons';
import localState from '../LocalState';
import Key from '../nostr/Key';
import Relays from '../nostr/Relays';
import { translate as t } from '../translations/Translation';
import { translate as t } from '../translations/Translation.mjs';
import { Button, PrimaryButton } from './buttons/Button';
import Identicon from './Identicon';
@ -18,8 +18,8 @@ import Name from './Name';
import SearchBox from './SearchBox';
export default class Header extends Component {
chatId = null;
iv = null;
chatId = null as string | null;
iv = null as any;
constructor() {
super();
@ -39,7 +39,7 @@ export default class Header extends Component {
componentWillUnmount() {
super.componentWillUnmount();
clearInterval(this.iv);
this.iv && clearInterval(this.iv);
document.removeEventListener('keydown', this.escFunction, false);
}
@ -73,7 +73,7 @@ export default class Header extends Component {
);
this.setState({ title });
} else {
const title = <Name key={this.chatId} pub={this.chatId} />;
const title = <Name key={this.chatId} pub={this.chatId || ''} />;
this.setState({ title });
}
}
@ -187,11 +187,11 @@ export default class Header extends Component {
}`}
onClick={() => {
// also synchronously make element visible so it can be focused
document.querySelector('.mobile-search-visible').classList.remove('hidden-xs', 'hidden');
document.querySelector('.mobile-search-visible')?.classList.remove('hidden-xs', 'hidden');
document
.querySelector('.mobile-search-hidden')
.classList.remove('visible-xs-inline-block');
document.querySelector('.mobile-search-hidden').classList.add('hidden');
?.classList.remove('visible-xs-inline-block');
document.querySelector('.mobile-search-hidden')?.classList.add('hidden');
const input = document.querySelector('.search-box input');
if (input) {
setTimeout(() => {

View File

@ -110,14 +110,14 @@ class MyIdenticon extends Component<Props, State> {
<div class="identicon">
{hasPicture ? (
<SafeImg
src={this.state.picture}
src={this.state.picture as string}
width={width}
square={true}
style={{ objectFit: 'cover' }}
onError={() => this.setState({ hasError: true })}
/>
) : (
<img width={width} style="max-width:100%" src={this.state.identicon} />
<img width={width} style="max-width:100%" src={this.state.identicon || ''} />
)}
</div>
{this.props.showTooltip && this.state.name ? (

View File

@ -5,7 +5,7 @@ import {
AVAILABLE_LANGUAGE_KEYS,
AVAILABLE_LANGUAGES,
language,
} from '../translations/Translation';
} from '../translations/Translation.mjs';
function onLanguageChange(e: Event): void {
const target = e.target as HTMLSelectElement;

View File

@ -98,7 +98,7 @@ class MediaPlayer extends Component<Record<string, never>, State> {
if (existing) {
this.onTorrent(existing);
} else {
client.add(this.torrentId, (e: Error, t: any) => this.onTorrent(t));
client.add(this.torrentId, (_e: Error, t: any) => this.onTorrent(t));
}
}
@ -117,7 +117,7 @@ class MediaPlayer extends Component<Record<string, never>, State> {
<a href={`/torrent/${encodeURIComponent(this.state.torrentId ?? '')}`} className="info">
{s.splitPath
? s.splitPath.map((str, i) => {
if (i === s.splitPath.length - 1) {
if (i === (s.splitPath?.length || 0) - 1) {
str = str.split('.').slice(0, -1).join('.');
return (
<p>

View File

@ -12,12 +12,12 @@ import {
} from '@heroicons/react/24/solid';
import { route } from 'preact-router';
import logo from '../../assets/img/icon128.png';
import logo from '../../../public/img/icon128.png';
import BaseComponent from '../BaseComponent';
import Icons from '../Icons';
import localState from '../LocalState';
import Key from '../nostr/Key';
import { translate as t } from '../translations/Translation';
import { translate as t } from '../translations/Translation.mjs';
import { Button, PrimaryButton } from './buttons/Button';
import Modal from './modal/Modal';
@ -26,8 +26,18 @@ import PublicMessageForm from './PublicMessageForm';
const APPLICATIONS = [
{ url: '/', text: 'home', icon: HomeIcon, activeIcon: HomeIconFull },
{ url: '/chat', text: 'messages', icon: PaperAirplaneIcon, activeIcon: PaperAirplaneIconFull },
{ url: '/settings', text: 'settings', icon: Cog8ToothIcon, activeIcon: Cog8ToothIconFull },
{
url: '/chat',
text: 'messages',
icon: PaperAirplaneIcon,
activeIcon: PaperAirplaneIconFull,
},
{
url: '/settings',
text: 'settings',
icon: Cog8ToothIcon,
activeIcon: Cog8ToothIconFull,
},
{
url: '/about',
text: 'about',

View File

@ -1,98 +0,0 @@
import { Component } from 'preact';
import Helpers from '../Helpers';
import Events from '../nostr/Events';
import Key from '../nostr/Key';
const mentionRegex = /\B@[\u00BF-\u1FFF\u2C00-\uD7FF\w]*$/;
export default class MessageForm extends Component {
async sendNostr(msg) {
const event = {
kind: 1,
content: msg.text,
};
if (msg.replyingTo) {
const id = Key.toNostrHexAddress(msg.replyingTo);
const replyingTo = await new Promise((resolve) => {
Events.getEventById(id, true, (e) => resolve(e));
});
event.tags = replyingTo.tags.filter((tag) => tag[0] === 'p');
let rootTag = replyingTo.tags?.find((t) => t[0] === 'e' && t[3] === 'root');
if (!rootTag) {
rootTag = replyingTo.tags?.find((t) => t[0] === 'e');
}
if (rootTag) {
event.tags.unshift(['e', id, '', 'reply']);
event.tags.unshift(['e', rootTag[1], '', 'root']);
} else {
event.tags.unshift(['e', id, '', 'root']);
}
if (!event.tags?.find((t) => t[0] === 'p' && t[1] === replyingTo.pubkey)) {
event.tags.push(['p', replyingTo.pubkey]);
}
}
function handleTagged(regex, tagType) {
const taggedItems = [...msg.text.matchAll(regex)]
.map((m) => m[0])
.filter((m, i, a) => a.indexOf(m) === i);
if (taggedItems) {
event.tags = event.tags || [];
for (const tag of taggedItems) {
const match = tag.match(/npub[a-zA-Z0-9]{59,60}/)?.[0];
const hexTag = match && Key.toNostrHexAddress(match);
if (!hexTag) {
continue;
}
const newTag = [tagType, hexTag, '', 'mention'];
// add if not already present
if (!event.tags?.find((t) => t[0] === newTag[0] && t[1] === newTag[1])) {
event.tags.push(newTag);
}
}
}
}
handleTagged(Helpers.pubKeyRegex, 'p');
handleTagged(Helpers.noteRegex, 'e');
const hashtags = [...msg.text.matchAll(Helpers.hashtagRegex)].map((m) => m[0].slice(1));
if (hashtags.length) {
event.tags = event.tags || [];
for (const hashtag of hashtags) {
if (!event.tags?.find((t) => t[0] === 't' && t[1] === hashtag)) {
event.tags.push(['t', hashtag]);
}
}
}
console.log('sending event', event);
return Events.publish(event);
}
onMsgTextPaste(event) {
const pasted = (event.clipboardData || window.clipboardData).getData('text');
const magnetRegex = /^magnet:\?xt=urn:btih:*/;
if (
(pasted !== this.state.torrentId && pasted.indexOf('.torrent') > -1) ||
pasted.match(magnetRegex)
) {
this.setState({ torrentId: pasted });
}
}
checkMention(event) {
const val = event.target.value.slice(0, event.target.selectionStart);
const matches = val.match(mentionRegex);
if (matches) {
const match = matches[0].slice(1);
if (!Key.toNostrHexAddress(match)) {
this.setState({ mentioning: match });
}
} else if (this.state.mentioning) {
this.setState({ mentioning: null });
}
}
}

View File

@ -17,7 +17,7 @@ const Name = (props: Props) => {
console.error('Name component requires a pub', props);
return null;
}
const nostrAddr = Key.toNostrHexAddress(props.pub);
const nostrAddr = Key.toNostrHexAddress(props.pub) || '';
let initialName = '';
let initialDisplayName;
let isGenerated = false;

View File

@ -6,7 +6,7 @@ import Component from '../BaseComponent';
import Helpers from '../Helpers';
import localState from '../LocalState';
import Key from '../nostr/Key';
import { translate as t } from '../translations/Translation';
import { translate as t } from '../translations/Translation.mjs';
import { Button, PrimaryButton } from './buttons/Button';
import Copy from './buttons/Copy';

View File

@ -16,6 +16,10 @@ const ProfilePicture = ({ picture, onError }: Props) => {
setShowModal(false);
};
if (!picture) {
return null;
}
return (
<>
<SafeImg class="profile-picture" src={picture} onError={onError} onClick={handleClick} />

View File

@ -1,31 +1,46 @@
import { PaperAirplaneIcon } from '@heroicons/react/24/outline';
import { html } from 'htm/preact';
import $ from 'jquery';
import { Event } from 'nostr-tools';
import { createRef } from 'preact';
import Component from '../BaseComponent';
import Helpers from '../Helpers';
import Icons from '../Icons';
import EmojiButton from '../lib/emoji-button';
import localState from '../LocalState';
import { translate as t } from '../translations/Translation';
import Events from '../nostr/Events';
import Key from '../nostr/Key';
import { translate as t } from '../translations/Translation.mjs';
import MessageForm from './MessageForm';
import SafeImg from './SafeImg';
import SearchBox from './SearchBox';
import Torrent from './Torrent';
const mentionRegex = /\B@[\u00BF-\u1FFF\u2C00-\uD7FF\w]*$/;
class PublicMessageForm extends MessageForm {
interface IProps {
replyingTo?: string;
forceAutofocusMobile?: boolean;
autofocus?: boolean;
onSubmit?: (msg: any) => void;
waitForFocus?: boolean;
class?: string;
index?: string;
placeholder?: string;
}
interface IState {
attachments?: any[];
torrentId?: string;
mentioning?: boolean;
focused?: boolean;
}
class PublicMessageForm extends Component<IProps, IState> {
newMsgRef = createRef();
componentDidMount() {
const textEl = $(this.newMsgRef.current);
this.picker = new EmojiButton({ position: 'top-start' });
this.picker.on('emoji', (emoji) => {
textEl.val(textEl.val() + emoji);
textEl.focus();
});
if (
(!Helpers.isMobile || this.props.forceAutofocusMobile == true) &&
this.props.autofocus !== false
@ -60,7 +75,7 @@ class PublicMessageForm extends MessageForm {
if (!text.length) {
return;
}
const msg = { text };
const msg: any = { text };
if (this.props.replyingTo) {
msg.replyingTo = this.props.replyingTo;
}
@ -69,18 +84,12 @@ class PublicMessageForm extends MessageForm {
}
await this.sendNostr(msg);
this.props.onSubmit && this.props.onSubmit(msg);
this.setState({ attachments: null, torrentId: null });
this.setState({ attachments: undefined, torrentId: undefined });
textEl.val('');
textEl.height('');
this.saveDraftToHistory();
}
onEmojiButtonClick(event) {
event.preventDefault();
event.stopPropagation();
this.picker.pickerVisible ? this.picker.hidePicker() : this.picker.showPicker(event.target);
}
setTextareaHeight(textarea) {
textarea.style.height = '';
textarea.style.height = `${textarea.scrollHeight}px`;
@ -134,10 +143,10 @@ class PublicMessageForm extends MessageForm {
}
attachmentsChanged(event) {
let files = event.target.files || event.dataTransfer.files;
const files = event.target.files || event.dataTransfer.files;
if (files) {
for (let i = 0; i < files.length; i++) {
let formData = new FormData();
const formData = new FormData();
formData.append('fileToUpload', files[i]);
const a = this.state.attachments || [];
@ -254,13 +263,6 @@ class PublicMessageForm extends MessageForm {
>
${Icons.attach}
</button>
<button
class="emoji-picker-btn hidden-xs"
type="button"
onClick=${(e) => this.onEmojiButtonClick(e)}
>
${Icons.smile}
</button>
<button type="submit">
<span>${t('post')} </span>
<${PaperAirplaneIcon} width="24" style="margin-top:5px" />
@ -279,7 +281,7 @@ class PublicMessageForm extends MessageForm {
href=""
onClick=${(e) => {
e.preventDefault();
this.setState({ attachments: null });
this.setState({ attachments: undefined });
}}
>${t('remove_attachment')}</a
>
@ -321,6 +323,88 @@ class PublicMessageForm extends MessageForm {
</div>
</form>`;
}
async sendNostr(msg: { text: string; replyingTo?: string }) {
const event = {
kind: 1,
content: msg.text,
} as any;
if (msg.replyingTo) {
const id = Key.toNostrHexAddress(msg.replyingTo);
if (!id) {
throw new Error('invalid replyingTo');
}
const replyingTo: Event = await new Promise((resolve) => {
Events.getEventById(id, true, (e) => resolve(e));
});
event.tags = replyingTo.tags.filter((tag) => tag[0] === 'p');
let rootTag = replyingTo.tags?.find((t) => t[0] === 'e' && t[3] === 'root');
if (!rootTag) {
rootTag = replyingTo.tags?.find((t) => t[0] === 'e');
}
if (rootTag) {
event.tags.unshift(['e', id, '', 'reply']);
event.tags.unshift(['e', rootTag[1], '', 'root']);
} else {
event.tags.unshift(['e', id, '', 'root']);
}
if (!event.tags?.find((t) => t[0] === 'p' && t[1] === replyingTo.pubkey)) {
event.tags.push(['p', replyingTo.pubkey]);
}
}
function handleTagged(regex, tagType) {
const taggedItems = [...msg.text.matchAll(regex)]
.map((m) => m[0])
.filter((m, i, a) => a.indexOf(m) === i);
if (taggedItems) {
event.tags = event.tags || [];
for (const tag of taggedItems) {
const match = tag.match(/npub[a-zA-Z0-9]{59,60}/)?.[0];
const hexTag = match && Key.toNostrHexAddress(match);
if (!hexTag) {
continue;
}
const newTag = [tagType, hexTag, '', 'mention'];
// add if not already present
if (!event.tags?.find((t) => t[0] === newTag[0] && t[1] === newTag[1])) {
event.tags.push(newTag);
}
}
}
}
handleTagged(Helpers.pubKeyRegex, 'p');
handleTagged(Helpers.noteRegex, 'e');
const hashtags = [...msg.text.matchAll(Helpers.hashtagRegex)].map((m) => m[0].slice(1));
if (hashtags.length) {
event.tags = event.tags || [];
for (const hashtag of hashtags) {
if (!event.tags?.find((t) => t[0] === 't' && t[1] === hashtag)) {
event.tags.push(['t', hashtag]);
}
}
}
console.log('sending event', event);
return Events.publish(event);
}
checkMention(event: any) {
const val = event.target.value.slice(0, event.target.selectionStart);
const matches = val.match(mentionRegex);
if (matches) {
const match = matches[0].slice(1);
if (!Key.toNostrHexAddress(match)) {
this.setState({ mentioning: match });
}
} else if (this.state.mentioning) {
this.setState({ mentioning: undefined });
}
}
}
export default PublicMessageForm;

View File

@ -8,7 +8,7 @@ import FuzzySearch from '../FuzzySearch';
import localState from '../LocalState';
import Events from '../nostr/Events';
import Key from '../nostr/Key';
import { translate as t } from '../translations/Translation';
import { translate as t } from '../translations/Translation.mjs';
import Identicon from './Identicon';
import Name from './Name';
@ -91,7 +91,7 @@ class SearchBox extends Component<Props, State> {
$(document)
.off('keydown')
.on('keydown', (e) => {
if (e.key === 'Tab' && document.activeElement.tagName === 'BODY') {
if (e.key === 'Tab' && document.activeElement?.tagName === 'BODY') {
e.preventDefault();
$(this.base).find('input').focus();
} else if (e.key === 'Escape') {
@ -184,7 +184,7 @@ class SearchBox extends Component<Props, State> {
pubKey &&
query === String(this.props.query || $(this.base).find('input').first().val())
) {
this.props.onSelect({ key: pubKey });
this.props.onSelect?.({ key: pubKey });
}
});
}
@ -232,7 +232,7 @@ class SearchBox extends Component<Props, State> {
this.close();
}
onResultFocus(e, index) {
onResultFocus(_e, index) {
this.setState({ selected: index });
}

View File

@ -5,7 +5,7 @@ import Component from '../BaseComponent';
import Helpers from '../Helpers';
import Icons from '../Icons';
import localState from '../LocalState';
import { translate as t } from '../translations/Translation';
import { translate as t } from '../translations/Translation.mjs';
const isOfType = (f, types) => types.indexOf(f.name.slice(-4)) !== -1;
const isVideo = (f) => isOfType(f, ['webm', '.mp4', '.ogg']);
@ -14,7 +14,21 @@ const isImage = (f) => isOfType(f, ['.jpg', 'jpeg', '.gif', '.png']);
class Torrent extends Component {
coverRef = createRef();
state = { settings: {} };
state = {
settings: {} as any,
player: {} as any,
activeFilePath: '',
torrent: {} as any,
isAudioOpen: false,
showFiles: false,
torrenting: false,
hasNext: false,
splitPath: null as any,
ogImageUrl: '',
};
player: any;
torrent: any;
observer: any;
componentDidMount() {
console.log('componentDidMount torrent');
@ -53,7 +67,7 @@ class Torrent extends Component {
}
}
async startTorrenting(clicked) {
async startTorrenting(clicked?: boolean) {
this.setState({ torrenting: true });
const torrentId = this.props.torrentId;
const { default: AetherTorrent } = await import('aether-torrent');
@ -66,7 +80,7 @@ class Torrent extends Component {
}
}
playAudio(filePath, e) {
playAudio(filePath, e?) {
e && e.preventDefault();
localState.get('player').put({ torrentId: this.props.torrentId, filePath, paused: false });
}
@ -76,8 +90,8 @@ class Torrent extends Component {
localState.get('player').put({ paused: true });
}
openFile(file, clicked) {
const base = document.querySelector(this.base);
openFile(file, clicked?: boolean) {
const base = this.base as Element;
const isVid = isVideo(file);
const isAud = !isVid && isAudio(file);
if (this.state.activeFilePath === file.path) {
@ -103,8 +117,8 @@ class Torrent extends Component {
autoplay = isVid && this.state.settings.autoplayVideos;
muted = autoplay;
}
const el = base.querySelector('.player');
el.innerHTML = '';
const el = base?.querySelector('.player');
el && (el.innerHTML = '');
if (isAud && clicked) {
this.playAudio(file.path);
}
@ -116,9 +130,9 @@ class Torrent extends Component {
const handlePlay = (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
autoplay && vid.play();
autoplay && vid?.play();
} else {
vid.pause();
vid?.pause();
}
});
};
@ -130,8 +144,9 @@ class Torrent extends Component {
this.observer.observe(vid);
}
base.querySelector('.info').style.display = !isVid ? 'block' : 'none';
const player = base.querySelector('video, audio');
const info = base.querySelector('.info') as HTMLElement;
info && (info.style.display = !isVid ? 'block' : 'none');
const player = base.querySelector('video, audio') as HTMLMediaElement;
if (player) {
player.addEventListener('ended', () => {
const typeCheck = player.tagName === 'VIDEO' ? isVideo : isAudio;
@ -176,7 +191,7 @@ class Torrent extends Component {
return;
}
this.torrent = torrent;
let interval = setInterval(() => {
const interval = setInterval(() => {
if (!torrent.files) {
console.log('no files found in torrent:', torrent);
return;
@ -215,7 +230,7 @@ class Torrent extends Component {
const to = s.torrent;
const p = s.player;
const playing = p && p.torrentId === this.props.torrentId && !p.paused;
let playButton = '';
let playButton = '' as any;
if (s.isAudioOpen) {
playButton = playing ? (
<a href="#" onClick={(e) => this.pauseAudio(e)}>
@ -277,7 +292,7 @@ class Torrent extends Component {
{to.files.map((f) => (
<div
key={f.path}
onClick={(e) => this.openFile(f, e)}
onClick={() => this.openFile(f, true)}
className={`flex-row ${s.activeFilePath === f.path ? 'active' : ''}`}
>
<div className="flex-cell">{f.name}</div>

View File

@ -1,7 +1,7 @@
import Component from '../../BaseComponent';
import Key from '../../nostr/Key';
import SocialNetwork from '../../nostr/SocialNetwork';
import { translate as t } from '../../translations/Translation';
import { translate as t } from '../../translations/Translation.mjs';
import Name from '../Name';
import { PrimaryButton as Button } from './Button';
@ -32,12 +32,13 @@ class Block extends Component<Props> {
onClick(e) {
e.preventDefault();
const newValue = !this.state[this.key];
SocialNetwork.block(Key.toNostrHexAddress(this.props.id), newValue);
const hex = Key.toNostrHexAddress(this.props.id);
hex && SocialNetwork.block(hex, newValue);
}
componentDidMount() {
SocialNetwork.getBlockedUsers((blocks) => {
const blocked = blocks?.has(Key.toNostrHexAddress(this.props.id));
const blocked = blocks?.has(Key.toNostrHexAddress(this.props.id) as string);
this.setState({ blocked });
});
}

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'preact/hooks';
import Helpers from '../../Helpers';
import { translate as t } from '../../translations/Translation';
import { translate as t } from '../../translations/Translation.mjs';
import { OptionalGetter } from '../../types';
import { PrimaryButton as Button } from './Button';

View File

@ -1,7 +1,7 @@
import Component from '../../BaseComponent';
import Key from '../../nostr/Key';
import SocialNetwork from '../../nostr/SocialNetwork';
import { translate as t } from '../../translations/Translation';
import { translate as t } from '../../translations/Translation.mjs';
import { Button } from './Button';
@ -29,19 +29,22 @@ class Follow extends Component<Props> {
onClick(e) {
e.preventDefault();
const newValue = !this.state[this.key];
const hex = Key.toNostrHexAddress(this.props.id);
if (!hex) return;
if (this.key === 'follow') {
SocialNetwork.setFollowed(Key.toNostrHexAddress(this.props.id), newValue);
SocialNetwork.setFollowed(hex, newValue);
return;
}
if (this.key === 'block') {
SocialNetwork.setBlocked(Key.toNostrHexAddress(this.props.id), newValue);
SocialNetwork.setBlocked(hex, newValue);
}
}
componentDidMount() {
if (this.key === 'follow') {
SocialNetwork.getFollowedByUser(Key.getPubKey(), (follows) => {
const follow = follows?.has(Key.toNostrHexAddress(this.props.id));
const hex = Key.toNostrHexAddress(this.props.id);
const follow = hex && follows?.has(hex);
this.setState({ follow });
});
return;

View File

@ -1,6 +1,6 @@
import Key from '../../nostr/Key';
import SocialNetwork from '../../nostr/SocialNetwork';
import { translate as t } from '../../translations/Translation';
import { translate as t } from '../../translations/Translation.mjs';
import Block from './Block';
@ -19,13 +19,15 @@ class Report extends Block {
e.preventDefault();
const newValue = !this.state[this.key];
if (confirm(newValue ? 'Publicly report this user?' : 'Unreport user?')) {
SocialNetwork.flag(Key.toNostrHexAddress(this.props.id), newValue);
const hex = Key.toNostrHexAddress(this.props.id);
hex && SocialNetwork.flag(hex, newValue);
}
}
componentDidMount() {
SocialNetwork.getFlaggedUsers((flags) => {
const reported = flags?.has(Key.toNostrHexAddress(this.props.id));
const hex = Key.toNostrHexAddress(this.props.id);
const reported = hex && flags?.has(hex);
this.setState({ reported });
});
}

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useState } from 'react';
const Upload = (props) => {
const [error, setError] = useState(null);
const [error, setError] = useState('');
const handleFileUpload = (event) => {
const files = event.target.files || event.dataTransfer.files;
if (files && files.length) {

View File

@ -4,7 +4,7 @@ import Icons from '../../Icons';
import Events from '../../nostr/Events';
import Key from '../../nostr/Key';
import SocialNetwork from '../../nostr/SocialNetwork';
import { translate as t } from '../../translations/Translation';
import { translate as t } from '../../translations/Translation.mjs';
import EventDropdown from './EventDropdown';
import Follow from './Follow';
@ -35,7 +35,9 @@ interface EventComponentProps {
}
const EventComponent = (props: EventComponentProps) => {
const [state, setState] = useState<{ [key: string]: any }>({ sortedReplies: [] });
const [state, setState] = useState<{ [key: string]: any }>({
sortedReplies: [],
});
const subscriptions: (() => void)[] = [];
const retrievingTimeout = useRef<any>();
const unmounted = useRef<boolean>(false);
@ -86,7 +88,7 @@ const EventComponent = (props: EventComponentProps) => {
retrievingTimeout.current = setTimeout(() => {
setState((prevState) => ({ ...prevState, retrieving: true }));
}, 1000);
Events.getEventById(hexId, true, (event) => handleEvent(event));
hexId && Events.getEventById(hexId, true, (event) => handleEvent(event));
return () => {
subscriptions.forEach((unsub) => {
@ -107,7 +109,7 @@ const EventComponent = (props: EventComponentProps) => {
});
const renderDropdown = () => {
return props.asInlineQuote ? null : <EventDropdown id={props.id} event={state.event} />;
return props.asInlineQuote ? null : <EventDropdown id={props.id || ''} event={state.event} />;
};
const getClassName = () => {
@ -189,7 +191,7 @@ const EventComponent = (props: EventComponentProps) => {
event={state.event}
meta={state.meta}
fullWidth={props.fullWidth}
fadeIn={props.feedOpenedAt < state.event.created_at}
fadeIn={!props.feedOpenedAt || props.feedOpenedAt < state.event.created_at}
{...props}
/>
);

View File

@ -1,12 +1,12 @@
import { Event } from 'nostr-tools';
import { useState } from 'preact/hooks';
import styled from 'styled-components';
import Helpers from '../../Helpers';
import { Event } from '../../lib/nostr-tools';
import localState from '../../LocalState';
import Events from '../../nostr/Events';
import Key from '../../nostr/Key';
import { translate as t } from '../../translations/Translation';
import { translate as t } from '../../translations/Translation.mjs';
import Block from '../buttons/Block';
import { PrimaryButton } from '../buttons/Button';
import Copy from '../buttons/Copy';
@ -17,7 +17,7 @@ import Modal from '../modal/Modal';
import EventRelaysList from './EventRelaysList';
interface EventDropdownProps {
event?: Event & { id: string };
event?: Event;
onTranslate?: (text: string) => void;
id: string;
}
@ -31,7 +31,7 @@ const EventDetail = styled.div`
const EventDropdown = (props: EventDropdownProps) => {
const { event, id } = props;
const [muted, setMuted] = useState<boolean>(false);
const [muted] = useState<boolean>(false); // TODO setMuted
const [showingDetails, setShowingDetails] = useState(false);
const closeModal = () => setShowingDetails(false);
@ -64,7 +64,7 @@ const EventDropdown = (props: EventDropdownProps) => {
e.preventDefault();
if (confirm('Publicly report and hide message?')) {
const hexId = Key.toNostrHexAddress(props.id);
if (hexId) {
if (hexId && props.event) {
Events.publish({
kind: 5,
content: 'reported',
@ -80,9 +80,10 @@ const EventDropdown = (props: EventDropdownProps) => {
const translate = (e: any) => {
e.preventDefault();
Helpers.translateText(props.event.content).then((res) => {
props.onTranslate?.(res);
});
props.event &&
Helpers.translateText(props.event.content).then((res) => {
props.onTranslate?.(res);
});
};
const onBroadcast = (e: any) => {
@ -108,12 +109,12 @@ const EventDropdown = (props: EventDropdownProps) => {
<Copy
key={`${id!}copy_id`}
text={t('copy_note_ID')}
copyStr={Key.toNostrBech32Address(id, 'note')}
copyStr={Key.toNostrBech32Address(id, 'note') || ''}
/>
<a href="#" onClick={onMute}>
{muted ? t('unmute_notifications') : t('mute_notifications')}
</a>
{event && (
{event ? (
<>
<a href="#" onClick={onBroadcast}>
{t('resend_to_relays')}
@ -151,9 +152,11 @@ const EventDropdown = (props: EventDropdownProps) => {
{t('event_detail')}
</a>
</>
) : (
<></>
)}
</Dropdown>
{showingDetails && (
{event && showingDetails && (
<Modal showContainer onClose={closeModal}>
<EventDetail>
<EventRelaysList event={event} />

View File

@ -1,9 +1,10 @@
import { FC, useEffect, useState } from 'react';
import { Event } from 'nostr-tools';
import styled from 'styled-components';
import { Event } from '../../lib/nostr-tools';
import Events from '../../nostr/Events';
import { translate as t } from '../../translations/Translation';
import { EventMetadata } from '../../nostr/EventsMeta';
import { translate as t } from '../../translations/Translation.mjs';
const Wrapper = styled.div`
display: flex;
@ -30,14 +31,14 @@ const Codeblock = styled.pre`
`;
const EventRelaysList: FC<{ event: Event }> = ({ event }) => {
const [eventMeta, setEventMeta] = useState(null);
const [eventMeta, setEventMeta] = useState(null as null | EventMetadata);
useEffect(() => {
if (!event?.id) {
return;
}
const id = Events.getOriginalPostEventId(event);
const val = Events.eventsMetaDb.get(id);
const val = id && Events.eventsMetaDb.get(id);
if (val) {
setEventMeta(val);
}

View File

@ -1,5 +1,6 @@
import { Event } from 'nostr-tools';
import Icons from '../../Icons';
import { Event } from '../../lib/nostr-tools';
import Key from '../../nostr/Key';
import Name from '../Name';

View File

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { HeartIcon as HeartIconFull } from '@heroicons/react/24/solid';
import { Event } from 'nostr-tools';
import { route } from 'preact-router';
import { Event } from '../../lib/nostr-tools';
import Events from '../../nostr/Events';
import Key from '../../nostr/Key';
import Name from '../Name';
@ -38,15 +38,20 @@ export default function Like(props: Props) {
: 'liked a note';
useEffect(() => {
const unsub = Events.getRepliesAndReactions(
likedId,
(_replies: Set<string>, likedBy: Set<string>) => {
setAllLikes(Array.from(likedBy));
},
);
return () => unsub();
if (likedId) {
return Events.getRepliesAndReactions(
likedId,
(_replies: Set<string>, likedBy: Set<string>) => {
setAllLikes(Array.from(likedBy));
},
);
}
}, [likedId]);
if (!likedId) {
return null;
}
const userLink = `/${Key.toNostrBech32Address(props.event.pubkey, 'npub')}`;
return (
<div className="msg" key={props.event.id}>

View File

@ -7,7 +7,7 @@ import localState from '../../LocalState';
import Events from '../../nostr/Events';
import Key from '../../nostr/Key';
import SocialNetwork from '../../nostr/SocialNetwork';
import { translate as t } from '../../translations/Translation';
import { translate as t } from '../../translations/Translation.mjs';
import Identicon from '../Identicon';
import ImageModal from '../modal/Image';
import Name from '../Name';
@ -77,7 +77,7 @@ const Note = ({
let text = event.content || '';
meta = meta || {};
const attachments = [];
const attachments = [] as any[];
const urls = text.match(/(https?:\/\/[^\s]+)/g);
if (urls) {
urls.forEach((url) => {
@ -150,7 +150,7 @@ const Note = ({
if (['A', 'BUTTON', 'TEXTAREA', 'IMG', 'INPUT'].find((tag) => event.target.closest(tag))) {
return;
}
if (window.getSelection().toString()) {
if (window.getSelection()?.toString()) {
return;
}
event.stopPropagation();
@ -207,7 +207,7 @@ const Note = ({
function renderShowThread() {
return (
<div style={{ flexBasis: '100%', marginBottom: '12px' }}>
<a href={`/${Key.toNostrBech32Address(rootMsg, 'note')}`}>{t('show_thread')}</a>
<a href={`/${Key.toNostrBech32Address(rootMsg || '', 'note')}`}>{t('show_thread')}</a>
</div>
);
}
@ -296,7 +296,6 @@ const Note = ({
waitForFocus={true}
autofocus={!standalone}
replyingTo={event.id}
replyingToUser={event.pubkey}
placeholder={t('write_your_reply')}
/>
);

View File

@ -1,8 +1,9 @@
import { memo, useState } from 'react';
import { Event } from 'nostr-tools';
import { JSX } from 'preact';
import styled, { css, keyframes } from 'styled-components';
import Icons from '../../Icons';
import { Event } from '../../lib/nostr-tools';
import Events from '../../nostr/Events';
import Key from '../../nostr/Key';
@ -19,19 +20,19 @@ const fadeIn = keyframes`
}
`;
function VideoIcon({ attachment }) {
return (
attachment.type === 'video' && (
<div
style={{
position: 'absolute',
top: '8px',
right: '8px',
}}
>
{Icons.video}
</div>
)
function VideoIcon({ attachment }): JSX.Element {
return attachment.type === 'video' ? (
<div
style={{
position: 'absolute',
top: '8px',
right: '8px',
}}
>
{Icons.video}
</div>
) : (
<></>
);
}
@ -83,7 +84,7 @@ function NoteImage(props: { event: Event; fadeIn?: boolean }) {
const id = Events.getEventReplyingTo(props.event);
return <EventComponent id={id} renderAs="NoteImage" />;
}
const attachments = [];
const attachments = [] as { type: string; url: string }[];
const urls = props.event.content?.match(/(https?:\/\/[^\s]+)/g);
if (urls) {
urls.forEach((url) => {

View File

@ -53,7 +53,7 @@ const Reactions = (props) => {
reposts: 0,
reposted: false,
likes: 0,
zappers: null,
zappers: null as string[] | null,
totalZapped: '',
liked: false,
repostedBy: new Set<string>(),
@ -177,7 +177,7 @@ const Reactions = (props) => {
likedBy: Set<string>,
threadReplyCount: number,
repostedBy: Set<string>,
zaps: Set<string>,
zaps: any,
) {
// zaps.size &&
// console.log('zaps.size', zaps.size, Key.toNostrBech32Address(event.id, 'note'));
@ -204,7 +204,9 @@ const Reactions = (props) => {
});
const zapEvents = Array.from(zaps?.values()).map((eventId) => Events.db.by('id', eventId));
const zappers = zapEvents.map((event) => Events.getZappingUser(event.id));
const zappers = zapEvents
.map((event) => Events.getZappingUser(event.id))
.filter((user) => user !== null) as string[];
const totalZapped = zapEvents.reduce((acc, event) => {
const bolt11 = event?.tags.find((tag) => tag[0] === 'bolt11')[1];
if (!bolt11) {

View File

@ -1,10 +1,10 @@
import { useEffect, useState } from 'react';
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import { Event } from 'nostr-tools';
import { Event } from '../../lib/nostr-tools';
import Events from '../../nostr/Events';
import Key from '../../nostr/Key';
import { translate as t } from '../../translations/Translation';
import { translate as t } from '../../translations/Translation.mjs';
import Name from '../Name';
import EventComponent from './EventComponent';
@ -17,7 +17,7 @@ interface Props {
export default function Repost(props: Props) {
const [allReposts, setAllReposts] = useState<string[]>([]);
const repostedEventId = Events.getRepostedEventId(props.event);
const repostedEventId = Events.getRepostedEventId(props.event) || '';
useEffect(() => {
if (props.notification) {
@ -35,7 +35,12 @@ export default function Repost(props: Props) {
<div className="msg">
<div className="msg-content" style={{ padding: '12px 0 0 0' }}>
<div
style={{ display: 'flex', alignItems: 'center', flexBasis: '100%', marginLeft: '15px' }}
style={{
display: 'flex',
alignItems: 'center',
flexBasis: '100%',
marginLeft: '15px',
}}
>
<small className="reposted">
<i>

View File

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { Event } from 'nostr-tools';
import { route } from 'preact-router';
import Icons from '../../Icons';
import { Event } from '../../lib/nostr-tools';
import Events from '../../nostr/Events';
import Key from '../../nostr/Key';
import Name from '../Name';
@ -38,16 +38,17 @@ export default function Zap(props: Props) {
: 'zapped a note';
useEffect(() => {
const unsub = Events.getRepliesAndReactions(
zappedId,
(_a: Set<string>, _b: Set<string>, _c: number, _d: Set<string>, zappedBy: Set<string>) => {
setAllZaps(Array.from(zappedBy.values()));
},
);
return () => unsub();
return zappedId
? Events.getRepliesAndReactions(
zappedId,
(_a: Set<string>, _b: Set<string>, _c: number, _d: Set<string>, zappedBy: any) => {
setAllZaps(Array.from(zappedBy.values()));
},
)
: () => null;
}, [zappedId]);
let zappingUser = null;
let zappingUser = null as string | null;
try {
zappingUser = Events.getZappingUser(props.event.id);
} catch (e) {
@ -55,9 +56,10 @@ export default function Zap(props: Props) {
return '';
}
const userLink = `/${zappingUser}`;
return (
<div className="msg">
<div className="msg-content" onClick={(e) => messageClicked(e, zappedId)}>
<div className="msg-content" onClick={(e) => messageClicked(e, zappedId || '')}>
<div style={{ display: 'flex', flex: 1, 'flex-direction': 'column' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<i className="zap-btn zapped" style={{ marginRight: '15px' }}>
@ -65,7 +67,7 @@ export default function Zap(props: Props) {
</i>
<div>
<a href={userLink} style={{ marginRight: '5px' }}>
<Name pub={zappingUser} />
<Name pub={zappingUser || ''} />
</a>
{allZaps.length > 1 && <span> and {allZaps.length - 1} others </span>}
{zappedText}

View File

@ -1,4 +1,3 @@
import React from 'react';
import { throttle } from 'lodash';
import isEqual from 'lodash/isEqual';
@ -9,7 +8,7 @@ import Events from '../../nostr/Events';
import Key from '../../nostr/Key';
import PubSub, { Unsubscribe } from '../../nostr/PubSub';
import SocialNetwork from '../../nostr/SocialNetwork';
import { translate as t } from '../../translations/Translation';
import { translate as t } from '../../translations/Translation.mjs';
import { PrimaryButton as Button } from '../buttons/Button';
import ErrorBoundary from '../ErrorBoundary';
import EventComponent from '../events/EventComponent';
@ -64,7 +63,7 @@ class Feed extends BaseComponent<FeedProps, FeedState> {
getSettings(override = { display: undefined }) {
// override default & saved settings with url params
let settings = { ...DEFAULT_SETTINGS };
if (['global', 'follows'].includes(this.props?.index)) {
if (['global', 'follows'].includes(this.props?.index || '')) {
settings = Object.assign(settings, override);
}
if (this.props?.index !== 'notifications' && override.display) {
@ -73,7 +72,7 @@ class Feed extends BaseComponent<FeedProps, FeedState> {
if (this.props?.index === 'posts') {
settings.showReplies = false;
}
if (['postsAndReplies', 'notifications', 'likes'].includes(this.props?.index)) {
if (['postsAndReplies', 'notifications', 'likes'].includes(this.props?.index || '')) {
settings.showReplies = true;
}
for (const key in settings) {
@ -103,7 +102,7 @@ class Feed extends BaseComponent<FeedProps, FeedState> {
}
const settings = this.state.settings;
// iterate over sortedEvents and add newer than eventsShownTime to queue
const queuedEvents = [];
const queuedEvents = [] as string[];
let hasMyEvent;
if (settings.sortDirection === 'desc' && !settings.realtime) {
for (let i = 0; i < sortedEvents.length; i++) {
@ -129,11 +128,14 @@ class Feed extends BaseComponent<FeedProps, FeedState> {
// increase page size when scrolling down
if (this.state.displayCount < this.state.sortedEvents.length) {
if (
this.props.scrollElement &&
this.props.scrollElement.scrollTop + this.props.scrollElement.clientHeight >=
this.props.scrollElement.scrollHeight - 1000
this.props.scrollElement.scrollHeight - 1000
) {
// TODO load more events
this.setState({ displayCount: this.state.displayCount + INITIAL_PAGE_SIZE });
this.setState({
displayCount: this.state.displayCount + INITIAL_PAGE_SIZE,
});
}
}
this.checkScrollPosition();
@ -264,7 +266,7 @@ class Feed extends BaseComponent<FeedProps, FeedState> {
}
}
getEvents(callback): Unsubscribe {
getEvents(_callback): Unsubscribe {
return () => {
// override this
};
@ -370,7 +372,7 @@ class Feed extends BaseComponent<FeedProps, FeedState> {
global: 'global_feed',
follows: 'following',
notifications: 'notifications',
}[this.props.index];
}[this.props.index || 'global'];
const renderAs = this.state.settings.display === 'grid' ? 'NoteImage' : null;
const events = this.renderEvents(displayCount, renderAs, showRepliedMsg);

View File

@ -36,14 +36,14 @@ class Feed extends BaseFeed {
subscribeToNostrUser(since, callback) {
if (this.props.index === 'likes') {
return PubSub.subscribe(
{ authors: [this.props.nostrUser], kinds: [7], since },
{ authors: [this.props.nostrUser || ''], kinds: [7], since },
callback,
false,
false,
);
} else {
return PubSub.subscribe(
{ authors: [this.props.nostrUser], kinds: [1, 6], since },
{ authors: [this.props.nostrUser || ''], kinds: [1, 6], since },
this.getCallbackForPostsIndex(callback),
false,
false,
@ -54,6 +54,9 @@ class Feed extends BaseFeed {
subscribeToKeyword(since, callback) {
const keyword = this.props.keyword.toLowerCase();
return PubSub.subscribe(
// Filter type doesn't have "keywords"...
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
{ keywords: [keyword], kinds: [1], limit: 1000, since },
(e) => e.content?.toLowerCase().includes(keyword) && callback(e),
false,
@ -64,7 +67,12 @@ class Feed extends BaseFeed {
const myPub = Key.getPubKey();
const followedUsers = Array.from(SocialNetwork.followedByUser.get(myPub) || []);
followedUsers.push(myPub);
const filter = { kinds: [1, 6], limit: 300, since, authors: undefined };
const filter = {
kinds: [1, 6],
limit: 300,
since,
authors: undefined as any,
};
if (followedUsers.length < 1000) {
filter.authors = followedUsers;
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'preact/hooks';
import { translate as t } from '../../translations/Translation';
import { translate as t } from '../../translations/Translation.mjs';
import { PrimaryButton } from '../buttons/Button';
interface Settings {
@ -51,7 +51,11 @@ const FeedSettings = ({ settings, onChange }) => {
id: 'display_realtime',
checked: localSettings.realtime,
label: t('realtime'),
onChange: () => setLocalSettings({ ...localSettings, realtime: !localSettings.realtime }),
onChange: () =>
setLocalSettings({
...localSettings,
realtime: !localSettings.realtime,
}),
},
{
type: 'checkbox',
@ -60,7 +64,10 @@ const FeedSettings = ({ settings, onChange }) => {
name: 'show_replies',
label: t('show_replies'),
onChange: () =>
setLocalSettings({ ...localSettings, showReplies: !localSettings.showReplies }),
setLocalSettings({
...localSettings,
showReplies: !localSettings.showReplies,
}),
},
];

View File

@ -4,7 +4,7 @@ import { route } from 'preact-router';
import styled from 'styled-components';
import Icons from '../../Icons';
import { translate as t } from '../../translations/Translation';
import { translate as t } from '../../translations/Translation.mjs';
const IconLink = styled.a`
padding-right: 10px;

View File

@ -1,4 +1,5 @@
import { Event } from '../../lib/nostr-tools';
import { Event } from 'nostr-tools';
import Events from '../../nostr/Events';
export default class SortedEventMap {

View File

@ -1,4 +1,4 @@
interface FeedProps {
export interface FeedProps {
index?: string;
scrollElement?: HTMLElement;
filter?: any;
@ -7,7 +7,7 @@ interface FeedProps {
nostrUser?: string;
}
interface FeedState {
export interface FeedState {
sortedEvents: string[];
queuedEvents: string[];
displayCount: number;
@ -23,5 +23,3 @@ interface FeedState {
settingsOpen?: boolean;
showNewMsgsFixedTop?: boolean;
}
export { FeedProps, FeedState };

View File

@ -1,9 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { XMarkIcon } from '@heroicons/react/24/solid';
import { Event } from 'nostr-tools';
import styled from 'styled-components';
import Helpers from '../../Helpers';
import { Event } from '../../lib/nostr-tools';
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from '../../LNURL';
import localState from '../../LocalState';
import Events from '../../nostr/Events';
@ -43,7 +43,7 @@ export interface ZapProps {
}
function chunks<T>(arr: T[], length: number) {
const result = [];
const result = [] as any;
let idx = 0;
let n = arr.length / length;
while (n > 0) {
@ -424,7 +424,7 @@ export default function SendSats(props: ZapProps) {
<div className="lnurl-header">
<h2>
{props.title || title}
<Name pub={recipient} />
<Name pub={recipient || ''} />
</h2>
</div>
{invoiceForm()}

File diff suppressed because one or more lines are too long

View File

@ -1,156 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"parserOptions": {
"ecmaVersion": 9,
"ecmaFeatures": {
"jsx": true
},
"sourceType": "module",
"allowImportExportEverywhere": false
},
"env": {
"es6": true,
"node": true
},
"plugins": ["babel"],
"globals": {
"document": false,
"navigator": false,
"window": false,
"location": false,
"URL": false,
"URLSearchParams": false,
"fetch": false,
"EventSource": false,
"localStorage": false,
"sessionStorage": false
},
"rules": {
"accessor-pairs": 2,
"arrow-spacing": [2, {"before": true, "after": true}],
"block-spacing": [2, "always"],
"brace-style": [2, "1tbs", {"allowSingleLine": true}],
"comma-dangle": 0,
"comma-spacing": [2, {"before": false, "after": true}],
"comma-style": [2, "last"],
"constructor-super": 2,
"curly": [0, "multi-line"],
"dot-location": [2, "property"],
"eol-last": 2,
"eqeqeq": [2, "allow-null"],
"generator-star-spacing": [2, {"before": true, "after": true}],
"handle-callback-err": [2, "^(err|error)$"],
"indent": 0,
"jsx-quotes": [2, "prefer-double"],
"key-spacing": [2, {"beforeColon": false, "afterColon": true}],
"keyword-spacing": [2, {"before": true, "after": true}],
"new-cap": 0,
"new-parens": 0,
"no-array-constructor": 2,
"no-caller": 2,
"no-class-assign": 2,
"no-cond-assign": 2,
"no-const-assign": 2,
"no-control-regex": 0,
"no-debugger": 0,
"no-delete-var": 2,
"no-dupe-args": 2,
"no-dupe-class-members": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-empty-character-class": 2,
"no-empty-pattern": 2,
"no-eval": 0,
"no-ex-assign": 2,
"no-extend-native": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-parens": [2, "functions"],
"no-fallthrough": 2,
"no-floating-decimal": 2,
"no-func-assign": 2,
"no-implied-eval": 2,
"no-inner-declarations": [0, "functions"],
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": [2, {"allowLoop": false, "allowSwitch": false}],
"no-lone-blocks": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [2, {"max": 2}],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-new": 0,
"no-new-func": 2,
"no-new-object": 2,
"no-new-require": 2,
"no-new-symbol": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-octal": 2,
"no-octal-escape": 2,
"no-path-concat": 0,
"no-proto": 2,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-return-assign": 0,
"no-self-assign": 2,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-this-before-super": 2,
"no-throw-literal": 2,
"no-trailing-spaces": 2,
"no-undef": 2,
"no-undef-init": 2,
"no-unexpected-multiline": 2,
"no-unneeded-ternary": [2, {"defaultAssignment": false}],
"no-unreachable": 2,
"no-unused-vars": [
2,
{"vars": "local", "args": "none", "varsIgnorePattern": "^_"}
],
"no-useless-call": 2,
"no-useless-constructor": 2,
"no-with": 2,
"one-var": [0, {"initialized": "never"}],
"operator-linebreak": [
2,
"after",
{"overrides": {"?": "before", ":": "before"}}
],
"padded-blocks": [2, "never"],
"quotes": [
2,
"single",
{"avoidEscape": true, "allowTemplateLiterals": true}
],
"semi": [2, "never"],
"semi-spacing": [2, {"before": false, "after": true}],
"space-before-blocks": [2, "always"],
"space-before-function-paren": 0,
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-unary-ops": [2, {"words": true, "nonwords": false}],
"spaced-comment": 0,
"template-curly-spacing": [2, "never"],
"use-isnan": 2,
"valid-typeof": 2,
"wrap-iife": [2, "any"],
"yield-star-spacing": [2, "both"],
"yoda": [0]
}
}

View File

@ -1,7 +0,0 @@
node_modules
dist
yarn.lock
package-lock.json
.envrc
lib
test.html

View File

@ -1,10 +0,0 @@
semi: false
arrowParens: avoid
insertPragma: false
printWidth: 80
proseWrap: preserve
singleQuote: true
trailingComma: none
useTabs: false
jsxBracketSameLine: false
bracketSpacing: false

View File

@ -1,207 +0,0 @@
# nostr-tools
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
## Usage
### Generating a private key and a public key
```js
import {generatePrivateKey, getPublicKey} from 'nostr-tools'
let sk = generatePrivateKey() // `sk` is a hex string
let pk = getPublicKey(sk) // `pk` is a hex string
```
### Creating, signing and verifying events
```js
import {
validateEvent,
verifySignature,
signEvent,
getEventHash,
getPublicKey
} from 'nostr-tools'
let event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello'
}
event.id = getEventHash(event.id)
event.pubkey = getPublicKey(privateKey)
event.sig = await signEvent(event, privateKey)
let ok = validateEvent(event)
let veryOk = await verifySignature(event)
```
### Interacting with a relay
```js
import {
relayInit,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} from 'nostr-tools'
const relay = relayInit('wss://relay.example.com')
relay.connect()
relay.on('connect', () => {
console.log(`connected to ${relay.url}`)
})
relay.on('error', () => {
console.log(`failed to connect to ${relay.url}`)
})
// let's query for an event that exists
let sub = relay.sub([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
}
])
sub.on('event', event => {
console.log('we got the event we wanted:', event)
})
sub.on('eose', () => {
sub.unsub()
})
// let's publish a new event while simultaneously monitoring the relay for it
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
let sub = relay.sub([
{
kinds: [1],
authors: [pk]
}
])
sub.on('event', event => {
console.log('got event:', event)
})
let event = {
kind: 1,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello world'
}
event.id = getEventHash(event)
event.sig = await signEvent(event, sk)
let pub = relay.publish(event)
pub.on('ok', () => {
console.log(`{relay.url} has accepted our event`)
})
pub.on('seen', () => {
console.log(`we saw the event on {relay.url}`)
})
pub.on('failed', reason => {
console.log(`failed to publish to {relay.url}: ${reason}`)
})
await relay.close()
```
### Querying profile data from a NIP-05 address
```js
import {nip05} from 'nostr-tools'
let profile = await nip05.queryProfile('jb55.com')
console.log(profile.pubkey)
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
console.log(profile.relays)
// prints: [wss://relay.damus.io]
// on nodejs, install node-fetch@2 and call this first:
nip05.useFetchImplementation(require('node-fetch'))
```
### Encoding and decoding NIP-19 codes
```js
import {nip19, generatePrivateKey, getPublicKey} from 'nostr-tools'
let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk)
let {type, data} = nip19.decode(nsec)
assert(type === 'nsec')
assert(data === sk)
let pk = getPublicKey(generatePrivateKey())
let npub = nip19.npubEncode(pk)
let {type, data} = nip19.decode(npub)
assert(type === 'npub')
assert(data === pk)
let pk = getPublicKey(generatePrivateKey())
let relays = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
let {type, data} = nip19.decode(nprofile)
assert(type === 'nprofile')
assert(data.pubkey === pk)
assert(data.relays.length === 2)
```
### Encrypting and decrypting direct messages
```js
import {nip04, getPublicKey, generatePrivateKey} from 'nostr-tools'
// sender
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// receiver
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
// on the sender side
let message = 'hello'
let ciphertext = nip04.encrypt(sk1, pk2, 'hello')
let event = {
kind: 4,
pubkey: pk1,
tags: [['p', pk2]],
content: ciphertext,
...otherProperties
}
sendEvent(event)
// on the receiver side
sub.on('event', (event) => {
let sender = event.tags.find(([k, v]) => k === 'p' && && v && v !== '')[1]
pk1 === sender
let plaintext = nip04.decrypt(sk2, pk1, event.content)
})
```
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
### Using from the browser (if you don't want to use a bundler)
```html
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script>
window.NostrTools.generatePrivateKey('...') // and so on
</script>
```
## License
Public domain.

View File

@ -1,41 +0,0 @@
#!/usr/bin/env node
const esbuild = require('esbuild')
let common = {
entryPoints: ['index.ts'],
bundle: true,
sourcemap: 'external'
}
esbuild
.build({
...common,
outfile: 'lib/nostr.esm.js',
format: 'esm',
packages: 'external'
})
.then(() => console.log('esm build success.'))
esbuild
.build({
...common,
outfile: 'lib/nostr.cjs.js',
format: 'cjs',
packages: 'external'
})
.then(() => console.log('cjs build success.'))
esbuild
.build({
...common,
outfile: 'lib/nostr.bundle.js',
format: 'iife',
globalName: 'NostrTools',
define: {
window: 'self',
global: 'self',
process: '{"env": {}}'
}
})
.then(() => console.log('standalone build success.'))

View File

@ -1,48 +0,0 @@
/* eslint-env jest */
const {
validateEvent,
verifySignature,
signEvent,
getPublicKey
} = require('./lib/nostr.cjs')
const event = {
id: 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027',
kind: 1,
pubkey: '22a12a128a3be27cd7fb250cbe796e692896398dc1440ae3fa567812c8107c1c',
created_at: 1670869179,
content:
'NOSTR "WINE-ACCOUNT" WITH HARVEST DATE STAMPED\n\n\n"The older the wine, the greater its reputation"\n\n\n22a12a128a3be27cd7fb250cbe796e692896398dc1440ae3fa567812c8107c1c\n\n\nNWA 2022-12-12\nAA',
tags: [['client', 'astral']],
sig: 'f110e4fdf67835fb07abc72469933c40bdc7334615610cade9554bf00945a1cebf84f8d079ec325d26fefd76fe51cb589bdbe208ac9cdbd63351ddad24a57559'
}
const unsigned = {
created_at: 1671217411,
kind: 0,
tags: [],
content:
'{"name":"fiatjaf","about":"buy my merch at fiatjaf store","picture":"https://fiatjaf.com/static/favicon.jpg","nip05":"_@fiatjaf.com"}'
}
const privateKey =
'5c6c25b7ef18d8633e97512159954e1aa22809c6b763e94b9f91071836d00217'
test('validate event', () => {
expect(validateEvent(event)).toBeTruthy()
})
test('check signature', async () => {
expect(verifySignature(event)).toBeTruthy()
})
test('sign event', async () => {
let pubkey = getPublicKey(privateKey)
let authored = {...unsigned, pubkey}
let sig = signEvent(authored, privateKey)
let signed = {...authored, sig}
expect(verifySignature(signed)).toBeTruthy()
})

View File

@ -1,105 +0,0 @@
import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {utf8Encoder} from './utils'
/* eslint-disable no-unused-vars */
export enum Kind {
Metadata = 0,
Text = 1,
RecommendRelay = 2,
Contacts = 3,
EncryptedDirectMessage = 4,
EventDeletion = 5,
Repost = 6,
Reaction = 7,
BadgeAward = 8,
ChannelCreation = 40,
ChannelMetadata = 41,
ChannelMessage = 42,
ChannelHideMessage = 43,
ChannelMuteUser = 44,
Report = 1984,
ZapRequest = 9734,
Zap = 9735,
RelayList = 10002,
BlockList = 16462,
FlagList = 16463,
ClientAuth = 22242,
ReplaceableByTag = 30000,
BadgeDefinition = 30008,
ProfileBadge = 30009,
Article = 30023
}
export type Event = {
id?: string
sig?: string
kind: Kind
tags: string[][]
pubkey: string
content: string
created_at: number
}
export function getBlankEvent(): Event {
return {
kind: 255,
pubkey: '',
content: '',
tags: [],
created_at: 0
}
}
export function serializeEvent(evt: Event): string {
if (!validateEvent(evt))
throw new Error("can't serialize event with wrong or missing properties")
return JSON.stringify([
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags,
evt.content
])
}
export function getEventHash(event: Event): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return secp256k1.utils.bytesToHex(eventHash)
}
export function validateEvent(event: Event): boolean {
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
if (typeof event.pubkey !== 'string') return false
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
if (!Array.isArray(event.tags)) return false
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false
}
}
return true
}
export function verifySignature(event: Event & {sig: string}): boolean {
return secp256k1.schnorr.verifySync(
event.sig,
getEventHash(event),
event.pubkey
)
}
export function signEvent(event: Event, key: string): string {
return secp256k1.utils.bytesToHex(
secp256k1.schnorr.signSync(getEventHash(event), key)
)
}

View File

@ -1,42 +0,0 @@
/* eslint-env jest */
const {matchFilters} = require('./lib/nostr.cjs')
test('test if filters match', () => {
;[
{
filters: [{ids: ['i']}],
good: [{id: 'i'}],
bad: [{id: 'j'}]
},
{
filters: [{authors: ['abc']}, {kinds: [1, 3]}],
good: [
{pubkey: 'xyz', kind: 3},
{pubkey: 'abc', kind: 12},
{pubkey: 'abc', kind: 1}
],
bad: [{pubkey: 'hhh', kind: 12}]
},
{
filters: [{'#e': ['yyy'], since: 444}],
good: [
{
tags: [
['e', 'uuu'],
['e', 'yyy']
],
created_at: 555
}
],
bad: [{tags: [['e', 'uuu']], created_at: 111}]
}
].forEach(({filters, good, bad}) => {
good.forEach(ev => {
expect(matchFilters(filters, ev)).toBeTruthy()
})
bad.forEach(ev => {
expect(matchFilters(filters, ev)).toBeFalsy()
})
})
})

View File

@ -1,57 +0,0 @@
import {Event} from './event'
export type Filter = {
ids?: string[]
kinds?: number[]
authors?: string[]
keywords?: string[]
since?: number
until?: number
limit?: number
[key: `#${string}`]: string[]
}
export function matchFilter(
filter: Filter,
event: Event & {id: string}
): boolean {
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
if (filter.keywords) {
const lowercase = event.content?.toLowerCase()
for (let i = 0; i < filter.keywords.length; i++) {
if (lowercase?.indexOf(filter.keywords[i].toLowerCase()) === -1) return false
}
}
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
return false
for (let f in filter) {
if (f[0] === '#') {
let tagName = f.slice(1)
let values = filter[`#${tagName}`]
if (
values &&
!event.tags?.find(
([t, v]) => t === f.slice(1) && values.indexOf(v) !== -1
)
)
return false
}
}
if (filter.since && event.created_at < filter.since) return false
if (filter.until && event.created_at >= filter.until) return false
return true
}
export function matchFilters(
filters: Filter[],
event: Event & {id: string}
): boolean {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true
}
return false
}

View File

@ -1,20 +0,0 @@
export * from './keys'
export * from './relay'
export * from './event'
export * from './filter'
export * from './path'
export * as nip04 from './nip04'
export * as nip05 from './nip05'
export * as nip06 from './nip06'
export * as nip19 from './nip19'
export * as nip26 from './nip26'
// monkey patch secp256k1
import * as secp256k1 from '@noble/secp256k1'
import {hmac} from '@noble/hashes/hmac'
import {sha256} from '@noble/hashes/sha256'
secp256k1.utils.hmacSha256Sync = (key, ...msgs) =>
hmac(sha256, key, secp256k1.utils.concatBytes(...msgs))
secp256k1.utils.sha256Sync = (...msgs) =>
sha256(secp256k1.utils.concatBytes(...msgs))

View File

@ -1,20 +0,0 @@
/* eslint-env jest */
const {generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
test('test private key generation', () => {
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
})
test('test public key generation', () => {
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
})
test('test public key from private key deterministic', () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
for (let i = 0; i < 5; i++) {
expect(getPublicKey(sk)).toEqual(pk)
}
})

View File

@ -1,9 +0,0 @@
import * as secp256k1 from '@noble/secp256k1'
export function generatePrivateKey(): string {
return secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey())
}
export function getPublicKey(privateKey: string): string {
return secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKey))
}

View File

@ -1,15 +0,0 @@
/* eslint-env jest */
globalThis.crypto = require('crypto')
const {nip04, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
expect(
await nip04.decrypt(sk2, pk1, await nip04.encrypt(sk1, pk2, 'hello'))
).toEqual('hello')
})

View File

@ -1,66 +0,0 @@
import {randomBytes} from '@noble/hashes/utils'
import * as secp256k1 from '@noble/secp256k1'
import {base64} from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils'
export async function encrypt(
privkey: string,
pubkey: string,
text: string
): Promise<string> {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key)
let iv = Uint8Array.from(randomBytes(16))
let plaintext = utf8Encoder.encode(text)
let cryptoKey = await crypto.subtle.importKey(
'raw',
normalizedKey,
{name: 'AES-CBC'},
false,
['encrypt']
)
let ciphertext = await crypto.subtle.encrypt(
{name: 'AES-CBC', iv},
cryptoKey,
plaintext
)
let ctb64 = base64.encode(new Uint8Array(ciphertext))
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
return `${ctb64}?iv=${ivb64}`
}
export async function decrypt(
privkey: string,
pubkey: string,
data: string
): Promise<string> {
let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let normalizedKey = getNormalizedX(key)
let cryptoKey = await crypto.subtle.importKey(
'raw',
normalizedKey,
{name: 'AES-CBC'},
false,
['decrypt']
)
let ciphertext = base64.decode(ctb64)
let iv = base64.decode(ivb64)
let plaintext = await crypto.subtle.decrypt(
{name: 'AES-CBC', iv},
cryptoKey,
ciphertext
)
let text = utf8Decoder.decode(plaintext)
return text
}
function getNormalizedX(key: Uint8Array): Uint8Array {
return key.slice(1, 33)
}

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