Compare commits

..

No commits in common. "main" and "v0.1.23" have entirely different histories.

809 changed files with 23833 additions and 54606 deletions

View File

@ -1,10 +0,0 @@
**/node_modules
**/.pnp.*
**/.yarn/*
!**/.yarn/patches
!**/.yarn/plugins
!**/.yarn/releases
!**/.yarn/sdks
!**/.yarn/versions
**/.idea
**/target

View File

@ -17,19 +17,17 @@ steps:
commands:
- git fetch --tags
- name: Build site
image: node:current
image: node:current-bullseye
volumes:
- name: cache
path: /cache
environment:
YARN_CACHE_FOLDER: /cache/.yarn-docker
NODE_CONFIG_ENV: default
commands:
- apt update && apt install -y git
- yarn install
- yarn build
- name: build docker image
image: docker
image: r.j3ss.co/img
privileged: true
volumes:
- name: cache
@ -38,11 +36,9 @@ steps:
TOKEN:
from_secret: docker_hub
commands:
- dockerd &
- docker login -u voidic -p $TOKEN
- docker buildx create --name mybuilder --bootstrap --use
- docker buildx build -t voidic/snort:latest --platform linux/amd64,linux/arm64 --push -f Dockerfile.prebuilt .
- kill $(cat /var/run/docker.pid)
- img login -u voidic -p $TOKEN
- img build -t voidic/snort:latest --platform linux/amd64,linux/arm64 -f Dockerfile.prebuilt .
- img push voidic/snort:latest
volumes:
- name: cache
claim:
@ -57,13 +53,12 @@ metadata:
namespace: git
steps:
- name: Test/Lint
image: node:current
image: node:current-bullseye
volumes:
- name: cache
path: /cache
environment:
YARN_CACHE_FOLDER: /cache/.yarn-test
NODE_CONFIG_ENV: default
commands:
- yarn install
- yarn build
@ -89,13 +84,12 @@ metadata:
namespace: git
steps:
- name: Push/Pull translations
image: node:current
image: node:current-bullseye
volumes:
- name: cache
path: /cache
environment:
YARN_CACHE_FOLDER: /cache/.yarn-translations
NODE_CONFIG_ENV: default
TOKEN:
from_secret: gitea
CTOKEN:
@ -135,19 +129,17 @@ steps:
commands:
- git fetch --tags
- name: Build site
image: node:current
image: node:current-bullseye
volumes:
- name: cache
path: /cache
environment:
YARN_CACHE_FOLDER: /cache/.yarn-docker-
NODE_CONFIG_ENV: default
YARN_CACHE_FOLDER: /cache/.yarn-docker-release
commands:
- apt update && apt install -y git
- yarn install
- yarn build
- name: build docker image
image: docker
image: r.j3ss.co/img
privileged: true
volumes:
- name: cache
@ -156,11 +148,9 @@ steps:
TOKEN:
from_secret: docker_hub
commands:
- dockerd &
- docker login -u voidic -p $TOKEN
- docker buildx create --name mybuilder --bootstrap --use
- docker buildx build -t voidic/snort:$DRONE_TAG --platform linux/amd64,linux/arm64 --push -f Dockerfile.prebuilt .
- kill $(cat /var/run/docker.pid)
- img login -u voidic -p $TOKEN
- img build -t voidic/snort:$DRONE_TAG --platform linux/amd64,linux/arm64 -f Dockerfile.prebuilt .
- img push voidic/snort:$DRONE_TAG
volumes:
- name: cache
claim:

View File

@ -8,6 +8,42 @@ env:
DOCKER_CLI_EXPERIMENTAL: enabled
TAG_FMT: "^refs/tags/(((.?[0-9]+){3,4}))$"
jobs:
tauri_release:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
platform: [macos-latest, ubuntu-20.04, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev
- name: Rust setup
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> target"
- name: Sync node version and setup cache
uses: actions/setup-node@v3
with:
node-version: "16"
cache: "yarn"
- name: Install frontend dependencies
run: yarn install
- name: Build the app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
app:
runs-on: ubuntu-latest
permissions:
@ -56,8 +92,7 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "33.0.0"
- name: Sign APK
uses: r0adkll/sign-android-release@v1
with:
@ -66,8 +101,6 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "33.0.0"
- name: Rename files
run: |-
mkdir -p snort_android/app/release

4
.gitignore vendored
View File

@ -11,6 +11,4 @@ dist/
*.tgz
*.log
.DS_Store
.pnp*
docs/
.wrangler/
.pnp*

874
.yarn/releases/yarn-3.6.3.cjs vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,9 +1 @@
compressionLevel: mixed
enableGlobalCache: false
npmScopes:
here:
npmRegistryServer: "https://repo.platform.here.com/artifactory/api/npm/maps-api-for-javascript/"
yarnPath: .yarn/releases/yarn-4.1.1.cjs
yarnPath: .yarn/releases/yarn-3.6.3.cjs

View File

@ -1,12 +1,12 @@
FROM node:current as build
WORKDIR /src
RUN apt update \
&& apt install -y --no-install-recommends git \
&& git clone --single-branch -b main https://git.v0l.io/Kieran/snort \
&& cd snort \
&& yarn --network-timeout 1000000 \
&& yarn build
FROM node:19 as build
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml .
COPY .yarn .yarn
COPY packages packages
RUN yarn --network-timeout 1000000
RUN yarn build
FROM nginxinc/nginx-unprivileged:mainline-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/snort/packages/app/build /usr/share/nginx/html
COPY --from=build /app/packages/app/build /usr/share/nginx/html

View File

@ -44,7 +44,7 @@ Snort supports the following NIP's:
- [x] NIP-65: Relay List Metadata
- [x] NIP-75: Zap Goals
- [x] NIP-78: App specific data
- [x] NIP-89: App handlers
- [ ] NIP-89: App handlers
- [x] NIP-94: File Metadata
- [x] NIP-96: HTTP File Storage Integration (Draft)
- [x] NIP-98: HTTP Auth
@ -65,19 +65,6 @@ To build the application and system packages, use
$ yarn build
```
Tauri desktop application:
```
# install dependencies
yarn
# develop
yarn tauri dev
# build
yarn tauri build
```
### Translations
[![Crowdin](https://badges.crowdin.net/snort/localized.svg)](https://crowdin.com/project/snort)

View File

@ -1,30 +0,0 @@
# Reactions
## Problem
When presented with a feed of notes, either a timeline (social) or a live chat log (live stream chat)
how do you fetch the reactions to such notes and maintain realtime updates.
## Current solution
When a list of reactions is requested we use the expensive `buildDiff` operation to compute a
list of new (added) filters and send them to relays.
Usually if `leaveOpen` is specified (as it should be for realtime updates) this new trace will be sent
as a separate subscription causing exhasution.
Another side effect of this this approach is that over time (especially in live chat) the number of filters that get passed to `buildDiff` increases and so the computation time takes longer and causes jank (https://git.v0l.io/Kieran/zap.stream/issues/126).
There is also the question of updating the "root" query, since this is not updated, each independant query trace receives its own set of updates which is a problem of its own.
## Proposed solution (Live chat)
The ideal solution is to update only the "root" query as new filters are detected along with appending the current timestamp as the `since` value.
In this way only 1 subscription is maintained, the "root" query trace.
Each time a new set of filters is created from `buildDiff` we push the same `REQ` again with the new filters which **should** result in no new results from the relays as we expect there to be none `since` the current time is the time of the latest message.
## Proposed solution (Timeline)
TBD

View File

@ -3,9 +3,6 @@ server {
server_name _;
root /usr/share/nginx/html;
index index.html;
add_header Content-Security-Policy "default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com";
add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;
location / {
try_files $uri $uri/ /index.html =404;

View File

@ -1,50 +0,0 @@
interface Env {}
const HOST = "snort.social";
export const onRequest: PagesFunction<Env> = async context => {
const u = new URL(context.request.url);
const prefixes = ["npub1", "nprofile1", "naddr1", "nevent1", "note1"];
const isEntityPath = prefixes.some(
a => u.pathname.startsWith(`/${a}`) || u.pathname.startsWith(`/e/${a}`) || u.pathname.startsWith(`/p/${a}`),
);
const nostrAddress = u.pathname.match(/^\/([a-zA-Z0-9_]+)$/i);
const next = await context.next();
if (u.pathname != "/" && (isEntityPath || nostrAddress)) {
//console.log("Handeling path: ", u.pathname, isEntityPath, nostrAddress[1]);
try {
let id = u.pathname.split("/").at(-1);
if (!isEntityPath && nostrAddress) {
id = `${id}@${HOST}`;
}
const fetchApi = `https://nostr.api.v0l.io/api/v1/opengraph/${id}?canonical=${encodeURIComponent(
`https://${HOST}/%s`,
)}`;
console.log("Fetching tags from: ", fetchApi);
const rsp = await fetch(fetchApi, {
method: "POST",
body: await next.arrayBuffer(),
headers: {
"user-agent": `SnortFunctions/1.0 (https://${HOST})`,
"content-type": "text/html",
accept: "text/html",
},
});
if (rsp.ok) {
const body = await rsp.text();
if (body.length > 0) {
return new Response(body, {
headers: {
...Object.fromEntries(rsp.headers.entries()),
"cache-control": "no-cache",
},
});
}
}
} catch (e) {
console.error(e);
}
}
return next;
};

30
functions/e/[id].ts Normal file
View File

@ -0,0 +1,30 @@
interface Env {}
export const onRequest: PagesFunction<Env> = async context => {
const id = context.params.id as string;
const next = await context.next();
try {
const rsp = await fetch(`https://api.snort.social/api/v1/og/tag/e/${id}`, {
method: "POST",
body: await next.arrayBuffer(),
headers: {
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
"content-type": "text/plain",
},
});
if (rsp.ok) {
const body = await rsp.text();
if (body.length > 0) {
return new Response(body, {
headers: {
"content-type": "text/html",
},
});
}
}
} catch {
// ignore
}
return next;
};

30
functions/p/[id].ts Normal file
View File

@ -0,0 +1,30 @@
interface Env {}
export const onRequest: PagesFunction<Env> = async context => {
const id = context.params.id as string;
const next = await context.next();
try {
const rsp = await fetch(`https://api.snort.social/api/v1/og/tag/p/${id}`, {
method: "POST",
body: await next.arrayBuffer(),
headers: {
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
"content-type": "text/plain",
},
});
if (rsp.ok) {
const body = await rsp.text();
if (body.length > 0) {
return new Response(body, {
headers: {
"content-type": "text/html",
},
});
}
}
} catch {
// ignore
}
return next;
};

View File

@ -1,8 +0,0 @@
maintainers:
- npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
relays:
- wss://relay.snort.social/
- wss://pyramid.fiatjaf.com/
- wss://nos.lol/
- ws://skzzn6cimfdv5e2phjc4yr5v7ikbxtn5f7dkwn5c7v47tduzlbosqmqd.onion/

View File

@ -4,29 +4,24 @@
"packages/*"
],
"scripts": {
"build": "yarn workspace @snort/shared build && yarn workspace @snort/worker-relay build && yarn workspace @snort/system build && yarn workspace @snort/system-web build && yarn workspace @snort/system-react build && yarn workspace @snort/wallet build && yarn workspace @snort/app build",
"build": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/system-web build && yarn workspace @snort/system-react build && yarn workspace @snort/app build",
"start": "yarn build && yarn workspace @snort/app start",
"test": "yarn build && yarn workspace @snort/app test && yarn workspace @snort/system test",
"pre:commit": "yarn workspace @snort/app intl-extract && yarn workspace @snort/app intl-compile && yarn prettier --write .",
"push-prod": "git switch snort-prod && git merge --ff-only main && git push && git checkout main",
"docs": "typedoc --entryPointStrategy packages ./packages/* --exclude ./packages/app --exclude ./packages/webrtc-server --name snort.social"
"push-prod": "git checkout snort-prod && git merge --ff-only main && git push && git checkout main"
},
"prettier": {
"printWidth": 120,
"bracketSameLine": true,
"arrowParens": "avoid",
"trailingComma": "all",
"endOfLine": "lf"
"trailingComma": "all"
},
"packageManager": "yarn@4.1.1",
"packageManager": "yarn@3.6.3",
"dependencies": {
"@cloudflare/workers-types": "^4.20230307.0",
"@tauri-apps/cli": "^1.2.3",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"typescript": "^5.2.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0-rc.14",
"typedoc": "^0.25.7"
}
}

View File

@ -0,0 +1,21 @@
module.exports = {
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "formatjs"],
rules: {
"formatjs/enforce-id": [
"error",
{
idInterpolationPattern: "[sha512:contenthash:base64:6]",
},
],
},
root: true,
ignorePatterns: ["build/", "*.test.ts", "*.js"],
env: {
browser: true,
worker: true,
commonjs: true,
node: false,
},
};

View File

@ -1,31 +0,0 @@
/* eslint-disable import/no-anonymous-default-export */
module.exports = {
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended"],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "formatjs", "react-refresh", "simple-import-sort"],
rules: {
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"react-refresh/only-export-components": "error",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"@typescript-eslint/no-unused-vars": "error",
"max-lines": ["warn", { max: 300, skipBlankLines: true, skipComments: true }],
},
overrides: [
{
files: ["*.tsx"],
rules: {
"max-lines": ["warn", { max: 200, skipBlankLines: true, skipComments: true }],
},
},
],
root: true,
ignorePatterns: ["build/", "*.test.ts", "*.js"],
env: {
browser: true,
worker: true,
commonjs: true,
node: false,
},
};

View File

@ -25,5 +25,4 @@ yarn-error.log*
.idea
dist/
dev-dist/
.wrangler/
dev-dist/

View File

