Compare commits
No commits in common. "main" and "v0.1.23" have entirely different histories.
@ -1,10 +0,0 @@
|
||||
**/node_modules
|
||||
**/.pnp.*
|
||||
**/.yarn/*
|
||||
!**/.yarn/patches
|
||||
!**/.yarn/plugins
|
||||
!**/.yarn/releases
|
||||
!**/.yarn/sdks
|
||||
!**/.yarn/versions
|
||||
**/.idea
|
||||
**/target
|
36
.drone.yml
@ -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:
|
||||
|
41
.github/workflows/release.yml
vendored
@ -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
@ -11,6 +11,4 @@ dist/
|
||||
*.tgz
|
||||
*.log
|
||||
.DS_Store
|
||||
.pnp*
|
||||
docs/
|
||||
.wrangler/
|
||||
.pnp*
|
874
.yarn/releases/yarn-3.6.3.cjs
vendored
Executable file
893
.yarn/releases/yarn-4.1.1.cjs
vendored
10
.yarnrc.yml
@ -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
|
||||
|
18
Dockerfile
@ -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
|
||||
|
15
README.md
@ -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)
|
||||
|
@ -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
|
@ -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;
|
||||
|
@ -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
@ -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
@ -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;
|
||||
};
|
@ -1,8 +0,0 @@
|
||||
maintainers:
|
||||
- npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
relays:
|
||||
- wss://relay.snort.social/
|
||||
- wss://pyramid.fiatjaf.com/
|
||||
- wss://nos.lol/
|
||||
- ws://skzzn6cimfdv5e2phjc4yr5v7ikbxtn5f7dkwn5c7v47tduzlbosqmqd.onion/
|
15
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
21
packages/app/.eslintrc.cjs
Normal 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,
|
||||
},
|
||||
};
|
@ -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,
|
||||
},
|
||||
};
|
3
packages/app/.gitignore
vendored
@ -25,5 +25,4 @@ yarn-error.log*
|
||||
.idea
|
||||
|
||||
dist/
|
||||
dev-dist/
|
||||
.wrangler/
|
||||
dev-dist/
|
47
packages/app/.yarn/.cache/webpack-dev-server/server.pem
Normal 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-----
|
@ -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
@ -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;
|
@ -1,11 +0,0 @@
|
||||
{
|
||||
"plugins": [
|
||||
[
|
||||
"formatjs",
|
||||
{
|
||||
"idInterpolationPattern": "[sha512:contenthash:base64:6]",
|
||||
"ast": true
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
55
packages/app/custom.d.ts
vendored
@ -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
|
||||
*/
|
||||
|
@ -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>
|
||||
|
9
packages/app/jest.config.js
Normal 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"],
|
||||
};
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
BIN
packages/app/public/iris/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 15 KiB |
@ -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;
|
Before Width: | Height: | Size: 165 B |
Before Width: | Height: | Size: 405 B |
Before Width: | Height: | Size: 3.9 KiB |
@ -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;
|
Before Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 44 KiB |
@ -1,4 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Sitemap: https://api.snort.social/api/v1/sitemap/index.xml
|
@ -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>>[];
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
@ -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()];
|
||||
}
|
||||
}
|
42
packages/app/src/Cache/EventInteractionCache.ts
Normal 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()];
|
||||
}
|
||||
}
|
47
packages/app/src/Cache/FollowListCache.ts
Normal 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));
|
||||
}
|
||||
}
|
126
packages/app/src/Cache/FollowsFeed.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
45
packages/app/src/Cache/Notifications.ts
Normal 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()];
|
||||
}
|
||||
}
|
16
packages/app/src/Cache/PaymentsCache.ts
Normal 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()];
|
||||
}
|
||||
}
|
@ -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()];
|
||||
}
|
||||
}
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
@ -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,
|
||||
};
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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} />;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
.cashu {
|
||||
background: var(--cashu-gradient);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>;
|
||||
});
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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} />;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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("+")}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -1,5 +0,0 @@
|
||||
import { processWorkQueue, WorkQueueItem } from "@snort/shared";
|
||||
|
||||
export const ZapperQueue: Array<WorkQueueItem> = [];
|
||||
|
||||
processWorkQueue(ZapperQueue);
|
@ -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>
|
||||
);
|
||||
}
|
@ -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} />;
|
||||
}
|
@ -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] && (
|
||||
<>
|
||||
|
||||
<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;
|
||||
});
|
@ -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;
|
@ -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;
|
@ -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:
|
||||
{(mentions?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
{pubMentions} {others}
|
||||
</>
|
||||
) : (
|
||||
replyLink && <Link to={`/${link}`}>{link?.substring(0, 12)}</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>;
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|