vite build

This commit is contained in:
Martti Malmi 2023-06-09 13:59:21 +03:00
parent 0d8fc05a87
commit de42e249ae
207 changed files with 15046 additions and 25437 deletions

View File

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

40
.eslintrc.cjs Normal file
View File

@ -0,0 +1,40 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'plugin:prettier/recommended',
],
overrides: [],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['simple-import-sort', '@typescript-eslint'],
rules: {
'simple-import-sort/imports': [
'error',
{
groups: [
// Packages `react` related packages come first.
['^react', '^@?\\w'],
// Internal packages.
['^(@|components)(/.*|$)'],
// Side effect imports.
['^\\u0000'],
// Parent imports. Put `..` last.
['^\\.\\.(?!/?$)', '^\\.\\./?$'],
// Other relative imports. Put same-folder imports and `.` last.
['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
// Style imports.
['^.+\\.?(css)$'],
],
},
],
},
};

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

@ -1,39 +0,0 @@
# Build Stage
FROM node:19-buster-slim AS build-stage
# Install tools
RUN apt-get update \
&& apt-get install -y git \
&& apt-get install -y jq \
&& apt-get install -y python3 \
&& apt-get install -y build-essential
# Create build directory
WORKDIR /build
# Copy package.json and yarn.lock
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')
COPY . .
# Build app
RUN cat package.json | jq '.scripts.serve="sirv build --host 0.0.0.0 --port 8080 --cors --single"' > package.json.new && mv -vf package.json.new package.json
RUN yarn build
# Final image
FROM node:19-buster-slim
# Change directory to '/app'
WORKDIR /app
# Copy built code from build stage to '/app' directory
COPY --from=build-stage /build .
EXPOSE 8080
CMD [ "yarn", "serve" ]

View File

@ -1,13 +0,0 @@
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 .
COPY yarn.lock .
RUN yarn
COPY . .
CMD [ "yarn", "dev-docker" ]

21
LICENSE
View File

@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2020 Martti Malmi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

1
dev-dist/registerSW.js Normal file
View File

@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

92
dev-dist/sw.js Normal file
View File

@ -0,0 +1,92 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5357ef54'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.kjcf5pmcino"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));

3394
dev-dist/workbox-5357ef54.js Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
dist/assets/icon128-e988831c.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

2904
dist/assets/index-21a91155.js vendored Normal file

File diff suppressed because one or more lines are too long

8
dist/assets/index-305001f9.js vendored Normal file

File diff suppressed because one or more lines are too long

9
dist/assets/index-5b940218.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

39
dist/index.html vendored Normal file
View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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="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"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta property="og:title" content="Iris The nostr client for better social networks" data-react-helmet="true" />
<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"
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="mask-icon" href="/assets/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" />
<script type="module" crossorigin src="/assets/index-21a91155.js"></script>
<link rel="stylesheet" href="/assets/index-5b940218.css">
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
<body>
<div id="app"></div>
</body>
</html>

1
dist/manifest.webmanifest vendored Normal file
View File

@ -0,0 +1 @@
{"name":"iris-vite-preact","short_name":"iris-vite-preact","start_url":"/","display":"standalone","background_color":"#ffffff","lang":"en","scope":"/"}

1
dist/registerSW.js vendored Normal file
View File

@ -0,0 +1 @@
if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('/sw.js', { scope: '/' })})}

1
dist/sw.js vendored Normal file
View File