@ -0,0 +1,47 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEApyUVkYJVwV7XgluUnllgCtrsdq1ctRICm5gQy8nd+aEdDQjA
CKPOWh5miLl/fAQVZGZy/JxavzXulwXo8238E6n6bmNB1Us2nuw7a0aW4iUSQ1Pt
P4ZhPpcrqeqMf+hp7iBW0nAHFy/aa2UR84d7tBmSk5J3NNrfBsZdUex/7FqF1EVv
mEzlc8kepU9lRXWFQDtZCllEZ1kY3SBJPm10h0g9saI8YIVRxUuNII5GHDYAE3hb
EmoY6fuSEoiXA8u0Yt9soBQxgxIhQVKSRPPoIPjGFOxsGHY6h8R9nx1kxhHKFRuV
nwsn0uWl/7yjhwyHanogJu73/WgelPcgP/hMDQIDAQABAoIBAAru+xU0oGVwzcoi
MXuWPxkWrwcoWfsiPXduIBMklleg+WSD4QPvqyzr9isVb0huf/O8W+M4WxtM7NmG
MnHSDP5ATThxV7obHGyS6WQgDvimEibDU66nHK9adim8RQqM6nkANo23dE9I+xGx
X9Y9U5M5ZQQwPYoAkzw/N5WHUerk+cSEYWYV8jDtO7wJhYOMu5qliPeuNOaWZ1W6
1uwr8A4ih69WwzugPuBSgBrPAW1c84zWIFN+njAugqPF5x8xp2uM3tUO9s5UlHJC
FWEuU40KcDT2utSUY+2HXSHbycF4KLKT5jAKSa4sPziLfo+YifrlN0Y3rhofUlZT
jCaeZ8ECgYEA5/xpk8aVhCEvv5iCghv0p/IHcjdXjx5+PCWh3Adx0fF91UvU5oqn
okdyYZDShZMuLDfJ0lG+OMKZd01JapnbTtiVNceVRMnraIdoWEM2/4bTXTSZGtdA
8gh/Kc/PMbPf5ppVWwqTCbUkPOSyGHyGc7+DQquq1w6yZu04A3x9vHECgYEAuHJk
uz8YKY5ZUR7CZ3y7YFuwq5Lcpl43AfiiCasjRch0P8yLrITc/6fORsXyy64XW9fC
h3YmXvEPaM03W2dxw2aQDvXEvXiEITzmILs7SE3UmZR9m7OMy7Jeqr3+JOc0ckZe
Rz5FfuMt1IvNB6lrpfHVtoVrpCOXpzHgC/k/x10CgYA6lU18GfwL/+107uiWPsUL
3FzxBPTBmau7OK2lSOP/ZoKmaJ39Eiq/GlfSN6ZSQRa55+S5jhcBcnMa45OUrgHp
6VvU1u/lDTC7luZM07yBzuR1dyDq3Ez0Uhz6zBXAsXHrZDIF6ae0HeBm2EH5WQkD
Fevp3DwqTvXSdDle+AMwoQKBgQCBSlaH1rNmNc0wCsK07f8ejUcrDZgz2mjurc1P
v7HK8bdjHUtvE/ciEguLGqiV06O2EmjesZg2Bv4JNYivPrTFBrjGc8qEEd10uw6J
NRVaGoyDV04w/UwdYRvwzZs/XP4reF4PzHvEdRSkH5cJ3t2BhiKLfby1YumkHlbx
rbbiVQKBgB02jyZUiB6pPTCP8vXZCJbZELgqNyS04ALhBBpdfGMcU1+0hRLJFBaE
tClJPGARFXl+MPkY032vmJZOuH3LrcTCm8DmMLzM/hT1EWawQ8BJkkwiIokE4lqc
Bi8CrkvuQs2cuCStK6C3Nkyr1lTkDge46trsb7KTcfHdtLsS7EPj
-----END RSA PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDWzCCAkOgAwIBAgIJDji8iiceMvQlMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
BAMTCWxvY2FsaG9zdDAeFw0yMzEwMTYwOTI0MThaFw0yMzExMTUxMDI0MThaMBQx
EjAQBgNVBAMTCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBAKclFZGCVcFe14JblJ5ZYAra7HatXLUSApuYEMvJ3fmhHQ0IwAijzloeZoi5
f3wEFWRmcvycWr817pcF6PNt/BOp+m5jQdVLNp7sO2tGluIlEkNT7T+GYT6XK6nq
jH/oae4gVtJwBxcv2mtlEfOHe7QZkpOSdzTa3wbGXVHsf+xahdRFb5hM5XPJHqVP
ZUV1hUA7WQpZRGdZGN0gST5tdIdIPbGiPGCFUcVLjSCORhw2ABN4WxJqGOn7khKI
lwPLtGLfbKAUMYMSIUFSkkTz6CD4xhTsbBh2OofEfZ8dZMYRyhUblZ8LJ9Llpf+8
o4cMh2p6ICbu9/1oHpT3ID/4TA0CAwEAAaOBrzCBrDAMBgNVHRMEBTADAQH/MAsG
A1UdDwQEAwIC9DAxBgNVHSUEKjAoBggrBgEFBQcDAQYIKwYBBQUHAwIGCCsGAQUF
BwMDBggrBgEFBQcDCDBcBgNVHREEVTBTgglsb2NhbGhvc3SCFWxvY2FsaG9zdC5s
b2NhbGRvbWFpboIGbHZoLm1lgggqLmx2aC5tZYIFWzo6MV2HBH8AAAGHEP6AAAAA
AAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggEBABY0rgWuzLYvVtvoVvWKS9cg
8rVhBRIFvpYO814ocN1iaxYQ9t9uLRsJXj0K+z1BHWf0zBiw4mB3dD9VpiKpuliL
4tRT+vATA96OYCd9G5k7DFQascAau40H3jxckh9rimIWa45FUSd7FIcddo1jeciv
gdAdiNUuHBen82O8KHJb+1PCBdA8RYeO5EGKfJM2yrOovu7dAFilf1ZPkXWgXnfG
nN6YfDDo9rAVDbvNXImrkwmGqEcN3Pq909IHiM/VETlU5lP4AbTNgrDa/aaZ+I+b
1MC1p87MvnibyXs+rTlK5+j8E6noNcD7tsHNd4ufkVCqr+pvSpuA3OvnXjbbm54=
-----END CERTIFICATE-----

View File

@ -1,95 +1,3 @@
# v0.2.0
`+16,990,-9,649`
## Added
- Check notification settings page
- New settings page layout - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Community Leaders / Invite system - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Settings->Tools pages (Check follows relay health etc) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- New wallet pages design - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Alby OAuth wallet connection - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Cashu wallet support (WIP) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Followed by friends feed page - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Fuzzysearch profiles everywhere - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Worker Relay package `@snort/worker-relay` - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Replaces all previous caching objects, all caches are handled inside `@snort/system` via worker relay
- "View as user" button - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Play live streams directly in feed with embed iframe - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Negentropy v1 support - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
## Changed
- Hidden note styles & preferences - nostr:npub1cz2ve34nk0ukn0ph4yq2qx3ud8rfy5e0ak4epx42dn8gha0sdgpsgra9kv
- Keybinds for grid modal navigation - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Cache trending sections in browser - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Cache images / nostr.json in service worker - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Add dimensions to `imeta` tag for void.cat uploads - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Check event sigs in `@snort/system-wasm` - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Primary color scheme - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Note creator styles (removed hashtags input) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Cache link preview results in memory - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Show only 1 task at a time in task list - nostr:
- Render media in reply to note creator - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Show top zappers inline with footer icons on notes - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Add more search relays - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Moved link previews and opengraph tagging to https://nostr.api.v0l.io - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
## Fixed
- Iris account error mesage - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Light theme color fixes - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Notifications page overflow - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
# v0.1.24
`+11,573,-3,010`
## Added
- 3 Column layout - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Fuzzy cache search - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Followed by on profile pages - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Show more on long notes - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Better error message page - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Media grid feed - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Mobile fixed footer - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Follow button on profile search results - nostr:npub17q5n2z8naw0xl6vu9lvt560lg33pdpe29k0k09umlfxm3vc4tqrq466f2y
- Invite codes (WIP Community Program) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- `imeta` tag insertion for images - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Wallet settings page improvements - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Nostr Wallet Connect upgrade (balance + history) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Schnorr sig check in WASM binary - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Autoplay videos in feed (muted) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Followed by friends feed (a feed of your 2nd degree follows posts) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- imgproxy image integrity check (sha256 from `imeta` passed to imgproxy) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
## Changed
- Removed Twitter embed - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Removed attachment button on DM's - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Note broadcaster dialog changed to toast notification - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Removed npub link from profile (use QR button) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Render image size from `imeta` tags - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Style fixes - nostr:npub1cz2ve34nk0ukn0ph4yq2qx3ud8rfy5e0ak4epx42dn8gha0sdgpsgra9kv
- Zap pool slider tweak - nostr:npub1ltx67888tz7lqnxlrg06x234vjnq349tcfyp52r0lstclp548mcqnuz40t
- New Malay translations - nostr:npub1cjtt3nywuflj65ftld4v7zzpg0qh3ergycjcym0956vf9eftv7esekxpmn
- Updated Persian translations - nostr:npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk
- Updated Finnish translations - nostr:npub1ust7u0v3qffejwhqee45r49zgcyewrcn99vdwkednd356c9resyqtnn3mj
- Updated French translations - nostr:npub1x8dzy9xegwmdk2vy30l8u08caspcqq2yzncxehdsa6kvnte9pr3qnt8pg4 & nostr:npub13w02l37gkjwv90lnklfet5653jj0p5ueu976v3dpda5afvxgw3uslcqdnv
- Updated German translations - nostr:npub19a6x8frkkn2660fw0flz74a7qg8c2jxk5v9p2rsh7tv5e6ftsq3sav63vp
- Updated Hungarian translations - nostr:npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp
- Updated Swedish translations - nostr:npub19jk45jz45gczwfm22y9z69xhaex3nwg47dz84zw096xl6z62amkqj99rv7
- Updated Japanese translations - nostr:npub1wh69w45awqnlsxw7jt5tkymets87h6t4phplkx6ug2ht2qkssswswntjk0
## Fixed
- Longform note overlfow-x - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Trim zap content - nostr:npub1u8lnhlw5usp3t9vmpz60ejpyt649z33hu82wc2hpv6m5xdqmuxhs46turz
---
# v0.1.23
## Added
@ -627,7 +535,7 @@ https://git.v0l.io/Kieran/snort/compare/v0.1.9...v0.1.10
- Fix event mention bug by @SamSamskies in https://github.com/v0l/snort/pull/421
- fix NaN when parsing empty string by @SamSamskies in https://github.com/v0l/snort/pull/422
- NIP06 support by @w3irdrobot in https://github.com/v0l/snort/pull/425
- Added key attr to TabSelectors to remove React warning by @w3irdrobot in https://github.com/v0l/snort/pull/424
- Added key attr to Tabs to remove React warning by @w3irdrobot in https://github.com/v0l/snort/pull/424
- New Crowdin updates by @v0l in https://github.com/v0l/snort/pull/426
- New Crowdin updates by @v0l in https://github.com/v0l/snort/pull/436
- Update Wavlake embed URL, add support for album & artist links by @blastshielddown in https://github.com/v0l/snort/pull/439

2
packages/app/_headers Normal file
View File

@ -0,0 +1,2 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://nostrnests.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://analytics.v0l.io https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;

View File

@ -1,11 +0,0 @@
{
"plugins": [
[
"formatjs",
{
"idInterpolationPattern": "[sha512:contenthash:base64:6]",
"ast": true
}
]
]
}

View File

@ -4,54 +4,23 @@
"appTitle": "Snort - Nostr",
"hostname": "snort.social",
"nip05Domain": "snort.social",
"icon": "/nostrich_512.png",
"navLogo": null,
"favicon": "public/favicon.ico",
"appleTouchIconUrl": "/nostrich_512.png",
"publicDir": "public/snort",
"httpCache": "",
"animalNamePlaceholders": false,
"defaultZapPoolFee": 1,
"defaultZapPoolFee": 0.5,
"features": {
"analytics": true,
"subscriptions": true,
"deck": true,
"zapPool": true,
"communityLeaders": true,
"nostrAddress": true,
"pushNotifications": true
"zapPool": true
},
"signUp": {
"quickStart": false,
"defaultFollows": ["npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws"]
},
"defaultPreferences": {
"hideMutedNotes": false,
"defaultRootTab": "following"
},
"media": {
"bypassImgProxyError": false,
"preferLargeMedia": true
},
"communityLeaders": {
"list": "naddr1qq4xc6tnw3ez6vp58y6rywpjxckngdtyxukngwr9vckkze33vcknzcnrxcenje35xqmn2cczyp3lucccm3v9s087z6qslpkap8schltk427zfgqgrn3g2menq5zw6qcyqqq82vqprpmhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv7rajfl"
},
"noteCreatorToast": false,
"hideFromNavbar": ["/graph"],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",
"profileLinkPrefix": "nprofile",
"defaultRelays": {
"wss://relay.snort.social/": { "read": true, "write": true },
"wss://nostr.wine/": { "read": true, "write": false },
"wss://relay.damus.io/": { "read": true, "write": true },
"wss://nos.lol/": { "read": true, "write": true }
},
"alby": {
"clientId": "pohiJjPhQR",
"clientSecret": "GAl1YKLA3FveK1gLBYok"
},
"chatChannels": [
{ "type": "telegram", "value": "https://t.me/irismessenger" },
{ "type": "nip28", "value": "23286a4602ada10cc10200553bff62a110e8dc0eacddf73277395a89ddf26a09" }
]
"wss://eden.nostr.land/": { "read": true, "write": false }
}
}

View File

@ -4,52 +4,22 @@
"appTitle": "iris",
"hostname": "iris.to",
"nip05Domain": "iris.to",
"icon": "/img/icon128.png",
"navLogo": "/img/icon128.png",
"favicon": "public/iris/favicon.ico",
"appleTouchIconUrl": "/img/apple-touch-icon.png",
"publicDir": "public/iris",
"httpCache": "",
"httpCache": "https://api.iris.to",
"animalNamePlaceholders": true,
"defaultZapPoolFee": 0.5,
"features": {
"analytics": true,
"subscriptions": true,
"subscriptions": false,
"deck": true,
"zapPool": true,
"communityLeaders": true
"zapPool": true
},
"defaultPreferences": {
"hideMutedNotes": true,
"defaultRootTab": "for-you"
},
"signUp": {
"quickStart": true,
"defaultFollows": ["npub1wnwwcv0a8wx0m9stck34ajlwhzuua68ts8mw3kjvspn42dcfyjxs4n95l8"]
},
"media": {
"bypassImgProxyError": true,
"preferLargeMedia": true
},
"communityLeaders": {
"list": "naddr1qq4xc6tnw3ez6vp58y6rywpjxckngdtyxukngwr9vckkze33vcknzcnrxcenje35xqmn2cczyp3lucccm3v9s087z6qslpkap8schltk427zfgqgrn3g2menq5zw6qcyqqq82vqprpmhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv7rajfl"
},
"noteCreatorToast": false,
"hideFromNavbar": [],
"eventLinkPrefix": "note",
"profileLinkPrefix": "npub",
"showPowIcon": false,
"defaultRelays": {
"wss://relay.snort.social/": { "read": true, "write": true },
"wss://nostr.wine/": { "read": true, "write": false },
"wss://eden.nostr.land/": { "read": true, "write": false },
"wss://relay.nostr.band/": { "read": true, "write": true },
"wss://relay.damus.io/": { "read": true, "write": true }
},
"chatChannels": [
{ "type": "telegram", "value": "https://t.me/irismessenger" },
{ "type": "nip28", "value": "23286a4602ada10cc10200553bff62a110e8dc0eacddf73277395a89ddf26a09" }
],
"alby": {
"clientId": "5rYcHDrlDb",
"clientSecret": "QAI3QmgiaPH3BfTMzzFd"
"wss://eden.nostr.land/": { "read": true, "write": false }
}
}

View File

@ -1,49 +0,0 @@
{
"appName": "めく",
"appNameCapitalized": "めく",
"appTitle": "めく",
"hostname": "meku.app",
"nip05Domain": "meku.app",
"icon": "/nostr.jpg",
"navLogo": null,
"publicDir": "public/nostr",
"httpCache": "",
"animalNamePlaceholders": false,
"defaultZapPoolFee": 0,
"features": {
"analytics": true,
"subscriptions": false,
"deck": false,
"zapPool": false,
"communityLeaders": false,
"nostrAddress": false,
"pushNotifications": true
},
"signUp": {
"quickStart": false,
"defaultFollows": []
},
"defaultPreferences": {
"hideMutedNotes": false,
"defaultRootTab": "following",
"language": "ja"
},
"media": {
"bypassImgProxyError": false,
"preferLargeMedia": true
},
"communityLeaders": null,
"noteCreatorToast": false,
"hideFromNavbar": ["/graph"],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",
"profileLinkPrefix": "nprofile",
"defaultRelays": {
"wss://relay.nostr.wirednet.jp/": { "read": true, "write": true },
"wss://yabu.me/": { "read": true, "write": true },
"wss://nos.lol/": { "read": true, "write": true }
},
"alby": null,
"chatChannels": null
}

View File

@ -1,49 +0,0 @@
{
"appName": "Nostr",
"appNameCapitalized": "Nostr",
"appTitle": "Nostr",
"hostname": "nostr.com",
"nip05Domain": "nostr.com",
"icon": "/nostr.jpg",
"navLogo": null,
"publicDir": "public/nostr",
"httpCache": "",
"animalNamePlaceholders": false,
"defaultZapPoolFee": 0,
"features": {
"analytics": false,
"subscriptions": false,
"deck": false,
"zapPool": false,
"communityLeaders": false,
"nostrAddress": false,
"pushNotifications": false
},
"signUp": {
"quickStart": false,
"defaultFollows": []
},
"defaultPreferences": {
"hideMutedNotes": false,
"defaultRootTab": "following"
},
"media": {
"bypassImgProxyError": false,
"preferLargeMedia": true
},
"communityLeaders": null,
"noteCreatorToast": true,
"hideFromNavbar": ["/graph"],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",
"profileLinkPrefix": "nprofile",
"defaultRelays": {
"wss://relay.snort.social/": { "read": true, "write": true },
"wss://nostr.wine/": { "read": true, "write": false },
"wss://eden.nostr.land/": { "read": true, "write": false },
"wss://nos.lol/": { "read": true, "write": true }
},
"alby": null,
"chatChannels": null
}

View File

@ -1,5 +1,4 @@
/// <reference types="@webbtc/webln-types" />
/// <reference types="vite/client" />
declare module "*.jpg" {
const value: unknown;
@ -47,65 +46,27 @@ declare const CONFIG: {
appTitle: string;
hostname: string;
nip05Domain: string;
icon: string;
navLogo: string | null;
favicon: string;
appleTouchIconUrl: string;
httpCache: string;
animalNamePlaceholders: boolean;
defaultZapPoolFee: number;
defaultZapPoolFee?: number;
features: {
analytics: boolean;
subscriptions: boolean;
deck: boolean;
zapPool: boolean;
communityLeaders: boolean;
nostrAddress: boolean;
pushNotifications: boolean;
};
defaultPreferences: {
hideMutedNotes: boolean;
defaultRootTab: "following" | "for-you";
};
signUp: {
quickStart: boolean;
defaultFollows: Array<string>;
};
media: {
bypassImgProxyError: boolean;
preferLargeMedia: boolean;
};
communityLeaders?: {
list: string;
};
// Filter urls from nav sidebar
hideFromNavbar: Array<string>;
// Limit deck to certain subscriber tier
deckSubKind?: number;
showDeck?: boolean;
// Create toast notifications when publishing notes
noteCreatorToast: boolean;
eventLinkPrefix: NostrPrefix;
profileLinkPrefix: NostrPrefix;
defaultRelays: Record<string, RelaySettings>;
showPowIcon: boolean;
// Alby wallet oAuth config
alby?: {
clientId: string;
clientSecret: string;
};
// public chat channels for site
chatChannels?: Array<{
type: "nip28" | "telegram";
value: string;
}>;
};
/**
* Single relay (Debug)
*/
declare const SINGLE_RELAY: string | undefined;
/**
* Build git hash
*/

View File

@ -2,21 +2,17 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Feature packed nostr client" />
<meta
name="keywords"
content="nostr snort fast decentralized social media censorship resistant open source software" />
<link rel="preconnect" href="https://imgproxy.snort.social" />
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
<link rel="apple-touch-icon" href="" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/favicon.png" />
<title></title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>

View File

@ -0,0 +1,9 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
bail: true,
preset: "ts-jest",
testEnvironment: "jsdom",
roots: ["src"],
moduleDirectories: ["src", "node_modules"],
setupFiles: ["./src/setupTests.ts"],
};

View File