@ -0,0 +1 @@
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise((s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()})).then((()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didnt register its module`);return e})));self.define=(n,r)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(s[t])return;let o={};const l=e=>i(e,t),d={module:{uri:t},exports:o,require:l};s[t]=Promise.all(n.map((e=>d[e]||l(e)))).then((e=>(r(...e),o)))}}define(["./workbox-fa446783"],(function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"assets/index-21a91155.js",revision:null},{url:"assets/index-305001f9.js",revision:null},{url:"assets/index-5b940218.css",revision:null},{url:"google7b3cf94231e5de15.html",revision:"9780f149ace4cba81af0b077230eadf6"},{url:"index.html",revision:"27ad6c2eba6fd3bdd7cbc2ba351d248b"},{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"manifest.webmanifest",revision:"0a80f59ffad67c7508b861c6f74f169e"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))}));

1
dist/workbox-fa446783.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,12 +0,0 @@
version: '3.8'
services:
iris-messenger:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/iris-messenger
- /iris-messenger/node_modules
ports:
- 8080:8080

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"
@ -21,24 +18,20 @@
/>
<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="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,60 @@
{
"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.0",
"@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.1",
"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": {
"@preact/preset-vite": "^2.5.0",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.59.8",
"eslint": "^8.41.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

@ -0,0 +1,11 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "to.iris.twa",
"sha256_cert_fingerprints":
["63:B5:70:E8:F1:75:7E:D6:EF:81:11:66:F4:9D:47:AB:49:3C:2E:00:B9:67:92:40:89:A5:03:0B:96:B9:40:09"]
}
}
]

View File

@ -0,0 +1,9 @@
{
"names": {
"_": "74dcec31fd3b8cfd960bc5a35ecbeeb8b9cee8eb81f6e8da4c8067553709248d",
"iris": "74dcec31fd3b8cfd960bc5a35ecbeeb8b9cee8eb81f6e8da4c8067553709248d",
"sirius": "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
"petri":"e417ee3d910253993ae0ce6b41d4a24609970f132958d75b2d9b634d60a3cc08",
"rockstar":"91c9a5e1a9744114c6fe2d61ae4de82629eaaa0fb52f48288093c7e7e036f832"
}
}

9
public/browserconfig.xml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/assets/images/mstile-150x150.png"/>
<TileColor>#000000</TileColor>
</tile>
</msapplication>
</browserconfig>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
google-site-verification: google7b3cf94231e5de15.html

14
public/privacy.txt Normal file
View File

@ -0,0 +1,14 @@
Iris Privacy policy
By default, the application connects to a number of relays in the Nostr network to send and receive messages. The relays are not operated by Iris developers. You can change the default relays at will.
On Nostr, private messages are encrypted, but anyone can see who you're chatting with and when.
The application wraps the web application at https://iris.to. The hosting provider logs user IP addresses and page-view counts, which are used to follow unique user count and usage by country.
Iris users can register an optional convenience username that redirects to their Nostr public profile https://iris.to/username. In this process, user IP address and country code are saved.
Otherwise, Iris developers do not collect any information about you or your usage of the application.
We reserve the right to modify this Privacy Policy at any time and without prior notice. Your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
If you have any questions about this Privacy Policy, please contact us at sirius@iris.to.

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: *?*s=*k=*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

View File

@ -1,42 +0,0 @@
import fs from 'fs';
import { parse } from 'csv-parse/sync';
// Read the csv file with translations
let csv = fs.readFileSync('translations.csv', 'utf8');
// Parse the csv into an array of arrays
let lines = parse(csv, {
trim: true,
quote: '"',
relax_column_count: true,
});
// Get the list of available languages
let languages = lines[0];
languages.shift();
// Create an object to store the translations
let translations = {};
// Iterate through the csv lines and add the translations to the `translations` object
for (let i = 1; i < lines.length; i++) {
let line = lines[i];
let key = line[0].replace(/,/g, '');
line.shift();
for (let j = 0; j < languages.length; j++) {
if (!translations[languages[j]]) {
translations[languages[j]] = {};
}
if (line[j]) {
translations[languages[j]][key] = line[j].trim() || null;
}
}
}
// Write the translations back to the language files
for (let lang in translations) {
let fileContent = `export default ${JSON.stringify(translations[lang], null, 2)};`;
fs.writeFileSync(`../src/js/translations/${lang}.mjs`, fileContent);
}
console.log('Translations added successfully.');

View File

@ -1,92 +0,0 @@
import fs from 'fs';
import { glob } from 'glob';
import { AVAILABLE_LANGUAGE_KEYS } from '../src/js/translations/Translation.mjs';
const EXTRA_KEYS = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
'today',
'yesterday',
'global_feed',
'messages',
'feeds',
'social_network',
'content',
'images',
'audio',
'videos',
'autoplay_videos',
'playback',
'embeds',
'everyone',
];
async function translationsToCsv() {
let csv = '';
let languages = [];
let translationKeys = new Set();
let translations = {};
for (let lang of AVAILABLE_LANGUAGE_KEYS) {
const translation = (await import(`../src/js/translations/${lang}.mjs`)).default;
translations[lang] = translation;
languages.push(lang);
}
// Collect used translation keys from code
const files = glob.sync('../src/js/**/*.{js,jsx,ts,tsx}', { ignore: '../src/js/lib/**/*' });
files.forEach((file) => {
const content = fs.readFileSync(file, 'utf8');
const matches = content.match(/(^|[^a-zA-Z])t\(['"`]([^'"`]+)['"`]\)/g);
if (matches) {
matches.forEach((match) => {
const key = match.match(/(^|[^a-zA-Z])t\(['"`]([^'"`]+)['"`]\)/)[2];
translationKeys.add(key);
});
}
});
console.log('found', translationKeys.size, 'translation keys from', files.length, 'files');
// Translation keys from variables are not found by the regex above
EXTRA_KEYS.forEach((key) => {
translationKeys.add(key);
});
translationKeys = Array.from(translationKeys);
translationKeys.sort();
// add language names to csv
csv += '"","';
for (let i = 0; i < languages.length; i++) {
csv += languages[i];
if (i < languages.length - 1) {
csv += '","';
} else {
csv += '"\n';
}
}
csv += '"';
for (let key of translationKeys) {
let row = key;
for (let lang of languages) {
row += '","' + (translations[lang][key] || '')
.replace(/"/g, '""')
.replace(/\\/g, '')
}
csv += row + '"\n';
if (key !== translationKeys[translationKeys.length - 1]) {
csv += '"';
}
}
// output csv to file
fs.writeFileSync('translations.csv', csv);
console.log('wrote translations.csv');
}
translationsToCsv();

1
src/assets/preact.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="27.68" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 296"><path fill="#673AB8" d="m128 0l128 73.9v147.8l-128 73.9L0 221.7V73.9z"></path><path fill="#FFF" d="M34.865 220.478c17.016 21.78 71.095 5.185 122.15-34.704c51.055-39.888 80.24-88.345 63.224-110.126c-17.017-21.78-71.095-5.184-122.15 34.704c-51.055 39.89-80.24 88.346-63.224 110.126Zm7.27-5.68c-5.644-7.222-3.178-21.402 7.573-39.253c11.322-18.797 30.541-39.548 54.06-57.923c23.52-18.375 48.303-32.004 69.281-38.442c19.922-6.113 34.277-5.075 39.92 2.148c5.644 7.223 3.178 21.403-7.573 39.254c-11.322 18.797-30.541 39.547-54.06 57.923c-23.52 18.375-48.304 32.004-69.281 38.441c-19.922 6.114-34.277 5.076-39.92-2.147Z"></path><path fill="#FFF" d="M220.239 220.478c17.017-21.78-12.169-70.237-63.224-110.126C105.96 70.464 51.88 53.868 34.865 75.648c-17.017 21.78 12.169 70.238 63.224 110.126c51.055 39.889 105.133 56.485 122.15 34.704Zm-7.27-5.68c-5.643 7.224-19.998 8.262-39.92 2.148c-20.978-6.437-45.761-20.066-69.28-38.441c-23.52-18.376-42.74-39.126-54.06-57.923c-10.752-17.851-13.218-32.03-7.575-39.254c5.644-7.223 19.999-8.261 39.92-2.148c20.978 6.438 45.762 20.067 69.281 38.442c23.52 18.375 42.739 39.126 54.06 57.923c10.752 17.85 13.218 32.03 7.574 39.254Z"></path><path fill="#FFF" d="M127.552 167.667c10.827 0 19.603-8.777 19.603-19.604c0-10.826-8.776-19.603-19.603-19.603c-10.827 0-19.604 8.777-19.604 19.603c0 10.827 8.777 19.604 19.604 19.604Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

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;

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +1,29 @@
/* 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;
};
export default abstract class BaseComponent<Props = any, State = any> extends PureComponent<
Props,
State & OwnState
> {
export default abstract class BaseComponent<
Props = any,
State = any
> extends PureComponent<Props, State & OwnState> {
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

@ -1,21 +1,21 @@
import Fuse from 'fuse.js';
import _ from 'lodash';
import Fuse from "fuse.js";
import _ from "lodash";
import localState from './LocalState';
import localState from "./LocalState";
const options = {
keys: ['name', 'display_name'],
keys: ["name", "display_name"],
includeScore: true,
includeMatches: true,
threshold: 0.3,
};
const notifyUpdate = _.throttle(() => {
localState.get('searchIndexUpdated').put(true);
localState.get("searchIndexUpdated").put(true);
}, 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)) {

File diff suppressed because it is too large Load Diff

View File

@ -38,9 +38,24 @@ export default {
feed: (
<svg width="32" viewBox="0 0 24 24" fill="none">
<g>
<path d="M5 12H18" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<path d="M5 17H11" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<path d="M5 7H15" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<path
d="M5 12H18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
></path>
<path
d="M5 17H11"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
></path>
<path
d="M5 7H15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
></path>
</g>
</svg>
),
@ -94,7 +109,12 @@ export default {
),
close: (
<svg height="25px" viewBox="0 0 329.26933 329" width="25px" fill="currentColor">
<svg
height="25px"
viewBox="0 0 329.26933 329"
width="25px"
fill="currentColor"
>
<path d="m194.800781 164.769531 128.210938-128.214843c8.34375-8.339844 8.34375-21.824219 0-30.164063-8.339844-8.339844-21.824219-8.339844-30.164063 0l-128.214844 128.214844-128.210937-128.214844c-8.34375-8.339844-21.824219-8.339844-30.164063 0-8.34375 8.339844-8.34375 21.824219 0 30.164063l128.210938 128.214843-128.210938 128.214844c-8.34375 8.339844-8.34375 21.824219 0 30.164063 4.15625 4.160156 9.621094 6.25 15.082032 6.25 5.460937 0 10.921875-2.089844 15.082031-6.25l128.210937-128.214844 128.214844 128.214844c4.160156 4.160156 9.621094 6.25 15.082032 6.25 5.460937 0 10.921874-2.089844 15.082031-6.25 8.34375-8.339844 8.34375-21.824219 0-30.164063zm0 0" />
</svg>
),
@ -291,7 +311,11 @@ export default {
stroke="currentColor"
className="w-6 h-6"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
),
network: (

View File

@ -1,26 +1,29 @@
import { Event } from './lib/nostr-tools';
import Events from './nostr/Events';
import Key from './nostr/Key';
import SocialNetwork from './nostr/SocialNetwork';
import localState from './LocalState';
import Events from "./nostr/Events";
import Key from "./nostr/Key";
import SocialNetwork from "./nostr/SocialNetwork";
import localState from "./LocalState";
export default {
async checkExistingAccount(pub) {
if (!['iris.to', 'beta.iris.to', 'localhost'].includes(window.location.hostname)) {
if (
!["iris.to", "beta.iris.to", "localhost"].includes(
window.location.hostname
)
) {
return;
}
// get username linked to pub along with its user_confirmed status
const res = await fetch(`https://api.iris.to/user/find?public_key=${pub}`);
if (res.status === 200) {
const json = await res.json();
console.log('existingIrisToAddress', json);
localState.get('existingIrisToAddress').put(json);
console.log("existingIrisToAddress", json);
localState.get("existingIrisToAddress").put(json);
const timeout = setTimeout(() => {
if (!json?.confirmed) {
localState.get('showNoIrisToAddress').put(true);
localState.get("showNoIrisToAddress").put(true);
}
}, 1000);
localState.get('showNoIrisToAddress').on((show) => {
localState.get("showNoIrisToAddress").on((show) => {
if (show) {
clearTimeout(timeout);
}
@ -28,9 +31,9 @@ export default {
return { existing: json };
}
const timeout = setTimeout(() => {
localState.get('showNoIrisToAddress').put(true);
localState.get("showNoIrisToAddress").put(true);
}, 2000);
localState.get('showNoIrisToAddress').on((show) => {
localState.get("showNoIrisToAddress").on((show) => {
if (!show) {
clearTimeout(timeout);
}
@ -38,7 +41,7 @@ export default {
},
setAsPrimary(name) {
const newNip = name + '@iris.to';
const newNip = name + "@iris.to";
const timeout = setTimeout(() => {
SocialNetwork.setMetadata({ nip05: newNip });
}, 2000);
@ -50,13 +53,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: [],
@ -66,26 +68,26 @@ export default {
event.id = Events.getEventHash(event);
event.sig = (await Key.sign(event)) as string;
// post signed event as request body to https://api.iris.to/user/confirm_user
const res = await fetch('https://api.iris.to/user/confirm_user', {
method: 'POST',
const res = await fetch("https://api.iris.to/user/confirm_user", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify(event),
});
// should perhaps be in the next block, but users are having cache issues. this may help.
localState.get('showNoIrisToAddress').put(false);
localState.get('existingIrisToAddress').put({ confirmed: true, name });
localState.get("showNoIrisToAddress").put(false);
localState.get("existingIrisToAddress").put({ confirmed: true, name });
if (res.status === 200) {
return { error: null, existing: { confirmed: true, name } };
} else {
res
.json()
.then((json) => {
return { error: json.message || 'error' };
return { error: json.message || "error" };
})
.catch(() => {
return { error: 'error' };
return { error: "error" };
});
}
},
@ -95,7 +97,7 @@ export default {
return;
}
const pubkey = Key.getPubKey();
const event: Event = {
const event: any = {
content: `decline iris.to/${name}`,
kind: 1,
tags: [],
@ -105,25 +107,25 @@ export default {
event.id = Events.getEventHash(event);
event.sig = (await Key.sign(event)) as string;
// post signed event as request body to https://api.iris.to/user/confirm_user
const res = await fetch('https://api.iris.to/user/decline_user', {
method: 'POST',
const res = await fetch("https://api.iris.to/user/decline_user", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify(event),
});
if (res.status === 200) {
localState.get('showNoIrisToAddress').put(false);
localState.get('existingIrisToAddress').put(null);
localState.get("showNoIrisToAddress").put(false);
localState.get("existingIrisToAddress").put(null);
return { confirmSuccess: false, error: null, existing: null };
} else {
res
.json()
.then((json) => {
return { error: json.message || 'error' };
return { error: json.message || "error" };
})
.catch(() => {
return { error: 'error' };
return { error: "error" };
});
}
},

View File

@ -1,9 +1,10 @@
import { Event } from './lib/nostr-tools';
import Helpers from './Helpers';
import { Event } from "nostr-tools";
import Helpers from "./Helpers";
// Code kindly contributed by @Kieran from Snort
const PayServiceTag = 'payRequest';
const PayServiceTag = "payRequest";
export enum LNURLErrorCode {
ServiceUnavailable = 1,
@ -29,23 +30,26 @@ export class LNURL {
*/
constructor(lnurl: string) {
lnurl = lnurl.toLowerCase().trim();
if (lnurl.startsWith('lnurl')) {
if (lnurl.startsWith("lnurl")) {
const decoded = Helpers.bech32ToText(lnurl);
if (!decoded.startsWith('http')) {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, 'Not a url');
if (!decoded.startsWith("http")) {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "Not a url");
}
this.#url = new URL(decoded);
} else if (lnurl.match(/[\w.-]+@[\w.-]/)) {
const [handle, domain] = lnurl.split('@');
const [handle, domain] = lnurl.split("@");
this.#url = new URL(`https://${domain}/.well-known/lnurlp/${handle}`);
} else if (lnurl.startsWith('https:')) {
} else if (lnurl.startsWith("https:")) {
this.#url = new URL(lnurl);
} else if (lnurl.startsWith('lnurlp:')) {
} else if (lnurl.startsWith("lnurlp:")) {
const tmp = new URL(lnurl);
tmp.protocol = 'https:';
tmp.protocol = "https:";
this.#url = tmp;
} else {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, 'Could not determine service url');
throw new LNURLError(
LNURLErrorCode.InvalidLNURL,
"Could not determine service url"
);
}
}
@ -71,30 +75,30 @@ export class LNURL {
if (callback.search.length > 0) {
callback.search
.slice(1)
.split('&')
.split("&")
.forEach((a) => {
const pSplit = a.split('=');
const pSplit = a.split("=");
query.set(pSplit[0], pSplit[1]);
});
}
query.set('amount', Math.floor(amount * 1000).toString());
query.set("amount", Math.floor(amount * 1000).toString());
if (comment && this.#service?.commentAllowed) {
query.set('comment', comment);
query.set("comment", comment);
}
if (this.#service?.nostrPubkey && zap) {
query.set('nostr', JSON.stringify(zap)); // zap.ToObject() ?
query.set("nostr", JSON.stringify(zap)); // zap.ToObject() ?
}
const baseUrl = `${callback.protocol}//${callback.host}${callback.pathname}`;
const queryJoined = [...query.entries()]
.map((v) => `${v[0]}=${encodeURIComponent(v[1])}`)
.join('&');
.join("&");
try {
const rsp = await fetch(`${baseUrl}?${queryJoined}`);
if (rsp.ok) {
const data: LNURLInvoice = await rsp.json();
console.debug('[LNURL]: ', data);
if (data.status === 'ERROR') {
console.debug("[LNURL]: ", data);
if (data.status === "ERROR") {
throw new Error(data.reason);
} else {
return data;
@ -102,11 +106,14 @@ export class LNURL {
} else {
throw new LNURLError(
LNURLErrorCode.ServiceUnavailable,
`Failed to fetch invoice (${rsp.statusText})`,
`Failed to fetch invoice (${rsp.statusText})`
);
}
} catch (e) {
throw new LNURLError(LNURLErrorCode.ServiceUnavailable, 'Failed to load callback');
throw new LNURLError(
LNURLErrorCode.ServiceUnavailable,
"Failed to load callback"
);
}
}
@ -147,10 +154,13 @@ export class LNURL {
#validateService() {
if (this.#service?.tag !== PayServiceTag) {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, 'Only LNURLp is supported');
throw new LNURLError(
LNURLErrorCode.InvalidLNURL,
"Only LNURLp is supported"
);
}
if (!this.#service?.callback) {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, 'No callback url');
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "No callback url");
}
}
}
@ -166,7 +176,7 @@ export interface LNURLService {
}
export interface LNURLStatus {
status: 'SUCCESS' | 'ERROR';
status: "SUCCESS" | "ERROR";
reason?: string;
}

View File

@ -1,68 +1,70 @@
import { bytesToHex } from '@noble/hashes/utils';
import { decode as invoiceDecode } from 'light-bolt11-decoder';
import { bytesToHex } from "@noble/hashes/utils";
import { decode as invoiceDecode } from "light-bolt11-decoder";
import localState from './LocalState';
import localState from "./LocalState";
let lastBitcoinPrice;
const currencies = {
USD: '$',
EUR: '€',
JPY: '¥',
SATS: '',
USD: "$",
EUR: "€",
JPY: "¥",
SATS: "",
};
// set default according to locale
let displayCurrency = 'USD';
let displayCurrency = "USD";
const locale = navigator.language;
const EUR_LOCALE_PREFIXES = [
'de',
'fr',
'it',
'es',
'pt',
'el',
'nl',
'ga',
'fi',
'sv',
'et',
'lv',
'lt',
'sk',
'sl',
'mt',
'hu',
'pl',
'cs',
'bg',
'ro',
"de",
"fr",
"it",
"es",
"pt",
"el",
"nl",
"ga",
"fi",
"sv",
"et",
"lv",
"lt",
"sk",
"sl",
"mt",
"hu",
"pl",
"cs",
"bg",
"ro",
];
if (EUR_LOCALE_PREFIXES.some((prefix) => locale.startsWith(prefix))) {
displayCurrency = 'EUR';
} else if (locale.startsWith('ja')) {
displayCurrency = 'JPY';
displayCurrency = "EUR";
} else if (locale.startsWith("ja")) {
displayCurrency = "JPY";
}
const getExchangeRate = () => {
fetch('https://api.kraken.com/0/public/Ticker?pair=XBT' + displayCurrency)
fetch("https://api.kraken.com/0/public/Ticker?pair=XBT" + displayCurrency)
.then((response) => {
if (response.ok) {
return response.json();
} else {
throw new Error('Failed to fetch data from Kraken API');
throw new Error("Failed to fetch data from Kraken API");
}
})
.then((data) => {
lastBitcoinPrice = parseFloat(data?.result?.['XXBTZ' + displayCurrency]?.c?.[0]);
console.log('lastBitcoinPrice', lastBitcoinPrice, displayCurrency);
lastBitcoinPrice = parseFloat(
data?.result?.["XXBTZ" + displayCurrency]?.c?.[0]
);
console.log("lastBitcoinPrice", lastBitcoinPrice, displayCurrency);
})
.catch((error) => {
console.error('Error:', error);
console.error("Error:", error);
});
};
localState.get('displayCurrency').on((value) => {
localState.get("displayCurrency").on((value) => {
displayCurrency = value;
getExchangeRate();
});
@ -87,21 +89,31 @@ export function decodeInvoice(pr: string): InvoiceDetails | undefined {
try {
const parsed = invoiceDecode(pr);
const amountSection = parsed.sections.find((a) => a.name === 'amount');
const amount = amountSection ? Number(amountSection.value as number | string) : undefined;
const amountSection = parsed.sections.find((a) => a.name === "amount");
const amount = amountSection
? Number(amountSection.value as number | string)
: undefined;
const timestampSection = parsed.sections.find((a) => a.name === 'timestamp');
const timestampSection = parsed.sections.find(
(a) => a.name === "timestamp"
);
const timestamp = timestampSection
? Number(timestampSection.value as number | string)
: 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',
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 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,
@ -110,7 +122,9 @@ export function decodeInvoice(pr: string): InvoiceDetails | undefined {
descriptionHash: descriptionHashSection
? bytesToHex(descriptionHashSection as Uint8Array)
: undefined,
paymentHash: paymentHashSection ? bytesToHex(paymentHashSection as Uint8Array) : undefined,
paymentHash: paymentHashSection
? bytesToHex(paymentHashSection as Uint8Array)
: undefined,
expired: false,
};
if (ret.expire) {
@ -124,19 +138,19 @@ export function decodeInvoice(pr: string): InvoiceDetails | undefined {
// 1000 -> 1.00K etc
export function formatSats(amount: number): string {
if (typeof amount !== 'number') {
return '';
if (typeof amount !== "number") {
return "";
}
if (amount < 1000) {
return amount.toString();
}
if (amount < 1000000) {
return (amount / 1000).toFixed(2) + 'K';
return (amount / 1000).toFixed(2) + "K";
}
if (amount < 1000000000) {
return (amount / 1000000).toFixed(2) + 'M';
return (amount / 1000000).toFixed(2) + "M";
}
return (amount / 1000000000).toFixed(2) + 'B';
return (amount / 1000000000).toFixed(2) + "B";
}
function customFormatNumber(value) {
@ -152,17 +166,17 @@ function customFormatNumber(value) {
const factor = Math.pow(10, maxDecimals);
value = Math.round(value * factor) / factor;
return value.toLocaleString('en-US', {
return value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: maxDecimals,
});
}
export function formatAmount(sats: number): string {
if (lastBitcoinPrice && displayCurrency !== 'SATS') {
if (lastBitcoinPrice && displayCurrency !== "SATS") {
const dollarAmount = (sats / 100000000) * lastBitcoinPrice;
const formattedAmount = customFormatNumber(dollarAmount);
return currencies[displayCurrency] + ' ' + formattedAmount;
return currencies[displayCurrency] + " " + formattedAmount;
} else {
return formatSats(sats);
}

View File

@ -1,15 +1,17 @@
import localForage from 'localforage';
import _ from 'lodash';
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.
const LOCALFORAGE_NULL = 'c2fc1ad0-f76f-11ec-b939-0242ac120002';
const LOCALFORAGE_NULL = "c2fc1ad0-f76f-11ec-b939-0242ac120002";
const notInLocalForage = new Set();
localForage.config({
@ -30,7 +32,7 @@ class Node {
loaded = false;
/** */
constructor(id = '', parent: Node | null = null) {
constructor(id = "", parent: Node | null = null) {
this.id = id;
this.parent = parent;
}
@ -45,7 +47,10 @@ class Node {
} else if (this.value === undefined) {
localForage.removeItem(this.id);
} else {
localForage.setItem(this.id, this.value === null ? LOCALFORAGE_NULL : this.value);
localForage.setItem(
this.id,
this.value === null ? LOCALFORAGE_NULL : this.value
);
}
}, 500);
@ -67,7 +72,7 @@ class Node {
await Promise.all(
result.map(async (key) => {
newResult[key] = await this.get(key).once();
}),
})
);
result = newResult;
} else {
@ -80,17 +85,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);
@ -122,7 +127,7 @@ class Node {
if (Array.isArray(value)) {
throw new Error("Sorry, we don't deal with arrays");
}
if (typeof value === 'object' && value !== null) {
if (typeof value === "object" && value !== null) {
this.value = undefined;
for (const key in value) {
this.get(key).put(value[key]);
@ -144,15 +149,19 @@ 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) {
result = this.value;
@ -160,7 +169,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 +186,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 +199,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

@ -1,37 +1,37 @@
//import "preact/debug";
import { Helmet } from 'react-helmet';
import { Router, RouterOnChangeArgs } from 'preact-router';
import { Helmet } from "react-helmet";
import { Router, RouterOnChangeArgs } from "preact-router";
import Footer from './components/Footer';
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 About from './views/About';
import Chat from './views/chat/Chat';
import EditProfile from './views/EditProfile';
import Explorer from './views/explorer/Explorer';
import Feed from './views/Feed';
import FeedList from './views/FeedList';
import Follows from './views/Follows';
import KeyConverter from './views/KeyConverter';
import Login from './views/Login';
import LogoutConfirmation from './views/LogoutConfirmation';
import Note from './views/Note';
import Notifications from './views/Notifications';
import Profile from './views/Profile';
import Settings from './views/settings/Settings';
import Subscribe from './views/Subscribe';
import Torrent from './views/Torrent';
import Component from './BaseComponent';
import Helpers from './Helpers';
import localState from './LocalState';
import Footer from "./components/Footer";
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.mjs";
import About from "./views/About";
import Chat from "./views/chat/Chat";
import EditProfile from "./views/EditProfile";
import Explorer from "./views/explorer/Explorer";
import Feed from "./views/Feed";
import FeedList from "./views/FeedList";
import Follows from "./views/Follows";
import KeyConverter from "./views/KeyConverter";
import Login from "./views/Login";
import LogoutConfirmation from "./views/LogoutConfirmation";
import Note from "./views/Note";
import Notifications from "./views/Notifications";
import Profile from "./views/Profile";
import Settings from "./views/settings/Settings";
import Subscribe from "./views/Subscribe";
import Torrent from "./views/Torrent";
import Component from "./BaseComponent";
import Helpers from "./Helpers";
import localState from "./LocalState";
import '@fontsource/lato/400.css';
import '@fontsource/lato/700.css';
import '../css/style.css';
import '../css/cropper.min.css';
import "@fontsource/lato/400.css";
import "@fontsource/lato/700.css";
import "../css/style.css";
import "../css/cropper.min.css";
type Props = Record<string, unknown>;
@ -51,24 +51,25 @@ class Main extends Component<Props, ReactState> {
componentDidMount() {
// if location contains a hash #, redirect to the same url without the hash. For example #/profile -> /profile
if (window.location.hash.length) {
window.location.href = window.location.origin + window.location.hash.replace('#', '');
window.location.href =
window.location.origin + window.location.hash.replace("#", "");
}
window.onload = () => {
// this makes sure that window.nostr is there
localState.get('loggedIn').on(this.inject());
localState.get("loggedIn").on(this.inject());
};
localState.get('toggleMenu').put(false);
localState.get('toggleMenu').on((show: boolean) => this.toggleMenu(show));
localState.get("toggleMenu").put(false);
localState.get("toggleMenu").on((show: boolean) => this.toggleMenu(show));
// iris.electron && iris.electron.get('platform').on(this.inject());
localState.get('unseenMsgsTotal').on(this.inject());
localState.get("unseenMsgsTotal").on(this.inject());
translationLoaded.then(() => this.setState({ translationLoaded: true }));
localState.get('showLoginModal').on(this.inject());
localState.get("showLoginModal").on(this.inject());
}
handleRoute(e: RouterOnChangeArgs) {
const activeRoute = e.url;
this.setState({ activeRoute });
localState.get('activeRoute').put(activeRoute);
localState.get("activeRoute").put(activeRoute);
}
onClickOverlay(): void {
@ -79,7 +80,7 @@ class Main extends Component<Props, ReactState> {
toggleMenu(show: boolean): void {
this.setState({
showMenu: typeof show === 'undefined' ? !this.state.showMenu : show,
showMenu: typeof show === "undefined" ? !this.state.showMenu : show,
});
}
@ -89,14 +90,18 @@ class Main extends Component<Props, ReactState> {
}
render() {
let title = '';
let title = "";
const s = this.state;
if (s.activeRoute && s.activeRoute.length > 1) {
title = Helpers.capitalize(s.activeRoute.replace('/', '').split('?')[0]);
title = Helpers.capitalize(s.activeRoute.replace("/", "").split("?")[0]);
}
const isDesktopNonMac = s.platform && s.platform !== 'darwin';
const titleTemplate = s.unseenMsgsTotal ? `(${s.unseenMsgsTotal}) %s | iris` : '%s | iris';
const defaultTitle = s.unseenMsgsTotal ? `(${s.unseenMsgsTotal}) iris` : 'iris';
const isDesktopNonMac = s.platform && s.platform !== "darwin";
const titleTemplate = s.unseenMsgsTotal
? `(${s.unseenMsgsTotal}) %s | iris`
: "%s | iris";
const defaultTitle = s.unseenMsgsTotal
? `(${s.unseenMsgsTotal}) iris`
: "iris";
if (!s.translationLoaded) {
return <div id="main-content" />;
}
@ -110,7 +115,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} />;
@ -122,21 +127,30 @@ class Main extends Component<Props, ReactState> {
<div className="windows-titlebar">
<span>iris</span>
<div className="title-bar-btns">
<button className="min-btn" onClick={() => this.electronCmd('minimize')}>
<button
className="min-btn"
onClick={() => this.electronCmd("minimize")}
>
-
</button>
<button className="max-btn" onClick={() => this.electronCmd('maximize')}>
<button
className="max-btn"
onClick={() => this.electronCmd("maximize")}
>
+
</button>
<button className="close-btn" onClick={() => this.electronCmd('close')}>
<button
className="close-btn"
onClick={() => this.electronCmd("close")}
>
x
</button>
</div>
</div>
) : null}
<section
className={`main ${isDesktopNonMac ? 'desktop-non-mac' : ''} ${
s.showMenu ? 'menu-visible-xs' : ''
className={`main ${isDesktopNonMac ? "desktop-non-mac" : ""} ${
s.showMenu ? "menu-visible-xs" : ""
}`}
style="flex-direction: row;"
>
@ -146,10 +160,19 @@ class Main extends Component<Props, ReactState> {
<meta name="description" content="Social Networking Freedom" />
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content="Social Networking Freedom" />
<meta property="og:image" content="https://iris.to/assets/img/irisconnects.png" />
<meta
property="og:description"
content="Social Networking Freedom"
/>
<meta
property="og:image"
content="https://iris.to/assets/img/irisconnects.png"
/>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://iris.to/assets/img/irisconnects.png" />
<meta
name="twitter:image"
content="https://iris.to/assets/img/irisconnects.png"
/>
</Helmet>
<div className="overlay" onClick={() => this.onClickOverlay()}></div>
<div className="view-area">
@ -188,7 +211,7 @@ class Main extends Component<Props, ReactState> {
<Modal
centerVertically={true}
showContainer={true}
onClose={() => localState.get('showLoginModal').put(false)}
onClose={() => localState.get("showLoginModal").put(false)}
>
<Login />
</Modal>

View File

@ -1,16 +1,18 @@
import Icons from '../Icons';
import Key from '../nostr/Key';
import SocialNetwork from '../nostr/SocialNetwork';
import { translate as t } from '../translations/Translation';
import { JSX } from "preact";
export default function Badge(props) {
import Icons from "../Icons";
import Key from "../nostr/Key";
import SocialNetwork from "../nostr/SocialNetwork";
import { translate as t } from "../translations/Translation.mjs";
export default function Badge(props): JSX.Element | null {
const myPub = Key.getPubKey();
const hexAddress = Key.toNostrHexAddress(props.pub);
if (hexAddress === myPub) {
return (
<span class="badge first tooltip">
{Icons.checkmark}
<span class="tooltiptext right">{t('you')}</span>
<span class="tooltiptext right">{t("you")}</span>
</span>
);
}
@ -22,21 +24,23 @@ export default function Badge(props) {
return (
<span class="badge first tooltip">
{Icons.checkmark}
<span class="tooltiptext right">{t('following')}</span>
<span class="tooltiptext right">{t("following")}</span>
</span>
);
} else {
const count = SocialNetwork.followedByFriendsCount(hexAddress);
if (count > 0) {
const className = count > 10 ? 'second' : 'third';
const className = count > 10 ? "second" : "third";
return (
<span class={`badge ${className} tooltip`}>
{Icons.checkmark}
<span class="tooltiptext right">
{count} {t('friends_following')}
{count} {t("friends_following")}
</span>
</span>
);
} else {
return null;
}
}
}

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from 'preact/hooks';
import { JSX } from "preact";
import { useEffect, useState } from "preact/hooks";
type Props = {
children: JSX.Element | JSX.Element[];
@ -9,9 +10,9 @@ const Dropdown = ({ children }: Props) => {
const toggle = (e: MouseEvent, newOpenState: boolean) => {
if (
e.type === 'click' &&
e.type === "click" &&
e.target !== null &&
!(e.target as HTMLElement).classList.contains('dropbtn')
!(e.target as HTMLElement).classList.contains("dropbtn")
) {
return;
}
@ -22,15 +23,18 @@ const Dropdown = ({ children }: Props) => {
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (e.target && !(e.target as HTMLElement).classList.contains('dropbtn')) {
if (
e.target &&
!(e.target as HTMLElement).classList.contains("dropbtn")
) {
setOpen(false);
}
};
document.addEventListener('click', handleClickOutside);
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener("click", handleClickOutside);
};
}, []);
@ -42,7 +46,7 @@ const Dropdown = ({ children }: Props) => {
onMouseLeave={(e) => toggle(e, false)}
>
<div class="dropbtn"></div>
{open ? <div class="dropdown-content">{children}</div> : ''}
{open ? <div class="dropdown-content">{children}</div> : ""}
</div>
);
};

View File

@ -1,58 +1,64 @@
import { PrimaryButton as Button } from './buttons/Button';
import { PrimaryButton as Button } from "./buttons/Button";
const text = (
<>
<b>End User Licence Agreement</b>
<p>
This End User Licence Agreement ("EULA") is a legal agreement between you and Sirius Business
Ltd. for the use of the mobile application Iris. By installing, accessing, or using our
application, you agree to be bound by the terms and conditions of this EULA. If you do not
agree to this EULA, you may not use our Application.
This End User Licence Agreement ("EULA") is a legal agreement between you
and Sirius Business Ltd. for the use of the mobile application Iris. By
installing, accessing, or using our application, you agree to be bound by
the terms and conditions of this EULA. If you do not agree to this EULA,
you may not use our Application.
</p>
<p>
You agree not to use Iris for Prohibited Content or Conduct that includes, but is not limited
to:
You agree not to use Iris for Prohibited Content or Conduct that includes,
but is not limited to:
</p>
<ul>
<li>
impersonate, harass or bully others, harmful to minors, incite or promote hate speech,
illegal;
impersonate, harass or bully others, harmful to minors, incite or
promote hate speech, illegal;
</li>
<li>
obscene, sexually explicit, offensive, defamatory, libellous, slanderous, violent and/or
unlawful content or profanity;
obscene, sexually explicit, offensive, defamatory, libellous,
slanderous, violent and/or unlawful content or profanity;
</li>
<li>
content that infringes upon the rights of any third party, including copyright, trademark,
privacy, publicity or other personal or proprietary rights, or that is deceptive or
fraudulent;
content that infringes upon the rights of any third party, including
copyright, trademark, privacy, publicity or other personal or
proprietary rights, or that is deceptive or fraudulent;
</li>
<li>
content that promotes the use or sale of illegal or regulated substances, tobacco products,
ammunition and/or firearms; and
content that promotes the use or sale of illegal or regulated
substances, tobacco products, ammunition and/or firearms; and
</li>
<li>illegal content related to gambling.</li>
</ul>
<p>
Any violation of this EULA, including the Prohibited Content and Conduct outlined above, may
result in the termination of your access to our application.
</p>
<p>
Our application is provided "as is" and "as available" without warranty of any kind, either
express or implied, including but not limited to the implied warranties of merchantability and
fitness for a particular purpose. We do not guarantee that our application will be
uninterrupted or error-free. In no event shall Sirius Business Ltd. be liable for any damages
whatsoever, including but not limited to direct, indirect, special, incidental, or
consequential damages, arising out of or in connection with the use or inability to use our
Any violation of this EULA, including the Prohibited Content and Conduct
outlined above, may result in the termination of your access to our
application.
</p>
<p>
We reserve the right to modify this EULA at any time and without prior notice. Your continued
use of the app after the posting of any modified EULA indicates your acceptance of the terms
of the modified EULA.
Our application is provided "as is" and "as available" without warranty of
any kind, either express or implied, including but not limited to the
implied warranties of merchantability and fitness for a particular
purpose. We do not guarantee that our application will be uninterrupted or
error-free. In no event shall Sirius Business Ltd. be liable for any
damages whatsoever, including but not limited to direct, indirect,
special, incidental, or consequential damages, arising out of or in
connection with the use or inability to use our application.
</p>
<p>
We reserve the right to modify this EULA at any time and without prior
notice. Your continued use of the app after the posting of any modified
EULA indicates your acceptance of the terms of the modified EULA.
</p>
<p>
If you have any questions about this EULA, please contact us at
sirius@iris.to.
</p>
<p>If you have any questions about this EULA, please contact us at sirius@iris.to.</p>
</>
);

View File

@ -1,4 +1,4 @@
import { Component } from 'preact';
import { Component } from "preact";
export default class ErrorBoundary extends Component {
state = { error: null };

View File

@ -1,16 +1,20 @@
import { HomeIcon, PaperAirplaneIcon, PlusCircleIcon } from '@heroicons/react/24/outline';
import {
HomeIcon,
PaperAirplaneIcon,
PlusCircleIcon,
} from "@heroicons/react/24/outline";
import {
HomeIcon as HomeIconFull,
PaperAirplaneIcon as PaperAirplaneIconFull,
PlusCircleIcon as PlusCircleIconFull,
} from '@heroicons/react/24/solid';
import { route } from 'preact-router';
} from "@heroicons/react/24/solid";
import { route } from "preact-router";
import Component from '../BaseComponent';
import localState from '../LocalState';
import Key from '../nostr/Key';
import Component from "../BaseComponent";
import localState from "../LocalState";
import Key from "../nostr/Key";
import Identicon from './Identicon';
import Identicon from "./Identicon";
type Props = Record<string, unknown>;
@ -23,74 +27,96 @@ type State = {
class Footer extends Component<Props, State> {
constructor() {
super();
this.state = { unseenMsgsTotal: 0, activeRoute: '/' };
this.state = { unseenMsgsTotal: 0, activeRoute: "/" };
}
componentDidMount() {
localState.get('unseenMsgsTotal').on(this.inject());
localState.get('activeRoute').on(
localState.get("unseenMsgsTotal").on(this.inject());
localState.get("activeRoute").on(
this.sub((activeRoute) => {
const replaced = activeRoute.replace('/chat/new', '').replace('/chat/', '');
const replaced = activeRoute
.replace("/chat/new", "")
.replace("/chat/", "");
const chatId = replaced.length < activeRoute.length ? replaced : null;
this.setState({ activeRoute, chatId });
}),
})
);
}
handleFeedClick(e) {
e.preventDefault();
e.stopPropagation();
localState.get('lastOpenedFeed').once((lastOpenedFeed) => {
if (lastOpenedFeed !== this.state.activeRoute.replace('/', '')) {
route('/' + (lastOpenedFeed || ''));
localState.get("lastOpenedFeed").once((lastOpenedFeed) => {
if (lastOpenedFeed !== this.state.activeRoute.replace("/", "")) {
route("/" + (lastOpenedFeed || ""));
} else {
localState.get('lastOpenedFeed').put('');
route('/');
localState.get("lastOpenedFeed").put("");
route("/");
}
});
}
render() {
const key = Key.toNostrBech32Address(Key.getPubKey(), 'npub');
const key = Key.toNostrBech32Address(Key.getPubKey(), "npub");
if (!key) {
return;
}
const activeRoute = this.state.activeRoute;
if (this.state.chatId) {
return '';
return "";
}
return (
<footer class="visible-xs-flex nav footer">
<div class="header-content" onClick={() => localState.get('scrollUp').put(true)}>
<div
class="header-content"
onClick={() => localState.get("scrollUp").put(true)}
>
<a
href="/"
onClick={(e) => this.handleFeedClick(e)}
class={`btn ${activeRoute === '/' ? 'active' : ''}`}
class={`btn ${activeRoute === "/" ? "active" : ""}`}
>
{activeRoute === '/' ? <HomeIconFull width={24} /> : <HomeIcon width={24} />}
</a>
<a href="/chat" className={`btn ${activeRoute.indexOf('/chat') === 0 ? 'active' : ''}`}>
{this.state.unseenMsgsTotal ? (
<span className="unseen unseen-total">{this.state.unseenMsgsTotal}</span>
{activeRoute === "/" ? (
<HomeIconFull width={24} />
) : (
''
<HomeIcon width={24} />
)}
{activeRoute.indexOf('/chat') === 0 ? (
</a>
<a
href="/chat"
className={`btn ${
activeRoute.indexOf("/chat") === 0 ? "active" : ""
}`}
>
{this.state.unseenMsgsTotal ? (
<span className="unseen unseen-total">
{this.state.unseenMsgsTotal}
</span>
) : (
""
)}
{activeRoute.indexOf("/chat") === 0 ? (
<PaperAirplaneIconFull width={24} />
) : (
<PaperAirplaneIcon width={24} />
)}
</a>
<a href="/post/new" class={`btn ${activeRoute === '/post/new' ? 'active' : ''}`}>
{activeRoute === '/post/new' ? (
<a
href="/post/new"
class={`btn ${activeRoute === "/post/new" ? "active" : ""}`}
>
{activeRoute === "/post/new" ? (
<PlusCircleIconFull width={24} />
) : (
<PlusCircleIcon width={24} />
)}
</a>
<a href={`/${key}`} class={`${activeRoute === `/${key}` ? 'active' : ''} my-profile`}>
<a
href={`/${key}`}
class={`${activeRoute === `/${key}` ? "active" : ""} my-profile`}
>
<Identicon str={key} width={34} />
</a>
</div>

View File

@ -1,25 +1,25 @@
import { HeartIcon } from '@heroicons/react/24/outline';
import { HeartIcon as HeartIconFull } from '@heroicons/react/24/solid';
import { route } from 'preact-router';
import { Link } from 'preact-router/match';
import { HeartIcon } from "@heroicons/react/24/outline";
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 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 logo from "../../assets/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.mjs";
import { Button, PrimaryButton } from './buttons/Button';
import Identicon from './Identicon';
import Name from './Name';
import SearchBox from './SearchBox';
import { Button, PrimaryButton } from "./buttons/Button";
import Identicon from "./Identicon";
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,53 +39,69 @@ export default class Header extends Component {
componentWillUnmount() {
super.componentWillUnmount();
clearInterval(this.iv);
document.removeEventListener('keydown', this.escFunction, false);
this.iv && clearInterval(this.iv);
document.removeEventListener("keydown", this.escFunction, false);
}
componentDidMount() {
document.addEventListener('keydown', this.escFunction, false);
localState.get('showParticipants').on(this.inject());
localState.get('unseenMsgsTotal').on(this.inject());
localState.get('unseenNotificationCount').on(this.inject());
localState.get('showConnectedRelays').on(this.inject());
localState.get('activeRoute').on(
document.addEventListener("keydown", this.escFunction, false);
localState.get("showParticipants").on(this.inject());
localState.get("unseenMsgsTotal").on(this.inject());
localState.get("unseenNotificationCount").on(this.inject());
localState.get("showConnectedRelays").on(this.inject());
localState.get("activeRoute").on(
this.sub((activeRoute) => {
this.setState({
about: null,
title: '',
title: "",
activeRoute,
showMobileSearch: false,
});
const replaced = activeRoute.replace('/chat/new', '').replace('/chat/', '');
const replaced = activeRoute
.replace("/chat/new", "")
.replace("/chat/", "");
this.chatId = replaced.length < activeRoute.length ? replaced : null;
if (this.chatId) {
localState.get('channels').get(this.chatId).get('isTyping').on(this.inject());
localState.get('channels').get(this.chatId).get('theirLastActiveTime').on(this.inject());
localState
.get("channels")
.get(this.chatId)
.get("isTyping")
.on(this.inject());
localState
.get("channels")
.get(this.chatId)
.get("theirLastActiveTime")
.on(this.inject());
}
if (activeRoute.indexOf('/chat/') === 0 && activeRoute.indexOf('/chat/new') !== 0) {
if (activeRoute.indexOf('/chat/') === 0 && this.chatId === Key.getPubKey()) {
if (
activeRoute.indexOf("/chat/") === 0 &&
activeRoute.indexOf("/chat/new") !== 0
) {
if (
activeRoute.indexOf("/chat/") === 0 &&
this.chatId === Key.getPubKey()
) {
const title = (
<>
<b style="margin-right:5px">📝</b> <b>{t('note_to_self')}</b>
<b style="margin-right:5px">📝</b> <b>{t("note_to_self")}</b>
</>
);
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 });
}
}
}),
})
);
this.updateRelayCount();
this.iv = setInterval(() => this.updateRelayCount(), 1000);
}
onTitleClicked() {
if (this.chatId && this.chatId.indexOf('hashtag') === -1) {
const view = this.chatId.length < 40 ? '/group/' : '/';
if (this.chatId && this.chatId.indexOf("hashtag") === -1) {
const view = this.chatId.length < 40 ? "/group/" : "/";
route(view + this.chatId);
}
}
@ -93,9 +109,9 @@ export default class Header extends Component {
onLogoClick(e) {
e.preventDefault();
e.stopPropagation();
(document.querySelector('a.logo') as HTMLElement).blur();
window.innerWidth > 625 && route('/');
localState.get('toggleMenu').put(true);
(document.querySelector("a.logo") as HTMLElement).blur();
window.innerWidth > 625 && route("/");
localState.get("toggleMenu").put(true);
}
updateRelayCount() {
@ -104,7 +120,7 @@ export default class Header extends Component {
renderBackButton() {
const { activeRoute } = this.state;
const chatting = activeRoute && activeRoute.indexOf('/chat/') === 0;
const chatting = activeRoute && activeRoute.indexOf("/chat/") === 0;
return chatting ? (
<div
id="back-button"
@ -114,24 +130,32 @@ export default class Header extends Component {
{Icons.backArrow}
</div>
) : (
''
""
);
}
renderMenuIcon() {
const { activeRoute } = this.state;
const chatting = activeRoute && activeRoute.indexOf('/chat/') === 0;
const chatting = activeRoute && activeRoute.indexOf("/chat/") === 0;
return !Helpers.isElectron && !chatting ? (
<a href="/" onClick={(e) => this.onLogoClick(e)} class="visible-xs-flex logo">
<a
href="/"
onClick={(e) => this.onLogoClick(e)}
class="visible-xs-flex logo"
>
<div class="mobile-menu-icon">{Icons.menu}</div>
</a>
) : (
''
""
);
}
renderSearchBox() {
return !this.chatId ? <SearchBox onSelect={(item) => route(`/${item.key}`)} /> : '';
return !this.chatId ? (
<SearchBox onSelect={(item) => route(`/${item.key}`)} />
) : (
""
);
}
renderConnectedRelays() {
@ -139,10 +163,10 @@ export default class Header extends Component {
<a
href="/settings/network"
class={`connected-peers tooltip mobile-search-hidden ${
this.state.showMobileSearch ? 'hidden-xs' : ''
} ${this.state.connectedRelays > 0 ? 'connected' : ''}`}
this.state.showMobileSearch ? "hidden-xs" : ""
} ${this.state.connectedRelays > 0 ? "connected" : ""}`}
>
<span class="tooltiptext right">{t('connected_relays')}</span>
<span class="tooltiptext right">{t("connected_relays")}</span>
<small>
<span class="icon">{Icons.network}</span>
<span>{this.state.connectedRelays}</span>
@ -154,21 +178,35 @@ export default class Header extends Component {
renderHeaderText() {
const { chat, activeRoute } = this.state;
const isTyping = chat && chat.isTyping;
const chatting = activeRoute && activeRoute.indexOf('/chat/') === 0;
const chatting = activeRoute && activeRoute.indexOf("/chat/") === 0;
return (
<div
class="text"
style={this.chatId ? 'cursor:pointer;text-align:center' : ''}
style={this.chatId ? "cursor:pointer;text-align:center" : ""}
onClick={() => this.onTitleClicked()}
>
{this.state.title && chatting ? <div class="name">{this.state.title}</div> : ''}
{isTyping ? <small class="typing-indicator">{t('typing')}</small> : ''}
{this.state.about ? <small class="participants">{this.state.about}</small> : ''}
{this.chatId ? <small class="last-seen">{this.state.onlineStatus || ''}</small> : ''}
{this.state.title && chatting ? (
<div class="name">{this.state.title}</div>
) : (
""
)}
{isTyping ? <small class="typing-indicator">{t("typing")}</small> : ""}
{this.state.about ? (
<small class="participants">{this.state.about}</small>
) : (
""
)}
{this.chatId ? (
<small class="last-seen">{this.state.onlineStatus || ""}</small>
) : (
""
)}
{!chatting && (
<div
id="mobile-search"
class={`mobile-search-visible ${this.state.showMobileSearch ? '' : 'hidden-xs'}`}
class={`mobile-search-visible ${
this.state.showMobileSearch ? "" : "hidden-xs"
}`}
>
{this.renderSearchBox()}
</div>
@ -183,16 +221,20 @@ export default class Header extends Component {
<div
id="mobile-search-btn"
class={`mobile-search-hidden ${
this.state.showMobileSearch ? 'hidden' : 'visible-xs-inline-block'
this.state.showMobileSearch ? "hidden" : "visible-xs-inline-block"
}`}
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-hidden')
.classList.remove('visible-xs-inline-block');
document.querySelector('.mobile-search-hidden').classList.add('hidden');
const input = document.querySelector('.search-box input');
.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");
const input = document.querySelector(".search-box input");
if (input) {
setTimeout(() => {
(input as HTMLInputElement).focus();
@ -204,18 +246,18 @@ export default class Header extends Component {
{Icons.search}
</div>
) : (
''
""
);
}
renderMyProfile() {
const key = Key.getPubKey();
const npub = Key.toNostrBech32Address(key, 'npub');
const npub = Key.toNostrBech32Address(key, "npub");
return (
<Link
activeClassName="active"
href={`/${npub}`}
onClick={() => localState.get('scrollUp').put(true)}
onClick={() => localState.get("scrollUp").put(true)}
class="hidden-xs my-profile"
>
<Identicon str={npub} width={34} />
@ -228,20 +270,22 @@ export default class Header extends Component {
<a
href="/notifications"
class={`notifications-button mobile-search-hidden ${
this.state.showMobileSearch ? 'hidden' : ''
this.state.showMobileSearch ? "hidden" : ""
}`}
>
{this.state.activeRoute === '/notifications' ? (
{this.state.activeRoute === "/notifications" ? (
<HeartIconFull width={28} />
) : (
<HeartIcon width={28} />
)}
{this.state.unseenNotificationCount ? (
<span class="unseen">
{this.state.unseenNotificationCount > 99 ? '' : this.state.unseenNotificationCount}
{this.state.unseenNotificationCount > 99
? ""
: this.state.unseenNotificationCount}
</span>
) : (
''
""
)}
</a>
);
@ -259,11 +303,17 @@ export default class Header extends Component {
renderLoginBtns() {
return (
<div class="login-buttons">
<PrimaryButton small onClick={() => localState.get('showLoginModal').put(true)}>
{t('log_in')}
<PrimaryButton
small
onClick={() => localState.get("showLoginModal").put(true)}
>
{t("log_in")}
</PrimaryButton>
<Button small onClick={() => localState.get('showLoginModal').put(true)}>
{t('sign_up')}
<Button
small
onClick={() => localState.get("showLoginModal").put(true)}
>
{t("sign_up")}
</Button>
</div>
);
@ -276,11 +326,17 @@ export default class Header extends Component {
{!loggedIn && this.renderLogo()}
{this.renderBackButton()}
<div className="header-content">
<div className={`mobile-search-hidden ${this.state.showMobileSearch ? 'hidden-xs' : ''}`}>
<div
className={`mobile-search-hidden ${
this.state.showMobileSearch ? "hidden-xs" : ""
}`}
>
{loggedIn && this.renderMenuIcon()}
</div>
<a
className={`mobile-search-visible ${this.state.showMobileSearch ? '' : 'hidden-xs'}`}
className={`mobile-search-visible ${
this.state.showMobileSearch ? "" : "hidden-xs"
}`}
href=""
onClick={(e) => {
e.preventDefault();
@ -289,7 +345,9 @@ export default class Header extends Component {
>
<span class="visible-xs-inline-block">{Icons.backArrow}</span>
</a>
{loggedIn && this.state.showConnectedRelays && this.renderConnectedRelays()}
{loggedIn &&
this.state.showConnectedRelays &&
this.renderConnectedRelays()}
{this.renderHeaderText()}
{loggedIn && this.renderNotifications()}
{loggedIn && this.renderMyProfile()}

View File

@ -1,13 +1,13 @@
import { sha256 } from '@noble/hashes/sha256';
import Identicon from 'identicon.js';
import styled from 'styled-components';
import { sha256 } from "@noble/hashes/sha256";
import Identicon from "identicon.js";
import styled from "styled-components";
import Component from '../BaseComponent';
import Key from '../nostr/Key';
import { Unsubscribe } from '../nostr/PubSub';
import SocialNetwork from '../nostr/SocialNetwork';
import Component from "../BaseComponent";
import Key from "../nostr/Key";
import { Unsubscribe } from "../nostr/PubSub";
import SocialNetwork from "../nostr/SocialNetwork";
import SafeImg from './SafeImg';
import SafeImg from "./SafeImg";
type Props = {
str: unknown;
@ -45,8 +45,8 @@ class MyIdenticon extends Component<Props, State> {
const hash = sha256(this.props.str as string);
// convert to hex
const hex = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const identicon = new Identicon(hex, {
width: this.props.width,
@ -91,39 +91,45 @@ class MyIdenticon extends Component<Props, State> {
render() {
const width = this.props.width;
const activity =
['online', 'active'].indexOf(this.state.activity ?? '') > -1 ? this.state.activity : '';
["online", "active"].indexOf(this.state.activity ?? "") > -1
? this.state.activity
: "";
const hasPicture =
this.state.picture &&
!this.state.hasError &&
!this.props.hidePicture &&
!SocialNetwork.blockedUsers.has(this.props.str as string);
const hasPictureStyle = hasPicture ? 'has-picture' : '';
const showTooltip = this.props.showTooltip ? 'tooltip' : '';
const hasPictureStyle = hasPicture ? "has-picture" : "";
const showTooltip = this.props.showTooltip ? "tooltip" : "";
return (
<IdenticonContainer
width={width}
onClick={this.props.onClick}
style={{ cursor: this.props.onClick ? 'pointer' : undefined }}
style={{ cursor: this.props.onClick ? "pointer" : undefined }}
className={`identicon-container ${hasPictureStyle} ${showTooltip} ${activity}`}
>
<div class="identicon">
{hasPicture ? (
<SafeImg
src={this.state.picture}
src={this.state.picture as string}
width={width}
square={true}
style={{ objectFit: 'cover' }}
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 ? (
<span class="tooltiptext">{this.state.name}</span>
) : (
''
""
)}
</IdenticonContainer>
);

View File

@ -1,17 +1,17 @@
import $ from 'jquery';
import $ from "jquery";
import Icons from '../Icons';
import Icons from "../Icons";
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;
const l = $(target).val();
if (AVAILABLE_LANGUAGE_KEYS.indexOf(l as string) >= 0) {
localStorage.setItem('language', l as string);
localStorage.setItem("language", l as string);
location.reload();
}
}
@ -19,7 +19,11 @@ function onLanguageChange(e: Event): void {
const LanguageSelector = () => (
<>
{Icons.language}
<select className="language-selector" onChange={(e) => onLanguageChange(e)} value={language}>
<select
className="language-selector"
onChange={(e) => onLanguageChange(e)}
value={language}
>
{Object.keys(AVAILABLE_LANGUAGES).map((l) => (
<option value={l}>{AVAILABLE_LANGUAGES[l]}</option>
))}

View File

@ -1,11 +1,12 @@
import { Component } from 'preact';
import { Component } from "preact";
import Icons from '../Icons';
import localState from '../LocalState';
import Icons from "../Icons";
import localState from "../LocalState";
const isOfType = (f: string, types: string[]): boolean => types.indexOf(f.slice(-4)) !== -1;
const isOfType = (f: string, types: string[]): boolean =>
types.indexOf(f.slice(-4)) !== -1;
const isImage = (f: { name: string }): boolean =>
isOfType(f.name, ['.jpg', 'jpeg', '.gif', '.png']);
isOfType(f.name, [".jpg", "jpeg", ".gif", ".png"]);
interface State {
torrentId?: string;
@ -19,7 +20,7 @@ class MediaPlayer extends Component<Record<string, never>, State> {
private torrent?: any;
componentDidMount() {
localState.get('player').on((player: any) => {
localState.get("player").on((player: any) => {
const torrentId = player && player.torrentId;
const filePath = player && player.filePath;
if (torrentId !== this.torrentId) {
@ -28,26 +29,26 @@ class MediaPlayer extends Component<Record<string, never>, State> {
this.setState({
torrentId,
isOpen: !!player,
splitPath: filePath && filePath.split('/'),
splitPath: filePath && filePath.split("/"),
});
if (torrentId) {
this.startTorrenting();
}
} else if (filePath && filePath !== this.filePath) {
this.filePath = filePath;
this.setState({ splitPath: filePath && filePath.split('/') });
this.setState({ splitPath: filePath && filePath.split("/") });
this.openFile();
}
});
localState
.get('player')
.get('paused')
.get("player")
.get("paused")
.on((p: boolean) => this.setPaused(p));
}
setPaused(paused: boolean) {
const el = this.base as HTMLElement;
const audio = el.querySelector('audio');
const audio = el.querySelector("audio");
if (audio) {
paused ? audio.pause() : audio.play();
}
@ -57,12 +58,14 @@ class MediaPlayer extends Component<Record<string, never>, State> {
this.torrent = torrent;
const img = torrent.files.find((f: any) => isImage(f));
let poster = torrent.files.find(
(f: any) => isImage(f) && (f.name.indexOf('cover') > -1 || f.name.indexOf('poster') > -1),
(f: any) =>
isImage(f) &&
(f.name.indexOf("cover") > -1 || f.name.indexOf("poster") > -1)
);
poster = poster || img;
const el = this.base as HTMLElement;
const cover = el.querySelector('.cover') as HTMLElement;
cover.innerHTML = '';
const cover = el.querySelector(".cover") as HTMLElement;
cover.innerHTML = "";
if (poster) {
// Assuming poster.appendTo is appending the image element to the cover element
cover.appendChild(poster.appendTo());
@ -73,52 +76,60 @@ class MediaPlayer extends Component<Record<string, never>, State> {
openFile() {
if (this.torrent) {
const file = this.torrent.files.find((f: any) => f.path === this.filePath);
const file = this.torrent.files.find(
(f: any) => f.path === this.filePath
);
const el = this.base as HTMLElement;
const player = el.querySelector('.player') as HTMLElement;
player.innerHTML = '';
const player = el.querySelector(".player") as HTMLElement;
player.innerHTML = "";
if (file) {
// Assuming file.appendTo is appending the media element to the player element
player.appendChild(file.appendTo({ autoplay: true, muted: false }));
}
const audio = player.querySelector('audio');
const audio = player.querySelector("audio");
if (audio) {
audio.onpause = audio.onplay = (e: Event) => {
const target = e.target as HTMLAudioElement;
localState.get('player').get('paused').put(!!target.paused);
localState.get("player").get("paused").put(!!target.paused);
};
}
}
}
async startTorrenting() {
const { default: AetherTorrent } = await import('aether-torrent');
const { default: AetherTorrent } = await import("aether-torrent");
const client = new AetherTorrent();
const existing = client.get(this.torrentId);
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));
}
}
closeClicked() {
this.setPaused(true);
localState.get('player').put(null);
localState.get("player").put(null);
}
render() {
const s = this.state;
return (
<>
<div className="media-player" style={{ display: s.isOpen ? '' : 'none' }}>
<div
className="media-player"
style={{ display: s.isOpen ? "" : "none" }}
>
<div className="player"></div>
<div className="cover"></div>
<a href={`/torrent/${encodeURIComponent(this.state.torrentId ?? '')}`} className="info">
<a
href={`/torrent/${encodeURIComponent(this.state.torrentId ?? "")}`}
className="info"
>
{s.splitPath
? s.splitPath.map((str, i) => {
if (i === s.splitPath.length - 1) {
str = str.split('.').slice(0, -1).join('.');
if (i === (s.splitPath?.length || 0) - 1) {
str = str.split(".").slice(0, -1).join(".");
return (
<p>
<b>{str}</b>
@ -127,7 +138,7 @@ class MediaPlayer extends Component<Record<string, never>, State> {
}
return <p>{str}</p>;
})
: ''}
: ""}
</a>
<div className="close" onClick={() => this.closeClicked()}>
{Icons.close}

View File

@ -3,34 +3,44 @@ import {
HomeIcon,
InformationCircleIcon,
PaperAirplaneIcon,
} from '@heroicons/react/24/outline';
} from "@heroicons/react/24/outline";
import {
Cog8ToothIcon as Cog8ToothIconFull,
HomeIcon as HomeIconFull,
InformationCircleIcon as InformationCircleIconFull,
PaperAirplaneIcon as PaperAirplaneIconFull,
} from '@heroicons/react/24/solid';
import { route } from 'preact-router';
} from "@heroicons/react/24/solid";
import { route } from "preact-router";
import logo from '../../assets/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 logo from "../../assets/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.mjs";
import { Button, PrimaryButton } from './buttons/Button';
import Modal from './modal/Modal';
import QRModal from './modal/QRModal';
import PublicMessageForm from './PublicMessageForm';
import { Button, PrimaryButton } from "./buttons/Button";
import Modal from "./modal/Modal";
import QRModal from "./modal/QRModal";
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: "/", text: "home", icon: HomeIcon, activeIcon: HomeIconFull },
{
url: '/about',
text: 'about',
url: "/chat",
text: "messages",
icon: PaperAirplaneIcon,
activeIcon: PaperAirplaneIconFull,
},
{
url: "/settings",
text: "settings",
icon: Cog8ToothIcon,
activeIcon: Cog8ToothIconFull,
},
{
url: "/about",
text: "about",
icon: InformationCircleIcon,
activeIcon: InformationCircleIconFull,
},
@ -39,34 +49,34 @@ const APPLICATIONS = [
export default class Menu extends BaseComponent {
state = {
unseenMsgsTotal: 0,
activeRoute: '',
activeRoute: "",
showBetaFeatures: false,
showNewPostModal: false,
showQrModal: false,
};
componentDidMount() {
localState.get('unseenMsgsTotal').on(this.inject());
localState.get('activeRoute').on(this.inject());
localState.get("unseenMsgsTotal").on(this.inject());
localState.get("activeRoute").on(this.inject());
}
menuLinkClicked = (e, a?, openFeed = false) => {
if (a?.text === 'home' || openFeed) {
if (a?.text === "home" || openFeed) {
this.openFeedClicked(e);
}
localState.get('toggleMenu').put(false);
localState.get('scrollUp').put(true);
localState.get("toggleMenu").put(false);
localState.get("scrollUp").put(true);
};
openFeedClicked = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
localState.get('lastOpenedFeed').once((lastOpenedFeed: string) => {
if (lastOpenedFeed !== this.state.activeRoute.replace('/', '')) {
route('/' + (lastOpenedFeed || ''));
localState.get("lastOpenedFeed").once((lastOpenedFeed: string) => {
if (lastOpenedFeed !== this.state.activeRoute.replace("/", "")) {
route("/" + (lastOpenedFeed || ""));
} else {
localState.get('lastOpenedFeed').put('');
route('/');
localState.get("lastOpenedFeed").put("");
route("/");
}
});
};
@ -80,19 +90,22 @@ export default class Menu extends BaseComponent {
>
<PublicMessageForm
onSubmit={() => this.setState({ showNewPostModal: false })}
placeholder={t('whats_on_your_mind')}
placeholder={t("whats_on_your_mind")}
autofocus={true}
/>
</Modal>
) : (
''
""
);
renderQrModal = () =>
this.state.showQrModal ? (
<QRModal pub={Key.getPubKey()} onClose={() => this.setState({ showQrModal: false })} />
<QRModal
pub={Key.getPubKey()}
onClose={() => this.setState({ showQrModal: false })}
/>
) : (
''
""
);
render() {
@ -110,21 +123,23 @@ export default class Menu extends BaseComponent {
{APPLICATIONS.map((a: any) => {
if (a.url && (!a.beta || this.state.showBetaFeatures)) {
let isActive = this.state.activeRoute.startsWith(a.url);
if (a.url === '/') {
if (a.url === "/") {
isActive = this.state.activeRoute.length <= 1;
}
const Icon = isActive ? a.activeIcon : a.icon;
return (
<a
onClick={(e) => this.menuLinkClicked(e, a)}
className={isActive ? 'active' : ''}
className={isActive ? "active" : ""}
href={a.url}
>
<span class="icon">
{a.text === 'messages' && this.state.unseenMsgsTotal ? (
<span class="unseen unseen-total">{this.state.unseenMsgsTotal}</span>
{a.text === "messages" && this.state.unseenMsgsTotal ? (
<span class="unseen unseen-total">
{this.state.unseenMsgsTotal}
</span>
) : (
''
""
)}
<Icon width={24} />
</span>
@ -135,11 +150,17 @@ export default class Menu extends BaseComponent {
})}
<div class="menu-new-post">
<PrimaryButton
onClick={() => this.setState({ showNewPostModal: !this.state.showNewPostModal })}
onClick={() =>
this.setState({ showNewPostModal: !this.state.showNewPostModal })
}
>
<span class="icon">{Icons.post}</span>
</PrimaryButton>
<Button onClick={() => this.setState({ showQrModal: !this.state.showQrModal })}>
<Button
onClick={() =>
this.setState({ showQrModal: !this.state.showQrModal })
}
>
<span class="icon">{Icons.QRcode}</span>
</Button>
{this.renderNewPostModal()} {this.renderQrModal()}

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

@ -1,10 +1,10 @@
import { memo, useEffect, useState } from 'react';
import { memo, useEffect, useState } from "react";
import AnimalName from '../AnimalName';
import Key from '../nostr/Key';
import SocialNetwork from '../nostr/SocialNetwork';
import AnimalName from "../AnimalName";
import Key from "../nostr/Key";
import SocialNetwork from "../nostr/SocialNetwork";
import Badge from './Badge';
import Badge from "./Badge";
type Props = {
pub: string;
@ -14,21 +14,23 @@ type Props = {
const Name = (props: Props) => {
if (!props.pub) {
console.error('Name component requires a pub', props);
console.error("Name component requires a pub", props);
return null;
}
const nostrAddr = Key.toNostrHexAddress(props.pub);
let initialName = '';
const nostrAddr = Key.toNostrHexAddress(props.pub) || "";
let initialName = "";
let initialDisplayName;
let isGenerated = false;
const profile = SocialNetwork.profiles.get(nostrAddr);
// should we change SocialNetwork.getProfile() and use it here?
if (profile) {
initialName = profile.name?.trim().slice(0, 100) || '';
initialName = profile.name?.trim().slice(0, 100) || "";
initialDisplayName = profile.display_name?.trim().slice(0, 100);
}
if (!initialName) {
initialName = AnimalName(Key.toNostrBech32Address(props.pub, 'npub') || props.pub);
initialName = AnimalName(
Key.toNostrBech32Address(props.pub, "npub") || props.pub
);
isGenerated = true;
}
const [name, setName] = useState(initialName);
@ -39,9 +41,11 @@ const Name = (props: Props) => {
// return Unsubscribe function so it unsubs on unmount
return SocialNetwork.getProfile(nostrAddr, (profile) => {
if (profile) {
setName(profile.name?.trim().slice(0, 100) || '');
setDisplayName(profile.display_name?.trim().slice(0, 100) || '');
setIsNameGenerated(profile.name || profile.display_name ? false : true);
setName(profile.name?.trim().slice(0, 100) || "");
setDisplayName(profile.display_name?.trim().slice(0, 100) || "");
setIsNameGenerated(
profile.name || profile.display_name ? false : true
);
}
});
}
@ -49,10 +53,10 @@ const Name = (props: Props) => {
return (
<>
<span className={`display-name ${isNameGenerated ? 'generated' : ''}`}>
<span className={`display-name ${isNameGenerated ? "generated" : ""}`}>
{name || displayName || props.placeholder}
</span>
{props.hideBadge ? '' : <Badge pub={props.pub} />}
{props.hideBadge ? "" : <Badge pub={props.pub} />}
</>
);
};

View File

@ -1,46 +1,55 @@
import { XMarkIcon } from '@heroicons/react/24/solid';
import { route } from 'preact-router';
import styled from 'styled-components';
import { XMarkIcon } from "@heroicons/react/24/solid";
import { route } from "preact-router";
import styled from "styled-components";
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 Component from "../BaseComponent";
import Helpers from "../Helpers";
import localState from "../LocalState";
import Key from "../nostr/Key";
import { translate as t } from "../translations/Translation.mjs";
import { Button, PrimaryButton } from './buttons/Button';
import Copy from './buttons/Copy';
import Follow from './buttons/Follow';
import QRModal from './modal/QRModal';
import Identicon from './Identicon';
import Name from './Name';
import { Button, PrimaryButton } from "./buttons/Button";
import Copy from "./buttons/Copy";
import Follow from "./buttons/Follow";
import QRModal from "./modal/QRModal";
import Identicon from "./Identicon";
import Name from "./Name";
const SUGGESTED_FOLLOWS = [
[
'npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9',
"npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9",
'"I used to work for the government. Now I work for the public."',
], // snowden
['npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m', 'Former CEO of Twitter'], // jack
[
'npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a',
"npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m",
"Former CEO of Twitter",
], // jack
[
"npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a",
'"Fundamental investing with a global macro overlay"',
], // Lyn Alden
[
'npub15dqlghlewk84wz3pkqqvzl2w2w36f97g89ljds8x6c094nlu02vqjllm5m',
'MicroStrategy Founder & Chairman',
"npub15dqlghlewk84wz3pkqqvzl2w2w36f97g89ljds8x6c094nlu02vqjllm5m",
"MicroStrategy Founder & Chairman",
], // saylor
['npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk', 'iris.to developer'], // sirius
['npub1z4m7gkva6yxgvdyclc7zp0vz4ta0s2d9jh8g83w03tp5vdf3kzdsxana6p', 'Digital artist'], // yegorpetrov
[
'npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8',
'Bitcoin hardware entrepreneur and podcaster',
"npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk",
"iris.to developer",
], // sirius
[
"npub1z4m7gkva6yxgvdyclc7zp0vz4ta0s2d9jh8g83w03tp5vdf3kzdsxana6p",
"Digital artist",
], // yegorpetrov
[
"npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8",
"Bitcoin hardware entrepreneur and podcaster",
], // nvk
[
'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6',
'Original developer of Nostr',
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
"Original developer of Nostr",
], // fiatjaf
[
'npub1hu3hdctm5nkzd8gslnyedfr5ddz3z547jqcl5j88g4fame2jd08qh6h8nh',
"npub1hu3hdctm5nkzd8gslnyedfr5ddz3z547jqcl5j88g4fame2jd08qh6h8nh",
'"Lover of memes, maker of videos"',
], // carla
];
@ -61,17 +70,17 @@ const CloseIconWrapper = styled.div`
export default class OnboardingNotification extends Component {
componentDidMount() {
localState.get('noFollowers').on(this.inject());
localState.get('hasNostrFollowers').on(this.inject());
localState.get('showFollowSuggestions').on(this.inject());
localState.get('showNoIrisToAddress').on(this.inject());
localState.get('existingIrisToAddress').on(this.inject());
localState.get("noFollowers").on(this.inject());
localState.get("hasNostrFollowers").on(this.inject());
localState.get("showFollowSuggestions").on(this.inject());
localState.get("showNoIrisToAddress").on(this.inject());
localState.get("existingIrisToAddress").on(this.inject());
}
renderFollowSuggestions() {
return (
<div style="display:flex;flex-direction:column;flex:1">
<p>{t('follow_someone_info')}</p>
<p>{t("follow_someone_info")}</p>
{SUGGESTED_FOLLOWS.map(([pub, description]) => (
<div class="profile-link-container">
<a href={`/${pub}`} className="profile-link">
@ -88,15 +97,17 @@ export default class OnboardingNotification extends Component {
</div>
))}
<p>
<PrimaryButton onClick={() => localState.get('showFollowSuggestions').put(false)}>
{t('done')}
<PrimaryButton
onClick={() => localState.get("showFollowSuggestions").put(false)}
>
{t("done")}
</PrimaryButton>
</p>
<p>
{t('alternatively')}
{t("alternatively")}
<i> </i>
<a href={`/${Key.toNostrBech32Address(Key.getPubKey(), 'npub')}`}>
{t('give_your_profile_link_to_someone')}
<a href={`/${Key.toNostrBech32Address(Key.getPubKey(), "npub")}`}>
{t("give_your_profile_link_to_someone")}
</a>
.
</p>
@ -107,17 +118,24 @@ export default class OnboardingNotification extends Component {
renderNoFollowers() {
return (
<NoFollowersWrapper>
<CloseIconWrapper onClick={() => localState.get('noFollowers').put(false)}>
<CloseIconWrapper
onClick={() => localState.get("noFollowers").put(false)}
>
<XMarkIcon width={24} />
</CloseIconWrapper>
<p>{t('no_followers_yet')}</p>
<p>{t("no_followers_yet")}</p>
<p>
<Copy text={t('copy_link')} copyStr={Helpers.getMyProfileLink()} />
<Button onClick={() => this.setState({ showQrModal: true })}>{t('show_qr_code')}</Button>
<Copy text={t("copy_link")} copyStr={Helpers.getMyProfileLink()} />
<Button onClick={() => this.setState({ showQrModal: true })}>
{t("show_qr_code")}
</Button>
</p>
<small>{t('no_followers_yet_info')}</small>
<small>{t("no_followers_yet_info")}</small>
{this.state.showQrModal && (
<QRModal onClose={() => this.setState({ showQrModal: false })} pub={Key.getPubKey()} />
<QRModal
onClose={() => this.setState({ showQrModal: false })}
pub={Key.getPubKey()}
/>
)}
</NoFollowersWrapper>
);
@ -129,10 +147,12 @@ export default class OnboardingNotification extends Component {
<div>
<p>Get your own iris.to/username?</p>
<p>
<PrimaryButton onClick={() => route('/settings/iris_account')}>
<PrimaryButton onClick={() => route("/settings/iris_account")}>
Yes please
</PrimaryButton>
<PrimaryButton onClick={() => localState.get('showNoIrisToAddress').put(false)}>
<PrimaryButton
onClick={() => localState.get("showNoIrisToAddress").put(false)}
>
No thanks
</PrimaryButton>
</p>
@ -158,6 +178,6 @@ export default class OnboardingNotification extends Component {
</div>
);
}
return '';
return "";
}
}

View File

@ -1,12 +1,12 @@
import $ from 'jquery';
import { useEffect, useState } from 'preact/hooks';
import { route } from 'preact-router';
import $ from "jquery";
import { useEffect, useState } from "preact/hooks";
import { route } from "preact-router";
import Helpers from '../Helpers';
import Key from '../nostr/Key';
import Helpers from "../Helpers";
import Key from "../nostr/Key";
import Name from './Name';
import Torrent from './Torrent';
import Name from "./Name";
import Torrent from "./Torrent";
const seenIndicator = (
<span class="seen-indicator">
@ -25,14 +25,14 @@ const seenIndicator = (
);
const PrivateMessage = (props) => {
const [text, setText] = useState('');
const [text, setText] = useState("");
useEffect(() => {
$('a').click((e) => {
const href = $(e.target).attr('href');
if (href && href.indexOf('https://iris.to/') === 0) {
$("a").click((e) => {
const href = $(e.target).attr("href");
if (href && href.indexOf("https://iris.to/") === 0) {
e.preventDefault();
route(href.replace('https://iris.to/', ''));
route(href.replace("https://iris.to/", ""));
}
});
Key.decryptMessage(props.id, (decryptedText) => {
@ -41,20 +41,22 @@ const PrivateMessage = (props) => {
}, [props.id]);
const onNameClick = () => {
route(`/${Key.toNostrBech32Address(props.pubkey, 'npub')}`);
route(`/${Key.toNostrBech32Address(props.pubkey, "npub")}`);
};
const emojiOnly = text && text.length === 2 && Helpers.isEmoji(text);
const formattedText = Helpers.highlightEverything(text || '');
const formattedText = Helpers.highlightEverything(text || "");
// TODO opts.onImageClick show image in modal
const time =
typeof props.created_at === 'object' ? props.created_at : new Date(props.created_at * 1000);
typeof props.created_at === "object"
? props.created_at
: new Date(props.created_at * 1000);
const status: any = ''; // this.getSeenStatus();
const seen = status.seen ? 'seen' : '';
const delivered = status.delivered ? 'delivered' : '';
const whose = props.selfAuthored ? 'our' : 'their';
const status: any = ""; // this.getSeenStatus();
const seen = status.seen ? "seen" : "";
const delivered = status.delivered ? "delivered" : "";
const whose = props.selfAuthored ? "our" : "their";
return (
<div className={`msg ${whose} ${seen} ${delivered}`}>
@ -67,10 +69,12 @@ const PrivateMessage = (props) => {
)}
</div>
{props.torrentId && <Torrent torrentId={props.torrentId} />}
<div class={`text ${emojiOnly && 'emoji-only'}`}>{formattedText}</div>
<div class={`text ${emojiOnly && "emoji-only"}`}>{formattedText}</div>
<div class="below-text">
<div class="time">
{props.id ? Helpers.getRelativeTimeText(time) : Helpers.formatTime(time)}
{props.id
? Helpers.getRelativeTimeText(time)
: Helpers.formatTime(time)}
{props.selfAuthored && seenIndicator}
</div>
</div>

View File

@ -1,7 +1,7 @@
import { useState } from 'preact/hooks';
import { useState } from "preact/hooks";
import Modal from './modal/Modal';
import SafeImg from './SafeImg';
import Modal from "./modal/Modal";
import SafeImg from "./SafeImg";
type Props = { picture?: string; onError?: () => void };
@ -16,9 +16,18 @@ const ProfilePicture = ({ picture, onError }: Props) => {
setShowModal(false);
};
if (!picture) {
return null;
}
return (
<>
<SafeImg class="profile-picture" src={picture} onError={onError} onClick={handleClick} />
<SafeImg
class="profile-picture"
src={picture}
onError={onError}
onClick={handleClick}
/>
{showModal && (
<Modal centerVertically={true} onClose={handleClose}>
<SafeImg src={picture} onError={onError} />

View File

@ -1,31 +1,46 @@
import { PaperAirplaneIcon } from '@heroicons/react/24/outline';
import { html } from 'htm/preact';
import $ from 'jquery';
import { createRef } from 'preact';
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 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 Component from "../BaseComponent";
import Helpers from "../Helpers";
import Icons from "../Icons";
import localState from "../LocalState";
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';
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
@ -34,14 +49,17 @@ class PublicMessageForm extends MessageForm {
}
if (!this.props.replyingTo) {
localState
.get('channels')
.get('public')
.get('msgDraft')
.get("channels")
.get("public")
.get("msgDraft")
.once((t) => !textEl.val() && textEl.val(t));
} else {
const currentHistoryState = window.history.state;
if (currentHistoryState && currentHistoryState['replyTo' + this.props.replyingTo]) {
textEl.val(currentHistoryState['replyTo' + this.props.replyingTo]);
if (
currentHistoryState &&
currentHistoryState["replyTo" + this.props.replyingTo]
) {
textEl.val(currentHistoryState["replyTo" + this.props.replyingTo]);
}
}
}
@ -53,14 +71,14 @@ class PublicMessageForm extends MessageForm {
async submit() {
if (!this.props.replyingTo) {
localState.get('channels').get('public').get('msgDraft').put(null);
localState.get("channels").get("public").get("msgDraft").put(null);
}
const textEl = $(this.newMsgRef.current);
const text = textEl.val();
if (!text.length) {
return;
}
const msg = { text };
const msg: any = { text };
if (this.props.replyingTo) {
msg.replyingTo = this.props.replyingTo;
}
@ -69,28 +87,24 @@ class PublicMessageForm extends MessageForm {
}
await this.sendNostr(msg);
this.props.onSubmit && this.props.onSubmit(msg);
this.setState({ attachments: null, torrentId: null });
textEl.val('');
textEl.height('');
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.style.height = `${textarea.scrollHeight}px`;
}
onMsgTextPaste(event) {
const pasted = (event.clipboardData || window.clipboardData).getData('text');
const pasted = (event.clipboardData || window.clipboardData).getData(
"text"
);
const magnetRegex = /(magnet:\?xt=urn:btih:.*)/gi;
const match = magnetRegex.exec(pasted);
console.log('magnet match', match);
console.log("magnet match", match);
if (match) {
this.setState({ torrentId: match[0] });
}
@ -103,7 +117,7 @@ class PublicMessageForm extends MessageForm {
}
onKeyDown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
this.submit();
}
}
@ -114,14 +128,18 @@ class PublicMessageForm extends MessageForm {
const newHistoryState = {
...currentHistoryState,
};
newHistoryState['replyTo' + this.props.replyingTo] = text;
window.history.replaceState(newHistoryState, '');
newHistoryState["replyTo" + this.props.replyingTo] = text;
window.history.replaceState(newHistoryState, "");
}
onMsgTextInput(event) {
this.setTextareaHeight(event.target);
if (!this.props.replyingTo) {
localState.get('channels').get('public').get('msgDraft').put($(event.target).val());
localState
.get("channels")
.get("public")
.get("msgDraft")
.put($(event.target).val());
}
this.checkMention(event);
this.saveDraftToHistory();
@ -130,15 +148,15 @@ class PublicMessageForm extends MessageForm {
attachFileClicked(event) {
event.stopPropagation();
event.preventDefault();
$(this.base).find('.attachment-input').click();
$(this.base).find(".attachment-input").click();
}
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();
formData.append('fileToUpload', files[i]);
const formData = new FormData();
formData.append("fileToUpload", files[i]);
const a = this.state.attachments || [];
a[i] = a[i] || {
@ -150,28 +168,28 @@ class PublicMessageForm extends MessageForm {
this.setState({ attachments: a });
});
fetch('https://nostr.build/api/upload/iris.php', {
method: 'POST',
fetch("https://nostr.build/api/upload/iris.php", {
method: "POST",
body: formData,
})
.then(async (response) => {
const url = await response.json();
console.log('upload response', url);
console.log("upload response", url);
if (url) {
a[i].url = url;
this.setState({ attachments: a });
const textEl = $(this.newMsgRef.current);
const currentVal = textEl.val();
if (currentVal) {
textEl.val(currentVal + '\n\n' + url);
textEl.val(currentVal + "\n\n" + url);
} else {
textEl.val(url);
}
}
})
.catch((error) => {
console.error('upload error', error);
a[i].error = 'upload failed';
console.error("upload error", error);
a[i].error = "upload failed";
this.setState({ attachments: a });
});
}
@ -181,13 +199,13 @@ class PublicMessageForm extends MessageForm {
}
onSelectMention(item) {
const textarea = $(this.base).find('textarea').get(0);
const textarea = $(this.base).find("textarea").get(0);
const pos = textarea.selectionStart;
const join = [
textarea.value.slice(0, pos).replace(mentionRegex, 'nostr:'),
textarea.value.slice(0, pos).replace(mentionRegex, "nostr:"),
item.key,
textarea.value.slice(pos),
].join('');
].join("");
textarea.value = `${join} `;
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = pos + item.key.length;
@ -196,10 +214,12 @@ class PublicMessageForm extends MessageForm {
render() {
const textareaPlaceholder =
this.props.placeholder ||
(this.props.index === 'media' ? 'type_a_message_or_paste_a_magnet_link' : 'type_a_message');
(this.props.index === "media"
? "type_a_message_or_paste_a_magnet_link"
: "type_a_message");
return html`<form
autocomplete="off"
class="message-form ${this.props.class || ''} public"
class="message-form ${this.props.class || ""} public"
onSubmit=${(e) => this.onMsgFormSubmit(e)}
>
<input
@ -242,9 +262,9 @@ class PublicMessageForm extends MessageForm {
onSelect=${(item) => this.onSelectMention(item)}
/>
`
: ''}
: ""}
${this.props.waitForFocus && !this.state.focused
? ''
? ""
: html`
<div>
<button
@ -254,15 +274,8 @@ 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>
<span>${t("post")} </span>
<${PaperAirplaneIcon} width="24" style="margin-top:5px" />
</button>
</div>
@ -270,8 +283,10 @@ class PublicMessageForm extends MessageForm {
<div class="attachment-preview">
${this.state.torrentId
? html` <${Torrent} preview=${true} torrentId=${this.state.torrentId} /> `
: ''}
? html`
<${Torrent} preview=${true} torrentId=${this.state.torrentId} />
`
: ""}
${this.state.attachments && this.state.attachments.length
? html`
<p>
@ -279,21 +294,21 @@ class PublicMessageForm extends MessageForm {
href=""
onClick=${(e) => {
e.preventDefault();
this.setState({ attachments: null });
this.setState({ attachments: undefined });
}}
>${t('remove_attachment')}</a
>${t("remove_attachment")}</a
>
</p>
`
: ''}
: ""}
${this.state.attachments &&
this.state.attachments.map((a) => {
const status = html` ${a.error
? html`<span class="error">${a.error}</span>`
: a.url || 'uploading...'}`;
: a.url || "uploading..."}`;
// if a.url matches audio regex
if (a.type?.startsWith('audio')) {
if (a.type?.startsWith("audio")) {
return html`
${status}
<audio controls>
@ -302,7 +317,7 @@ class PublicMessageForm extends MessageForm {
`;
}
// if a.url matches video regex
if (a.type?.startsWith('video')) {
if (a.type?.startsWith("video")) {
return html`
${status}
<video controls loop=${true} autoplay=${true} muted=${true}>
@ -312,15 +327,105 @@ class PublicMessageForm extends MessageForm {
}
// image regex
if (a.type?.startsWith('image')) {
if (a.type?.startsWith("image")) {
return html`${status} <${SafeImg} src=${a.data} /> `;
}
return 'unknown attachment type';
return "unknown attachment type";
})}
</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

@ -1,7 +1,7 @@
import { createRef } from 'preact';
import { useEffect } from 'preact/hooks';
import { createRef } from "preact";
import { useEffect } from "preact/hooks";
import QRCode from '../lib/qrcode.min';
import QRCode from "../lib/qrcode.min";
export default function Qr(props) {
const ref = createRef();
@ -15,11 +15,13 @@ export default function Qr(props) {
text: props.data,
width: props.width || 300,
height: props.width || 300,
colorDark: '#000000',
colorLight: '#ffffff',
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.H,
});
}, [props.data]);
return <div style="border: 5px solid white; display: inline-block;" ref={ref} />;
return (
<div style="border: 5px solid white; display: inline-block;" ref={ref} />
);
}

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from "react";
type Props = {
src: string;
@ -13,10 +13,10 @@ type Props = {
// need to have trailing slash, otherwise you could do https://imgur.com.myevilwebsite.com/image.png
const safeOrigins = [
'data:image',
'https://imgur.com/',
'https://i.imgur.com/',
'https://imgproxy.iris.to/',
"data:image",
"https://imgur.com/",
"https://i.imgur.com/",
"https://imgproxy.iris.to/",
];
export const isSafeOrigin = (url: string) => {
@ -29,14 +29,14 @@ const SafeImg = (props: Props) => {
let proxyFailed = false;
if (
props.src &&
!props.src.startsWith('data:image') &&
!props.src.startsWith("data:image") &&
(!isSafeOrigin(props.src) || props.width)
) {
// free proxy with a 250 images per 10 min limit? https://images.weserv.nl/docs/
const originalSrc = props.src;
if (props.width) {
const width = props.width * 2;
const resizeType = props.square ? 'fill' : 'fit';
const resizeType = props.square ? "fill" : "fit";
mySrc = `https://imgproxy.iris.to/insecure/rs:${resizeType}:${width}:${width}/plain/${originalSrc}`;
} else {
mySrc = `https://imgproxy.iris.to/insecure/plain/${originalSrc}`;
@ -45,10 +45,15 @@ const SafeImg = (props: Props) => {
// try without proxy if it fails
onError = () => {
if (proxyFailed) {
console.log('original source failed too', originalSrc);
console.log("original source failed too", originalSrc);
originalOnError && originalOnError();
} else {
console.log('image proxy failed', mySrc, 'trying original source', originalSrc);
console.log(
"image proxy failed",
mySrc,
"trying original source",
originalSrc
);
proxyFailed = true;
setSrc(originalSrc);
}

View File

@ -1,23 +1,23 @@
import $ from 'jquery';
import { debounce } from 'lodash';
import isEqual from 'lodash/isEqual';
import { route } from 'preact-router';
import $ from "jquery";
import { debounce } from "lodash";
import isEqual from "lodash/isEqual";
import { route } from "preact-router";
import Component from '../BaseComponent';
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 Component from "../BaseComponent";
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.mjs";
import Identicon from './Identicon';
import Name from './Name';
import SafeImg from './SafeImg';
import Identicon from "./Identicon";
import Name from "./Name";
import SafeImg from "./SafeImg";
const RESULTS_MAX = 5;
type Props = {
onSelect?: (result: Pick<ResultItem, 'key'>) => void;
onSelect?: (result: Pick<ResultItem, "key">) => void;
query?: string;
focus?: boolean;
resultsOnly?: boolean;
@ -51,7 +51,7 @@ class SearchBox extends Component<Props, State> {
super();
this.state = {
results: [],
query: '',
query: "",
showFollowSuggestions: true,
offsetLeft: 0,
selected: -1, // -1 - 'search by keyword'
@ -74,29 +74,29 @@ class SearchBox extends Component<Props, State> {
}
close() {
$(this.base).find('input').val('');
this.setState({ results: [], query: '' });
$(this.base).find("input").val("");
this.setState({ results: [], query: "" });
}
componentDidMount() {
localState.get('showFollowSuggestions').on(this.inject());
localState.get('searchIndexUpdated').on(this.sub(() => this.search()));
localState.get('activeRoute').on(
localState.get("showFollowSuggestions").on(this.inject());
localState.get("searchIndexUpdated").on(this.sub(() => this.search()));
localState.get("activeRoute").on(
this.sub(() => {
this.close();
}),
})
);
this.adjustResultsPosition();
this.search();
$(document)
.off('keydown')
.on('keydown', (e) => {
if (e.key === 'Tab' && document.activeElement.tagName === 'BODY') {
.off("keydown")
.on("keydown", (e) => {
if (e.key === "Tab" && document.activeElement?.tagName === "BODY") {
e.preventDefault();
$(this.base).find('input').focus();
} else if (e.key === 'Escape') {
$(this.base).find("input").focus();
} else if (e.key === "Escape") {
this.close();
$(this.base).find('input').blur();
$(this.base).find("input").blur();
}
});
}
@ -104,7 +104,7 @@ class SearchBox extends Component<Props, State> {
componentDidUpdate(prevProps, prevState) {
this.adjustResultsPosition();
if (prevProps.focus !== this.props.focus) {
$(this.base).find('input:visible').focus();
$(this.base).find("input:visible").focus();
}
if (prevProps.query !== this.props.query) {
this.search();
@ -114,7 +114,7 @@ class SearchBox extends Component<Props, State> {
this.state.selected >= 0 &&
!isEqual(
this.state.results.slice(0, this.state.selected + 1),
prevState.results.slice(0, this.state.selected + 1),
prevState.results.slice(0, this.state.selected + 1)
)
) {
this.setState({ selected: -1 });
@ -123,11 +123,11 @@ class SearchBox extends Component<Props, State> {
// remove keyup listener on unmount
componentWillUnmount() {
$(document).off('keyup');
$(document).off("keyup");
}
adjustResultsPosition() {
const input = $(this.base).find('input');
const input = $(this.base).find("input");
if (input.length) {
this.setState({ offsetLeft: input[0].offsetLeft });
}
@ -135,11 +135,11 @@ class SearchBox extends Component<Props, State> {
onSubmit(e) {
e.preventDefault();
const el = $(this.base).find('input');
el.val('');
el.trigger('blur');
const el = $(this.base).find("input");
el.val("");
el.trigger("blur");
// TODO go to first result
const selected = $(this.base).find('.result.selected');
const selected = $(this.base).find(".result.selected");
if (selected.length) {
selected[0].click();
}
@ -164,14 +164,17 @@ class SearchBox extends Component<Props, State> {
}, 500);
search() {
let query = this.props.query || ($(this.base).find('input').first().val() as string) || '';
let query =
this.props.query ||
($(this.base).find("input").first().val() as string) ||
"";
query = query.toString().trim().toLowerCase();
if (!query) {
this.close();
return;
}
if (query.match(/nsec1[a-zA-Z0-9]{30,65}/gi)) {
$(this.base).find('input').first().val('');
$(this.base).find("input").first().val("");
return;
}
@ -182,29 +185,32 @@ class SearchBox extends Component<Props, State> {
// if query hasn't changed since we started the request
if (
pubKey &&
query === String(this.props.query || $(this.base).find('input').first().val())
query ===
String(
this.props.query || $(this.base).find("input").first().val()
)
) {
this.props.onSelect({ key: pubKey });
this.props.onSelect?.({ key: pubKey });
}
});
}
if (query.startsWith('https://iris.to/')) {
const path = query.replace('https://iris.to', '');
if (query.startsWith("https://iris.to/")) {
const path = query.replace("https://iris.to", "");
route(path);
return;
}
const noteMatch = query.match(/note[a-zA-Z0-9]{59,60}/gi);
if (noteMatch) {
route('/' + noteMatch[0]);
route("/" + noteMatch[0]);
return;
}
const npubMatch = query.match(/npub[a-zA-Z0-9]{59,60}/gi);
if (npubMatch) {
route('/' + npubMatch[0]);
route("/" + npubMatch[0]);
return;
}
const s = query.split('/profile/');
const s = query.split("/profile/");
if (s.length > 1) {
return this.props.onSelect({ key: s[1] });
}
@ -232,7 +238,7 @@ class SearchBox extends Component<Props, State> {
this.close();
}
onResultFocus(e, index) {
onResultFocus(_e, index) {
this.setState({ selected: index });
}
@ -240,7 +246,7 @@ class SearchBox extends Component<Props, State> {
return (
<div class={`search-box ${this.props.class}`}>
{this.props.resultsOnly ? (
''
""
) : (
<form onSubmit={(e) => this.onSubmit(e)}>
<label>
@ -249,7 +255,7 @@ class SearchBox extends Component<Props, State> {
onKeyPress={(e) => this.preventUpDownDefault(e)}
onKeyDown={(e) => this.preventUpDownDefault(e)}
onKeyUp={(e) => this.onKeyUp(e)}
placeholder={t('search')}
placeholder={t("search")}
tabIndex={1}
onInput={() => this.onInput()}
/>
@ -265,7 +271,9 @@ class SearchBox extends Component<Props, State> {
<a
onFocus={(e) => this.onResultFocus(e, -1)}
tabIndex={2}
className={'result ' + (-1 === this.state.selected ? 'selected' : '')}
className={
"result " + (-1 === this.state.selected ? "selected" : "")
}
href={`/search/${encodeURIComponent(this.state.query)}`}
>
<div class="identicon-container">
@ -274,30 +282,32 @@ class SearchBox extends Component<Props, State> {
<div>
<span>{this.state.query}</span>
<br />
<small>{t('search_posts')}</small>
<small>{t("search_posts")}</small>
</div>
</a>
) : (
''
""
)}
{this.state.results.map((r, index) => {
const i = r.item;
let followText = '';
let followText = "";
if (i.followers) {
if (i.followDistance === 0) {
followText = t('you');
followText = t("you");
} else if (i.followDistance === 1) {
followText = t('following');
followText = t("following");
} else {
followText = `${i.followers.size} ${t('followers')}`;
followText = `${i.followers.size} ${t("followers")}`;
}
}
const npub = Key.toNostrBech32Address(i.key, 'npub');
const npub = Key.toNostrBech32Address(i.key, "npub");
return (
<a
onFocus={(e) => this.onResultFocus(e, index)}
tabIndex={2}
className={'result ' + (index === this.state.selected ? 'selected' : '')}
className={
"result " + (index === this.state.selected ? "selected" : "")
}
href={`/${npub}`}
onClick={(e) => this.onClick(e, i)}
>
@ -309,7 +319,7 @@ class SearchBox extends Component<Props, State> {
<Identicon key={`${npub}ic`} str={npub} width={40} />
)}
<div>
<Name pub={i.key} key={i.key + 'searchResult'} />
<Name pub={i.key} key={i.key + "searchResult"} />
<br />
<small>{followText}</small>
</div>

View File

@ -1,40 +1,58 @@
import { Helmet } from 'react-helmet';
import { createRef } from 'preact';
import { Helmet } from "react-helmet";
import { createRef } from "preact";
import Component from '../BaseComponent';
import Helpers from '../Helpers';
import Icons from '../Icons';
import localState from '../LocalState';
import { translate as t } from '../translations/Translation';
import Component from "../BaseComponent";
import Helpers from "../Helpers";
import Icons from "../Icons";
import localState from "../LocalState";
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']);
const isAudio = (f) => isOfType(f, ['.mp3', '.wav', '.m4a']);
const isImage = (f) => isOfType(f, ['.jpg', 'jpeg', '.gif', '.png']);
const isVideo = (f) => isOfType(f, ["webm", ".mp4", ".ogg"]);
const isAudio = (f) => isOfType(f, [".mp3", ".wav", ".m4a"]);
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');
localState.get('player').on(
console.log("componentDidMount torrent");
localState.get("player").on(
this.sub((player) => {
this.player = player;
this.setState({ player });
if (this.torrent && this.player && this.player.filePath !== this.state.activeFilePath) {
if (
this.torrent &&
this.player &&
this.player.filePath !== this.state.activeFilePath
) {
const file = this.getActiveFile(this.torrent);
file && this.openFile(file);
}
}),
})
);
const showFiles = this.props.showFiles;
showFiles && this.setState({ showFiles });
localState.get('settings').on(this.inject());
localState.get("settings").on(this.inject());
(async () => {
if (
this.props.standalone ||
(await localState.get('settings').get('enableWebtorrent').once())
(await localState.get("settings").get("enableWebtorrent").once())
) {
this.startTorrenting();
}
@ -49,50 +67,55 @@ class Torrent extends Component {
onPlay(e) {
if (!e.target.muted) {
localState.get('player').get('paused').put(true);
localState.get("player").get("paused").put(true);
}
}
async startTorrenting(clicked) {
async startTorrenting(clicked?: boolean) {
this.setState({ torrenting: true });
const torrentId = this.props.torrentId;
const { default: AetherTorrent } = await import('aether-torrent');
const { default: AetherTorrent } = await import("aether-torrent");
const client = new AetherTorrent();
const existing = client.get(torrentId);
if (existing) {
this.onTorrent(existing, clicked);
} else {
client.add(torrentId, (err, t) => t && !err && this.onTorrent(t, clicked));
client.add(
torrentId,
(err, t) => t && !err && this.onTorrent(t, clicked)
);
}
}
playAudio(filePath, e) {
playAudio(filePath, e?) {
e && e.preventDefault();
localState.get('player').put({ torrentId: this.props.torrentId, filePath, paused: false });
localState
.get("player")
.put({ torrentId: this.props.torrentId, filePath, paused: false });
}
pauseAudio(e) {
e && e.preventDefault();
localState.get('player').put({ paused: true });
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) {
if (isVid) {
const el = base.querySelector('video');
const el = base.querySelector("video");
el && el.play();
} else if (isAud) {
localState.get('player').get('paused').put(false);
localState.get("player").get("paused").put(false);
}
return;
}
let splitPath;
if (!isVid) {
splitPath = file.path.split('/');
splitPath = file.path.split("/");
}
this.setState({ activeFilePath: file.path, splitPath, isAudioOpen: isAud });
let autoplay, muted;
@ -103,8 +126,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);
}
@ -112,29 +135,30 @@ class Torrent extends Component {
file.appendTo(el, { autoplay, muted });
}
if (isVid && this.props.autopause) {
const vid = base.querySelector('video');
const vid = base.querySelector("video");
const handlePlay = (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
autoplay && vid.play();
autoplay && vid?.play();
} else {
vid.pause();
vid?.pause();
}
});
};
const options = {
rootMargin: '0px',
rootMargin: "0px",
threshold: [0.25, 0.75],
};
this.observer = new IntersectionObserver(handlePlay, options);
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;
player.addEventListener("ended", () => {
const typeCheck = player.tagName === "VIDEO" ? isVideo : isAudio;
this.openNextFile(typeCheck);
});
player.onplay = player.onvolumechange = this.onPlay;
@ -143,7 +167,9 @@ class Torrent extends Component {
getNextIndex(typeCheck) {
const files = this.state.torrent.files;
const currentIndex = files.findIndex((f) => f.path === this.state.activeFilePath);
const currentIndex = files.findIndex(
(f) => f.path === this.state.activeFilePath
);
let nextIndex = files.findIndex((f, i) => i > currentIndex && typeCheck(f));
if (nextIndex === -1) {
nextIndex = files.findIndex((f) => typeCheck(f));
@ -171,14 +197,14 @@ class Torrent extends Component {
}
onTorrent(torrent, clicked) {
console.log('onTorrent', this.props.torrentId, torrent);
console.log("onTorrent", this.props.torrentId, torrent);
if (!this.coverRef.current) {
return;
}
this.torrent = torrent;
let interval = setInterval(() => {
const interval = setInterval(() => {
if (!torrent.files) {
console.log('no files found in torrent:', torrent);
console.log("no files found in torrent:", torrent);
return;
}
clearInterval(interval);
@ -186,12 +212,19 @@ class Torrent extends Component {
const audio = torrent.files.find((f) => isAudio(f));
const img = torrent.files.find((f) => isImage(f));
const file = this.getActiveFile(torrent) || video || audio || img || torrent.files[0];
const file =
this.getActiveFile(torrent) ||
video ||
audio ||
img ||
torrent.files[0];
this.setState({ torrent, cover: img });
file && this.openFile(file, clicked);
let poster = torrent.files.find(
(f) => isImage(f) && (f.name.indexOf('cover') > -1 || f.name.indexOf('poster') > -1),
(f) =>
isImage(f) &&
(f.name.indexOf("cover") > -1 || f.name.indexOf("poster") > -1)
);
poster = poster || img;
if (poster) {
@ -215,7 +248,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)}>
@ -234,14 +267,14 @@ class Torrent extends Component {
<div
className="cover"
ref={this.coverRef}
style={s.isAudioOpen ? {} : { display: 'none' }}
style={s.isAudioOpen ? {} : { display: "none" }}
/>
<div className="info">
{s.splitPath
? s.splitPath.map((str, i) => {
if (i === s.splitPath.length - 1) {
if (s.isAudioOpen) {
str = str.split('.').slice(0, -1).join('.');
str = str.split(".").slice(0, -1).join(".");
}
return (
<p>
@ -260,28 +293,38 @@ class Torrent extends Component {
<>
<a href={this.props.torrentId}>Magnet link</a>
{to && to.files ? (
<a href="" style={{ marginLeft: '30px' }} onClick={(e) => this.showFilesClicked(e)}>
{t(s.showFiles ? 'hide_files' : 'show_files')}
<a
href=""
style={{ marginLeft: "30px" }}
onClick={(e) => this.showFilesClicked(e)}
>
{t(s.showFiles ? "hide_files" : "show_files")}
</a>
) : null}
</>
) : (
<a href={`/torrent/${encodeURIComponent(this.props.torrentId)}`}>{t('show_files')}</a>
<a href={`/torrent/${encodeURIComponent(this.props.torrentId)}`}>
{t("show_files")}
</a>
)}
{s.showFiles && to && to.files ? (
<>
<p>
{t('peers')}: {to.numPeers}
{t("peers")}: {to.numPeers}
</p>
<div className="flex-table details">
{to.files.map((f) => (
<div
key={f.path}
onClick={(e) => this.openFile(f, e)}
className={`flex-row ${s.activeFilePath === f.path ? 'active' : ''}`}
onClick={() => this.openFile(f, true)}
className={`flex-row ${
s.activeFilePath === f.path ? "active" : ""
}`}
>
<div className="flex-cell">{f.name}</div>
<div className="flex-cell no-flex">{Helpers.formatBytes(f.length)}</div>
<div className="flex-cell no-flex">
{Helpers.formatBytes(f.length)}
</div>
</div>
))}
</div>
@ -294,11 +337,15 @@ class Torrent extends Component {
renderMeta() {
const s = this.state;
const title =
(s.splitPath && s.splitPath[s.splitPath.length - 1].split('.').slice(0, -1).join('.')) ||
'File sharing';
(s.splitPath &&
s.splitPath[s.splitPath.length - 1]
.split(".")
.slice(0, -1)
.join(".")) ||
"File sharing";
const ogTitle = `${title} | Iris`;
const description = 'Shared files';
const ogType = s.isAudioOpen ? 'music:song' : 'video.movie';
const description = "Shared files";
const ogType = s.isAudioOpen ? "music:song" : "video.movie";
return (
<Helmet>
<title>{title}</title>
@ -306,7 +353,9 @@ class Torrent extends Component {
<meta property="og:type" content={ogType} />
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={description} />
{s.ogImageUrl ? <meta property="og:image" content={s.ogImageUrl} /> : null}
{s.ogImageUrl ? (
<meta property="og:image" content={s.ogImageUrl} />
) : null}
</Helmet>
);
}
@ -319,7 +368,7 @@ class Torrent extends Component {
!this.state.settings.torrenting &&
!this.props.standalone ? (
<a href="" onClick={(e) => this.openTorrentClicked(e)}>
{t('show_attachment')}
{t("show_attachment")}
</a>
) : (
this.renderLoadingTorrent()

View File

@ -1,10 +1,10 @@
import Component from '../../BaseComponent';
import Key from '../../nostr/Key';
import SocialNetwork from '../../nostr/SocialNetwork';
import { translate as t } from '../../translations/Translation';
import Name from '../Name';
import Component from "../../BaseComponent";
import Key from "../../nostr/Key";
import SocialNetwork from "../../nostr/SocialNetwork";
import { translate as t } from "../../translations/Translation.mjs";
import Name from "../Name";
import { PrimaryButton as Button } from './Button';
import { PrimaryButton as Button } from "./Button";
type Props = {
id: string;
@ -21,23 +21,26 @@ class Block extends Component<Props> {
constructor() {
super();
this.cls = 'block';
this.key = 'blocked';
this.activeClass = 'blocked';
this.action = t('block');
this.actionDone = t('blocked');
this.hoverAction = t('unblock');
this.cls = "block";
this.key = "blocked";
this.activeClass = "blocked";
this.action = t("block");
this.actionDone = t("blocked");
this.hoverAction = t("unblock");
}
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 });
});
}
@ -45,12 +48,18 @@ class Block extends Component<Props> {
render() {
return (
<Button
className={`${this.cls || this.key} ${this.state[this.key] ? this.activeClass : ''}`}
className={`${this.cls || this.key} ${
this.state[this.key] ? this.activeClass : ""
}`}
onClick={(e) => this.onClick(e)}
>
<span className="nonhover">
{t(this.state[this.key] ? this.actionDone : this.action)}{' '}
{this.props.showName ? <Name pub={this.props.id} hideBadge={true} /> : ''}
{t(this.state[this.key] ? this.actionDone : this.action)}{" "}
{this.props.showName ? (
<Name pub={this.props.id} hideBadge={true} />
) : (
""
)}
</span>
<span className="hover">{t(this.hoverAction)}</span>
</Button>

View File

@ -1,4 +1,4 @@
import styled from 'styled-components';
import styled from "styled-components";
const Button = styled.button`
background: var(--button-bg);
@ -7,7 +7,7 @@ const Button = styled.button`
font-weight: bold;
cursor: pointer;
transition: all 0.25s ease;
width: ${(props) => props.width || 'auto'};
width: ${(props) => props.width || "auto"};
${(props) =>
props.small &&

View File

@ -1,10 +1,10 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from "preact/hooks";
import Helpers from '../../Helpers';
import { translate as t } from '../../translations/Translation';
import { OptionalGetter } from '../../types';
import Helpers from "../../Helpers";
import { translate as t } from "../../translations/Translation.mjs";
import { OptionalGetter } from "../../types";
import { PrimaryButton as Button } from './Button';
import { PrimaryButton as Button } from "./Button";
type Props = {
copyStr: OptionalGetter<string>;
@ -13,8 +13,12 @@ type Props = {
const Copy = ({ copyStr, text }: Props) => {
const [copied, setCopied] = useState(false);
const [originalWidth, setOriginalWidth] = useState<number | undefined>(undefined);
const [timeout, setTimeoutState] = useState<ReturnType<typeof setTimeout> | undefined>(undefined);
const [originalWidth, setOriginalWidth] = useState<number | undefined>(
undefined
);
const [timeout, setTimeoutState] = useState<
ReturnType<typeof setTimeout> | undefined
>(undefined);
const copy = (e: MouseEvent, copyStr: string) => {
if (e.target === null) {
@ -39,7 +43,7 @@ const Copy = ({ copyStr, text }: Props) => {
const onClick = (e: MouseEvent) => {
e.preventDefault();
const copyStrValue = typeof copyStr === 'function' ? copyStr() : copyStr;
const copyStrValue = typeof copyStr === "function" ? copyStr() : copyStr;
copy(e, copyStrValue);
};
@ -52,7 +56,7 @@ const Copy = ({ copyStr, text }: Props) => {
};
}, [timeout]);
const buttonText = copied ? t('copied') : text || t('copy');
const buttonText = copied ? t("copied") : text || t("copy");
return (
<Button className="copy-button" onClick={(e) => onClick(e)}>
{buttonText}

View File

@ -1,9 +1,9 @@
import Component from '../../BaseComponent';
import Key from '../../nostr/Key';
import SocialNetwork from '../../nostr/SocialNetwork';
import { translate as t } from '../../translations/Translation';
import Component from "../../BaseComponent";
import Key from "../../nostr/Key";
import SocialNetwork from "../../nostr/SocialNetwork";
import { translate as t } from "../../translations/Translation.mjs";
import { Button } from './Button';
import { Button } from "./Button";
type Props = {
id: string;
@ -19,29 +19,32 @@ class Follow extends Component<Props> {
constructor() {
super();
this.key = 'follow';
this.activeClass = 'following';
this.action = t('follow_btn');
this.actionDone = t('following_btn');
this.hoverAction = t('unfollow_btn');
this.key = "follow";
this.activeClass = "following";
this.action = t("follow_btn");
this.actionDone = t("following_btn");
this.hoverAction = t("unfollow_btn");
}
onClick(e) {
e.preventDefault();
const newValue = !this.state[this.key];
if (this.key === 'follow') {
SocialNetwork.setFollowed(Key.toNostrHexAddress(this.props.id), newValue);
const hex = Key.toNostrHexAddress(this.props.id);
if (!hex) return;
if (this.key === "follow") {
SocialNetwork.setFollowed(hex, newValue);
return;
}
if (this.key === 'block') {
SocialNetwork.setBlocked(Key.toNostrHexAddress(this.props.id), newValue);
if (this.key === "block") {
SocialNetwork.setBlocked(hex, newValue);
}
}
componentDidMount() {
if (this.key === 'follow') {
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;
@ -51,10 +54,14 @@ class Follow extends Component<Props> {
render() {
return (
<Button
className={`${this.cls || this.key} ${this.state[this.key] ? this.activeClass : ''}`}
className={`${this.cls || this.key} ${
this.state[this.key] ? this.activeClass : ""
}`}
onClick={(e) => this.onClick(e)}
>
<span className="nonhover">{t(this.state[this.key] ? this.actionDone : this.action)}</span>
<span className="nonhover">
{t(this.state[this.key] ? this.actionDone : this.action)}
</span>
<span className="hover">{t(this.hoverAction)}</span>
</Button>
);

View File

@ -1,31 +1,33 @@
import Key from '../../nostr/Key';
import SocialNetwork from '../../nostr/SocialNetwork';
import { translate as t } from '../../translations/Translation';
import Key from "../../nostr/Key";
import SocialNetwork from "../../nostr/SocialNetwork";
import { translate as t } from "../../translations/Translation.mjs";
import Block from './Block';
import Block from "./Block";
class Report extends Block {
constructor() {
super();
this.cls = 'block';
this.key = 'reported';
this.activeClass = 'blocked';
this.action = t('report_public');
this.actionDone = t('reported');
this.hoverAction = t('unreport');
this.cls = "block";
this.key = "reported";
this.activeClass = "blocked";
this.action = t("report_public");
this.actionDone = t("reported");
this.hoverAction = t("unreport");
}
onClick(e) {
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);
if (confirm(newValue ? "Publicly report this user?" : "Unreport user?")) {
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,15 +1,15 @@
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) {
const formData = new FormData();
formData.append('fileToUpload', files[0]);
formData.append("fileToUpload", files[0]);
fetch('https://nostr.build/api/upload/iris.php', {
method: 'POST',
fetch("https://nostr.build/api/upload/iris.php", {
method: "POST",
body: formData,
})
.then(async (response) => {
@ -19,8 +19,8 @@ const Upload = (props) => {
}
})
.catch((error) => {
console.error('upload error', error);
setError('upload failed: ' + JSON.stringify(error));
console.error("upload error", error);
setError("upload failed: " + JSON.stringify(error));
});
}
};

View File

@ -1,18 +1,18 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from "preact/hooks";
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 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.mjs";
import EventDropdown from './EventDropdown';
import Follow from './Follow';
import Like from './Like';
import Note from './Note';
import NoteImage from './NoteImage';
import Repost from './Repost';
import Zap from './Zap';
import EventDropdown from "./EventDropdown";
import Follow from "./Follow";
import Like from "./Like";
import Note from "./Note";
import NoteImage from "./NoteImage";
import Repost from "./Repost";
import Zap from "./Zap";
declare global {
interface Window {
@ -29,13 +29,15 @@ interface EventComponentProps {
isReply?: boolean;
isQuote?: boolean;
isQuoting?: boolean;
renderAs?: 'NoteImage';
renderAs?: "NoteImage";
feedOpenedAt?: number;
fullWidth?: boolean;
}
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);
@ -53,8 +55,8 @@ const EventComponent = (props: EventComponentProps) => {
const replyingTo = Events.getNoteReplyingTo(event);
const meta = {
npub: Key.toNostrBech32Address(event.pubkey, 'npub'),
noteId: Key.toNostrBech32Address(event.id, 'note'),
npub: Key.toNostrBech32Address(event.pubkey, "npub"),
noteId: Key.toNostrBech32Address(event.id, "note"),
time: event.created_at * 1000,
isMine: Key.getPubKey() === event.pubkey,
attachments: [],
@ -66,7 +68,7 @@ const EventComponent = (props: EventComponentProps) => {
useEffect(() => {
if (!props.id) {
console.log('error: no id', props);
console.log("error: no id", props);
return;
}
unmounted.current = 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,29 +109,34 @@ 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 = () => {
let className = 'msg';
if (props.standalone) className += ' standalone';
const isQuote = props.isQuote || (props.showReplies && state.sortedReplies?.length);
if (isQuote) className += ' quote';
let className = "msg";
if (props.standalone) className += " standalone";
const isQuote =
props.isQuote || (props.showReplies && state.sortedReplies?.length);
if (isQuote) className += " quote";
return className;
};
if (!props.id) {
console.error('no id on event', props);
console.error("no id on event", props);
return null;
}
if (!state.event) {
return (
<div key={props.id} className={getClassName()}>
<div
className={`msg-content retrieving ${state.retrieving ? 'visible' : ''}`}
style={{ display: 'flex', alignItems: 'center' }}
className={`msg-content retrieving ${
state.retrieving ? "visible" : ""
}`}
style={{ display: "flex", alignItems: "center" }}
>
<div className="text">{t('looking_up_message')}</div>
<div className="text">{t("looking_up_message")}</div>
<div>{renderDropdown()}</div>
</div>
</div>
@ -141,8 +148,8 @@ const EventComponent = (props: EventComponentProps) => {
return (
<div className="msg">
<div className="msg-content">
<p style={{ display: 'flex', alignItems: 'center' }}>
<i style={{ marginRight: '15px' }}>{Icons.newFollower}</i>
<p style={{ display: "flex", alignItems: "center" }}>
<i style={{ marginRight: "15px" }}>{Icons.newFollower}</i>
<span> Message from a blocked user</span>
</p>
</div>
@ -158,7 +165,7 @@ const EventComponent = (props: EventComponentProps) => {
if (state.event.kind === 1) {
const mentionIndex = state.event?.tags?.findIndex(
(tag) => tag[0] === 'e' && tag[3] === 'mention',
(tag) => tag[0] === "e" && tag[3] === "mention"
);
if (state.event?.content === `#[${mentionIndex}]`) {
Component = Repost;
@ -173,12 +180,12 @@ const EventComponent = (props: EventComponentProps) => {
}[state.event.kind];
}
if (props.renderAs === 'NoteImage') {
if (props.renderAs === "NoteImage") {
Component = NoteImage;
}
if (!Component) {
console.error('unknown event kind', state.event);
console.error("unknown event kind", state.event);
return null;
}
@ -189,7 +196,9 @@ 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,23 +1,23 @@
import { useState } from 'preact/hooks';
import styled from 'styled-components';
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 Block from '../buttons/Block';
import { PrimaryButton } from '../buttons/Button';
import Copy from '../buttons/Copy';
import FollowButton from '../buttons/Follow';
import Dropdown from '../Dropdown';
import Modal from '../modal/Modal';
import Helpers from "../../Helpers";
import localState from "../../LocalState";
import Events from "../../nostr/Events";
import Key from "../../nostr/Key";
import { translate as t } from "../../translations/Translation.mjs";
import Block from "../buttons/Block";
import { PrimaryButton } from "../buttons/Button";
import Copy from "../buttons/Copy";
import FollowButton from "../buttons/Follow";
import Dropdown from "../Dropdown";
import Modal from "../modal/Modal";
import EventRelaysList from './EventRelaysList';
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);
@ -42,13 +42,13 @@ const EventDropdown = (props: EventDropdownProps) => {
const onDelete = (e: any) => {
e.preventDefault();
if (confirm('Delete message?')) {
if (confirm("Delete message?")) {
const hexId = Key.toNostrHexAddress(id);
if (hexId) {
Events.publish({
kind: 5,
content: 'deleted',
tags: [['e', hexId]],
content: "deleted",
tags: [["e", hexId]],
});
// TODO hide
}
@ -57,20 +57,20 @@ const EventDropdown = (props: EventDropdownProps) => {
const onMute = (e) => {
e.preventDefault();
localState.get('mutedNotes').get(props.id).put(!muted);
localState.get("mutedNotes").get(props.id).put(!muted);
};
const report = (e) => {
e.preventDefault();
if (confirm('Publicly report and hide message?')) {
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',
content: "reported",
tags: [
['e', hexId],
['p', props.event.pubkey],
["e", hexId],
["p", props.event.pubkey],
],
});
// this.setState({ msg: null });
@ -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) => {
@ -90,50 +91,50 @@ const EventDropdown = (props: EventDropdownProps) => {
e.preventDefault();
const hexId = Key.toNostrHexAddress(id);
if (hexId) {
const event = Events.db.by('id', hexId);
const event = Events.db.by("id", hexId);
if (event) {
// TODO indicate to user somehow
console.log('broadcasting', hexId, event);
console.log("broadcasting", hexId, event);
Events.publish(event);
}
}
};
const url = `https://iris.to/${Key.toNostrBech32Address(id, 'note')}`;
const url = `https://iris.to/${Key.toNostrBech32Address(id, "note")}`;
return (
<div className="msg-menu-btn">
<Dropdown>
<Copy key={`${id!}copy_link`} text={t('copy_link')} copyStr={url} />
<Copy key={`${id!}copy_link`} text={t("copy_link")} copyStr={url} />
<Copy
key={`${id!}copy_id`}
text={t('copy_note_ID')}
copyStr={Key.toNostrBech32Address(id, 'note')}
text={t("copy_note_ID")}
copyStr={Key.toNostrBech32Address(id, "note") || ""}
/>
<a href="#" onClick={onMute}>
{muted ? t('unmute_notifications') : t('mute_notifications')}
{muted ? t("unmute_notifications") : t("mute_notifications")}
</a>
{event && (
{event ? (
<>
<a href="#" onClick={onBroadcast}>
{t('resend_to_relays')}
{t("resend_to_relays")}
</a>
<a href="#" onClick={translate}>
{t('translate')}
{t("translate")}
</a>
<Copy
key={`${id!}copyRaw`}
text={t('copy_raw_data')}
text={t("copy_raw_data")}
copyStr={JSON.stringify(event, null, 2)}
/>
{event.pubkey === Key.getPubKey() ? (
<a href="#" onClick={onDelete}>
{t('delete')}
{t("delete")}
</a>
) : (
<>
<a href="#" onClick={report}>
{t('report_public')}
{t("report_public")}
</a>
<FollowButton id={event?.pubkey} />
<span onClick={onBlock}>
@ -148,16 +149,18 @@ const EventDropdown = (props: EventDropdownProps) => {
setShowingDetails(true);
}}
>
{t('event_detail')}
{t("event_detail")}
</a>
</>
) : (
<></>
)}
</Dropdown>
{showingDetails && (
{event && showingDetails && (
<Modal showContainer onClose={closeModal}>
<EventDetail>
<EventRelaysList event={event} />
<PrimaryButton onClick={closeModal}>{t('done')}</PrimaryButton>
<PrimaryButton onClick={closeModal}>{t("done")}</PrimaryButton>
</EventDetail>
</Modal>
)}

View File

@ -1,9 +1,10 @@
import { FC, useEffect, useState } from 'react';
import styled from 'styled-components';
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 Events from "../../nostr/Events";
import { EventMetadata } from "../../nostr/EventsMeta";
import { translate as t } from "../../translations/Translation.mjs";
const Wrapper = styled.div`
display: flex;
@ -20,7 +21,7 @@ const Wrapper = styled.div`
const Codeblock = styled.pre`
overflow: auto;
background-color: hsl(0, 0%, 16%);
[data-theme='light'] & {
[data-theme="light"] & {
background-color: hsl(0, 0%, 88%);
}
max-height: 400px;
@ -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);
}
@ -47,10 +48,10 @@ const EventRelaysList: FC<{ event: Event }> = ({ event }) => {
return (
<Wrapper>
<h2>{t('event_detail')}</h2>
<p>{t('seen_on')}</p>
<h2>{t("event_detail")}</h2>
<p>{t("seen_on")}</p>
{relays.length === 0 ? (
<p>{t('iris_api_source')}</p>
<p>{t("iris_api_source")}</p>
) : (
<ul>
{relays.map((r) => (
@ -59,7 +60,13 @@ const EventRelaysList: FC<{ event: Event }> = ({ event }) => {
</ul>
)}
<Codeblock>
<code>{JSON.stringify({ ...event, meta: undefined, $loki: undefined }, null, 2)}</code>
<code>
{JSON.stringify(
{ ...event, meta: undefined, $loki: undefined },
null,
2
)}
</code>
</Codeblock>
</Wrapper>
);

View File

@ -1,7 +1,8 @@
import Icons from '../../Icons';
import { Event } from '../../lib/nostr-tools';
import Key from '../../nostr/Key';
import Name from '../Name';
import { Event } from "nostr-tools";
import Icons from "../../Icons";
import Key from "../../nostr/Key";
import Name from "../Name";
type Props = {
event: Event;
@ -9,17 +10,19 @@ type Props = {
function Follow(props: Props) {
const followsYou = props.event.tags?.some(
(t: string[]) => t[0] === 'p' && t[1] === Key.getPubKey(),
(t: string[]) => t[0] === "p" && t[1] === Key.getPubKey()
);
const text = followsYou ? 'started following you' : 'updated their following list';
const text = followsYou
? "started following you"
: "updated their following list";
return (
<div className="msg">
<div className="msg-content">
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: "flex", alignItems: "center" }}>
<i className="repost-btn reposted" style={{ marginRight: 15 }}>
{Icons.newFollower}
</i>
<a href={`/${Key.toNostrBech32Address(props.event.pubkey, 'npub')}`}>
<a href={`/${Key.toNostrBech32Address(props.event.pubkey, "npub")}`}>
<Name pub={props.event.pubkey} />
</a>
<span className="mar-left5"> {text}</span>

View File

@ -1,13 +1,13 @@
import { useEffect, useState } from 'react';
import { HeartIcon as HeartIconFull } from '@heroicons/react/24/solid';
import { route } from 'preact-router';
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';
import Events from "../../nostr/Events";
import Key from "../../nostr/Key";
import Name from "../Name";
import EventComponent from './EventComponent';
import EventComponent from "./EventComponent";
type Props = {
event: Event;
@ -15,44 +15,55 @@ type Props = {
const messageClicked = (e: MouseEvent, likedId: string) => {
const target = e.target as HTMLElement;
if (['A', 'BUTTON', 'TEXTAREA', 'IMG', 'INPUT'].find((tag) => target.closest(tag))) {
if (
["A", "BUTTON", "TEXTAREA", "IMG", "INPUT"].find((tag) =>
target.closest(tag)
)
) {
return;
}
if (window.getSelection()?.toString()) {
return;
}
e.stopPropagation();
route(`/${Key.toNostrBech32Address(likedId, 'note')}`);
route(`/${Key.toNostrBech32Address(likedId, "note")}`);
};
export default function Like(props: Props) {
const [allLikes, setAllLikes] = useState<string[]>([]);
const likedId = Events.getEventReplyingTo(props.event);
const likedEvent = Events.db.by('id', likedId);
const likedEvent = Events.db.by("id", likedId);
const authorIsYou = likedEvent?.pubkey === Key.getPubKey();
const mentioned = likedEvent?.tags?.find((tag) => tag[0] === 'p' && tag[1] === Key.getPubKey());
const mentioned = likedEvent?.tags?.find(
(tag) => tag[0] === "p" && tag[1] === Key.getPubKey()
);
const likeText = authorIsYou
? 'liked your note'
? "liked your note"
: mentioned
? 'liked a note where you were mentioned'
: 'liked a note';
? "liked a note where you were mentioned"
: "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]);
const userLink = `/${Key.toNostrBech32Address(props.event.pubkey, 'npub')}`;
if (!likedId) {
return null;
}
const userLink = `/${Key.toNostrBech32Address(props.event.pubkey, "npub")}`;
return (
<div className="msg" key={props.event.id}>
<div className="msg-content" onClick={(e) => messageClicked(e, likedId)}>
<div style={{ display: 'flex', flex: 1, 'flex-direction': 'column' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: "flex", flex: 1, "flex-direction": "column" }}>
<div style={{ display: "flex", alignItems: "center" }}>
<i className="like-btn liked" style={{ marginRight: 15 }}>
<HeartIconFull width={24} />
</i>
@ -60,10 +71,15 @@ export default function Like(props: Props) {
<a href={userLink} style={{ marginRight: 5 }}>
<Name pub={props.event.pubkey} />
</a>
{allLikes.length > 1 && <> and {allLikes.length - 1} others </>} {likeText}
{allLikes.length > 1 && <> and {allLikes.length - 1} others </>}{" "}
{likeText}
</div>
</div>
<EventComponent key={likedId + props.event.id} id={likedId} fullWidth={false} />
<EventComponent
key={likedId + props.event.id}
id={likedId}
fullWidth={false}
/>
</div>
</div>
</div>

View File

@ -1,22 +1,22 @@
import { Helmet } from 'react-helmet';
import { useEffect, useState } from 'preact/hooks';
import { route } from 'preact-router';
import { Helmet } from "react-helmet";
import { useEffect, useState } from "preact/hooks";
import { route } from "preact-router";
import Helpers from '../../Helpers';
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 Identicon from '../Identicon';
import ImageModal from '../modal/Image';
import Name from '../Name';
import PublicMessageForm from '../PublicMessageForm';
import Torrent from '../Torrent';
import Helpers from "../../Helpers";
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.mjs";
import Identicon from "../Identicon";
import ImageModal from "../modal/Image";
import Name from "../Name";
import PublicMessageForm from "../PublicMessageForm";
import Torrent from "../Torrent";
import EventComponent from './EventComponent';
import EventDropdown from './EventDropdown';
import Reactions from './Reactions';
import EventComponent from "./EventComponent";
import EventDropdown from "./EventDropdown";
import Reactions from "./Reactions";
const MSG_TRUNCATE_LENGTH = 500;
const MSG_TRUNCATE_LINES = 8;
@ -25,7 +25,7 @@ let loadReactions = true;
let showLikes = true;
let showZaps = true;
let showReposts = true;
localState.get('settings').on((s) => {
localState.get("settings").on((s) => {
loadReactions = s.loadReactions !== false;
showLikes = s.showLikes !== false;
showZaps = s.showZaps !== false;
@ -47,8 +47,8 @@ const Note = ({
const [showMore, setShowMore] = useState(false);
const [showImageModal, setShowImageModal] = useState(false);
const [replies, setReplies] = useState([]);
const [translatedText, setTranslatedText] = useState('');
const [name, setName] = useState('');
const [translatedText, setTranslatedText] = useState("");
const [name, setName] = useState("");
showReplies = showReplies || 0;
if (!standalone && showReplies && replies.length) {
isQuote = true;
@ -68,16 +68,16 @@ const Note = ({
useEffect(() => {
if (standalone) {
return SocialNetwork.getProfile(event.pubkey, (profile) => {
setName(profile?.display_name || profile?.name || '');
setName(profile?.display_name || profile?.name || "");
});
}
});
// TODO fetch replies in useEffect
let text = event.content || '';
let text = event.content || "";
meta = meta || {};
const attachments = [];
const attachments = [] as any[];
const urls = text.match(/(https?:\/\/[^\s]+)/g);
if (urls) {
urls.forEach((url) => {
@ -85,16 +85,20 @@ const Note = ({
try {
parsedUrl = new URL(url);
} catch (e) {
console.log('invalid url', url);
console.log("invalid url", url);
return;
}
if (parsedUrl.pathname.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)) {
attachments.push({ type: 'image', data: `${parsedUrl.href}` });
if (
parsedUrl.pathname.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)
) {
attachments.push({ type: "image", data: `${parsedUrl.href}` });
}
});
}
const ogImageUrl = standalone && attachments?.find((a) => a.type === 'image')?.data;
const emojiOnly = event.content?.length === 2 && Helpers.isEmoji(event.content);
const ogImageUrl =
standalone && attachments?.find((a) => a.type === "image")?.data;
const emojiOnly =
event.content?.length === 2 && Helpers.isEmoji(event.content);
const shortText = text.length > 128 ? `${text.slice(0, 128)}...` : text;
const quotedShortText = `"${shortText}"`;
@ -103,10 +107,10 @@ const Note = ({
? `${text.slice(0, MSG_TRUNCATE_LENGTH)}...`
: text;
const lines = text.split('\n');
const lines = text.split("\n");
text =
lines.length > MSG_TRUNCATE_LINES && !showMore && !standalone
? `${lines.slice(0, MSG_TRUNCATE_LINES).join('\n')}...`
? `${lines.slice(0, MSG_TRUNCATE_LINES).join("\n")}...`
: text;
text = Helpers.highlightEverything(text.trim(), event, {
@ -116,13 +120,13 @@ const Note = ({
const time = new Date(event.created_at * 1000);
const dateStr = time.toLocaleString(window.navigator.language, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const timeStr = time.toLocaleTimeString(window.navigator.language, {
timeStyle: 'short',
timeStyle: "short",
});
let rootMsg = Events.getEventRoot(event);
@ -131,9 +135,9 @@ const Note = ({
}
let replyingToUsers = [];
const hasETags = event.tags?.some((t) => t[0] === 'e');
const hasETags = event.tags?.some((t) => t[0] === "e");
if (hasETags) {
replyingToUsers = event?.tags.filter((t) => t[0] === 'p').map((t) => t[1]);
replyingToUsers = event?.tags.filter((t) => t[0] === "p").map((t) => t[1]);
}
// remove duplicates
replyingToUsers = [...new Set(replyingToUsers)];
@ -147,46 +151,54 @@ const Note = ({
if (standalone) {
return;
}
if (['A', 'BUTTON', 'TEXTAREA', 'IMG', 'INPUT'].find((tag) => event.target.closest(tag))) {
if (
["A", "BUTTON", "TEXTAREA", "IMG", "INPUT"].find((tag) =>
event.target.closest(tag)
)
) {
return;
}
if (window.getSelection().toString()) {
if (window.getSelection()?.toString()) {
return;
}
event.stopPropagation();
if (event.kind === 7) {
const likedId = event.tags?.reverse().find((t) => t[0] === 'e')[1];
const likedId = event.tags?.reverse().find((t) => t[0] === "e")[1];
return route(`/${likedId}`);
}
openStandalone();
}
function openStandalone() {
route(`/${Key.toNostrBech32Address(event.id, 'note')}`);
route(`/${Key.toNostrBech32Address(event.id, "note")}`);
}
function renderDropdown() {
return asInlineQuote ? null : (
<EventDropdown id={event.id} event={event} onTranslate={setTranslatedText} />
<EventDropdown
id={event.id}
event={event}
onTranslate={setTranslatedText}
/>
);
}
function renderReplyingTo() {
return (
<small className="msg-replying-to">
{t('replying_to') + ' '}
{t("replying_to") + " "}
{replyingToUsers.slice(0, 3).map((u) => (
<a href={`/${Key.toNostrBech32Address(u, 'npub')}`}>
@<Name pub={u} hideBadge={true} />{' '}
<a href={`/${Key.toNostrBech32Address(u, "npub")}`}>
@<Name pub={u} hideBadge={true} />{" "}
</a>
))}
{replyingToUsers?.length > 3 ? '...' : ''}
{replyingToUsers?.length > 3 ? "..." : ""}
</small>
);
}
function renderHelmet() {
const title = `${name || 'User'} on Iris`;
const title = `${name || "User"} on Iris`;
return (
<Helmet titleTemplate="%s">
<title>{`${title}: ${quotedShortText}`}</title>
@ -201,13 +213,17 @@ const Note = ({
function renderImageModal() {
const images = attachments?.map((a) => a.data);
return <ImageModal images={images} onClose={() => setShowImageModal(false)} />;
return (
<ImageModal images={images} onClose={() => setShowImageModal(false)} />
);
}
function renderShowThread() {
return (
<div style={{ flexBasis: '100%', marginBottom: '12px' }}>
<a href={`/${Key.toNostrBech32Address(rootMsg, 'note')}`}>{t('show_thread')}</a>
<div style={{ flexBasis: "100%", marginBottom: "12px" }}>
<a href={`/${Key.toNostrBech32Address(rootMsg || "", "note")}`}>
{t("show_thread")}
</a>
</div>
);
}
@ -227,7 +243,7 @@ const Note = ({
return (
attachments?.length > 1 ||
event.content?.length > MSG_TRUNCATE_LENGTH ||
event.content.split('\n').length > MSG_TRUNCATE_LINES
event.content.split("\n").length > MSG_TRUNCATE_LINES
);
}
@ -236,12 +252,15 @@ const Note = ({
<div className="msg-identicon">
{event.pubkey ? (
<a href={`/${event.pubkey}`}>
<Identicon str={Key.toNostrBech32Address(event.pubkey, 'npub')} width={40} />
<Identicon
str={Key.toNostrBech32Address(event.pubkey, "npub")}
width={40}
/>
</a>
) : (
''
""
)}
{(isQuote && !standalone && <div className="line"></div>) || ''}
{(isQuote && !standalone && <div className="line"></div>) || ""}
</div>
);
}
@ -251,16 +270,22 @@ const Note = ({
<div className="msg-sender">
<div className="msg-sender-link">
{fullWidth && renderIdenticon()}
<a href={`/${Key.toNostrBech32Address(event.pubkey, 'npub')}`} className="msgSenderName">
<a
href={`/${Key.toNostrBech32Address(event.pubkey, "npub")}`}
className="msgSenderName"
>
<Name pub={event.pubkey} />
</a>
<div className="time">
{'· '}
<a href={`/${Key.toNostrBech32Address(event.id, 'note')}`} className="tooltip">
{"· "}
<a
href={`/${Key.toNostrBech32Address(event.id, "note")}`}
className="tooltip"
>
{time && Helpers.getRelativeTimeText(time)}
<span className="tooltiptext">
{' '}
{dateStr} {timeStr}{' '}
{" "}
{dateStr} {timeStr}{" "}
</span>
</a>
</div>
@ -271,22 +296,28 @@ const Note = ({
}
function getClassName() {
const classNames = ['msg'];
const classNames = ["msg"];
if (standalone) classNames.push('standalone');
if (isQuote) classNames.push('quote');
if (isQuoting) classNames.push('quoting');
if (asInlineQuote) classNames.push('inline-quote');
if (fullWidth) classNames.push('full-width');
if (standalone) classNames.push("standalone");
if (isQuote) classNames.push("quote");
if (isQuoting) classNames.push("quoting");
if (asInlineQuote) classNames.push("inline-quote");
if (fullWidth) classNames.push("full-width");
return classNames.join(' ');
return classNames.join(" ");
}
function renderReplies() {
return replies
.slice(0, showReplies)
.map((r) => (
<EventComponent key={r} id={r} isReply={true} isQuoting={!standalone} showReplies={1} />
<EventComponent
key={r}
id={r}
isReply={true}
isQuoting={!standalone}
showReplies={1}
/>
));
}
@ -296,8 +327,7 @@ const Note = ({
waitForFocus={true}
autofocus={!standalone}
replyingTo={event.id}
replyingToUser={event.pubkey}
placeholder={t('write_your_reply')}
placeholder={t("write_your_reply")}
/>
);
}
@ -305,17 +335,28 @@ const Note = ({
return (
<>
{meta.replyingTo && showRepliedMsg && renderRepliedMsg()}
<div key={event.id + 'note'} className={getClassName()} onClick={(e) => messageClicked(e)}>
<div
key={event.id + "note"}
className={getClassName()}
onClick={(e) => messageClicked(e)}
>
<div className="msg-content" onClick={(e) => messageClicked(e)}>
{!standalone && !isReply && !isQuoting && rootMsg && renderShowThread()}
{!standalone &&
!isReply &&
!isQuoting &&
rootMsg &&
renderShowThread()}
{!fullWidth && renderIdenticon()}
<div className="msg-main">
{renderMsgSender()}
{(replyingToUsers?.length && !isQuoting && renderReplyingTo()) || null}
{(replyingToUsers?.length && !isQuoting && renderReplyingTo()) ||
null}
{standalone && renderHelmet()}
{meta.torrentId && <Torrent torrentId={meta.torrentId} autopause={!standalone} />}
{meta.torrentId && (
<Torrent torrentId={meta.torrentId} autopause={!standalone} />
)}
{text?.length > 0 && (
<div className={`text ${emojiOnly && 'emoji-only'}`}>
<div className={`text ${emojiOnly && "emoji-only"}`}>
{text}
{translatedText && (
<p>
@ -331,7 +372,7 @@ const Note = ({
setShowMore(!showMore);
}}
>
{t(`show_${showMore ? 'less' : 'more'}`)}
{t(`show_${showMore ? "less" : "more"}`)}
</a>
)}
{meta.url && (
@ -341,14 +382,16 @@ const Note = ({
)}
{!asInlineQuote && loadReactions && (
<Reactions
key={event.id + 'reactions'}
key={event.id + "reactions"}
settings={{ showLikes, showZaps, showReposts }}
standalone={standalone}
event={event}
setReplies={(replies) => setReplies(replies)}
/>
)}
{isQuote && !loadReactions && <div style={{ marginBottom: '15px' }}></div>}
{isQuote && !loadReactions && (
<div style={{ marginBottom: "15px" }}></div>
)}
{standalone && renderReplyForm()}
</div>
</div>

View File

@ -1,13 +1,14 @@
import { memo, useState } from 'react';
import styled, { css, keyframes } from 'styled-components';
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';
import Icons from "../../Icons";
import Events from "../../nostr/Events";
import Key from "../../nostr/Key";
import EventComponent from './EventComponent';
import NoteImageModal from './NoteImageModal';
import EventComponent from "./EventComponent";
import NoteImageModal from "./NoteImageModal";
const fadeIn = keyframes`
from {
@ -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>
) : (
<></>
);
}
@ -43,7 +44,7 @@ const GalleryImage = styled.a`
background-position: center;
background-color: #ccc;
background-image: url(${(props) =>
props.attachment?.type === 'video'
props.attachment?.type === "video"
? `https://imgproxy.iris.to/thumbnail/428/${props.attachment.url}`
: `https://imgproxy.iris.to/insecure/rs:fill:428:428/plain/${props.attachment.url}`});
& .dropdown {
@ -73,7 +74,7 @@ const GalleryImage = styled.a`
? css`
${fadeIn} 0.5s ease-in-out forwards
`
: 'none'};
: "none"};
`;
function NoteImage(props: { event: Event; fadeIn?: boolean }) {
@ -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) => {
@ -91,15 +92,21 @@ function NoteImage(props: { event: Event; fadeIn?: boolean }) {
try {
parsedUrl = new URL(url);
} catch (e) {
console.log('invalid url', url);
console.log("invalid url", url);
return;
}
if (parsedUrl.pathname.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)) {
attachments.push({ type: 'image', url: parsedUrl.href });
if (
parsedUrl.pathname.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)
) {
attachments.push({ type: "image", url: parsedUrl.href });
}
// videos
if (parsedUrl.pathname.toLowerCase().match(/\.(mp4|mkv|avi|flv|wmv|mov|webm)$/)) {
attachments.push({ type: 'video', url: parsedUrl.href });
if (
parsedUrl.pathname
.toLowerCase()
.match(/\.(mp4|mkv|avi|flv|wmv|mov|webm)$/)
) {
attachments.push({ type: "video", url: parsedUrl.href });
}
});
}
@ -118,7 +125,7 @@ function NoteImage(props: { event: Event; fadeIn?: boolean }) {
{attachments.map((attachment, i) => (
<>
<GalleryImage
href={`/${Key.toNostrBech32Address(props.event.id, 'note')}`}
href={`/${Key.toNostrBech32Address(props.event.id, "note")}`}
key={props.event.id + i}
onClick={(e) => onClick(e, i)}
attachment={attachment}

View File

@ -1,9 +1,9 @@
import styled from 'styled-components';
import styled from "styled-components";
import Modal from '../modal/Modal';
import SafeImg from '../SafeImg';
import Modal from "../modal/Modal";
import SafeImg from "../SafeImg";
import EventComponent from './EventComponent';
import EventComponent from "./EventComponent";
const ContentContainer = styled.div`
display: flex;
@ -45,7 +45,7 @@ const NoteImageModal = ({ event, onClose, attachment }) => {
<Modal width="90%" height="90%" centerVertically={true} onClose={onClose}>
<ContentContainer>
<MediaContainer>
{attachment.type === 'image' ? (
{attachment.type === "image" ? (
<SafeImg src={attachment.url} />
) : (
<video
@ -58,7 +58,11 @@ const NoteImageModal = ({ event, onClose, attachment }) => {
)}
</MediaContainer>
<InfoContainer>
<EventComponent id={event.id} standalone={true} showReplies={Infinity} />
<EventComponent
id={event.id}
standalone={true}
showReplies={Infinity}
/>
</InfoContainer>
</ContentContainer>
</Modal>

View File

@ -1,18 +1,18 @@
import { ArrowPathIcon, HeartIcon } from '@heroicons/react/24/outline';
import { HeartIcon as HeartIconFull } from '@heroicons/react/24/solid';
import $ from 'jquery';
import { memo } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
import { route } from 'preact-router';
import styled from 'styled-components';
import { ArrowPathIcon, HeartIcon } from "@heroicons/react/24/outline";
import { HeartIcon as HeartIconFull } from "@heroicons/react/24/solid";
import $ from "jquery";
import { memo } from "preact/compat";
import { useEffect, useState } from "preact/hooks";
import { route } from "preact-router";
import styled from "styled-components";
import Icons from '../../Icons';
import { decodeInvoice, formatAmount } from '../../Lightning';
import Events from '../../nostr/Events';
import Key from '../../nostr/Key';
import SocialNetwork from '../../nostr/SocialNetwork';
import Identicon from '../Identicon';
import ZapModal from '../modal/Zap';
import Icons from "../../Icons";
import { decodeInvoice, formatAmount } from "../../Lightning";
import Events from "../../nostr/Events";
import Key from "../../nostr/Key";
import SocialNetwork from "../../nostr/SocialNetwork";
import Identicon from "../Identicon";
import ZapModal from "../modal/Zap";
const ReactionButtons = styled.div`
display: flex;
@ -45,7 +45,7 @@ const ReactionCount = styled.span`
margin-right: 5px;
}
${(props) => (props.active ? 'color: var(--text-color)' : '')};
${(props) => (props.active ? "color: var(--text-color)" : "")};
`;
const Reactions = (props) => {
@ -53,8 +53,8 @@ const Reactions = (props) => {
reposts: 0,
reposted: false,
likes: 0,
zappers: null,
totalZapped: '',
zappers: null as string[] | null,
totalZapped: "",
liked: false,
repostedBy: new Set<string>(),
likedBy: new Set<string>(),
@ -71,7 +71,7 @@ const Reactions = (props) => {
useEffect(() => {
if (event) {
const unsub1 = Events.getRepliesAndReactions(event.id, (...args) =>
handleRepliesAndReactions(...args),
handleRepliesAndReactions(...args)
);
const unsub2 = SocialNetwork.getProfile(event.pubkey, (profile) => {
@ -92,9 +92,9 @@ const Reactions = (props) => {
function replyBtnClicked() {
if (props.standalone) {
$(document).find('textarea').focus();
$(document).find("textarea").focus();
} else {
route(`/${Key.toNostrBech32Address(props.event.id, 'note')}`);
route(`/${Key.toNostrBech32Address(props.event.id, "note")}`);
}
}
@ -114,10 +114,10 @@ const Reactions = (props) => {
Events.publish({
kind: 6,
tags: [
['e', hexId, '', 'mention'],
['p', author],
["e", hexId, "", "mention"],
["p", author],
],
content: '',
content: "",
});
}
}
@ -131,10 +131,10 @@ const Reactions = (props) => {
if (hexId) {
Events.publish({
kind: 7,
content: '+',
content: "+",
tags: [
['e', hexId],
['p', author],
["e", hexId],
["p", author],
],
});
}
@ -142,7 +142,7 @@ const Reactions = (props) => {
}
function toggleLikes(e) {
console.log('toggle likes');
console.log("toggle likes");
e.stopPropagation();
setState((prevState) => ({
...prevState,
@ -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'));
@ -186,8 +186,8 @@ const Reactions = (props) => {
replies &&
Array.from(replies).sort((a, b) => {
// heavy op? unnecessary when they're not even shown?
const eventA = Events.db.by('id', a);
const eventB = Events.db.by('id', b);
const eventA = Events.db.by("id", a);
const eventB = Events.db.by("id", b);
// show our replies first
if (eventA?.pubkey === myPub && eventB?.pubkey !== myPub) {
return -1;
@ -195,20 +195,30 @@ const Reactions = (props) => {
return 1;
}
// show replies by original post's author first
if (eventA?.pubkey === event?.pubkey && eventB?.pubkey !== event?.pubkey) {
if (
eventA?.pubkey === event?.pubkey &&
eventB?.pubkey !== event?.pubkey
) {
return -1;
} else if (eventA?.pubkey !== event?.pubkey && eventB?.pubkey === event?.pubkey) {
} else if (
eventA?.pubkey !== event?.pubkey &&
eventB?.pubkey === event?.pubkey
) {
return 1;
}
return eventA?.created_at - eventB?.created_at;
});
const zapEvents = Array.from(zaps?.values()).map((eventId) => Events.db.by('id', eventId));
const zappers = zapEvents.map((event) => Events.getZappingUser(event.id));
const zapEvents = Array.from(zaps?.values()).map((eventId) =>
Events.db.by("id", eventId)
);
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];
const bolt11 = event?.tags.find((tag) => tag[0] === "bolt11")[1];
if (!bolt11) {
console.log('Invalid zap, missing bolt11 tag');
console.log("Invalid zap, missing bolt11 tag");
return acc;
}
const decoded = decodeInvoice(bolt11);
@ -236,9 +246,14 @@ const Reactions = (props) => {
return (
<div className="likes">
{Array.from(state.likedBy).map((key) => {
const npub = Key.toNostrBech32Address(key, 'npub');
const npub = Key.toNostrBech32Address(key, "npub");
return (
<Identicon showTooltip={true} onClick={() => route(`/${npub}`)} str={npub} width={32} />
<Identicon
showTooltip={true}
onClick={() => route(`/${npub}`)}
str={npub}
width={32}
/>
);
})}
</div>
@ -252,36 +267,43 @@ const Reactions = (props) => {
<a className="msg-btn reply-btn" onClick={() => replyBtnClicked()}>
{Icons.reply}
</a>
<ReactionCount>{s.replyCount || ''}</ReactionCount>
<ReactionCount>{s.replyCount || ""}</ReactionCount>
{props.settings.showReposts ? (
<>
<a
className={`msg-btn repost-btn ${s.reposted ? 'reposted' : ''}`}
className={`msg-btn repost-btn ${s.reposted ? "reposted" : ""}`}
onClick={(e) => repostBtnClicked(e)}
>
<ArrowPathIcon width={24} />
</a>
<ReactionCount active={s.showReposts} onClick={(e) => toggleReposts(e)}>
{s.reposts || ''}
<ReactionCount
active={s.showReposts}
onClick={(e) => toggleReposts(e)}
>
{s.reposts || ""}
</ReactionCount>
</>
) : (
''
""
)}
{props.settings.showLikes ? (
<>
<a
className={`msg-btn like-btn ${s.liked ? 'liked' : ''}`}
className={`msg-btn like-btn ${s.liked ? "liked" : ""}`}
onClick={(e) => likeBtnClicked(e)}
>
{s.liked ? <HeartIconFull width={24} /> : <HeartIcon width={24} />}
{s.liked ? (
<HeartIconFull width={24} />
) : (
<HeartIcon width={24} />
)}
</a>
<ReactionCount active={s.showLikes} onClick={(e) => toggleLikes(e)}>
{s.likes || ''}
{s.likes || ""}
</ReactionCount>
</>
) : (
''
""
)}
{props.settings.showZaps && state.lightning ? (
<>
@ -296,11 +318,11 @@ const Reactions = (props) => {
{Icons.lightning}
</a>
<ReactionCount active={s.showZaps} onClick={(e) => toggleZaps(e)}>
{s.totalZapped || ''}
{s.totalZapped || ""}
</ReactionCount>
</>
) : (
''
""
)}
</ReactionButtons>
);
@ -313,7 +335,9 @@ const Reactions = (props) => {
lnurl={state.lightning}
note={props.event.id}
recipient={props.event.pubkey}
onClose={() => setState((prevState) => ({ ...prevState, showZapModal: false }))}
onClose={() =>
setState((prevState) => ({ ...prevState, showZapModal: false }))
}
/>
);
}
@ -323,7 +347,12 @@ const Reactions = (props) => {
<div className="likes">
{(state.zappers || []).map((npub) => {
return (
<Identicon showTooltip={true} onClick={() => route(`/${npub}`)} str={npub} width={32} />
<Identicon
showTooltip={true}
onClick={() => route(`/${npub}`)}
str={npub}
width={32}
/>
);
})}
</div>
@ -334,9 +363,14 @@ const Reactions = (props) => {
return (
<div className="likes">
{Array.from(state.repostedBy).map((key) => {
const npub = Key.toNostrBech32Address(key, 'npub');
const npub = Key.toNostrBech32Address(key, "npub");
return (
<Identicon showTooltip={true} onClick={() => route(`/${npub}`)} str={npub} width={32} />
<Identicon
showTooltip={true}
onClick={() => route(`/${npub}`)}
str={npub}
width={32}
/>
);
})}
</div>

View File

@ -1,13 +1,13 @@
import { useEffect, useState } from 'react';
import { ArrowPathIcon } from '@heroicons/react/24/outline';
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 Name from '../Name';
import Events from "../../nostr/Events";
import Key from "../../nostr/Key";
import { translate as t } from "../../translations/Translation.mjs";
import Name from "../Name";
import EventComponent from './EventComponent';
import EventComponent from "./EventComponent";
interface Props {
event: Event;
@ -17,15 +17,20 @@ 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) {
const unsub = Events.getRepliesAndReactions(
repostedEventId,
(_a: Set<string>, _b: Set<string>, _c: number, repostedBy: Set<string>) => {
(
_a: Set<string>,
_b: Set<string>,
_c: number,
repostedBy: Set<string>
) => {
setAllReposts(Array.from(repostedBy));
},
}
);
return () => unsub();
}
@ -33,19 +38,27 @@ export default function Repost(props: Props) {
return (
<div className="msg">
<div className="msg-content" style={{ padding: '12px 0 0 0' }}>
<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>
<ArrowPathIcon width={24} />
</i>
<a href={`/${Key.toNostrBech32Address(props.event.pubkey, 'npub')}`}>
<a
href={`/${Key.toNostrBech32Address(props.event.pubkey, "npub")}`}
>
<Name pub={props.event?.pubkey} hideBadge={true} />
</a>
<span style={{ marginLeft: '5px' }}>
{allReposts.length > 1 && `and ${allReposts.length - 1} others`} {t('reposted')}
<span style={{ marginLeft: "5px" }}>
{allReposts.length > 1 && `and ${allReposts.length - 1} others`}{" "}
{t("reposted")}
</span>
</small>
</div>

View File

@ -1,13 +1,13 @@
import { useEffect, useState } from 'react';
import { route } from 'preact-router';
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';
import Icons from "../../Icons";
import Events from "../../nostr/Events";
import Key from "../../nostr/Key";
import Name from "../Name";
import EventComponent from './EventComponent';
import EventComponent from "./EventComponent";
interface Props {
event: Event;
@ -15,63 +15,86 @@ interface Props {
const messageClicked = (e: MouseEvent, zappedId: string) => {
const target = e.target as HTMLElement;
if (['A', 'BUTTON', 'TEXTAREA', 'IMG', 'INPUT'].find((tag) => target.closest(tag))) {
if (
["A", "BUTTON", "TEXTAREA", "IMG", "INPUT"].find((tag) =>
target.closest(tag)
)
) {
return;
}
if (window.getSelection()?.toString()) {
return;
}
e.stopPropagation();
route(`/${Key.toNostrBech32Address(zappedId, 'note')}`);
route(`/${Key.toNostrBech32Address(zappedId, "note")}`);
};
export default function Zap(props: Props) {
const [allZaps, setAllZaps] = useState<string[]>([]);
const zappedId = Events.getEventReplyingTo(props.event);
const zappedEvent = Events.db.by('id', zappedId);
const zappedEvent = Events.db.by("id", zappedId);
const authorIsYou = zappedEvent?.pubkey === Key.getPubKey();
const mentioned = zappedEvent?.tags?.find((tag) => tag[0] === 'p' && tag[1] === Key.getPubKey());
const mentioned = zappedEvent?.tags?.find(
(tag) => tag[0] === "p" && tag[1] === Key.getPubKey()
);
const zappedText = authorIsYou
? 'zapped your note'
? "zapped your note"
: mentioned
? 'zapped a note where you were mentioned'
: 'zapped a note';
? "zapped a note where you were mentioned"
: "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) {
console.error('no zapping user found for event', props.event.id, e);
return '';
console.error("no zapping user found for event", props.event.id, e);
return "";
}
const userLink = `/${zappingUser}`;
return (
<div className="msg">
<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' }}>
<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" }}>
{Icons.lightning}
</i>
<div>
<a href={userLink} style={{ marginRight: '5px' }}>
<Name pub={zappingUser} />
<a href={userLink} style={{ marginRight: "5px" }}>
<Name pub={zappingUser || ""} />
</a>
{allZaps.length > 1 && <span> and {allZaps.length - 1} others </span>}
{allZaps.length > 1 && (
<span> and {allZaps.length - 1} others </span>
)}
{zappedText}
</div>
</div>
<EventComponent key={zappedId + props.event.id} id={zappedId} fullWidth={false} />
<EventComponent
key={zappedId + props.event.id}
id={zappedId}
fullWidth={false}
/>
</div>
</div>
</div>

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