@ -1,35 +1,28 @@
{
"name": "@snort/app",
"version": "0.2.0",
"version": "0.1.23",
"dependencies": {
"@cashu/cashu-ts": "^1.0.0-rc.3",
"@here/maps-api-for-javascript": "^1.50.0",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"@scure/base": "^1.1.6",
"@scure/bip32": "^1.5.0",
"@scure/bip39": "^1.4.0",
"@cashu/cashu-ts": "^0.6.1",
"@lightninglabs/lnc-web": "^0.2.3-alpha",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.2.0",
"@scure/base": "^1.1.1",
"@scure/bip32": "^1.3.0",
"@scure/bip39": "^1.1.1",
"@snort/shared": "workspace:*",
"@snort/system": "workspace:*",
"@snort/system-react": "workspace:*",
"@snort/system-wasm": "workspace:*",
"@snort/system-web": "workspace:*",
"@snort/wallet": "workspace:*",
"@snort/worker-relay": "workspace:*",
"@szhsin/react-menu": "^3.5.3",
"@uidotdev/usehooks": "^2.4.1",
"@void-cat/api": "^1.0.12",
"@szhsin/react-menu": "^3.3.1",
"@uidotdev/usehooks": "^2.3.1",
"@void-cat/api": "^1.0.10",
"classnames": "^2.3.2",
"comlink": "^4.4.1",
"debug": "^4.3.4",
"dexie": "^3.2.4",
"emojilib": "^3.0.10",
"eventemitter3": "^5.0.1",
"fuse.js": "^7.0.0",
"highlight.js": "^11.8.0",
"latlon-geohash": "^2.0.0",
"light-bolt11-decoder": "^2.1.0",
"lottie-react": "^2.4.0",
"marked": "^9.1.0",
"marked-footnote": "^1.0.0",
"match-sorter": "^6.3.1",
@ -42,15 +35,13 @@
"react-router-dom": "^6.5.0",
"react-tag-input-component": "^2.0.2",
"react-textarea-autosize": "^8.4.0",
"react-twitter-embed": "^4.0.4",
"recharts": "^2.8.0",
"three": "^0.157.0",
"typescript-lru-cache": "^2.0.0",
"use-long-press": "^3.2.0",
"use-sync-external-store": "^1.2.0",
"uuid": "^9.0.0",
"workbox-cacheable-response": "^7.0.0",
"workbox-core": "^6.4.2",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^6.4.2",
"workbox-strategies": "^6.4.2"
@ -59,13 +50,10 @@
"start": "vite",
"build": "yarn eslint --fix && vite build",
"serve": "vite preview",
"test": "vitest run",
"test:watch": "vitest watch",
"test": "jest --runInBand",
"intl-extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file src/lang.json --flatten true",
"intl-compile": "formatjs compile src/lang.json --out-file src/translations/en.json",
"eslint": "eslint .",
"deploy:meku": "NODE_CONFIG_ENV=meku yarn build && npx wrangler pages deploy --project-name meku build/",
"deploy:notestr": "NODE_CONFIG_ENV=nostr yarn build && npx wrangler pages deploy --project-name nostr-generic build/"
"eslint": "eslint ."
},
"eslintConfig": {
"extends": [
@ -88,9 +76,10 @@
},
"devDependencies": {
"@formatjs/cli": "^6.1.3",
"@jest/globals": "^29.6.1",
"@types/config": "^3.3.3",
"@types/debug": "^4.1.8",
"@types/latlon-geohash": "^2.0.3",
"@types/jest": "^29.5.1",
"@types/node": "^20.4.1",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
@ -102,19 +91,14 @@
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"@vitejs/plugin-react": "^4.2.0",
"@webbtc/webln-types": "^3.0.0",
"@webbtc/webln-types": "^1.0.10",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"@welldone-software/why-did-you-render": "^8.0.1",
"autoprefixer": "^10.4.16",
"babel-plugin-formatjs": "^10.5.14",
"config": "^3.3.9",
"eslint": "^8.48.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-formatjs": "^4.11.3",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-simple-import-sort": "^10.0.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"postcss": "^8.4.31",
"postcss-preset-env": "^9.2.0",
"prettier": "2.8.3",
@ -122,11 +106,10 @@
"rollup-plugin-visualizer": "^5.9.2",
"tailwindcss": "^3.3.3",
"tinybench": "^2.5.1",
"ts-jest": "^29.1.1",
"typescript": "^5.2.2",
"vite": "^5.2.8",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-pwa": "^0.19.2",
"vite-plugin-version-mark": "^0.0.10",
"vitest": "^0.34.6"
"vite": "^5.0.0",
"vite-plugin-pwa": "^0.17.0",
"vite-plugin-version-mark": "^0.0.10"
}
}

View File

@ -1,4 +0,0 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;
/service-worker.js
Cache-Control: max-age=604800, must-revalidate;

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,2 +0,0 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -1,4 +0,0 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;
/service-worker.js
Cache-Control: max-age=604800, must-revalidate;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@ -1,4 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Sitemap: https://api.snort.social/api/v1/sitemap/index.xml

View File

@ -1,6 +1,5 @@
import { FeedCache } from "@snort/shared";
import { NostrEvent } from "@snort/system";
import { FeedCache } from "@snort/shared";
import { db } from "@/Db";
export class ChatCache extends FeedCache<NostrEvent> {
@ -27,8 +26,4 @@ export class ChatCache extends FeedCache<NostrEvent> {
takeSnapshot(): Array<NostrEvent> {
return [...this.cache.values()];
}
async search() {
return <Array<NostrEvent>>[];
}
}

View File

@ -1,16 +0,0 @@
import { ExternalStore } from "@snort/shared";
class CommunityLeadersStore extends ExternalStore<Array<string>> {
#leaders: Array<string> = [];
setLeaders(arr: Array<string>) {
this.#leaders = arr;
this.notifyChange();
}
takeSnapshot(): string[] {
return [...this.#leaders];
}
}
export const LeadersStore = new CommunityLeadersStore();

View File

@ -1,110 +0,0 @@
import { CachedTable, CacheEvents } from "@snort/shared";
import { CacheRelay, NostrEvent } from "@snort/system";
import { EventEmitter } from "eventemitter3";
export class EventCacheWorker extends EventEmitter<CacheEvents> implements CachedTable<NostrEvent> {
#relay: CacheRelay;
#keys = new Set<string>();
#cache = new Map<string, NostrEvent>();
constructor(relay: CacheRelay) {
super();
this.#relay = relay;
}
async preload() {
const ids = await this.#relay.query([
"REQ",
"preload-event-cache",
{
ids_only: true,
},
]);
this.#keys = new Set<string>(ids as unknown as Array<string>);
}
async search(q: string) {
const results = await this.#relay.query([
"REQ",
"events-search",
{
search: q,
},
]);
return results;
}
keysOnTable(): string[] {
return [...this.#keys];
}
getFromCache(key?: string | undefined): NostrEvent | undefined {
if (key) {
return this.#cache.get(key);
}
}
discover(ev: NostrEvent) {
this.#keys.add(this.key(ev));
}
async get(key?: string | undefined): Promise<NostrEvent | undefined> {
if (key) {
const res = await this.bulkGet([key]);
if (res.length > 0) {
return res[0];
}
}
}
async bulkGet(keys: string[]): Promise<NostrEvent[]> {
const results = await this.#relay.query([
"REQ",
"EventCacheWorker.bulkGet",
{
ids: keys,
},
]);
for (const ev of results) {
this.#cache.set(ev.id, ev);
}
return results;
}
async set(obj: NostrEvent): Promise<void> {
await this.#relay.event(obj);
this.#keys.add(obj.id);
}
async bulkSet(obj: NostrEvent[] | readonly NostrEvent[]): Promise<void> {
await Promise.all(
obj.map(async a => {
await this.#relay.event(a);
this.#keys.add(a.id);
}),
);
}
async update<TWithCreated extends NostrEvent & { created: number; loaded: number }>(
m: TWithCreated,
): Promise<"new" | "refresh" | "updated" | "no_change"> {
if (await this.#relay.event(m)) {
return "updated";
}
return "no_change";
}
async buffer(keys: string[]): Promise<string[]> {
const missing = keys.filter(a => !this.#keys.has(a));
const res = await this.bulkGet(missing);
return missing.filter(a => !res.some(b => this.key(b) === a));
}
key(of: NostrEvent): string {
return of.id;
}
snapshot(): NostrEvent[] {
return [...this.#cache.values()];
}
}

View File

@ -0,0 +1,42 @@
import { FeedCache } from "@snort/shared";
import { db, EventInteraction } from "@/Db";
import { LoginStore } from "@/Login";
import { sha256 } from "@/SnortUtils";
export class EventInteractionCache extends FeedCache<EventInteraction> {
constructor() {
super("EventInteraction", db.eventInteraction);
}
key(of: EventInteraction): string {
return sha256(of.event + of.by);
}
override async preload(): Promise<void> {
await super.preload();
const data = window.localStorage.getItem("zap-cache");
if (data) {
const toImport = [...new Set<string>(JSON.parse(data) as Array<string>)].map(a => {
const ret = {
event: a,
by: LoginStore.takeSnapshot().publicKey,
zapped: true,
reacted: false,
reposted: false,
} as EventInteraction;
ret.id = this.key(ret);
return ret;
});
await this.bulkSet(toImport);
console.debug(`Imported dumb-zap-cache events: `, toImport.length);
window.localStorage.removeItem("zap-cache");
}
await this.buffer([...this.onTable]);
}
takeSnapshot(): EventInteraction[] {
return [...this.cache.values()];
}
}

View File

@ -0,0 +1,47 @@
import { db } from "@/Db";
import { unixNowMs } from "@snort/shared";
import { EventKind, RequestBuilder, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { RefreshFeedCache } from "./RefreshFeedCache";
import { LoginSession } from "@/Login";
export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
constructor() {
super("FollowListCache", db.followLists);
}
buildSub(session: LoginSession, rb: RequestBuilder): void {
const since = this.newest();
rb.withFilter()
.kinds([EventKind.ContactList])
.authors(session.follows.item)
.since(since === 0 ? undefined : since);
}
async onEvent(evs: readonly TaggedNostrEvent[]) {
await Promise.all(
evs.map(async e => {
const update = await super.update({
...e,
created: e.created_at,
loaded: unixNowMs(),
});
if (update !== "no_change") {
socialGraphInstance.handleFollowEvent(e);
}
}),
);
}
key(of: TaggedNostrEvent): string {
return of.pubkey;
}
takeSnapshot() {
return [...this.cache.values()];
}
override async preload() {
await super.preload();
this.snapshot().forEach(e => socialGraphInstance.handleFollowEvent(e));
}
}

View File

@ -0,0 +1,126 @@
import debug from "debug";
import { EventKind, RequestBuilder, SystemInterface, TaggedNostrEvent } from "@snort/system";
import { unixNow, unixNowMs } from "@snort/shared";
import { db } from "@/Db";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
import { LoginSession } from "@/Login";
import { Day, Hour } from "@/Const";
const WindowSize = Hour * 6;
const MaxCacheWindow = Day * 7;
export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
#kinds = [EventKind.TextNote, EventKind.Repost, EventKind.Polls];
#oldest?: number;
constructor() {
super("FollowsFeedCache", db.followsFeed);
}
key(of: TWithCreated<TaggedNostrEvent>): string {
return of.id;
}
takeSnapshot(): TWithCreated<TaggedNostrEvent>[] {
return [...this.cache.values()];
}
buildSub(session: LoginSession, rb: RequestBuilder): void {
const since = this.newest();
rb.withFilter()
.kinds(this.#kinds)
.authors(session.follows.item)
.since(since === 0 ? unixNow() - WindowSize : since);
}
async onEvent(evs: readonly TaggedNostrEvent[]): Promise<void> {
const filtered = evs.filter(a => this.#kinds.includes(a.kind));
if (filtered.length > 0) {
await this.bulkSet(filtered);
this.notifyChange(filtered.map(a => this.key(a)));
}
}
override async preload() {
const start = unixNowMs();
const keys = (await this.table?.toCollection().primaryKeys()) ?? [];
this.onTable = new Set<string>(keys.map(a => a as string));
// load only latest 50 posts, rest can be loaded on-demand
const latest = await this.table?.orderBy("created_at").reverse().limit(50).toArray();
latest?.forEach(v => this.cache.set(this.key(v), v));
// cleanup older than 7 days
await this.table
?.where("created_at")
.below(unixNow() - MaxCacheWindow)
.delete();
const oldest = await this.table?.orderBy("created_at").first();
this.#oldest = oldest?.created_at;
this.notifyChange(latest?.map(a => this.key(a)) ?? []);
debug(this.name)(`Loaded %d/%d in %d ms`, latest?.length ?? 0, keys.length, (unixNowMs() - start).toLocaleString());
}
async loadMore(system: SystemInterface, session: LoginSession, before: number) {
if (this.#oldest && before <= this.#oldest) {
const rb = new RequestBuilder(`${this.name}-loadmore`);
rb.withFilter()
.kinds(this.#kinds)
.authors(session.follows.item)
.until(before)
.since(before - WindowSize);
await system.Fetch(rb, async evs => {
await this.bulkSet(evs);
});
} else {
const latest = await this.table
?.where("created_at")
.between(before - WindowSize, before)
.reverse()
.sortBy("created_at");
latest?.forEach(v => {
const k = this.key(v);
this.cache.set(k, v);
this.onTable.add(k);
});
this.notifyChange(latest?.map(a => this.key(a)) ?? []);
}
}
/**
* Backfill cache with new follows
*/
async backFill(system: SystemInterface, keys: Array<string>) {
if (keys.length === 0) return;
const rb = new RequestBuilder(`${this.name}-backfill`);
rb.withFilter()
.kinds(this.#kinds)
.authors(keys)
.until(unixNow())
.since(this.#oldest ?? unixNow() - MaxCacheWindow);
await system.Fetch(rb, async evs => {
await this.bulkSet(evs);
});
}
/**
* Backfill cache based on follows list
*/
async backFillIfMissing(system: SystemInterface, keys: Array<string>) {
if (!this.#oldest) return;
const start = unixNowMs();
const everything = await this.table?.toArray();
if ((everything?.length ?? 0) > 0) {
const allKeys = new Set(everything?.map(a => a.pubkey));
const missingKeys = keys.filter(a => !allKeys.has(a));
await this.backFill(system, missingKeys);
debug(this.name)(`Backfilled %d keys in %d ms`, missingKeys.length, (unixNowMs() - start).toLocaleString());
}
}
}

View File

@ -1,9 +1,8 @@
import { EventKind, EventPublisher, TaggedNostrEvent } from "@snort/system";
import { db, UnwrappedGift } from "@/Db";
import { findTag, unwrap } from "@/Utils";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
import { EventKind, EventPublisher, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { UnwrappedGift, db } from "@/Db";
import { findTag, unwrap } from "@/SnortUtils";
import { RefreshFeedCache } from "./RefreshFeedCache";
import { LoginSession, LoginSessionType } from "@/Login";
export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
constructor() {
@ -14,8 +13,11 @@ export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
return of.id;
}
buildSub(): void {
// not used
buildSub(session: LoginSession, rb: RequestBuilder): void {
const pubkey = session.publicKey;
if (pubkey && session.type === LoginSessionType.PrivateKey) {
rb.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubkey]).since(this.newest());
}
}
takeSnapshot(): Array<UnwrappedGift> {
@ -53,8 +55,4 @@ export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
}
await this.bulkSet(unwrapped);
}
search(): Promise<TWithCreated<UnwrappedGift>[]> {
throw new Error("Method not implemented.");
}
}

View File

@ -0,0 +1,45 @@
import { EventKind, NostrEvent, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
import { LoginSession } from "@/Login";
import { NostrEventForSession, db } from "@/Db";
import { Day } from "@/Const";
import { unixNow } from "@snort/shared";
export class NotificationsCache extends RefreshFeedCache<NostrEventForSession> {
#kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt];
constructor() {
super("notifications", db.notifications);
}
buildSub(session: LoginSession, rb: RequestBuilder) {
if (session.publicKey) {
const newest = this.newest(v => v.tags.some(a => a[0] === "p" && a[1] === session.publicKey));
rb.withFilter()
.kinds(this.#kinds)
.tag("p", [session.publicKey])
.since(newest === 0 ? unixNow() - Day * 30 : newest);
}
}
async onEvent(evs: readonly TaggedNostrEvent[], pubKey: string) {
const filtered = evs.filter(a => this.#kinds.includes(a.kind) && a.tags.some(b => b[0] === "p"));
if (filtered.length > 0) {
await this.bulkSet(
filtered.map(v => ({
...v,
forSession: pubKey,
})),
);
this.notifyChange(filtered.map(v => this.key(v)));
}
}
key(of: TWithCreated<NostrEvent>): string {
return of.id;
}
takeSnapshot() {
return [...this.cache.values()];
}
}

View File

@ -0,0 +1,16 @@
import { Payment, db } from "@/Db";
import { FeedCache } from "@snort/shared";
export class Payments extends FeedCache<Payment> {
constructor() {
super("PaymentsCache", db.payments);
}
key(of: Payment): string {
return of.url;
}
takeSnapshot(): Array<Payment> {
return [...this.cache.values()];
}
}

View File

@ -1,123 +0,0 @@
import { CachedTable, CacheEvents, removeUndefined, unixNowMs, unwrap } from "@snort/shared";
import { CachedMetadata, CacheRelay, mapEventToProfile, NostrEvent } from "@snort/system";
import debug from "debug";
import { EventEmitter } from "eventemitter3";
export class ProfileCacheRelayWorker extends EventEmitter<CacheEvents> implements CachedTable<CachedMetadata> {
#relay: CacheRelay;
#keys = new Set<string>();
#cache = new Map<string, CachedMetadata>();
#log = debug("ProfileCacheRelayWorker");
constructor(relay: CacheRelay) {
super();
this.#relay = relay;
}
async preload() {
const start = unixNowMs();
const profiles = await this.#relay.query([
"REQ",
"profiles-preload",
{
kinds: [0],
},
]);
this.#cache = new Map<string, CachedMetadata>(profiles.map(a => [a.pubkey, unwrap(mapEventToProfile(a))]));
this.#keys = new Set<string>(this.#cache.keys());
this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString());
}
async search(q: string) {
const profiles = await this.#relay.query([
"REQ",
"profiles-search",
{
kinds: [0],
search: q,
},
]);
return removeUndefined(profiles.map(mapEventToProfile));
}
keysOnTable(): string[] {
return [...this.#keys];
}
getFromCache(key?: string | undefined) {
if (key) {
return this.#cache.get(key);
}
}
discover(ev: NostrEvent) {
if (ev.kind === 0) {
this.#keys.add(ev.pubkey);
}
}
async get(key?: string | undefined) {
if (key) {
const cached = this.getFromCache(key);
if (cached) {
return cached;
}
const res = await this.bulkGet([key]);
if (res.length > 0) {
return res[0];
}
}
}
async bulkGet(keys: string[]) {
if (keys.length === 0) return [];
const results = await this.#relay.query([
"REQ",
"ProfileCacheRelayWorker.bulkGet",
{
authors: keys,
kinds: [0],
},
]);
const mapped = removeUndefined(results.map(a => mapEventToProfile(a)));
for (const pf of mapped) {
this.#cache.set(this.key(pf), pf);
}
this.emit(
"change",
mapped.map(a => this.key(a)),
);
return mapped;
}
async set(obj: CachedMetadata) {
this.#keys.add(this.key(obj));
}
async bulkSet(obj: CachedMetadata[] | readonly CachedMetadata[]) {
const mapped = obj.map(a => this.key(a));
mapped.forEach(a => this.#keys.add(a));
// todo: store in cache
this.emit("change", mapped);
}
async update(): Promise<"new" | "refresh" | "updated" | "no_change"> {
// do nothing
return "refresh";
}
async buffer(keys: string[]) {
const missing = keys.filter(a => !this.#cache.has(a));
const res = await this.bulkGet(missing);
return missing.filter(a => !res.some(b => this.key(b) === a));
}
key(of: CachedMetadata) {
return of.pubkey;
}
snapshot() {
return [...this.#cache.values()];
}
}

View File

@ -1,7 +1,6 @@
import { FeedCache } from "@snort/shared";
import { EventPublisher, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { LoginSession } from "@/Utils/Login";
import { LoginSession } from "@/Login";
export type TWithCreated<T> = (T | Readonly<T>) & { created_at: number };
@ -24,6 +23,7 @@ export abstract class RefreshFeedCache<T> extends FeedCache<TWithCreated<T>> {
override async preload(): Promise<void> {
await super.preload();
// load all dms to memory
await this.buffer([...this.onTable]);
}
}

View File

@ -1,6 +0,0 @@
import { ParsedFragment } from "@snort/system";
import { LRUCache } from "typescript-lru-cache";
export const TextCache = new LRUCache<string, Array<ParsedFragment>>({
maxSize: 1000,
});

View File

@ -1,128 +0,0 @@
import { CachedTable, CacheEvents, removeUndefined, unixNowMs, unwrap } from "@snort/shared";
import { CacheRelay, EventKind, NostrEvent, UsersFollows } from "@snort/system";
import debug from "debug";
import { EventEmitter } from "eventemitter3";
export class UserFollowsWorker extends EventEmitter<CacheEvents> implements CachedTable<UsersFollows> {
#relay: CacheRelay;
#keys = new Set<string>();
#cache = new Map<string, UsersFollows>();
#log = debug("UserFollowsWorker");
constructor(relay: CacheRelay) {
super();
this.#relay = relay;
}
async preload() {
const start = unixNowMs();
const profiles = await this.#relay.query([
"REQ",
"profiles-preload",
{
kinds: [3],
},
]);
this.#cache = new Map<string, UsersFollows>(profiles.map(a => [a.pubkey, unwrap(mapEventToUserFollows(a))]));
this.#keys = new Set<string>(this.#cache.keys());
this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString());
}
async search(q: string) {
const results = await this.#relay.query([
"REQ",
"contacts-search",
{
kinds: [3],
search: q,
},
]);
return removeUndefined(results.map(mapEventToUserFollows));
}
keysOnTable(): string[] {
return [...this.#keys];
}
getFromCache(key?: string | undefined): UsersFollows | undefined {
if (key) {
return this.#cache.get(key);
}
}
discover(ev: NostrEvent) {
this.#keys.add(ev.pubkey);
}
async get(key?: string | undefined): Promise<UsersFollows | undefined> {
if (key) {
const res = await this.bulkGet([key]);
if (res.length > 0) {
return res[0];
}
}
}
async bulkGet(keys: string[]) {
if (keys.length === 0) return [];
const results = await this.#relay.query([
"REQ",
"UserFollowsWorker.bulkGet",
{
authors: keys,
kinds: [3],
},
]);
const mapped = removeUndefined(results.map(a => mapEventToUserFollows(a)));
for (const pf of mapped) {
this.#cache.set(this.key(pf), pf);
}
this.emit(
"change",
mapped.map(a => this.key(a)),
);
return mapped;
}
async set(obj: UsersFollows) {
this.#keys.add(this.key(obj));
}
async bulkSet(obj: UsersFollows[] | readonly UsersFollows[]) {
const mapped = obj.map(a => this.key(a));
mapped.forEach(a => this.#keys.add(a));
// todo: store in cache
this.emit("change", mapped);
}
async update(): Promise<"new" | "refresh" | "updated" | "no_change"> {
// do nothing
return "refresh";
}
async buffer(keys: string[]): Promise<string[]> {
const missing = keys.filter(a => !this.#keys.has(a));
const res = await this.bulkGet(missing);
return missing.filter(a => !res.some(b => this.key(b) === a));
}
key(of: UsersFollows): string {
return of.pubkey;
}
snapshot(): UsersFollows[] {
return [...this.#cache.values()];
}
}
export function mapEventToUserFollows(ev: NostrEvent): UsersFollows | undefined {
if (ev.kind !== EventKind.ContactList) return;
return {
pubkey: ev.pubkey,
loaded: unixNowMs(),
created: ev.created_at,
follows: ev.tags,
};
}

View File

@ -1,85 +1,38 @@
import { CacheRelay, Connection, ConnectionCacheRelay, RelayMetricCache, UserRelaysCache } from "@snort/system";
import { UserProfileCache, UserRelaysCache, RelayMetricCache } from "@snort/system";
import { SnortSystemDb } from "@snort/system-web";
import { WorkerRelayInterface } from "@snort/worker-relay";
import WorkerVite from "@snort/worker-relay/src/worker?worker";
import { EventCacheWorker } from "./EventCacheWorker";
import { EventInteractionCache } from "./EventInteractionCache";
import { ChatCache } from "./ChatCache";
import { Payments } from "./PaymentsCache";
import { GiftWrapCache } from "./GiftWrapCache";
import { ProfileCacheRelayWorker } from "./ProfileWorkerCache";
import { UserFollowsWorker } from "./UserFollowsWorker";
const cacheRelay = localStorage.getItem("cache-relay");
const workerRelay = new WorkerRelayInterface(
import.meta.env.DEV ? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url) : new WorkerVite(),
);
export const Relay: CacheRelay = cacheRelay
? new ConnectionCacheRelay(new Connection(cacheRelay, { read: true, write: true }))
: workerRelay;
async function tryUseCacheRelay(url: string) {
try {
const conn = new Connection(url, { read: true, write: true });
await conn.connect(true);
localStorage.setItem("cache-relay", url);
return conn;
} catch (e) {
console.warn(e);
}
}
export async function tryUseLocalRelay() {
let conn = await tryUseCacheRelay("ws://localhost:4869");
if (!conn) {
conn = await tryUseCacheRelay("ws://umbrel:4848");
}
return conn;
}
export async function initRelayWorker() {
try {
if (Relay instanceof ConnectionCacheRelay) {
await Relay.connection.connect(true);
return;
}
} catch (e) {
localStorage.removeItem("cache-relay");
console.error(e);
if (cacheRelay) {
window.location.reload();
}
}
try {
await workerRelay.debug("*");
await workerRelay.init({
databasePath: "relay.db",
insertBatchSize: 100,
});
} catch (e) {
console.error(e);
}
}
import { NotificationsCache } from "./Notifications";
import { FollowsFeedCache } from "./FollowsFeed";
import { FollowListCache } from "./FollowListCache";
export const SystemDb = new SnortSystemDb();
export const UserCache = new UserProfileCache(SystemDb.users);
export const UserRelays = new UserRelaysCache(SystemDb.userRelays);
export const RelayMetrics = new RelayMetricCache(SystemDb.relayMetrics);
export const UserFollows = new UserFollowsWorker(Relay);
export const UserCache = new ProfileCacheRelayWorker(Relay);
export const EventsCache = new EventCacheWorker(Relay);
export const Chats = new ChatCache();
export const PaymentsCache = new Payments();
export const InteractionCache = new EventInteractionCache();
export const GiftsCache = new GiftWrapCache();
export const Notifications = new NotificationsCache();
export const FollowsFeed = new FollowsFeedCache();
export const FollowLists = new FollowListCache();
export async function preload(follows?: Array<string>) {
const preloads = [
UserCache.preload(),
UserCache.preload(follows),
Chats.preload(),
InteractionCache.preload(),
UserRelays.preload(follows),
RelayMetrics.preload(),
GiftsCache.preload(),
UserRelays.preload(follows),
EventsCache.preload(),
UserFollows.preload(),
Notifications.preload(),
FollowsFeed.preload(),
FollowLists.preload(),
];
await Promise.all(preloads);
}

View File

@ -1,16 +0,0 @@
import classNames from "classnames";
import Icon from "@/Components/Icons/Icon";
export default function CloseButton({ onClick, className }: { onClick?: () => void; className?: string }) {
return (
<div
onClick={onClick}
className={classNames(
"self-center circle flex flex-shrink-0 flex-grow-0 items-center justify-center hover:opacity-80 bg-dark p-2 cursor-pointer",
className,
)}>
<Icon name="close" size={12} />
</div>
);
}

View File

@ -1,20 +0,0 @@
import { NavLink as RouterNavLink, NavLinkProps, useLocation } from "react-router-dom";
export default function NavLink(props: NavLinkProps) {
const { to, onClick, ...rest } = props;
const location = useLocation();
const isActive = location.pathname === to.toString();
const handleClick = event => {
if (onClick) {
onClick(event);
}
if (isActive) {
window.scrollTo({ top: 0, behavior: "instant" });
}
};
return <RouterNavLink to={to} onClick={handleClick} {...rest} />;
}

View File

@ -1,54 +0,0 @@
export default function AwardIcon({ size }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 62 62" fill="none" className="award">
<defs>
<linearGradient
id="paint0_linear_2660_40043"
x1="31"
y1="3.57143"
x2="31"
y2="58.4286"
gradientUnits="userSpaceOnUse">
<stop stopColor="#5B2CB3" />
<stop offset="1" stopColor="#811EFF" />
</linearGradient>
<linearGradient
id="paint1_linear_2660_40043"
x1="15.5594"
y1="24.305"
x2="46.433"
y2="24.305"
gradientUnits="userSpaceOnUse">
<stop stopColor="#AC88FF" />
<stop offset="1" stopColor="#7234FF" />
</linearGradient>
</defs>
<g id="award-02">
<rect x="1.85713" y="1.85714" width="58.2857" height="58.2857" rx="29.1429" fill="#AC88FF" fillOpacity="0.2" />
<rect
x="1.85713"
y="1.85714"
width="58.2857"
height="58.2857"
rx="29.1429"
stroke="url(#paint0_linear_2660_40043)"
strokeWidth="3.42857"
/>
<path
id="Solid"
d="M23.2006 52.4983L22.5639 50.9066L23.2006 52.4983L30.9963 49.38L38.7919 52.4983C39.8813 52.934 41.116 52.801 42.0876 52.1432C43.0592 51.4854 43.6412 50.3885 43.6412 49.2151V38.1015C46.467 35.038 48.1957 30.9408 48.1957 26.4427C48.1957 16.9437 40.4952 9.24329 30.9963 9.24329C21.4973 9.24329 13.7968 16.9437 13.7968 26.4427C13.7968 30.9408 15.5255 35.038 18.3513 38.1015V49.2151C18.3513 50.3885 18.9333 51.4854 19.9049 52.1432C20.8765 52.801 22.1112 52.934 23.2006 52.4983ZM27.2967 43.2429L25.4234 43.9922V42.7187C26.0332 42.9275 26.6584 43.1029 27.2967 43.2429ZM34.6958 43.2429C35.3341 43.1029 35.9593 42.9275 36.5691 42.7187V43.9922L34.6958 43.2429Z"
fill="url(#paint1_linear_2660_40043)"
stroke="#251250"
strokeWidth="3.42857"
strokeLinecap="round"
/>
<path
id="Ellipse 1595"
d="M24.2557 14.6002C17.7766 18.3409 15.5567 26.6257 19.2974 33.1049L42.7604 19.5585C39.0196 13.0794 30.7348 10.8595 24.2557 14.6002Z"
fill="white"
fillOpacity="0.1"
/>
</g>
</svg>
);
}

View File

@ -1,49 +0,0 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import CloseButton from "../Button/CloseButton";
import Modal from "../Modal/Modal";
import AwardIcon from "./Award";
export function LeaderBadge() {
const [showModal, setShowModal] = useState(false);
return (
<>
<div
className="flex gap-1 p-1 pr-2 items-center border border-[#5B2CB3] rounded-full"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowModal(true);
}}>
<AwardIcon size={16} />
<div className="text-xs font-medium text-[#AC88FF]">
<FormattedMessage defaultMessage="Community Leader" />
</div>
</div>
{showModal && (
<Modal onClose={() => setShowModal(false)} id="leaders">
<div className="flex flex-col gap-4 items-center relative">
<CloseButton className="absolute right-2 top-2" onClick={() => setShowModal(false)} />
<AwardIcon size={80} />
<div className="text-3xl font-semibold">
<FormattedMessage defaultMessage="Community Leader" />
</div>
<p className="text-secondary">
<FormattedMessage
defaultMessage="Community leaders are individuals who grow the nostr ecosystem by being active in their local communities and helping onboard new users. Anyone can become a community leader, but few hold the current honorary title."
id="f1OxTe"
/>
</p>
<Link to="/settings/invite">
<button className="primary">
<FormattedMessage defaultMessage="Become a leader" />
</button>
</Link>
</div>
</Modal>
)}
</>
);
}

View File

@ -1,37 +0,0 @@
import "./Copy.css";
import classNames from "classnames";
import Icon from "@/Components/Icons/Icon";
import { useCopy } from "@/Hooks/useCopy";
export interface CopyProps {
text: string;
maxSize?: number;
className?: string;
showText?: boolean;
mask?: string;
}
export default function Copy({ text, maxSize = 32, className, showText, mask }: CopyProps) {
const { copy, copied } = useCopy();
const sliceLength = maxSize / 2;
const displayText = mask ? mask.repeat(text.length) : text;
const trimmed =
displayText.length > maxSize
? `${displayText.slice(0, sliceLength)}...${displayText.slice(-sliceLength)}`
: displayText;
return (
<div
className={classNames("copy flex pointer g8 items-center", className)}
onClick={e => {
e.stopPropagation();
copy(text);
}}>
{(showText ?? true) && <span className="copy-body">{trimmed}</span>}
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
{copied ? <Icon name="check" size={14} /> : <Icon name="copy-solid" size={14} />}
</span>
</div>
);
}

View File

@ -1,3 +0,0 @@
.cashu {
background: var(--cashu-gradient);
}

View File

@ -1,82 +0,0 @@
import "./CashuNuts.css";
import { useUserProfile } from "@snort/system-react";
import { useEffect, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import ECashIcon from "@/Components/Icons/ECash";
import Icon from "@/Components/Icons/Icon";
import { useCopy } from "@/Hooks/useCopy";
import useLogin from "@/Hooks/useLogin";
interface Token {
token: Array<{
mint: string;
proofs: Array<{
amount: number;
}>;
}>;
memo?: string;
}
export default function CashuNuts({ token }: { token: string }) {
const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
const profile = useUserProfile(publicKey);
const { copy } = useCopy();
async function redeemToken(token: string) {
const lnurl = profile?.lud16 ?? "";
const url = `https://redeem.cashu.me?token=${encodeURIComponent(token)}&lightning=${encodeURIComponent(
lnurl,
)}&autopay=yes`;
window.open(url, "_blank");
}
const [cashu, setCashu] = useState<Token>();
useEffect(() => {
try {
if (!token.startsWith("cashuA") || token.length < 10) {
return;
}
import("@cashu/cashu-ts").then(({ getDecodedToken }) => {
const tkn = getDecodedToken(token);
setCashu(tkn);
});
} catch {
// ignored
}
}, [token]);
if (!cashu) return <>{token}</>;
const amount = cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
return (
<div className="cashu flex justify-between p24 br items-center">
<div className="flex flex-col gap-2 f-ellipsis">
<div className="flex items-center gap-4">
<ECashIcon width={30} />
<FormattedMessage
defaultMessage="{n} eSats"
id="yAztTU"
values={{
n: (
<span className="text-3xl">
<FormattedNumber value={amount} />
</span>
),
}}
/>
</div>
</div>
<div className="flex gap-2 items-center">
<AsyncButton onClick={() => copy(token)}>
<Icon name="copy" />
</AsyncButton>
<AsyncButton onClick={() => redeemToken(token)}>
<FormattedMessage defaultMessage="Redeem" />
</AsyncButton>
</div>
</div>
);
}

View File

@ -1,34 +0,0 @@
import { useState } from "react";
import Icon from "../Icons/Icon";
import { ProxyImg } from "../ProxyImg";
export default function GenericPlayer({ url, poster }: { url: string; poster: string }) {
const [play, setPlay] = useState(false);
if (!play) {
return (
<div
className="relative aspect-video"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setPlay(true);
}}>
<ProxyImg className="absolute" src={poster} />
<div className="absolute w-full h-full opacity-0 hover:opacity-100 hover:bg-black/30 flex items-center justify-center transition">
<Icon name="play-square-outline" size={50} />
</div>
</div>
);
}
return (
<iframe
className="aspect-video w-full"
src={url}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
);
}

View File

@ -1,76 +0,0 @@
import { Bech32Regex } from "@snort/shared";
import { ReactNode } from "react";
import AppleMusicEmbed from "@/Components/Embed/AppleMusicEmbed";
import LinkPreview from "@/Components/Embed/LinkPreview";
import MagnetLink from "@/Components/Embed/MagnetLink";
import MixCloudEmbed from "@/Components/Embed/MixCloudEmbed";
import NostrLink from "@/Components/Embed/NostrLink";
import SoundCloudEmbed from "@/Components/Embed/SoundCloudEmded";
import SpotifyEmbed from "@/Components/Embed/SpotifyEmbed";
import TidalEmbed from "@/Components/Embed/TidalEmbed";
import TwitchEmbed from "@/Components/Embed/TwitchEmbed";
import WavlakeEmbed from "@/Components/Embed/WavlakeEmbed";
import YoutubeEmbed from "@/Components/Embed/YoutubeEmbed";
import { magnetURIDecode } from "@/Utils";
import {
AppleMusicRegex,
MixCloudRegex,
SoundCloudRegex,
SpotifyRegex,
TidalRegex,
TwitchRegex,
WavlakeRegex,
YoutubeUrlRegex,
} from "@/Utils/Const";
interface HypeTextProps {
link: string;
children?: ReactNode | Array<ReactNode> | null;
depth?: number;
showLinkPreview?: boolean;
}
export default function HyperText({ link, depth, showLinkPreview, children }: HypeTextProps) {
const a = link;
try {
const url = new URL(a);
let m = null;
if (a.match(YoutubeUrlRegex)) {
return <YoutubeEmbed link={a} />;
} else if (a.match(TidalRegex)) {
return <TidalEmbed link={a} />;
} else if (a.match(SoundCloudRegex)) {
return <SoundCloudEmbed link={a} />;
} else if (a.match(MixCloudRegex)) {
return <MixCloudEmbed link={a} />;
} else if (a.match(SpotifyRegex)) {
return <SpotifyEmbed link={a} />;
} else if (a.match(TwitchRegex)) {
return <TwitchEmbed link={a} />;
} else if (a.match(AppleMusicRegex)) {
return <AppleMusicEmbed link={a} />;
} else if (a.match(WavlakeRegex)) {
return <WavlakeEmbed link={a} />;
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
return <NostrLink link={a} depth={depth} />;
} else if (url.protocol === "magnet:") {
const parsed = magnetURIDecode(a);
if (parsed) {
return <MagnetLink magnet={parsed} />;
}
} else if ((m = a.match(Bech32Regex)) != null) {
return <NostrLink link={`nostr:${m[1]}`} depth={depth} />;
} else if (showLinkPreview ?? true) {
return <LinkPreview url={a} />;
}
} catch {
// Ignore the error.
}
return (
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{children ?? a}
</a>
);
}

View File

@ -1,128 +0,0 @@
import { IMeta } from "@snort/system";
import classNames from "classnames";
import React, { CSSProperties, useEffect, useMemo, useRef } from "react";
import { useInView } from "react-intersection-observer";
import { ProxyImg } from "@/Components/ProxyImg";
import useImgProxy from "@/Hooks/useImgProxy";
interface MediaElementProps {
mime: string;
url: string;
meta?: IMeta;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
size?: number;
}
interface AudioElementProps {
url: string;
}
interface VideoElementProps {
url: string;
meta?: IMeta;
}
interface ImageElementProps {
url: string;
meta?: IMeta;
size?: number;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
}
const AudioElement = ({ url }: AudioElementProps) => {
return <audio key={url} src={url} controls />;
};
const ImageElement = ({ url, meta, onMediaClick, size }: ImageElementProps) => {
const imageRef = useRef<HTMLImageElement | null>(null);
const style = useMemo(() => {
const style = {} as CSSProperties;
if (meta?.height && meta.width && imageRef.current) {
const scale = imageRef.current.offsetWidth / meta.width;
style.height = `${Math.min(document.body.clientHeight * 0.8, meta.height * scale)}px`;
}
return style;
}, [imageRef?.current, meta]);
return (
<div
className={classNames("flex items-center -mx-4 md:mx-0 my-2", {
"md:h-[510px]": !meta && !CONFIG.media.preferLargeMedia,
"cursor-pointer": onMediaClick,
})}>
<ProxyImg
key={url}
src={url}
size={size}
sha256={meta?.sha256}
onClick={onMediaClick}
className={classNames("max-h-[80vh] w-full h-full object-contain object-center", {
"md:max-h-[510px]": !meta && !CONFIG.media.preferLargeMedia,
})}
style={style}
ref={imageRef}
/>
</div>
);
};
const VideoElement = ({ url }: VideoElementProps) => {
const { proxy } = useImgProxy();
const videoRef = useRef<HTMLVideoElement | null>(null);
const { ref: videoContainerRef, inView } = useInView({ threshold: 0.33 });
const isMobile = window.innerWidth < 768;
useEffect(() => {
if (isMobile || !videoRef.current) {
return;
}
if (inView) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
}, [inView]);
return (
<div
ref={videoContainerRef}
className={classNames("flex justify-center items-center -mx-4 md:mx-0 my-2", {
"md:h-[510px]": !CONFIG.media.preferLargeMedia,
})}>
<video
crossOrigin="anonymous"
ref={videoRef}
loop={true}
muted={!isMobile}
src={url}
controls
poster={proxy(url)}
className={classNames("max-h-[80vh]", { "md:max-h-[510px]": !CONFIG.media.preferLargeMedia })}
onClick={e => e.stopPropagation()}
/>
</div>
);
};
export function MediaElement(props: MediaElementProps) {
if (props.mime.startsWith("image/")) {
return <ImageElement url={props.url} meta={props.meta} onMediaClick={props.onMediaClick} size={props.size} />;
} else if (props.mime.startsWith("audio/")) {
return <AudioElement url={props.url} />;
} else if (props.mime.startsWith("video/")) {
return <VideoElement url={props.url} />;
} else {
return (
<a
key={props.url}
href={props.url}
onClick={e => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext">
{props.url}
</a>
);
}
}

View File

@ -1,35 +0,0 @@
import { NostrLink, NostrPrefix } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useCallback, useRef, useState } from "react";
import DisplayName from "@/Components/User/DisplayName";
import { ProfileCard } from "@/Components/User/ProfileCard";
import { ProfileLink } from "@/Components/User/ProfileLink";
export default function Mention({ link }: { link: NostrLink }) {
const profile = useUserProfile(link.id);
const [isHovering, setIsHovering] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseEnter = useCallback(() => {
hoverTimeoutRef.current && clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = setTimeout(() => setIsHovering(true), 100); // Adjust timeout as needed
}, []);
const handleMouseLeave = useCallback(() => {
hoverTimeoutRef.current && clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = setTimeout(() => setIsHovering(false), 300); // Adjust timeout as needed
}, []);
if (link.type !== NostrPrefix.Profile && link.type !== NostrPrefix.PublicKey) return;
return (
<span className="highlight" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<ProfileLink pubkey={link.id} link={link} user={profile} onClick={e => e.stopPropagation()}>
@<DisplayName user={profile} pubkey={link.id} />
</ProfileLink>
{isHovering && <ProfileCard pubkey={link.id} user={profile} show={true} />}
</span>
);
}

View File

@ -1,23 +0,0 @@
import usePreferences from "@/Hooks/usePreferences";
import { MixCloudRegex } from "@/Utils/Const";
const MixCloudEmbed = ({ link }: { link: string }) => {
const match = link.match(MixCloudRegex);
if (!match) return;
const feedPath = match[1] + "%2F" + match[2];
const theme = usePreferences(s => s.theme);
const lightParams = theme === "light" ? "light=1" : "light=0";
return (
<iframe
title="SoundCloud player"
width="100%"
height="120"
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
loading="lazy"
/>
);
};
export default MixCloudEmbed;

View File

@ -1,17 +0,0 @@
import { YoutubeUrlRegex } from "@/Utils/Const";
export default function YoutubeEmbed({ link }: { link: string }) {
const m = link.match(YoutubeUrlRegex);
if (!m) return;
return (
<iframe
className="-mx-4 md:mx-0 w-max my-2"
src={`https://www.youtube.com/embed/${m[1]}${m[3] ? `?list=${m[3].slice(6)}` : ""}`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
);
}

View File

@ -1,44 +0,0 @@
import React from "react";
import { trackEvent } from "@/Utils";
interface ErrorBoundaryState {
hasError: boolean;
errorMessage?: string;
stack?: string;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
}
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, errorMessage: error.message, stack: error.stack };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Caught an error:", error, errorInfo);
trackEvent("error", { error: error.message, errorInfo: JSON.stringify(errorInfo) });
}
render() {
if (this.state.hasError) {
// Render any custom fallback UI with the error message
return (
<div className="p-2">
<h1>Something went wrong.</h1>
<p>Error: {this.state.errorMessage}</p>
<pre className="text-xs overflow-auto mt-8">{this.state.stack}</pre>
</div>
);
}
return this.props.children;
}
}

View File

@ -1,60 +0,0 @@
import { sanitizeRelayUrl, unwrap } from "@snort/shared";
import { OkResponse } from "@snort/system";
import { useState } from "react";
import { useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import IconButton from "@/Components/Button/IconButton";
import Icon from "@/Components/Icons/Icon";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { getRelayName } from "@/Utils";
export function OkResponseRow({ rsp, close }: { rsp: OkResponse; close: () => void }) {
const [r, setResult] = useState(rsp);
const { formatMessage } = useIntl();
const { system } = useEventPublisher();
const login = useLogin();
async function removeRelayFromResult(r: OkResponse) {
await login.state.removeRelay(unwrap(sanitizeRelayUrl(r.relay)), true);
close();
}
async function retryPublish(r: OkResponse) {
const rsp = await system.WriteOnceToRelay(unwrap(sanitizeRelayUrl(r.relay)), r.event);
setResult(rsp);
}
return (
<div className="flex items-center g16">
<div className="flex flex-col grow g4">
<b>{getRelayName(r.relay)}</b>
{r.message && <small>{r.message}</small>}
</div>
{!r.ok && (
<div className="flex g8">
<AsyncButton
onClick={() => retryPublish(r)}
className="p4 br-compact flex items-center secondary"
title={formatMessage({
defaultMessage: "Retry publishing",
id: "9kSari",
})}>
<Icon name="refresh-ccw-01" />
</AsyncButton>
<AsyncButton
onClick={() => removeRelayFromResult(r)}
className="p4 br-compact flex items-center secondary"
title={formatMessage({
defaultMessage: "Remove from my relays",
id: "UJTWqI",
})}>
<Icon name="trash-01" className="trash-icon" />
</AsyncButton>
</div>
)}
<IconButton icon={{ name: "x" }} onClick={close} />
</div>
);
}

View File

@ -1,28 +0,0 @@
import { removeUndefined } from "@snort/shared";
import { NostrEvent, OkResponse, SystemInterface } from "@snort/system";
export async function sendEventToRelays(
system: SystemInterface,
ev: NostrEvent,
customRelays?: Array<string>,
setResults?: (x: Array<OkResponse>) => void,
) {
if (customRelays) {
system.HandleEvent("*", { ...ev, relays: [] });
return removeUndefined(
await Promise.all(
customRelays.map(async r => {
try {
return await system.WriteOnceToRelay(r, ev);
} catch (e) {
console.error(e);
}
}),
),
);
} else {
const responses: OkResponse[] = await system.BroadcastEvent(ev);
setResults?.(responses);
return responses;
}
}

View File

@ -1,93 +0,0 @@
import "./EventComponent.css";
import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system";
import { memo, ReactNode } from "react";
import PubkeyList from "@/Components/Embed/PubkeyList";
import ZapstrEmbed from "@/Components/Embed/ZapstrEmbed";
import ErrorBoundary from "@/Components/ErrorBoundary";
import { NostrFileElement } from "@/Components/Event/NostrFileHeader";
import NoteReaction from "@/Components/Event/NoteReaction";
import { ZapGoal } from "@/Components/Event/ZapGoal";
import { LiveEvent } from "@/Components/LiveStream/LiveEvent";
import ProfilePreview from "@/Components/User/ProfilePreview";
import { LongFormText } from "./LongFormText";
import { Note } from "./Note/Note";
export interface NotePropsOptions {
isRoot?: boolean;
showHeader?: boolean;
showContextMenu?: boolean;
showProfileCard?: boolean;
showTime?: boolean;
showPinned?: boolean;
showBookmarked?: boolean;
showFooter?: boolean;
showReactionsLink?: boolean;
showMedia?: boolean;
canUnpin?: boolean;
canUnbookmark?: boolean;
canClick?: boolean;
showMediaSpotlight?: boolean;
longFormPreview?: boolean;
truncate?: boolean;
}
export interface NoteProps {
data: TaggedNostrEvent;
className?: string;
highlight?: boolean;
ignoreModeration?: boolean;
onClick?: (e: TaggedNostrEvent) => void;
depth?: number;
highlightText?: string;
threadChains?: Map<string, Array<NostrEvent>>;
context?: ReactNode;
options?: NotePropsOptions;
waitUntilInView?: boolean;
}
export default memo(function EventComponent(props: NoteProps) {
const { data: ev, className } = props;
let content;
switch (ev.kind) {
case EventKind.Repost:
content = <NoteReaction data={ev} key={ev.id} root={undefined} depth={(props.depth ?? 0) + 1} />;
break;
case EventKind.FileHeader:
content = <NostrFileElement ev={ev} />;
break;
case EventKind.ZapstrTrack:
content = <ZapstrEmbed ev={ev} />;
break;
case EventKind.FollowSet:
case EventKind.ContactList:
content = <PubkeyList ev={ev} className={className} />;
break;
case EventKind.LiveEvent:
content = <LiveEvent ev={ev} />;
break;
case EventKind.SetMetadata:
content = <ProfilePreview actions={<></>} pubkey={ev.pubkey} />;
break;
case 9041: // Assuming 9041 is a valid EventKind
content = <ZapGoal ev={ev} />;
break;
case EventKind.LongFormTextNote:
content = (
<LongFormText
ev={ev}
isPreview={props.options?.longFormPreview ?? false}
onClick={() => props.onClick?.(ev)}
truncate={props.options?.truncate}
/>
);
break;
default:
content = <Note {...props} />;
}
return <ErrorBoundary>{content}</ErrorBoundary>;
});

View File

@ -1,25 +0,0 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import usePreferences from "@/Hooks/usePreferences";
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
const hideMutedNotes = usePreferences(s => s.hideMutedNotes);
const [show, setShow] = useState(false);
if (hideMutedNotes) return;
return show ? (
children
) : (
<div className="bb p flex items-center justify-between">
<div className="text-sm text-secondary">
<FormattedMessage defaultMessage="This note has been muted" />
</div>
<button className="btn btn-sm btn-neutral" onClick={() => setShow(true)}>
<FormattedMessage defaultMessage="Show" />
</button>
</div>
);
};
export default HiddenNote;

View File

@ -1,40 +0,0 @@
import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
import usePageDimensions from "@/Hooks/usePageDimensions";
import { debounce } from "@/Utils";
interface ShowMoreProps {
text?: string;
className?: string;
onClick: () => void;
}
const LoadMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
return (
<button type="button" className={className} onClick={onClick}>
{text || <FormattedMessage defaultMessage="Load more" />}
</button>
);
};
export default LoadMore;
export function AutoLoadMore({ text, onClick, className }: ShowMoreProps) {
const { ref, inView } = useInView({ rootMargin: "1000px" });
const { height } = usePageDimensions();
useEffect(() => {
if (inView) {
// TODO improve feed performance. Something in image grid makes it slow when feed size grows.
return debounce(100, onClick);
}
}, [inView, height]);
return (
<div ref={ref}>
<LoadMore onClick={onClick} text={text} className={className} />
</div>
);
}

View File

@ -1,183 +0,0 @@
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import classNames from "classnames";
import { useCallback, useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { LRUCache } from "typescript-lru-cache";
import { Relay } from "@/Cache";
import NoteHeader from "@/Components/Event/Note/NoteHeader";
import NoteQuote from "@/Components/Event/Note/NoteQuote";
import { NoteText } from "@/Components/Event/Note/NoteText";
import { TranslationInfo } from "@/Components/Event/Note/TranslationInfo";
import { NoteTranslation } from "@/Components/Event/Note/types";
import Username from "@/Components/User/Username";
import useModeration from "@/Hooks/useModeration";
import { chainKey } from "@/Utils/Thread/ChainKey";
import { NoteProps, NotePropsOptions } from "../EventComponent";
import HiddenNote from "../HiddenNote";
import Poll from "../Poll";
import NoteAppHandler from "./NoteAppHandler";
import NoteFooter from "./NoteFooter/NoteFooter";
const defaultOptions = {
showHeader: true,
showTime: true,
showFooter: true,
canUnpin: false,
canUnbookmark: false,
showContextMenu: true,
};
const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls];
const translationCache = new LRUCache<string, NoteTranslation>({ maxSize: 300 });
export function Note(props: NoteProps) {
const { data: ev, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className ?? "");
const { isEventMuted } = useModeration();
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const { ref: setSeenAtRef, inView: setSeenAtInView } = useInView({ rootMargin: "0px", threshold: 1 });
const [showTranslation, setShowTranslation] = useState(true);
const [translated, setTranslated] = useState<NoteTranslation>(translationCache.get(ev.id));
const cachedSetTranslated = useCallback(
(translation: NoteTranslation) => {
translationCache.set(ev.id, translation);
setTranslated(translation);
},
[ev.id],
);
useEffect(() => {
let timeout: ReturnType<typeof setTimeout>;
if (setSeenAtInView) {
timeout = setTimeout(() => {
Relay.setEventMetadata(ev.id, { seen_at: Math.round(Date.now() / 1000) });
}, 1000);
}
return () => clearTimeout(timeout);
}, [setSeenAtInView]);
const optionsMerged = { ...defaultOptions, ...opt };
const goToEvent = useGoToEvent(props, optionsMerged);
if (!canRenderAsTextNote.includes(ev.kind)) {
return handleNonTextNote(ev);
}
function content() {
if (waitUntilInView && !inView) return null;
return (
<>
{optionsMerged.showHeader && (
<NoteHeader
ev={ev}
options={optionsMerged}
setTranslated={translated === null ? cachedSetTranslated : undefined}
/>
)}
<div className="body" onClick={e => goToEvent(e, ev)}>
<NoteText {...props} translated={translated} showTranslation={showTranslation} />
{translated && <TranslationInfo translated={translated} setShowTranslation={setShowTranslation} />}
{ev.kind === EventKind.Polls && <Poll ev={ev} zaps={[]} />}
{optionsMerged.showFooter && (
<div className="mt-4">
<NoteFooter ev={ev} replyCount={props.threadChains?.get(chainKey(ev))?.length} />
</div>
)}
<div ref={setSeenAtRef} />
</div>
</>
);
}
const noteElement = (
<div
className={classNames(baseClassName, {
active: highlight,
"hover:bg-nearly-bg-background cursor-pointer": !opt?.isRoot,
})}
onClick={e => goToEvent(e, ev)}
ref={ref}>
{content()}
</div>
);
return !ignoreModeration && isEventMuted(ev) ? <HiddenNote>{noteElement}</HiddenNote> : noteElement;
}
function useGoToEvent(props: NoteProps, options: NotePropsOptions) {
const navigate = useNavigate();
return useCallback(
(e: React.MouseEvent, eTarget: TaggedNostrEvent) => {
if (options?.canClick === false) {
return;
}
let target = e.target as HTMLElement | null;
while (target) {
if (
target.tagName === "A" ||
target.tagName === "BUTTON" ||
target.classList.contains("reaction-pill") ||
target.classList.contains("szh-menu-container")
) {
return;
}
target = target.parentElement;
}
e.stopPropagation();
// prevent navigation if selecting text
const cellText = document.getSelection();
if (cellText?.type === "Range") {
return;
}
// custom onclick handler
if (props.onClick) {
props.onClick(eTarget);
return;
}
// link to event
const link = NostrLink.fromEvent(eTarget);
if (e.metaKey) {
window.open(`/${link.encode(CONFIG.eventLinkPrefix)}`, "_blank");
} else {
navigate(`/${link.encode(CONFIG.eventLinkPrefix)}`, { state: eTarget });
}
},
[navigate, props, options],
);
}
function Reaction({ ev }: { ev: TaggedNostrEvent }) {
const reactedToTag = ev.tags.findLast(tag => tag[0] === "e");
const pTag = ev.tags.findLast(tag => tag[0] === "p");
if (!reactedToTag?.length) {
return null;
}
const link = NostrLink.fromTag(reactedToTag, pTag?.[1]);
return (
<div className="note card">
<div className="text-gray-medium font-bold">
<Username pubkey={ev.pubkey} onLinkVisit={() => {}} />
<span> </span>
<FormattedMessage defaultMessage="liked" />
</div>
<NoteQuote link={link} />
</div>
);
}
function handleNonTextNote(ev: TaggedNostrEvent) {
if (ev.kind === EventKind.Reaction) {
return <Reaction ev={ev} />;
} else {
return <NoteAppHandler ev={ev} />;
}
}

View File

@ -1,44 +0,0 @@
import { mapEventToProfile, NostrLink, TaggedNostrEvent } from "@snort/system";
import { FormattedMessage } from "react-intl";
import Icon from "@/Components/Icons/Icon";
import Avatar from "@/Components/User/Avatar";
import DisplayName from "@/Components/User/DisplayName";
import useAppHandler from "@/Hooks/useAppHandler";
export default function NoteAppHandler({ ev }: { ev: TaggedNostrEvent }) {
const handlers = useAppHandler(ev.kind);
const link = NostrLink.fromEvent(ev);
const profiles = handlers.apps
.map(a => ({ profile: mapEventToProfile(a), event: a }))
.filter(a => a.profile)
.slice(0, 5);
return (
<div className="card flex flex-col gap-2">
<small>
<FormattedMessage defaultMessage="Sorry, we dont understand this event kind, please try one of the following apps instead!" />
</small>
{profiles.map(a => (
<div className="flex justify-between items-center" key={a.event.id}>
<div className="flex items-center gap-2">
<Avatar size={40} pubkey={a.event.pubkey} user={a.profile} />
<div>
<DisplayName pubkey={a.event.pubkey} user={a.profile} />
</div>
</div>
<Icon
name="link"
onClick={() => {
const webHandler = a.event.tags.find(a => a[0] === "web" && a[2] === "nevent")?.[1];
if (webHandler) {
window.open(webHandler.replace("<bech32>", link.encode()), "_blank");
}
}}
/>
</div>
))}
</div>
);
}

View File

@ -1,21 +0,0 @@
import classNames from "classnames";
import { AsyncIcon, AsyncIconProps } from "@/Components/Button/AsyncIcon";
import { formatShort } from "@/Utils/Number";
export const AsyncFooterIcon = (props: AsyncIconProps & { value: number }) => {
const mergedProps = {
...props,
iconSize: 18,
className: classNames(
"transition duration-200 ease-in-out flex flex-row reaction-pill cursor-pointer gap-2 items-center",
props.className,
),
};
return (
<AsyncIcon {...mergedProps}>
{props.value > 0 && <div className="reaction-pill-number">{formatShort(props.value)}</div>}
</AsyncIcon>
);
};

View File

@ -1,155 +0,0 @@
import { barrierQueue } from "@snort/shared";
import { NostrLink, ParsedZap, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import React, { useEffect, useState } from "react";
import { useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
import { ZapperQueue } from "@/Components/Event/Note/NoteFooter/ZapperQueue";
import { ZapsSummary } from "@/Components/Event/ZapsSummary";
import ZapModal from "@/Components/ZapModal/ZapModal";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { getDisplayName } from "@/Utils";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
import { ZapPoolController } from "@/Utils/ZapPoolController";
import { useWallet } from "@/Wallet";
export interface ZapIconProps {
ev: TaggedNostrEvent;
zaps: Array<ParsedZap>;
onClickZappers?: () => void;
}
export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
const { publicKey, readonly } = useLogin(s => ({
publicKey: s.publicKey,
readonly: s.readonly,
}));
const preferences = usePreferences(s => ({ autoZap: s.autoZap, defaultZapAmount: s.defaultZapAmount }));
const walletState = useWallet();
const wallet = walletState.wallet;
const link = NostrLink.fromEvent(ev);
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = zaps.some(a => a.sender === publicKey);
const [showZapModal, setShowZapModal] = useState(false);
const { formatMessage } = useIntl();
const [zapping, setZapping] = useState(false);
const { publisher, system } = useEventPublisher();
const author = useUserProfile(ev.pubkey);
const isMine = ev.pubkey === publicKey;
const longPress = useLongPress(() => setShowZapModal(true), { captureEvent: true });
const getZapTarget = (): Array<ZapTarget> | undefined => {
if (ev.tags.some(v => v[0] === "zap")) {
return Zapper.fromEvent(ev);
}
const authorTarget = author?.lud16 || author?.lud06;
if (authorTarget) {
return [
{
type: "lnurl",
value: authorTarget,
weight: 1,
name: getDisplayName(author, ev.pubkey),
zap: {
pubkey: ev.pubkey,
event: link,
},
} as ZapTarget,
];
}
};
const fastZap = async (e: React.MouseEvent) => {
if (zapping || e?.isPropagationStopped()) return;
const lnurl = getZapTarget();
if (canFastZap && lnurl) {
setZapping(true);
try {
await fastZapInner(lnurl, preferences.defaultZapAmount);
} catch (e) {
console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") {
setShowZapModal(true);
}
} finally {
setZapping(false);
}
} else {
setShowZapModal(true);
}
};
async function fastZapInner(targets: Array<ZapTarget>, amount: number) {
if (wallet) {
// only allow 1 invoice req/payment at a time to avoid hitting rate limits
await barrierQueue(ZapperQueue, async () => {
const zapper = new Zapper(system, publisher);
const result = await zapper.send(wallet, targets, amount);
const totalSent = result.reduce((acc, v) => (acc += v.sent), 0);
if (totalSent > 0) {
if (CONFIG.features.zapPool) {
ZapPoolController?.allocate(totalSent);
}
}
});
}
}
const canFastZap = wallet?.isReady() && !readonly;
const targets = getZapTarget();
useEffect(() => {
if (preferences.autoZap && !didZap && !isMine && !zapping) {
const lnurl = getZapTarget();
if (wallet?.isReady() && lnurl) {
setZapping(true);
queueMicrotask(async () => {
try {
await fastZapInner(lnurl, preferences.defaultZapAmount);
} catch {
// ignored
} finally {
setZapping(false);
}
});
}
}
}, [preferences.autoZap, author, zapping]);
return (
<>
{targets && (
<>
<div className="flex flex-row flex-none min-w-[50px] md:min-w-[80px] gap-4 items-center">
<AsyncFooterIcon
className={didZap ? "reacted text-nostr-orange" : "hover:text-nostr-orange"}
{...longPress()}
title={formatMessage({ defaultMessage: "Zap", id: "fBI91o" })}
iconName={canFastZap ? "zapFast" : "zap"}
value={zapTotal}
onClick={fastZap}
/>
<ZapsSummary zaps={zaps} onClick={onClickZappers ?? (() => {})} />
</div>
{showZapModal && (
<ZapModal
targets={getZapTarget()}
onClose={() => setShowZapModal(false)}
note={ev.id}
show={true}
allocatePool={true}
/>
)}
</>
)}
</>
);
};

View File

@ -1,53 +0,0 @@
import { normalizeReaction } from "@snort/shared";
import { TaggedNostrEvent } from "@snort/system";
import classNames from "classnames";
import { useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
export const LikeButton = ({
ev,
positiveReactions,
}: {
ev: TaggedNostrEvent;
positiveReactions: TaggedNostrEvent[];
}) => {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
const { publisher, system } = useEventPublisher();
const hasReacted = (emoji: string) => {
return positiveReactions?.some(
({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey,
);
};
const react = async (content: string) => {
if (!hasReacted(content) && publisher) {
const evLike = await publisher.react(ev, content);
system.BroadcastEvent(evLike);
}
if (!publisher) {
navigate("/login");
}
};
const reacted = hasReacted("+");
return (
<AsyncFooterIcon
className={classNames(
"flex-none min-w-[50px] md:min-w-[80px]",
reacted ? "reacted text-nostr-red" : "hover:text-nostr-red",
)}
iconName={reacted ? "heart-solid" : "heart"}
title={formatMessage({ defaultMessage: "Like", id: "qtWLmt" })}
value={positiveReactions.length}
onClick={() => react("+")}
/>
);
};

View File

@ -1,41 +0,0 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import React, { useMemo, useState } from "react";
import { FooterZapButton } from "@/Components/Event/Note/NoteFooter/FooterZapButton";
import { LikeButton } from "@/Components/Event/Note/NoteFooter/LikeButton";
import { PowIcon } from "@/Components/Event/Note/NoteFooter/PowIcon";
import { ReplyButton } from "@/Components/Event/Note/NoteFooter/ReplyButton";
import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton";
import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
export interface NoteFooterProps {
replyCount?: number;
ev: TaggedNostrEvent;
}
export default function NoteFooter(props: NoteFooterProps) {
const { ev } = props;
const link = useMemo(() => NostrLink.fromEvent(ev), [ev.id]);
const [showReactions, setShowReactions] = useState(false);
const related = useReactions("reactions", link);
const { replies, reactions, zaps, reposts } = useEventReactions(link, related);
const { positive } = reactions;
const readonly = useLogin(s => s.readonly);
const enableReactions = usePreferences(s => s.enableReactions);
return (
<div className="flex flex-row gap-4 overflow-hidden max-w-full h-6 items-center">
<ReplyButton ev={ev} replyCount={props.replyCount ?? replies.length} readonly={readonly} />
<RepostButton ev={ev} reposts={reposts} />
{enableReactions && <LikeButton ev={ev} positiveReactions={positive} />}
{CONFIG.showPowIcon && <PowIcon ev={ev} />}
<FooterZapButton ev={ev} zaps={zaps} onClickZappers={() => setShowReactions(true)} />
{showReactions && <ReactionsModal initialTab={1} onClose={() => setShowReactions(false)} event={ev} />}
</div>
);
}

View File

@ -1,21 +0,0 @@
import { countLeadingZeros, TaggedNostrEvent } from "@snort/system";
import { useIntl } from "react-intl";
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
import { findTag } from "@/Utils";
export const PowIcon = ({ ev }: { ev: TaggedNostrEvent }) => {
const { formatMessage } = useIntl();
const powValue = findTag(ev, "nonce") ? countLeadingZeros(ev.id) : undefined;
if (!powValue) return null;
return (
<AsyncFooterIcon
className="hidden md:flex flex-none min-w-[50px] md:min-w-[80px]"
title={formatMessage({ defaultMessage: "Proof of Work", id: "grQ+mI" })}
iconName="diamond"
value={powValue}
/>
);
};

View File

@ -1,58 +0,0 @@
import { TaggedNostrEvent } from "@snort/system";
import classNames from "classnames";
import { useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
import useLogin from "@/Hooks/useLogin";
import { useNoteCreator } from "@/State/NoteCreator";
export const ReplyButton = ({
ev,
replyCount,
readonly,
}: {
ev: TaggedNostrEvent;
replyCount?: number;
readonly: boolean;
}) => {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const publicKey = useLogin(s => s.publicKey);
const note = useNoteCreator(n => ({
show: n.show,
replyTo: n.replyTo,
update: n.update,
quote: n.quote,
}));
const handleReplyButtonClick = () => {
if (!publicKey) {
navigate("/login");
return;
}
if (readonly) {
return;
}
note.update(v => {
if (v.replyTo?.id !== ev.id) {
v.reset();
}
v.show = true;
v.replyTo = ev;
});
};
return (
<AsyncFooterIcon
className={classNames(
"flex-none min-w-[50px] md:min-w-[80px]",
note.show ? "reacted text-nostr-purple" : "hover:text-nostr-purple",
)}
iconName="reply"
title={formatMessage({ defaultMessage: "Reply", id: "9HU8vw" })}
value={replyCount ?? 0}
onClick={handleReplyButtonClick}
/>
);
};

View File

@ -1,76 +0,0 @@
import { TaggedNostrEvent } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
import classNames from "classnames";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { useNoteCreator } from "@/State/NoteCreator";
export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: TaggedNostrEvent[] }) => {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const { publisher, system } = useEventPublisher();
const publicKey = useLogin(s => s.publicKey);
const confirmReposts = usePreferences(s => s.confirmReposts);
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
const hasReposted = () => {
return reposts.some(a => a.pubkey === publicKey);
};
const repost = async () => {
if (!hasReposted() && publisher) {
if (!confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev);
system.BroadcastEvent(evRepost);
}
}
if (!publisher) {
navigate("/login");
}
};
return (
<Menu
menuButton={
<AsyncFooterIcon
className={classNames(
"flex-none min-w-[50px] md:min-w-[80px]",
hasReposted() ? "reacted text-nostr-blue" : "hover:text-nostr-blue",
)}
iconName="repeat"
title={formatMessage({ defaultMessage: "Repost", id: "JeoS4y" })}
value={reposts.length}
/>
}
menuClassName="ctx-menu"
align="start">
<div className="close-menu-container">
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={repost} disabled={hasReposted()}>
<Icon name="repeat" />
<FormattedMessage defaultMessage="Repost" />
</MenuItem>
<MenuItem
onClick={() =>
note.update(n => {
n.reset();
n.quote = ev;
n.show = true;
})
}>
<Icon name="edit" />
<FormattedMessage defaultMessage="Quote Repost" />
</MenuItem>
</Menu>
);
};

View File

@ -1,5 +0,0 @@
import { processWorkQueue, WorkQueueItem } from "@snort/shared";
export const ZapperQueue: Array<WorkQueueItem> = [];
processWorkQueue(ZapperQueue);

View File

@ -1,85 +0,0 @@
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { NotePropsOptions } from "@/Components/Event/EventComponent";
import { NoteContextMenu } from "@/Components/Event/Note/NoteContextMenu";
import NoteTime from "@/Components/Event/Note/NoteTime";
import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
import ReplyTag from "@/Components/Event/Note/ReplyTag";
import { NoteTranslation } from "@/Components/Event/Note/types";
import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages";
import ProfileImage from "@/Components/User/ProfileImage";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
export default function NoteHeader(props: {
ev: TaggedNostrEvent;
options: NotePropsOptions;
setTranslated?: (t: NoteTranslation) => void;
context?: React.ReactNode;
}) {
const [showReactions, setShowReactions] = useState(false);
const { ev, options, setTranslated } = props;
const { formatMessage } = useIntl();
const { publisher } = useEventPublisher();
const login = useLogin();
async function unpin() {
if (options.canUnpin && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
await login.state.removeFromList(EventKind.PinList, NostrLink.fromEvent(ev));
}
}
}
async function unbookmark() {
if (options.canUnbookmark && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
await login.state.removeFromList(EventKind.BookmarksList, NostrLink.fromEvent(ev));
}
}
}
const onTranslated = setTranslated ? (t: NoteTranslation) => setTranslated(t) : undefined;
return (
<div className="header flex">
<ProfileImage
pubkey={ev.pubkey}
subHeader={<ReplyTag ev={ev} />}
link={options.canClick === undefined ? undefined : ""}
showProfileCard={options.showProfileCard ?? true}
showBadges={true}
/>
<div className="info">
{props.context}
{(options.showTime || options.showBookmarked) && (
<>
{options.showBookmarked && (
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark()}>
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
</div>
)}
{!options.showBookmarked && <NoteTime from={ev.created_at * 1000} />}
</>
)}
{options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin()}>
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
</div>
)}
{options.showContextMenu && (
<NoteContextMenu
ev={ev}
react={async () => {}}
onTranslated={onTranslated}
setShowReactions={setShowReactions}
/>
)}
</div>
{showReactions && <ReactionsModal onClose={() => setShowReactions(false)} event={ev} />}
</div>
);
}

View File

@ -1,67 +0,0 @@
import { dedupe, sanitizeRelayUrl } from "@snort/shared";
import { NostrLink, NostrPrefix } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import Copy from "@/Components/Copy/Copy";
import Note from "@/Components/Event/EventComponent";
import Spinner from "@/Components/Icons/Spinner";
const options = {
showFooter: false,
truncate: true,
};
export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: number }) {
const [tryLink, setLink] = useState<NostrLink>(link);
const [tryRelay, setTryRelay] = useState("");
const { formatMessage } = useIntl();
const ev = useEventFeed(tryLink);
if (!ev)
return (
<div className="note-quote flex flex-col gap-2">
<Spinner />
<div>
<FormattedMessage
defaultMessage="Looking for: {noteId}"
values={{
noteId: <Copy text={tryLink.encode()} />,
}}
/>
</div>
<div className="flex gap-2">
<input
type="text"
value={tryRelay}
onChange={e => setTryRelay(e.target.value)}
placeholder={formatMessage({ defaultMessage: "Try another relay" })}
/>
<AsyncButton
onClick={() => {
const u = sanitizeRelayUrl(tryRelay);
if (u) {
const relays = tryLink.relays ?? [];
relays.push(u);
setLink(
new NostrLink(
tryLink.type !== NostrPrefix.Address ? NostrPrefix.Event : NostrPrefix.Address,
tryLink.id,
tryLink.kind,
tryLink.author,
dedupe(relays),
tryLink.marker,
),
);
setTryRelay("");
}
}}>
<FormattedMessage defaultMessage="Add" />
</AsyncButton>
</div>
</div>
);
return <Note data={ev} className="note-quote" depth={(depth ?? 0) + 1} options={options} />;
}

View File

@ -1,93 +0,0 @@
import React, { memo, useState } from "react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { NoteProps } from "@/Components/Event/EventComponent";
import { NoteTranslation } from "@/Components/Event/Note/types";
import Reveal from "@/Components/Event/Reveal";
import Text from "@/Components/Text/Text";
import usePreferences from "@/Hooks/usePreferences";
const TEXT_TRUNCATE_LENGTH = 400;
export const NoteText = memo(function InnerContent(
props: NoteProps & { translated: NoteTranslation; showTranslation?: boolean },
) {
const { data: ev, options, translated, showTranslation } = props;
const showContentWarningPosts = usePreferences(s => s.showContentWarningPosts);
const [showMore, setShowMore] = useState(false);
const body = translated && !translated.skipped && showTranslation ? translated.text : ev?.content ?? "";
const id = translated && !translated.skipped && showTranslation ? `${ev.id}-translated` : ev.id;
const shouldTruncate = options?.truncate && body.length > TEXT_TRUNCATE_LENGTH;
const ToggleShowMore = () => (
<a
className="highlight"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? <FormattedMessage defaultMessage="Show less" /> : <FormattedMessage defaultMessage="Show more" />}
</a>
);
const innerContent = (
<>
{shouldTruncate && showMore && <ToggleShowMore />}
<Text
id={id}
highlightText={props.highlightText}
content={body}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
disableMedia={!(options?.showMedia ?? true)}
disableMediaSpotlight={!(props.options?.showMediaSpotlight ?? true)}
truncate={shouldTruncate && !showMore ? TEXT_TRUNCATE_LENGTH : undefined}
/>
{shouldTruncate && !showMore && <ToggleShowMore />}
</>
);
if (!showContentWarningPosts) {
const contentWarning = ev.tags.find(a => a[0] === "content-warning");
if (contentWarning) {
return (
<Reveal
message={
<>
<FormattedMessage
defaultMessage="The author has marked this note as a <i>sensitive topic</i>"
id="StKzTE"
values={{
i: c => <i>{c}</i>,
}}
/>
{contentWarning[1] && (
<>
&nbsp;
<FormattedMessage
defaultMessage="Reason: <i>{reason}</i>"
id="6OSOXl"
values={{
i: c => <i>{c}</i>,
reason: contentWarning[1],
}}
/>
</>
)}
. <FormattedMessage defaultMessage="Click here to load anyway" />.{" "}
<Link to="/settings/moderation">
<i>
<FormattedMessage defaultMessage="Settings" />
</i>
</Link>
</>
}>
{innerContent}
</Reveal>
);
}
}
return innerContent;
});

View File

@ -1,61 +0,0 @@
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
export interface NoteTimeProps {
from: number;
fallback?: string;
}
const secondsInAMinute = 60;
const secondsInAnHour = secondsInAMinute * 60;
const secondsInADay = secondsInAnHour * 24;
const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
const calcTime = useCallback((fromTime: number) => {
const currentTime = new Date();
const timeDifference = Math.floor((currentTime.getTime() - fromTime) / 1000);
if (timeDifference < secondsInAMinute) {
return <FormattedMessage defaultMessage="now" />;
} else if (timeDifference < secondsInAnHour) {
return `${Math.floor(timeDifference / secondsInAMinute)}m`;
} else if (timeDifference < secondsInADay) {
return `${Math.floor(timeDifference / secondsInAnHour)}h`;
} else {
const fromDate = new Date(fromTime);
if (fromDate.getFullYear() === currentTime.getFullYear()) {
return fromDate.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
} else {
return fromDate.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
}
}, []);
const [time] = useState<string | ReactNode>(calcTime(from));
const absoluteTime = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "long",
}).format(from),
[from],
);
const isoDate = useMemo(() => new Date(from).toISOString(), [from]);
return (
<time dateTime={isoDate} title={absoluteTime}>
{time || fallback}
</time>
);
};
export default NoteTime;

View File

@ -1,105 +0,0 @@
import "./ReactionsModal.css";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import { useMemo, useState } from "react";
import { FormattedMessage, MessageDescriptor, useIntl } from "react-intl";
import CloseButton from "@/Components/Button/CloseButton";
import Icon from "@/Components/Icons/Icon";
import Modal from "@/Components/Modal/Modal";
import TabSelectors, { Tab } from "@/Components/TabSelectors/TabSelectors";
import ProfileImage from "@/Components/User/ProfileImage";
import ZapAmount from "@/Components/zap-amount";
import useWoT from "@/Hooks/useWoT";
import messages from "../../messages";
interface ReactionsModalProps {
onClose(): void;
event: TaggedNostrEvent;
initialTab?: number;
}
const ReactionsModal = ({ onClose, event, initialTab = 0 }: ReactionsModalProps) => {
const { formatMessage } = useIntl();
const link = NostrLink.fromEvent(event);
const related = useReactions("reactions", link, undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related);
const { positive, negative } = reactions;
const { sortEvents } = useWoT();
const likes = useMemo(() => sortEvents([...positive]), [positive]);
const dislikes = useMemo(() => sortEvents([...negative]), [negative]);
const sortedReposts = useMemo(() => sortEvents([...reposts]), [reposts]);
const total = positive.length + negative.length + zaps.length + reposts.length;
const createTab = (message: MessageDescriptor, count: number, value: number, disabled = false) =>
({
text: formatMessage(message, { n: count }),
value,
disabled,
}) as Tab;
const tabs = useMemo(() => {
const baseTabs = [
createTab(messages.Likes, likes.length, 0),
createTab(messages.Zaps, zaps.length, 1, zaps.length === 0),
createTab(messages.Reposts, reposts.length, 2, reposts.length === 0),
];
return dislikes.length !== 0 ? baseTabs.concat(createTab(messages.Dislikes, dislikes.length, 3)) : baseTabs;
}, [likes.length, zaps.length, reposts.length, dislikes.length, formatMessage]);
const [tab, setTab] = useState(tabs[initialTab]);
const renderReactionItem = (ev: TaggedNostrEvent, icon: string, iconClass?: string, size?: number) => (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Icon name={icon} size={size} className={iconClass} />
</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
return (
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
<CloseButton onClick={onClose} className="absolute right-4 top-3" />
<div className="reactions-header">
<h2>
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
</h2>
</div>
<TabSelectors tabs={tabs} tab={tab} setTab={setTab} />
<div className="reactions-body" key={tab.value}>
{tab.value === 0 && likes.map(ev => renderReactionItem(ev, "heart-solid", "text-heart"))}
{tab.value === 1 &&
zaps.map(
z =>
z.sender && (
<div key={z.id} className="reactions-item">
<ZapAmount n={z.amount} />
<ProfileImage
showProfileCard={true}
pubkey={z.anonZap ? "" : z.sender}
subHeader={<div title={z.content}>{z.content}</div>}
link={z.anonZap ? "" : undefined}
overrideUsername={
z.anonZap ? formatMessage({ defaultMessage: "Anonymous", id: "LXxsbk" }) : undefined
}
/>
</div>
),
)}
{tab.value === 2 && sortedReposts.map(ev => renderReactionItem(ev, "repost", "text-repost", 16))}
{tab.value === 3 && dislikes.map(ev => renderReactionItem(ev, "dislike"))}
</div>
</Modal>
);
};
export default ReactionsModal;

View File

@ -1,67 +0,0 @@
import { EventExt, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import React, { ReactNode } from "react";
import { useIntl } from "react-intl";
import { Link } from "react-router-dom";
import { UserCache } from "@/Cache";
import messages from "@/Components/messages";
import DisplayName from "@/Components/User/DisplayName";
import { ProfileLink } from "@/Components/User/ProfileLink";
import { hexToBech32 } from "@/Utils";
export default function ReplyTag({ ev }: { ev: TaggedNostrEvent }) {
const { formatMessage } = useIntl();
const thread = EventExt.extractThread(ev);
if (thread === undefined) {
return undefined;
}
const maxMentions = 2;
const replyTo = thread?.replyTo ?? thread?.root;
const replyLink = replyTo
? NostrLink.fromTag(
[replyTo.key, replyTo.value ?? "", replyTo.relay ?? "", replyTo.marker ?? ""].filter(a => a.length > 0),
)
: undefined;
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
for (const pk of thread?.pubKeys ?? []) {
const u = UserCache.getFromCache(pk);
const npub = hexToBech32(NostrPrefix.PublicKey, pk);
const shortNpub = npub.substring(0, 12);
mentions.push({
pk,
name: u?.name ?? shortNpub,
link: (
<ProfileLink pubkey={pk} user={u}>
<DisplayName pubkey={pk} user={u} />{" "}
</ProfileLink>
),
});
}
mentions.sort(a => (a.name.startsWith(NostrPrefix.PublicKey) ? 1 : -1));
const othersLength = mentions.length - maxMentions;
const renderMention = (m: { link: React.ReactNode; pk: string; name: string }, idx: number) => {
return (
<React.Fragment key={m.pk}>
{idx > 0 && ", "}
{m.link}
</React.Fragment>
);
};
const pubMentions =
mentions.length > maxMentions ? mentions?.slice(0, maxMentions).map(renderMention) : mentions?.map(renderMention);
const others = mentions.length > maxMentions ? formatMessage(messages.Others, { n: othersLength }) : "";
const link = replyLink?.encode(CONFIG.eventLinkPrefix);
return (
<div className="reply">
re:&nbsp;
{(mentions?.length ?? 0) > 0 ? (
<>
{pubMentions} {others}
</>
) : (
replyLink && <Link to={`/${link}`}>{link?.substring(0, 12)}</Link>
)}
</div>
);
}

View File

@ -1,34 +0,0 @@
import React from "react";
import { FormattedMessage } from "react-intl";
import { NoteTranslation } from "@/Components/Event/Note/types";
import messages from "@/Components/messages";
interface TranslationInfoProps {
translated: NoteTranslation;
setShowTranslation: React.Dispatch<React.SetStateAction<boolean>>;
}
export function TranslationInfo({ translated, setShowTranslation }: TranslationInfoProps) {
if (translated && translated.confidence > 0.5) {
return (
<>
<span
className="text-xs font-semibold text-gray-light select-none"
onClick={e => {
e.stopPropagation();
setShowTranslation(show => !show);
}}>
<FormattedMessage {...messages.TranslatedFrom} values={{ lang: translated.fromLanguage }} />
</span>
</>
);
} else if (translated && !translated.skipped) {
return (
<p className="text-xs font-semibold text-gray-light">
<FormattedMessage {...messages.TranslationFailed} />
</p>
);
}
return null;
}

View File

@ -1,18 +0,0 @@
import { TaggedNostrEvent } from "@snort/system";
export interface NoteTranslation {
text: string;
fromLanguage: string;
confidence: number;
skipped?: boolean;
}
export interface NoteContextMenuProps {
ev: TaggedNostrEvent;
setShowReactions(b: boolean): void;
react(content: string): Promise<void>;
onTranslated?: (t: NoteTranslation) => void;
}

View File

@ -1,12 +0,0 @@
interface DividerProps {
variant?: "regular" | "small";
}
export const Divider = ({ variant = "regular" }: DividerProps) => {
const className = variant === "small" ? "divider divider-small" : "divider";
return (
<div className="divider-container">
<div className={className}></div>
</div>
);
};

View File

@ -1,50 +0,0 @@
import { TaggedNostrEvent, u256 } from "@snort/system";
import { Fragment } from "react";
import Note from "@/Components/Event/EventComponent";
import { Divider } from "@/Components/Event/Thread/Divider";
import { TierTwo } from "@/Components/Event/Thread/TierTwo";
import { getReplies } from "@/Components/Event/Thread/util";
export interface SubthreadProps {
isLastSubthread?: boolean;
active: u256;
notes: readonly TaggedNostrEvent[];
chains: Map<u256, Array<TaggedNostrEvent>>;
onNavigate: (e: TaggedNostrEvent) => void;
}
export const Subthread = ({ active, notes, chains, onNavigate }: SubthreadProps) => {
const renderSubthread = (a: TaggedNostrEvent, idx: number) => {
const isLastSubthread = idx === notes.length - 1;
const replies = getReplies(a.id, chains);
return (
<Fragment key={a.id}>
<div className={`subthread-container ${replies.length > 0 ? "subthread-multi" : ""}`}>
<Divider />
<Note
highlight={active === a.id}
className={`thread-note ${isLastSubthread && replies.length === 0 ? "is-last-note" : ""}`}
data={a}
key={a.id}
onClick={onNavigate}
threadChains={chains}
waitUntilInView={idx > 5}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<TierTwo
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
chains={chains}
onNavigate={onNavigate}
/>
)}
</Fragment>
);
};
return <div className="subthread">{notes.map(renderSubthread)}</div>;
};

View File

@ -1,145 +0,0 @@
import "./Thread.css";
import { EventExt, TaggedNostrEvent, u256 } from "@snort/system";
import { ReactNode, useCallback, useContext, useMemo } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import BackButton from "@/Components/Button/BackButton";
import Note from "@/Components/Event/EventComponent";
import NoteGhost from "@/Components/Event/Note/NoteGhost";
import { Subthread } from "@/Components/Event/Thread/Subthread";
import { chainKey } from "@/Utils/Thread/ChainKey";
import { ThreadContext } from "@/Utils/Thread/ThreadContext";
export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean }) {
const thread = useContext(ThreadContext);
const navigate = useNavigate();
const isSingleNote = thread.chains?.size === 1 && [thread.chains.values].every(v => v.length === 0);
const { formatMessage } = useIntl();
const rootOptions = useMemo(
() => ({ showReactionsLink: true, showMediaSpotlight: !props.disableSpotlight, isRoot: true }),
[props.disableSpotlight],
);
const navigateThread = useCallback(
(e: TaggedNostrEvent) => {
thread.setCurrent(e.id);
// navigate(`/${NostrLink.fromEvent(e).encode()}`, { replace: true });
},
[thread],
);
const parent = useMemo(() => {
if (thread.root) {
const currentThread = EventExt.extractThread(thread.root);
return (
currentThread?.replyTo?.value ??
currentThread?.root?.value ??
(currentThread?.root?.key === "a" && currentThread.root?.value)
);
}
}, [thread.root]);
function renderRoot(note: TaggedNostrEvent) {
const className = `thread-root${isSingleNote ? " thread-root-single" : ""}`;
if (note) {
return (
<Note
className={className}
key={note.id}
data={note}
options={rootOptions}
onClick={navigateThread}
threadChains={thread.chains}
waitUntilInView={false}
/>
);
} else {
return <NoteGhost className={className}>Loading thread root.. ({thread.data?.length} notes loaded)</NoteGhost>;
}
}
function renderChain(from: u256): ReactNode {
if (!from || thread.chains.size === 0) {
return;
}
const replies = thread.chains.get(from);
if (replies && thread.current) {
return <Subthread active={thread.current} notes={replies} chains={thread.chains} onNavigate={navigateThread} />;
}
}
function renderCurrent() {
if (thread.current) {
const note = thread.data.find(n => n.id === thread.current);
return (
note && (
<Note
data={note}
options={{ showReactionsLink: true, showMediaSpotlight: true }}
threadChains={thread.chains}
onClick={navigateThread}
/>
)
);
}
}
function goBack() {
if (parent) {
thread.setCurrent(parent);
} else if (props.onBack) {
props.onBack();
} else {
navigate(-1);
}
}
const parentText = formatMessage({
defaultMessage: "Parent",
id: "ADmfQT",
description: "Link to parent note in thread",
});
const debug = window.location.search.includes("debug=true");
return (
<>
{debug && (
<div className="main-content p xs">
<h1>Chains</h1>
<pre>
{JSON.stringify(
Object.fromEntries([...thread.chains.entries()].map(([k, v]) => [k, v.map(c => c.id)])),
undefined,
" ",
)}
</pre>
<h1>Current</h1>
<pre>{JSON.stringify(thread.current)}</pre>
<h1>Root</h1>
<pre>{JSON.stringify(thread.root, undefined, " ")}</pre>
<h1>Data</h1>
<pre>{JSON.stringify(thread.data, undefined, " ")}</pre>
</div>
)}
{parent && (
<div className="main-content p">
<BackButton onClick={goBack} text={parentText} />
</div>
)}
<div className="main-content">
{thread.root && renderRoot(thread.root)}
{thread.root && renderChain(chainKey(thread.root))}
{!thread.root && renderCurrent()}
{!thread.root && !thread.current && (
<NoteGhost>
<FormattedMessage defaultMessage="Looking up thread..." />
</NoteGhost>
)}
</div>
</>
);
}

View File

@ -1,59 +0,0 @@
import { TaggedNostrEvent } from "@snort/system";
import classNames from "classnames";
import { useState } from "react";
import { useIntl } from "react-intl";
import Collapsed from "@/Components/Collapsed";
import Note from "@/Components/Event/EventComponent";
import { Divider } from "@/Components/Event/Thread/Divider";
import { SubthreadProps } from "@/Components/Event/Thread/Subthread";
import { TierThree } from "@/Components/Event/Thread/TierThree";
import { getReplies } from "@/Components/Event/Thread/util";
import messages from "@/Components/messages";
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
note: TaggedNostrEvent;
isLast: boolean;
idx: number;
}
export const ThreadNote = ({ active, note, isLast, isLastSubthread, chains, onNavigate, idx }: ThreadNoteProps) => {
const { formatMessage } = useIntl();
const replies = getReplies(note.id, chains);
const activeInReplies = replies.map(r => r.id).includes(active);
const [collapsed, setCollapsed] = useState(!activeInReplies);
const hasMultipleNotes = replies.length > 1;
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes;
const className = classNames(
"subthread-container",
isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid",
);
return (
<>
<div className={className}>
<Divider variant="small" />
<Note
highlight={active === note.id}
className={classNames("thread-note", { "is-last-note": isLastVisibleNote })}
data={note}
key={note.id}
onClick={onNavigate}
threadChains={chains}
waitUntilInView={idx > 5}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<Collapsed text={formatMessage(messages.ShowReplies)} collapsed={collapsed} setCollapsed={setCollapsed}>
<TierThree
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
chains={chains}
onNavigate={onNavigate}
/>
</Collapsed>
)}
</>
);
};

View File

@ -1,17 +0,0 @@
import { NostrPrefix, parseNostrLink } from "@snort/system";
import { useParams } from "react-router-dom";
import { Thread } from "@/Components/Event/Thread/Thread";
import { ThreadContextWrapper } from "@/Utils/Thread/ThreadContextWrapper";
export function ThreadRoute({ id }: { id?: string }) {
const params = useParams();
const resolvedId = id ?? params.id;
const link = parseNostrLink(resolvedId ?? "", NostrPrefix.Note);
return (
<ThreadContextWrapper link={link}>
<Thread />
</ThreadContextWrapper>
);
}

View File

@ -1,71 +0,0 @@
import { TaggedNostrEvent } from "@snort/system";
import classNames from "classnames";
import Note from "@/Components/Event/EventComponent";
import { Divider } from "@/Components/Event/Thread/Divider";
import { SubthreadProps } from "@/Components/Event/Thread/Subthread";
import { getReplies } from "@/Components/Event/Thread/util";
export const TierThree = ({ active, isLastSubthread, notes, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
const replies = getReplies(first.id, chains);
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
const isLast = replies.length === 0 && rest.length === 0;
return (
<>
<div
className={classNames("subthread-container", {
"subthread-multi": hasMultipleNotes,
"subthread-last": isLast,
"subthread-mid": !isLast,
})}>
<Divider variant="small" />
<Note
highlight={active === first.id}
className={classNames("thread-note", { "is-last-note": isLastSubthread && isLast })}
data={first}
key={first.id}
threadChains={chains}
waitUntilInView={true}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<TierThree
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
chains={chains}
onNavigate={onNavigate}
/>
)}
{rest.map((r: TaggedNostrEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
const lastNote = isLastSubthread && lastReply;
return (
<div
key={r.id}
className={classNames("subthread-container", {
"subthread-multi": !lastReply,
"subthread-last": !lastReply,
"subthread-mid": lastReply,
})}>
<Divider variant="small" />
<Note
className={classNames("thread-note", { "is-last-note": lastNote })}
highlight={active === r.id}
data={r}
key={r.id}
onClick={onNavigate}
threadChains={chains}
waitUntilInView={idx > 5}
/>
<div className="line-container"></div>
</div>
);
})}
</>
);
};

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