Compare commits
248 Commits
e480e74882
...
b334f764dc
Author | SHA1 | Date | |
---|---|---|---|
b334f764dc | |||
63454728f8 | |||
b1f93c9fd8 | |||
74a3cd7754 | |||
9f1846351f | |||
5ffa8c4cf2 | |||
605a6e30d8 | |||
660944fd29 | |||
34f5ac291e | |||
e92ac1c850 | |||
10d7c26470 | |||
9be92003bb | |||
63428142e5 | |||
1cb27c1881 | |||
64d64b29c3 | |||
45a0ef4445 | |||
4958611cca | |||
68b9a89278 | |||
9640a7fa57 | |||
7c2340d7fb | |||
6851fe0b2b | |||
6ffa314694 | |||
770d96d721 | |||
f7cd8ac9a0 | |||
e893ee0071 | |||
71953a2e04 | |||
6c34328270 | |||
f15d021f23 | |||
583bbe76a1 | |||
ea9a079943 | |||
7d6fed6da7 | |||
9f114ffb44 | |||
9f8095b0df | |||
e2a5f3ebd1 | |||
18cf675651 | |||
7ce15d19e3 | |||
1315455b94 | |||
3354529114 | |||
379bce6f9e | |||
7c1d5273fc | |||
bdd2404bf7 | |||
a61e17c93b | |||
7ec602cc16 | |||
0cd43a731e | |||
75fd4fb7aa | |||
e9bc25bd88 | |||
a1409b6eaa | |||
a5ae907cf7 | |||
e5fe86692c | |||
234c1c092d | |||
4365fac9b5 | |||
38b22757f2 | |||
e71f82541a | |||
fc2304c9fa | |||
9f00157bdc | |||
1e5b573045 | |||
0c2f669017 | |||
d573d075fe | |||
12250c5e3d | |||
769e093663 | |||
adaa8a71e7 | |||
19a2589e77 | |||
206aaca7b4 | |||
fa823afa33 | |||
4c2a24d278 | |||
6830c4c74c | |||
84c639ee30 | |||
119d1c526e | |||
c02cd9c300 | |||
e07fa411b6 | |||
939084167a | |||
28052e98b9 | |||
180ac0da75 | |||
34f12e5fb5 | |||
a274fb12a6 | |||
6ba0361ba9 | |||
a9f3216102 | |||
0fdcd36877 | |||
0306683c87 | |||
f25e578928 | |||
fc11381ccd | |||
c2a3a706de | |||
ecd957792d | |||
603048c74a | |||
f0740cb6ca | |||
cc943fed50 | |||
3ffc9bc81f | |||
79011f8ca2 | |||
7992601f46 | |||
fdf3d855df | |||
a7fa996e84 | |||
5b3edc0f59 | |||
5a83f3d040 | |||
f1e089b0a1 | |||
e0218eff9a | |||
9515dcbbbb | |||
7da4f350a6 | |||
2ef0172de9 | |||
7bfe2f0c91 | |||
6c2cbd202c | |||
d50787e3f2 | |||
ae655dfc69 | |||
81ccb95d82 | |||
2b80109e3b | |||
8e6a1ecbc2 | |||
ae6618f0ed | |||
203f5f2841 | |||
25e7f68dce | |||
3deae50645 | |||
5e027e5195 | |||
86ec7f41d7 | |||
f86053c14f | |||
f09c4648c1 | |||
73a669069f | |||
8a0cc0bcf6 | |||
4572b69d43 | |||
9edf5c1b5d | |||
d5032d6439 | |||
baec8d6904 | |||
c9133cb917 | |||
86ed386bb6 | |||
5b0af9ecfc | |||
7ddd6eca93 | |||
f6a51fd80b | |||
9a33466c7c | |||
ca92b365e0 | |||
9bfb6ede0a | |||
f684658183 | |||
988416f353 | |||
2ccee7bd7c | |||
7b151e1b17 | |||
8f7a9a1327 | |||
c36544f9a3 | |||
126a55f3c4 | |||
185e089ffd | |||
78bfa57628 | |||
d2f6b5b75b | |||
4abf9f080a | |||
c4a6da9d18 | |||
aa4802fa44 | |||
5addd65d66 | |||
e3e3a79620 | |||
fc6c14ea79 | |||
c2138287fa | |||
d19991be6c | |||
fc662f3267 | |||
6b4a5bd047 | |||
c82a0faf28 | |||
c52eb38833 | |||
7ab8eff33a | |||
b65542560c | |||
adfd28ded3 | |||
5cc7f5a834 | |||
fcb8e633fc | |||
a79cd6fd96 | |||
dce003d7f0 | |||
1d45225336 | |||
cefc21709a | |||
08e3619418 | |||
09cde5ee86 | |||
6ca55309e9 | |||
167f1c5e65 | |||
202b9933e0 | |||
cf0b4b5b1a | |||
823bf431a5 | |||
e0251ab389 | |||
6e16e7b5fb | |||
f4f80b3e57 | |||
8f4c0f49ec | |||
1119df3e06 | |||
59164b65c8 | |||
00b4a25625 | |||
2c9569d3f9 | |||
154098b5dc | |||
3a8125f0bb | |||
6856527a51 | |||
7317bc4c35 | |||
9b3b3adef9 | |||
610cc5e761 | |||
bbb38515f5 | |||
fccb704f69 | |||
|
4fdea1fdbd | ||
cf1ea5853f | |||
d8fc92fafc | |||
323d4e761e | |||
a2e81b479d | |||
3f8cb11e36 | |||
602b2fa143 | |||
ed6461fbc8 | |||
6344a4356a | |||
21cdab493f | |||
f63d7a9cc3 | |||
e876942d77 | |||
d5d299a5cf | |||
663c2ea433 | |||
fdcf77ad55 | |||
2ebe06aa35 | |||
d115b93eb1 | |||
c9968a86d8 | |||
3bcce3c8aa | |||
c7d1d4ef67 | |||
f3cbb8a952 | |||
32a5a9434e | |||
6b070ba48f | |||
4c359a714d | |||
4c38704c29 | |||
539ed2dbd9 | |||
e5b5dabf2f | |||
38b4fd3bb7 | |||
3e5b7ec4a6 | |||
6fa242c5f9 | |||
064bc1f0af | |||
50744ee928 | |||
de54805280 | |||
a3cba728a9 | |||
21057e72fc | |||
084983c0b6 | |||
b470d2fd14 | |||
a0ced76d46 | |||
7a717a5c56 | |||
06da58ac52 | |||
41a2f06f58 | |||
dcf854ddbb | |||
a56591996b | |||
6de9c566e7 | |||
ec4e6498d3 | |||
2ed38d1b97 | |||
1143cdfc88 | |||
51aba89e45 | |||
ef291f0db7 | |||
8aafae7d6c | |||
01d6839080 | |||
59038d118e | |||
c36a3e0bc1 | |||
53cffb2865 | |||
88c4ea65ef | |||
1ddb2dc71c | |||
51e6e0984e | |||
241ead7a03 | |||
|
c52b56871c | ||
|
0b6b17f4f9 | ||
|
0cb006816e | ||
|
701049368e | ||
|
4f8f472e84 | ||
ca1bb86036 | |||
1923273f6f | |||
9d67da3b6f | |||
7baa85ca11 |
@ -1,8 +0,0 @@
|
||||
**/node_modules/
|
||||
.github/
|
||||
.vscode/
|
||||
**/build/
|
||||
yarn-error.log
|
||||
.husky/
|
||||
.git/
|
||||
**/dist/
|
142
.drone.yml
Normal file
@ -0,0 +1,142 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: kubernetes
|
||||
name: docker
|
||||
concurrency:
|
||||
limit: 1
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
metadata:
|
||||
namespace: git
|
||||
steps:
|
||||
- name: Build site
|
||||
image: node:current-bullseye
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
environment:
|
||||
YARN_CACHE_FOLDER: /cache/.yarn-docker
|
||||
commands:
|
||||
- yarn install
|
||||
- yarn build
|
||||
- name: build docker image
|
||||
image: r.j3ss.co/img
|
||||
privileged: true
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
environment:
|
||||
TOKEN:
|
||||
from_secret: docker_hub
|
||||
commands:
|
||||
- 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:
|
||||
name: docker-cache
|
||||
---
|
||||
kind: pipeline
|
||||
type: kubernetes
|
||||
name: test-lint
|
||||
concurrency:
|
||||
limit: 1
|
||||
metadata:
|
||||
namespace: git
|
||||
steps:
|
||||
- name: Test/Lint
|
||||
image: node:current-bullseye
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
environment:
|
||||
YARN_CACHE_FOLDER: /cache/.yarn-test
|
||||
commands:
|
||||
- yarn install
|
||||
- yarn build
|
||||
- yarn test
|
||||
- yarn workspace @snort/app eslint
|
||||
- yarn workspace @snort/app prettier --check .
|
||||
volumes:
|
||||
- name: cache
|
||||
claim:
|
||||
name: docker-cache
|
||||
---
|
||||
kind: pipeline
|
||||
type: kubernetes
|
||||
name: crowdin
|
||||
concurrency:
|
||||
limit: 1
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
metadata:
|
||||
namespace: git
|
||||
steps:
|
||||
- name: Push/Pull translations
|
||||
image: node:current-bullseye
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
environment:
|
||||
YARN_CACHE_FOLDER: /cache/.yarn-translations
|
||||
TOKEN:
|
||||
from_secret: gitea
|
||||
CTOKEN:
|
||||
from_secret: crowdin
|
||||
commands:
|
||||
- git config --global user.email drone@v0l.io
|
||||
- git config --global user.name "Drone CI"
|
||||
- git remote set-url origin https://drone:$TOKEN@git.v0l.io/Kieran/snort.git
|
||||
- yarn install
|
||||
- npx @crowdin/cli upload sources -b main -T $CTOKEN
|
||||
- npx @crowdin/cli pull -b main -T $CTOKEN
|
||||
- yarn workspace @snort/app format
|
||||
- git add .
|
||||
- 'git commit -a -m "chore: Update translations"'
|
||||
- git push -u origin main
|
||||
volumes:
|
||||
- name: cache
|
||||
claim:
|
||||
name: docker-cache
|
||||
---
|
||||
kind: pipeline
|
||||
type: kubernetes
|
||||
name: docker-release
|
||||
concurrency:
|
||||
limit: 1
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
metadata:
|
||||
namespace: git
|
||||
steps:
|
||||
- name: Build site
|
||||
image: node:current-bullseye
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
environment:
|
||||
YARN_CACHE_FOLDER: /cache/.yarn-docker-release
|
||||
commands:
|
||||
- yarn install
|
||||
- yarn build
|
||||
- name: build docker image
|
||||
image: r.j3ss.co/img
|
||||
privileged: true
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
environment:
|
||||
TOKEN:
|
||||
from_secret: docker_hub
|
||||
commands:
|
||||
- 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:
|
||||
name: docker-cache
|
12
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -7,9 +7,11 @@ assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
@ -18,23 +20,25 @@ Steps to reproduce the behavior:
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
- Browser: [e.g. chrome, safari]
|
||||
- Version: [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
- Browser: [e.g. stock browser, safari]
|
||||
- Version: [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
27
.github/workflows/docker.yaml
vendored
@ -1,27 +0,0 @@
|
||||
name: Docker build
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
- name: Build the Docker image
|
||||
run: |
|
||||
docker buildx build \
|
||||
-t ghcr.io/${{ github.repository_owner }}/snort:latest \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--push .
|
37
.github/workflows/release.yml
vendored
@ -29,13 +29,13 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src-tauri -> target'
|
||||
workspaces: "./src-tauri -> target"
|
||||
|
||||
- name: Sync node version and setup cache
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'yarn'
|
||||
node-version: "16"
|
||||
cache: "yarn"
|
||||
- name: Install frontend dependencies
|
||||
run: yarn install
|
||||
- name: Build the app
|
||||
@ -44,34 +44,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: 'Snort v__VERSION__'
|
||||
releaseBody: 'See the assets to download and install this version.'
|
||||
releaseName: "Snort v__VERSION__"
|
||||
releaseBody: "See the assets to download and install this version."
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
build_docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set env variables
|
||||
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
- name: Build the Docker image
|
||||
run: |
|
||||
docker buildx build \
|
||||
-t ghcr.io/${{ github.repository_owner }}/snort:$TAG \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push .
|
24
.github/workflows/test-lint.yaml
vendored
@ -1,24 +0,0 @@
|
||||
name: Test+Lint
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
test_and_lint:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 19
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Build packages
|
||||
run: yarn workspace @snort/nostr build
|
||||
- name: Run tests
|
||||
run: yarn workspace @snort/app test
|
||||
- name: Check Eslint
|
||||
run: yarn workspace @snort/app eslint
|
||||
- name: Check Formatting
|
||||
run: yarn workspace @snort/app prettier --check .
|
4
.gitignore
vendored
@ -2,3 +2,7 @@ node_modules/
|
||||
.idea
|
||||
.yarn
|
||||
yarn.lock
|
||||
dist/
|
||||
*.tgz
|
||||
*.log
|
||||
.DS_Store
|
20
.vscode/settings.json
vendored
@ -1,11 +1,11 @@
|
||||
{
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.svn": true,
|
||||
"**/.hg": true,
|
||||
"**/CVS": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/Thumbs.db": true,
|
||||
"**/node_modules": true
|
||||
}
|
||||
}
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.svn": true,
|
||||
"**/.hg": true,
|
||||
"**/CVS": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/Thumbs.db": true,
|
||||
"**/node_modules": true
|
||||
}
|
||||
}
|
||||
|
3
Dockerfile.prebuilt
Normal file
@ -0,0 +1,3 @@
|
||||
FROM nginx:mainline-alpine
|
||||
COPY packages/app/build /usr/share/nginx/html
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
@ -27,14 +27,18 @@ Snort supports the following NIP's:
|
||||
- [x] NIP-26: Delegated Event Signing (Display delegated signings only)
|
||||
- [x] NIP-27: Text note references (Parsing only)
|
||||
- [ ] NIP-28: Public Chat
|
||||
- [x] NIP-30: Custom Emoji
|
||||
- [x] NIP-36: Sensitive Content
|
||||
- [ ] NIP-40: Expiration Timestamp
|
||||
- [x] NIP-42: Authentication of clients to relays
|
||||
- [x] NIP-44: Versioned encryption
|
||||
- [x] NIP-50: Search
|
||||
- [x] NIP-51: Lists
|
||||
- [x] NIP-58: Badges
|
||||
- [x] NIP-59: Gift Wrap
|
||||
- [x] NIP-65: Relay List Metadata
|
||||
- [ ] NIP-78: App specific data
|
||||
- [x] NIP-102: Live Events
|
||||
|
||||
### Running
|
||||
|
||||
@ -59,6 +63,7 @@ $ yarn build
|
||||
Translations are managed on [Crowdin](https://crowdin.com/project/snort)
|
||||
|
||||
To extract translations run:
|
||||
|
||||
```bash
|
||||
yarn workspace @snort/app intl-extract
|
||||
yarn workspace @snort/app intl-compile
|
||||
|
@ -1,5 +1,5 @@
|
||||
project_id: snort
|
||||
project_id: 568149
|
||||
preserve_hierarchy: true
|
||||
files:
|
||||
- source: /packages/app/src/translations/en.json
|
||||
translation: /packages/app/src/translations/%locale_with_underscore%.json
|
||||
- source: packages/app/src/translations/en.json
|
||||
translation: packages/app/src/translations/%locale_with_underscore%.json
|
||||
|
@ -1,7 +1,6 @@
|
||||
interface Env {
|
||||
}
|
||||
interface Env {}
|
||||
|
||||
export const onRequest: PagesFunction<Env> = async (context) => {
|
||||
export const onRequest: PagesFunction<Env> = async context => {
|
||||
const id = context.params.id as string;
|
||||
|
||||
const next = await context.next();
|
||||
@ -11,16 +10,16 @@ export const onRequest: PagesFunction<Env> = async (context) => {
|
||||
body: await next.arrayBuffer(),
|
||||
headers: {
|
||||
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
|
||||
"content-type": "text/plain"
|
||||
}
|
||||
"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"
|
||||
}
|
||||
"content-type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -28,4 +27,4 @@ export const onRequest: PagesFunction<Env> = async (context) => {
|
||||
// ignore
|
||||
}
|
||||
return next;
|
||||
}
|
||||
};
|
||||
|
@ -1,7 +1,6 @@
|
||||
interface Env {
|
||||
}
|
||||
interface Env {}
|
||||
|
||||
export const onRequest: PagesFunction<Env> = async (context) => {
|
||||
export const onRequest: PagesFunction<Env> = async context => {
|
||||
const id = context.params.id as string;
|
||||
|
||||
const next = await context.next();
|
||||
@ -11,16 +10,16 @@ export const onRequest: PagesFunction<Env> = async (context) => {
|
||||
body: await next.arrayBuffer(),
|
||||
headers: {
|
||||
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
|
||||
"content-type": "text/plain"
|
||||
}
|
||||
"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"
|
||||
}
|
||||
"content-type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -28,4 +27,4 @@ export const onRequest: PagesFunction<Env> = async (context) => {
|
||||
// ignore
|
||||
}
|
||||
return next;
|
||||
}
|
||||
};
|
||||
|
@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"lib": ["esnext"],
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
}
|
||||
}
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"lib": ["esnext"],
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
}
|
||||
}
|
||||
|
12
package.json
@ -4,11 +4,17 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "yarn workspace @snort/nostr build && yarn workspace @snort/app build",
|
||||
"start": "yarn workspace @snort/nostr build && yarn workspace @snort/app start"
|
||||
"build": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/system-react build && yarn workspace @snort/app build",
|
||||
"start": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/system-react build && yarn workspace @snort/app start",
|
||||
"test": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/app test && yarn workspace @snort/system test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^1.2.3",
|
||||
"@cloudflare/workers-types": "^4.20230307.0"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 120,
|
||||
"bracketSameLine": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +0,0 @@
|
||||
{
|
||||
"plugins": [["formatjs"]]
|
||||
}
|
@ -3,11 +3,11 @@ module.exports = {
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint"],
|
||||
root: true,
|
||||
ignorePatterns: ["build/", "*.test.ts"],
|
||||
ignorePatterns: ["build/", "*.test.ts", "*.js"],
|
||||
env: {
|
||||
browser: true,
|
||||
worker: true,
|
||||
commonjs: true,
|
||||
node: true,
|
||||
node: false,
|
||||
},
|
||||
};
|
||||
|
2
packages/app/.gitignore
vendored
@ -23,3 +23,5 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
.idea
|
||||
|
||||
dist/
|
||||
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"bracketSameLine": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
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; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src *; img-src * data: blob:; font-src https://fonts.gstatic.com; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;
|
4
packages/app/babel.config.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||
"plugins": [["formatjs"]]
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { useBabelRc, override } = require("customize-cra");
|
||||
|
||||
module.exports = override(useBabelRc());
|
16
packages/app/d.ts → packages/app/custom.d.ts
vendored
@ -1,3 +1,5 @@
|
||||
/// <reference types="@webbtc/webln-types" />
|
||||
|
||||
declare module "*.jpg" {
|
||||
const value: unknown;
|
||||
export default value;
|
||||
@ -27,17 +29,3 @@ declare module "translations/*.json" {
|
||||
const value: Record<string, string>;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "light-bolt11-decoder" {
|
||||
export function decode(pr?: string): ParsedInvoice;
|
||||
|
||||
export interface ParsedInvoice {
|
||||
paymentRequest: string;
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
name: string;
|
||||
value: string | Uint8Array | number | undefined;
|
||||
}
|
||||
}
|
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,60 +1,41 @@
|
||||
{
|
||||
"name": "@snort/app",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.10",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@cashu/cashu-ts": "^0.6.1",
|
||||
"@jukben/emoji-search": "^2.0.1",
|
||||
"@lightninglabs/lnc-web": "^0.2.3-alpha",
|
||||
"@noble/curves": "^1.0.0",
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"@noble/secp256k1": "^1.7.0",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@reduxjs/toolkit": "^1.9.1",
|
||||
"@scure/bip32": "^1.1.5",
|
||||
"@scure/bip32": "^1.3.0",
|
||||
"@scure/bip39": "^1.1.1",
|
||||
"@snort/nostr": "^1.0.0",
|
||||
"@szhsin/react-menu": "^3.3.1",
|
||||
"base32-decode": "^1.0.0",
|
||||
"bech32": "^2.0.0",
|
||||
"dexie": "^3.2.2",
|
||||
"dexie-react-hooks": "^1.1.1",
|
||||
"@void-cat/api": "^1.0.4",
|
||||
"debug": "^4.3.4",
|
||||
"dexie": "^3.2.4",
|
||||
"dns-over-http-resolver": "^2.1.1",
|
||||
"events": "^3.3.0",
|
||||
"hls.js": "^1.4.6",
|
||||
"light-bolt11-decoder": "^2.1.0",
|
||||
"qr-code-styling": "^1.6.0-rc.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-intersection-observer": "^9.4.1",
|
||||
"react-intl": "^6.2.8",
|
||||
"react-markdown": "^8.0.4",
|
||||
"react-query": "^3.39.2",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.5.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
"react-twitter-embed": "^4.0.4",
|
||||
"throttle-debounce": "^5.0.0",
|
||||
"unist-util-visit": "^4.1.2",
|
||||
"use-long-press": "^2.0.3",
|
||||
"workbox-background-sync": "^6.4.2",
|
||||
"workbox-broadcast-update": "^6.4.2",
|
||||
"workbox-cacheable-response": "^6.4.2",
|
||||
"workbox-core": "^6.4.2",
|
||||
"workbox-expiration": "^6.4.2",
|
||||
"workbox-google-analytics": "^6.4.2",
|
||||
"workbox-navigation-preload": "^6.4.2",
|
||||
"workbox-precaching": "^6.4.2",
|
||||
"workbox-range-requests": "^6.4.2",
|
||||
"workbox-routing": "^6.4.2",
|
||||
"workbox-strategies": "^6.4.2",
|
||||
"workbox-streams": "^6.4.2"
|
||||
"workbox-strategies": "^6.4.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-app-rewired start",
|
||||
"build": "react-app-rewired build",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-scripts eject",
|
||||
"start": "webpack serve",
|
||||
"build": "webpack --node-env=production",
|
||||
"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",
|
||||
"format": "prettier --write .",
|
||||
@ -68,9 +49,11 @@
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
"chrome >= 67",
|
||||
"edge >= 79",
|
||||
"firefox >= 68",
|
||||
"opera >= 54",
|
||||
"safari >= 14"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
@ -80,22 +63,43 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-syntax-import-assertions": "^7.20.0",
|
||||
"@babel/preset-env": "^7.21.5",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@formatjs/cli": "^6.0.1",
|
||||
"@types/jest": "^29.2.5",
|
||||
"@formatjs/ts-transformer": "^3.13.1",
|
||||
"@types/debug": "^4.1.8",
|
||||
"@types/jest": "^29.5.1",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
||||
"@types/webtorrent": "^0.109.3",
|
||||
"@webbtc/webln-types": "^1.0.10",
|
||||
"@webpack-cli/generators": "^3.0.4",
|
||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||
"babel-plugin-formatjs": "^10.3.36",
|
||||
"babel-loader": "^9.1.2",
|
||||
"babel-plugin-formatjs": "^10.5.1",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"css-loader": "^6.7.3",
|
||||
"css-minimizer-webpack-plugin": "^5.0.0",
|
||||
"customize-cra": "^1.0.0",
|
||||
"eslint-plugin-formatjs": "^4.10.1",
|
||||
"eslint-webpack-plugin": "^4.0.1",
|
||||
"html-webpack-plugin": "^5.5.1",
|
||||
"husky": ">=6",
|
||||
"jest": "^29.5.0",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
"lint-staged": ">=10",
|
||||
"mini-css-extract-plugin": "^2.7.5",
|
||||
"prettier": "2.8.3",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.4"
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-loader": "^9.4.2",
|
||||
"typescript": "^5.0.4",
|
||||
"webpack": "^5.82.1",
|
||||
"webpack-bundle-analyzer": "^4.8.0",
|
||||
"webpack-cli": "^5.1.1",
|
||||
"webpack-dev-server": "^4.15.0",
|
||||
"workbox-webpack-plugin": "^6.5.4"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,css,md}": "prettier --write"
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
@ -145,5 +145,38 @@
|
||||
<symbol id="user" viewBox="0 0 18 20" fill="none">
|
||||
<path d="M17 19C17 17.6044 17 16.9067 16.8278 16.3389C16.44 15.0605 15.4395 14.06 14.1611 13.6722C13.5933 13.5 12.8956 13.5 11.5 13.5H6.5C5.10444 13.5 4.40665 13.5 3.83886 13.6722C2.56045 14.06 1.56004 15.0605 1.17224 16.3389C1 16.9067 1 17.6044 1 19M13.5 5.5C13.5 7.98528 11.4853 10 9 10C6.51472 10 4.5 7.98528 4.5 5.5C4.5 3.01472 6.51472 1 9 1C11.4853 1 13.5 3.01472 13.5 5.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="camera-plus" viewBox="0 0 22 21" fill="none">
|
||||
<path d="M21 10.5V13.6C21 15.8402 21 16.9603 20.564 17.816C20.1805 18.5686 19.5686 19.1805 18.816 19.564C17.9603 20 16.8402 20 14.6 20H7.4C5.15979 20 4.03969 20 3.18404 19.564C2.43139 19.1805 1.81947 18.5686 1.43597 17.816C1 16.9603 1 15.8402 1 13.6V8.4C1 6.15979 1 5.03969 1.43597 4.18404C1.81947 3.43139 2.43139 2.81947 3.18404 2.43597C4.03969 2 5.15979 2 7.4 2H11.5M18 7V1M15 4H21M15 11C15 13.2091 13.2091 15 11 15C8.79086 15 7 13.2091 7 11C7 8.79086 8.79086 7 11 7C13.2091 7 15 8.79086 15 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="edit" viewBox="0 0 23 23" fill="none">
|
||||
<path d="M10 3.99998H5.8C4.11984 3.99998 3.27976 3.99998 2.63803 4.32696C2.07354 4.61458 1.6146 5.07353 1.32698 5.63801C1 6.27975 1 7.11983 1 8.79998V17.2C1 18.8801 1 19.7202 1.32698 20.362C1.6146 20.9264 2.07354 21.3854 2.63803 21.673C3.27976 22 4.11984 22 5.8 22H14.2C15.8802 22 16.7202 22 17.362 21.673C17.9265 21.3854 18.3854 20.9264 18.673 20.362C19 19.7202 19 18.8801 19 17.2V13M6.99997 16H8.67452C9.1637 16 9.40829 16 9.63846 15.9447C9.84254 15.8957 10.0376 15.8149 10.2166 15.7053C10.4184 15.5816 10.5914 15.4086 10.9373 15.0627L20.5 5.49998C21.3284 4.67156 21.3284 3.32841 20.5 2.49998C19.6716 1.67156 18.3284 1.67155 17.5 2.49998L7.93723 12.0627C7.59133 12.4086 7.41838 12.5816 7.29469 12.7834C7.18504 12.9624 7.10423 13.1574 7.05523 13.3615C6.99997 13.5917 6.99997 13.8363 6.99997 14.3255V16Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="arrow-right" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M1.16663 6.99935H12.8333M12.8333 6.99935L6.99996 1.16602M12.8333 6.99935L6.99996 12.8327" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="piggy-bank" viewBox="0 0 22 20" fill="none">
|
||||
<path d="M3.99993 11C3.99993 12.6484 4.66466 14.1415 5.74067 15.226C5.84445 15.3305 5.89633 15.3828 5.92696 15.4331C5.95619 15.4811 5.9732 15.5224 5.98625 15.5771C5.99993 15.6343 5.99993 15.6995 5.99993 15.8298V18.2C5.99993 18.48 5.99993 18.62 6.05443 18.727C6.10236 18.8211 6.17885 18.8976 6.27293 18.9455C6.37989 19 6.5199 19 6.79993 19H8.69993C8.97996 19 9.11997 19 9.22693 18.9455C9.32101 18.8976 9.3975 18.8211 9.44543 18.727C9.49993 18.62 9.49993 18.48 9.49993 18.2V17.8C9.49993 17.52 9.49993 17.38 9.55443 17.273C9.60236 17.1789 9.67885 17.1024 9.77293 17.0545C9.87989 17 10.0199 17 10.2999 17H11.6999C11.98 17 12.12 17 12.2269 17.0545C12.321 17.1024 12.3975 17.1789 12.4454 17.273C12.4999 17.38 12.4999 17.52 12.4999 17.8V18.2C12.4999 18.48 12.4999 18.62 12.5544 18.727C12.6024 18.8211 12.6789 18.8976 12.7729 18.9455C12.8799 19 13.0199 19 13.2999 19H15.2C15.48 19 15.62 19 15.727 18.9455C15.8211 18.8976 15.8976 18.8211 15.9455 18.727C16 18.62 16 18.48 16 18.2V17.2243C16 17.0223 16 16.9212 16.0288 16.8401C16.0563 16.7624 16.0911 16.708 16.15 16.6502C16.2114 16.59 16.3155 16.5417 16.5237 16.445C17.5059 15.989 18.344 15.2751 18.9511 14.3902C19.0579 14.2346 19.1112 14.1568 19.1683 14.1108C19.2228 14.0668 19.2717 14.0411 19.3387 14.021C19.4089 14 19.4922 14 19.6587 14H20.2C20.48 14 20.62 14 20.727 13.9455C20.8211 13.8976 20.8976 13.8211 20.9455 13.727C21 13.62 21 13.48 21 13.2V9.78575C21 9.51916 21 9.38586 20.9505 9.28303C20.9013 9.181 20.819 9.09867 20.717 9.04953C20.6141 9 20.4808 9 20.2143 9C20.0213 9 19.9248 9 19.8471 8.9738C19.7633 8.94556 19.7045 8.90798 19.6437 8.84377C19.5874 8.78422 19.5413 8.68464 19.4493 8.48547C19.1538 7.84622 18.7492 7.26777 18.2593 6.77404C18.1555 6.66945 18.1036 6.61716 18.073 6.56687C18.0437 6.51889 18.0267 6.47759 18.0137 6.42294C18 6.36567 18 6.30051 18 6.17018V5.06058C18 4.70053 18 4.52051 17.925 4.39951C17.8593 4.29351 17.7564 4.21588 17.6365 4.18184C17.4995 4.14299 17.3264 4.19245 16.9802 4.29136L14.6077 4.96922C14.5673 4.98074 14.5472 4.9865 14.5267 4.99054C14.5085 4.99414 14.4901 4.99671 14.4716 4.99826C14.4508 5 14.4298 5 14.3879 5H13.959M3.99993 11C3.99993 8.69594 5.29864 6.6952 7.20397 5.6899M3.99993 11H3C1.89543 11 1 10.1046 1 9C1 8.25972 1.4022 7.61337 2 7.26756M14 4.5C14 6.433 12.433 8 10.5 8C8.567 8 7 6.433 7 4.5C7 2.567 8.567 1 10.5 1C12.433 1 14 2.567 14 4.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="clock" viewBox="0 0 22 22" fill="none">
|
||||
<path d="M11 5V11L15 13M21 11C21 16.5228 16.5228 21 11 21C5.47715 21 1 16.5228 1 11C1 5.47715 5.47715 1 11 1C16.5228 1 21 5.47715 21 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="book-closed" viewBox="0 0 18 22" fill="none">
|
||||
<path d="M17 18V15H4C2.34315 15 1 16.3431 1 18M5.8 21H13.8C14.9201 21 15.4802 21 15.908 20.782C16.2843 20.5903 16.5903 20.2843 16.782 19.908C17 19.4802 17 18.9201 17 17.8V4.2C17 3.07989 17 2.51984 16.782 2.09202C16.5903 1.71569 16.2843 1.40973 15.908 1.21799C15.4802 1 14.9201 1 13.8 1H5.8C4.11984 1 3.27976 1 2.63803 1.32698C2.07354 1.6146 1.6146 2.07354 1.32698 2.63803C1 3.27976 1 4.11984 1 5.8V16.2C1 17.8802 1 18.7202 1.32698 19.362C1.6146 19.9265 2.07354 20.3854 2.63803 20.673C3.27976 21 4.11984 21 5.8 21Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="shopping-bag" viewBox="0 0 22 22" fill="none">
|
||||
<path d="M15.0004 8V5C15.0004 2.79086 13.2095 1 11.0004 1C8.79123 1 7.00037 2.79086 7.00037 5V8M2.59237 9.35196L1.99237 15.752C1.82178 17.5717 1.73648 18.4815 2.03842 19.1843C2.30367 19.8016 2.76849 20.3121 3.35839 20.6338C4.0299 21 4.94374 21 6.77142 21H15.2293C17.057 21 17.9708 21 18.6423 20.6338C19.2322 20.3121 19.6971 19.8016 19.9623 19.1843C20.2643 18.4815 20.179 17.5717 20.0084 15.752L19.4084 9.35197C19.2643 7.81535 19.1923 7.04704 18.8467 6.46616C18.5424 5.95458 18.0927 5.54511 17.555 5.28984C16.9444 5 16.1727 5 14.6293 5L7.37142 5C5.82806 5 5.05638 5 4.44579 5.28984C3.90803 5.54511 3.45838 5.95458 3.15403 6.46616C2.80846 7.04704 2.73643 7.81534 2.59237 9.35196Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="wifi" viewBox="0 0 24 18" fill="none">
|
||||
<path d="M12 16.5H12.01M22.8064 5.70076C19.9595 3.09199 16.1656 1.5 11.9999 1.5C7.83414 1.5 4.04023 3.09199 1.19336 5.70076M4.73193 9.24297C6.67006 7.53566 9.21407 6.5 12 6.5C14.7859 6.5 17.3299 7.53566 19.268 9.24297M15.6983 12.7751C14.6792 11.9763 13.3952 11.5 11.9999 11.5C10.5835 11.5 9.28172 11.9908 8.25537 12.8116" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="snort-by" viewBox="0 0 1715.5309 201.961" fill="none">
|
||||
<g transform="translate(-0.001,-634.57194)">
|
||||
<path fill="currentColor" d="m 24.77,750.25 c 2.722,-36.745 14.697,-72.401 32.934,-108.058 47.089,-5.444 92.544,-7.35 136.91,-7.35 45.183,0 89.821,0.816 134.188,4.628 l -14.97,49.537 c -30.757,-1.36 -59.064,-3.266 -100.165,-3.266 -38.65,0 -66.686,1.36 -97.17,3.538 -2.722,4.899 -4.627,9.526 -5.172,16.876 64.508,3.266 131.466,0.816 194.069,14.153 -3.267,35.929 -14.698,72.401 -33.207,108.603 -47.36,5.443 -93.632,7.349 -138.815,7.349 -44.639,0 -89.005,-1.089 -133.371,-4.627 l 14.971,-49.538 c 31.029,1.633 60.97,2.994 103.703,2.994 38.378,0 66.141,-1.361 93.903,-3.267 2.45,-4.627 4.899,-11.159 5.444,-17.964 C 152.152,761.954 87.916,762.227 24.77,750.25 Z" />
|
||||
<path fill="currentColor" d="m 640.713,833.539 h -78.934 l -87.1,-138.543 c -4.083,-0.544 -9.799,-0.816 -15.243,-0.816 L 416.975,833.539 H 337.77 l 49.811,-163.312 -31.846,-26.946 1.905,-5.717 h 49.266 c 42.461,0 81.384,1.089 132.282,3.812 l 53.893,89.549 28.58,-93.36 h 78.934 z" />
|
||||
<path fill="currentColor" d="m 835.313,836.533 c -49.538,0 -97.715,-2.722 -145.62,-8.982 13.882,-61.786 32.663,-122.212 56.343,-183.998 51.716,-6.26 101.525,-8.981 151.063,-8.981 49.538,0 97.715,2.722 145.619,8.981 -14.153,62.059 -32.662,122.212 -56.342,183.998 -51.716,6.261 -101.525,8.982 -151.063,8.982 z m -47.906,-59.881 c 20.959,2.178 43.55,3.266 65.325,3.266 21.774,0 44.91,-1.088 66.958,-3.266 11.704,-27.491 19.869,-54.438 25.313,-82.2 -20.958,-2.178 -43.55,-3.267 -65.324,-3.267 -21.775,0 -44.911,1.089 -66.958,3.267 -5.988,14.153 -10.888,26.946 -14.97,41.1 -4.355,14.154 -7.622,26.946 -10.344,41.1 z" />
|
||||
<path fill="currentColor" d="m 1124.904,833.539 h -75.939 l 50.082,-164.4 -30.757,-25.857 1.905,-5.717 h 181.275 c 41.645,0 98.26,0.817 139.088,7.35 -6.261,43.822 -16.604,88.188 -41.101,134.46 -13.882,0.544 -29.668,2.178 -45.183,3.267 l 43.277,50.898 h -95.81 l -79.751,-91.727 1.634,-5.988 h 33.479 c 31.029,0 55.799,0 82.2,-1.361 7.077,-16.331 11.977,-31.301 14.698,-47.36 -22.047,-1.089 -41.372,-1.089 -68.047,-1.089 h -66.141 z" />
|
||||
<path fill="currentColor" d="m 1698.113,694.724 c -53.893,-1.633 -85.466,-2.449 -106.425,-2.722 l -43.005,141.537 h -79.479 l 43.006,-141.537 c -21.231,0.272 -53.077,1.089 -108.059,2.722 l 17.42,-56.342 c 50.082,-2.723 99.076,-3.539 148.069,-3.539 49.266,0 97.442,0.816 145.892,3.539 z" />
|
||||
</g>
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 46 KiB |
@ -2,23 +2,21 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Fast nostr web ui" />
|
||||
<meta name="description" content="Feature packed nostr client" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="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; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src *; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
|
||||
name="keywords"
|
||||
content="nostr snort fast decentralized social media censorship resistant open source software" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://imgproxy.snort.social" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>snort.social - Nostr interface</title>
|
||||
<link rel="apple-touch-icon" href="/logo_512.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Snort - Nostr</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
BIN
packages/app/public/logo.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
packages/app/public/logo_256.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
packages/app/public/logo_512.png
Normal file
After Width: | Height: | Size: 12 KiB |
@ -4,12 +4,12 @@
|
||||
"description": "Fast nostr web ui",
|
||||
"icons": [
|
||||
{
|
||||
"src": "nostrich_256.png",
|
||||
"src": "logo_256.png",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256"
|
||||
},
|
||||
{
|
||||
"src": "nostrich_512.png",
|
||||
"src": "logo_512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 528 KiB |
Before Width: | Height: | Size: 771 KiB |
@ -1,13 +1,13 @@
|
||||
import { RawEvent } from "@snort/nostr";
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import { FeedCache } from "@snort/shared";
|
||||
import { db } from "Db";
|
||||
import FeedCache from "./FeedCache";
|
||||
|
||||
class DMCache extends FeedCache<RawEvent> {
|
||||
export class ChatCache extends FeedCache<NostrEvent> {
|
||||
constructor() {
|
||||
super("DMCache", db.dms);
|
||||
super("ChatCache", db.chats);
|
||||
}
|
||||
|
||||
key(of: RawEvent): string {
|
||||
key(of: NostrEvent): string {
|
||||
return of.id;
|
||||
}
|
||||
|
||||
@ -23,13 +23,7 @@ class DMCache extends FeedCache<RawEvent> {
|
||||
return ret;
|
||||
}
|
||||
|
||||
allDms(): Array<RawEvent> {
|
||||
takeSnapshot(): Array<NostrEvent> {
|
||||
return [...this.cache.values()];
|
||||
}
|
||||
|
||||
takeSnapshot(): Array<RawEvent> {
|
||||
return this.allDms();
|
||||
}
|
||||
}
|
||||
|
||||
export const DmCache = new DMCache();
|
@ -1,9 +1,9 @@
|
||||
import { FeedCache } from "@snort/shared";
|
||||
import { db, EventInteraction } from "Db";
|
||||
import { LoginStore } from "Login";
|
||||
import { sha256 } from "Util";
|
||||
import FeedCache from "./FeedCache";
|
||||
import { sha256 } from "SnortUtils";
|
||||
|
||||
class EventInteractionCache extends FeedCache<EventInteraction> {
|
||||
export class EventInteractionCache extends FeedCache<EventInteraction> {
|
||||
constructor() {
|
||||
super("EventInteraction", db.eventInteraction);
|
||||
}
|
||||
@ -40,5 +40,3 @@ class EventInteractionCache extends FeedCache<EventInteraction> {
|
||||
return [...this.cache.values()];
|
||||
}
|
||||
}
|
||||
|
||||
export const InteractionCache = new EventInteractionCache();
|
||||
|
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,124 +0,0 @@
|
||||
import FeedCache from "Cache/FeedCache";
|
||||
import { db } from "Db";
|
||||
import { MetadataCache } from "Cache";
|
||||
import { LNURL } from "LNURL";
|
||||
|
||||
class UserProfileCache extends FeedCache<MetadataCache> {
|
||||
#zapperQueue: Array<{ pubkey: string; lnurl: string }> = [];
|
||||
|
||||
constructor() {
|
||||
super("UserCache", db.users);
|
||||
this.#processZapperQueue();
|
||||
}
|
||||
|
||||
key(of: MetadataCache): string {
|
||||
return of.pubkey;
|
||||
}
|
||||
|
||||
async search(q: string): Promise<Array<MetadataCache>> {
|
||||
if (db.ready) {
|
||||
// on-disk cache will always have more data
|
||||
return (
|
||||
await db.users
|
||||
.where("npub")
|
||||
.startsWithIgnoreCase(q)
|
||||
.or("name")
|
||||
.startsWithIgnoreCase(q)
|
||||
.or("display_name")
|
||||
.startsWithIgnoreCase(q)
|
||||
.or("nip05")
|
||||
.startsWithIgnoreCase(q)
|
||||
.toArray()
|
||||
).slice(0, 5);
|
||||
} else {
|
||||
return [...this.cache.values()]
|
||||
.filter(user => {
|
||||
const profile = user as MetadataCache;
|
||||
return (
|
||||
profile.name?.includes(q) ||
|
||||
profile.npub?.includes(q) ||
|
||||
profile.display_name?.includes(q) ||
|
||||
profile.nip05?.includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 5);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to update the profile metadata cache with a new version
|
||||
* @param m Profile metadata
|
||||
* @returns
|
||||
*/
|
||||
async update(m: MetadataCache) {
|
||||
const existing = this.getFromCache(m.pubkey);
|
||||
const updateType = (() => {
|
||||
if (!existing) {
|
||||
return "new_profile";
|
||||
}
|
||||
if (existing.created < m.created) {
|
||||
return "updated_profile";
|
||||
}
|
||||
if (existing && existing.loaded < m.loaded) {
|
||||
return "refresh_profile";
|
||||
}
|
||||
return "no_change";
|
||||
})();
|
||||
console.debug(`Updating ${m.pubkey} ${updateType}`, m);
|
||||
if (updateType !== "no_change") {
|
||||
const writeProfile = {
|
||||
...existing,
|
||||
...m,
|
||||
};
|
||||
await this.#setItem(writeProfile);
|
||||
if (updateType !== "refresh_profile") {
|
||||
const lnurl = m.lud16 ?? m.lud06;
|
||||
if (lnurl) {
|
||||
this.#zapperQueue.push({
|
||||
pubkey: m.pubkey,
|
||||
lnurl,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return updateType;
|
||||
}
|
||||
|
||||
takeSnapshot(): MetadataCache[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async #setItem(m: MetadataCache) {
|
||||
this.cache.set(m.pubkey, m);
|
||||
if (db.ready) {
|
||||
await db.users.put(m);
|
||||
this.onTable.add(m.pubkey);
|
||||
}
|
||||
this.notifyChange([m.pubkey]);
|
||||
}
|
||||
|
||||
async #processZapperQueue() {
|
||||
while (this.#zapperQueue.length > 0) {
|
||||
const i = this.#zapperQueue.shift();
|
||||
if (i) {
|
||||
try {
|
||||
const svc = new LNURL(i.lnurl);
|
||||
await svc.load();
|
||||
const p = this.getFromCache(i.pubkey);
|
||||
if (p) {
|
||||
this.#setItem({
|
||||
...p,
|
||||
zapService: svc.zapperPubkey,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to load LNURL for zapper pubkey", i.lnurl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => this.#processZapperQueue(), 1_000);
|
||||
}
|
||||
}
|
||||
|
||||
export const UserCache = new UserProfileCache();
|
@ -1,55 +1,22 @@
|
||||
import { HexKey, RawEvent, UserMetadata } from "@snort/nostr";
|
||||
import { hexToBech32, unixNowMs } from "Util";
|
||||
import { DmCache } from "./DMCache";
|
||||
import { InteractionCache } from "./EventInteractionCache";
|
||||
import { UserCache } from "./UserCache";
|
||||
import { UserProfileCache, UserRelaysCache, RelayMetricCache } from "@snort/system";
|
||||
import { EventInteractionCache } from "./EventInteractionCache";
|
||||
import { ChatCache } from "./ChatCache";
|
||||
import { Payments } from "./PaymentsCache";
|
||||
|
||||
export interface MetadataCache extends UserMetadata {
|
||||
/**
|
||||
* When the object was saved in cache
|
||||
*/
|
||||
loaded: number;
|
||||
export const UserCache = new UserProfileCache();
|
||||
export const UserRelays = new UserRelaysCache();
|
||||
export const RelayMetrics = new RelayMetricCache();
|
||||
export const Chats = new ChatCache();
|
||||
export const PaymentsCache = new Payments();
|
||||
export const InteractionCache = new EventInteractionCache();
|
||||
|
||||
/**
|
||||
* When the source metadata event was created
|
||||
*/
|
||||
created: number;
|
||||
|
||||
/**
|
||||
* The pubkey of the owner of this metadata
|
||||
*/
|
||||
pubkey: HexKey;
|
||||
|
||||
/**
|
||||
* The bech32 encoded pubkey
|
||||
*/
|
||||
npub: string;
|
||||
|
||||
/**
|
||||
* Pubkey of zapper service
|
||||
*/
|
||||
zapService?: HexKey;
|
||||
export async function preload(follows?: Array<string>) {
|
||||
const preloads = [
|
||||
UserCache.preload(follows),
|
||||
Chats.preload(),
|
||||
InteractionCache.preload(),
|
||||
UserRelays.preload(follows),
|
||||
RelayMetrics.preload(),
|
||||
];
|
||||
await Promise.all(preloads);
|
||||
}
|
||||
|
||||
export function mapEventToProfile(ev: RawEvent) {
|
||||
try {
|
||||
const data: UserMetadata = JSON.parse(ev.content);
|
||||
return {
|
||||
pubkey: ev.pubkey,
|
||||
npub: hexToBech32("npub", ev.pubkey),
|
||||
created: ev.created_at,
|
||||
...data,
|
||||
loaded: unixNowMs(),
|
||||
} as MetadataCache;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse JSON", ev, e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function preload() {
|
||||
await UserCache.preload();
|
||||
await DmCache.preload();
|
||||
await InteractionCache.preload();
|
||||
}
|
||||
|
||||
export { UserCache, DmCache };
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { RelaySettings } from "@snort/nostr";
|
||||
import { RelaySettings } from "@snort/system";
|
||||
|
||||
/**
|
||||
* Add-on api for snort features
|
||||
@ -33,7 +33,7 @@ export const DefaultConnectTimeout = 2000;
|
||||
/**
|
||||
* How long profile cache should be considered valid for
|
||||
*/
|
||||
export const ProfileCacheExpire = 1_000 * 60 * 30;
|
||||
export const ProfileCacheExpire = 1_000 * 60 * 60 * 6;
|
||||
|
||||
/**
|
||||
* Default bootstrap relays
|
||||
@ -47,7 +47,7 @@ export const DefaultRelays = new Map<string, RelaySettings>([
|
||||
/**
|
||||
* Default search relays
|
||||
*/
|
||||
export const SearchRelays = new Map<string, RelaySettings>([["wss://relay.nostr.band", { read: true, write: false }]]);
|
||||
export const SearchRelays = ["wss://relay.nostr.band"];
|
||||
|
||||
/**
|
||||
* List of recommended follows for new users
|
||||
@ -131,7 +131,7 @@ export const TweetUrlRegex = /https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:
|
||||
* Hashtag regex
|
||||
*/
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
export const HashtagRegex = /(#[^\s!@#$%^&*()=+.\/,\[{\]};:'"?><]+)/;
|
||||
export const HashtagRegex = /(#[^\s!@#$%^&*()=+.\/,\[{\]};:'"?><]+)/g;
|
||||
|
||||
/**
|
||||
* Tidal share link regex
|
||||
@ -178,4 +178,9 @@ export const MagnetRegex = /(magnet:[\S]+)/i;
|
||||
* Wavlake embed regex
|
||||
*/
|
||||
export const WavlakeRegex =
|
||||
/https?:\/\/(?:player\.|www\.)?wavlake\.com\/(?!top|new|artists|account|activity|login|preferences|feed)(?:(?:track|album)\/[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}|[a-z-]+)/i;
|
||||
/https?:\/\/(?:player\.|www\.)?wavlake\.com\/(?!top|new|artists|account|activity|login|preferences|feed|profile)(?:(?:track|album)\/[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}|[a-z-]+)/i;
|
||||
|
||||
/*
|
||||
* Regex to match any base64 string
|
||||
*/
|
||||
export const CashuRegex = /(cashuA[A-Za-z0-9_-]{0,10000}={0,3})/i;
|
||||
|
@ -1,9 +1,8 @@
|
||||
import Dexie, { Table } from "dexie";
|
||||
import { FullRelaySettings, HexKey, RawEvent, u256 } from "@snort/nostr";
|
||||
import { MetadataCache } from "Cache";
|
||||
import { HexKey, NostrEvent, u256 } from "@snort/system";
|
||||
|
||||
export const NAME = "snortDB";
|
||||
export const VERSION = 8;
|
||||
export const VERSION = 11;
|
||||
|
||||
export interface SubCache {
|
||||
id: string;
|
||||
@ -12,18 +11,6 @@ export interface SubCache {
|
||||
since?: number;
|
||||
}
|
||||
|
||||
export interface RelayMetrics {
|
||||
addr: string;
|
||||
events: number;
|
||||
disconnects: number;
|
||||
latency: number[];
|
||||
}
|
||||
|
||||
export interface UsersRelays {
|
||||
pubkey: HexKey;
|
||||
relays: FullRelaySettings[];
|
||||
}
|
||||
|
||||
export interface EventInteraction {
|
||||
id: u256;
|
||||
event: u256;
|
||||
@ -33,23 +20,24 @@ export interface EventInteraction {
|
||||
reposted: boolean;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
url: string;
|
||||
pr: string;
|
||||
preimage: string;
|
||||
macaroon: string;
|
||||
}
|
||||
|
||||
const STORES = {
|
||||
users: "++pubkey, name, display_name, picture, nip05, npub",
|
||||
relays: "++addr",
|
||||
userRelays: "++pubkey",
|
||||
events: "++id, pubkey, created_at",
|
||||
dms: "++id, pubkey",
|
||||
chats: "++id",
|
||||
eventInteraction: "++id",
|
||||
payments: "++url",
|
||||
};
|
||||
|
||||
export class SnortDB extends Dexie {
|
||||
ready = false;
|
||||
users!: Table<MetadataCache>;
|
||||
relayMetrics!: Table<RelayMetrics>;
|
||||
userRelays!: Table<UsersRelays>;
|
||||
events!: Table<RawEvent>;
|
||||
dms!: Table<RawEvent>;
|
||||
chats!: Table<NostrEvent>;
|
||||
eventInteraction!: Table<EventInteraction>;
|
||||
payments!: Table<Payment>;
|
||||
|
||||
constructor() {
|
||||
super(NAME);
|
||||
|
@ -12,6 +12,7 @@ export default function AsyncButton(props: AsyncButtonProps) {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
async function handle(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (loading || props.disabled) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
|
@ -8,6 +8,7 @@
|
||||
background-clip: content-box, border-box;
|
||||
background-size: cover;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--gray);
|
||||
}
|
||||
|
||||
.avatar[data-domain="snort.social"] {
|
||||
|
@ -1,26 +1,33 @@
|
||||
import "./Avatar.css";
|
||||
import Nostrich from "nostrich.webp";
|
||||
import Nostrich from "public/logo_256.png";
|
||||
|
||||
import { CSSProperties, useEffect, useState } from "react";
|
||||
import type { UserMetadata } from "@snort/nostr";
|
||||
import type { UserMetadata } from "@snort/system";
|
||||
|
||||
import useImgProxy from "Hooks/useImgProxy";
|
||||
|
||||
const Avatar = ({ user, ...rest }: { user?: UserMetadata; onClick?: () => void }) => {
|
||||
interface AvatarProps {
|
||||
user?: UserMetadata;
|
||||
onClick?: () => void;
|
||||
size?: number;
|
||||
}
|
||||
const Avatar = ({ user, size, onClick }: AvatarProps) => {
|
||||
const [url, setUrl] = useState<string>(Nostrich);
|
||||
const { proxy } = useImgProxy();
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.picture) {
|
||||
const url = proxy(user.picture, 120);
|
||||
const url = proxy(user.picture, size ?? 120);
|
||||
setUrl(url);
|
||||
} else {
|
||||
setUrl(Nostrich);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const backgroundImage = `url(${url})`;
|
||||
const style = { "--img-url": backgroundImage } as CSSProperties;
|
||||
const domain = user?.nip05 && user.nip05.split("@")[1];
|
||||
return <div {...rest} style={style} className="avatar" data-domain={domain?.toLowerCase()}></div>;
|
||||
return <div onClick={onClick} style={style} className="avatar" data-domain={domain?.toLowerCase()}></div>;
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
|
49
packages/app/src/Element/AvatarEditor.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import Icon from "Icons/Icon";
|
||||
import { useState } from "react";
|
||||
import useFileUpload from "Upload";
|
||||
import { openFile, unwrap } from "SnortUtils";
|
||||
|
||||
interface AvatarEditorProps {
|
||||
picture?: string;
|
||||
onPictureChange?: (newPicture: string) => void;
|
||||
}
|
||||
|
||||
export default function AvatarEditor({ picture, onPictureChange }: AvatarEditorProps) {
|
||||
const uploader = useFileUpload();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function uploadFile() {
|
||||
setError("");
|
||||
try {
|
||||
const f = await openFile();
|
||||
if (f) {
|
||||
const rsp = await uploader.upload(f, f.name);
|
||||
console.log(rsp);
|
||||
if (typeof rsp?.error === "string") {
|
||||
setError(`Upload failed: ${rsp.error}`);
|
||||
} else {
|
||||
onPictureChange?.(unwrap(rsp.url));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(`Upload failed: ${e.message}`);
|
||||
} else {
|
||||
setError(`Upload failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex f-center">
|
||||
<div style={{ backgroundImage: `url(${picture})` }} className="avatar">
|
||||
<div className={`edit${picture ? "" : " new"}`} onClick={() => uploadFile().catch(console.error)}>
|
||||
<Icon name={picture ? "edit" : "camera-plus"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <b className="error">{error}</b>}
|
||||
</>
|
||||
);
|
||||
}
|
@ -3,13 +3,13 @@ import "./BadgeList.css";
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { TaggedRawEvent } from "@snort/nostr";
|
||||
import { TaggedRawEvent } from "@snort/system";
|
||||
|
||||
import { ProxyImg } from "Element/ProxyImg";
|
||||
import Icon from "Icons/Icon";
|
||||
import Modal from "Element/Modal";
|
||||
import Username from "Element/Username";
|
||||
import { findTag } from "Util";
|
||||
import { findTag } from "SnortUtils";
|
||||
|
||||
export default function BadgeList({ badges }: { badges: TaggedRawEvent[] }) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { HexKey } from "@snort/system";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
import messages from "./messages";
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { useState, useMemo, ChangeEvent } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey, TaggedRawEvent } from "@snort/nostr";
|
||||
import { HexKey, TaggedRawEvent } from "@snort/system";
|
||||
|
||||
import Note from "Element/Note";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { UserCache } from "Cache/UserCache";
|
||||
import { UserCache } from "Cache";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
|
82
packages/app/src/Element/CashuNuts.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { System } from "index";
|
||||
|
||||
interface Token {
|
||||
token: Array<{
|
||||
mint: string;
|
||||
proofs: Array<{
|
||||
amount: number;
|
||||
}>;
|
||||
}>;
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export default function CashuNuts({ token }: { token: string }) {
|
||||
const login = useLogin();
|
||||
const profile = useUserProfile(System, login.publicKey);
|
||||
|
||||
async function copyToken(e: React.MouseEvent<HTMLButtonElement>, token: string) {
|
||||
e.stopPropagation();
|
||||
await navigator.clipboard.writeText(token);
|
||||
}
|
||||
async function redeemToken(e: React.MouseEvent<HTMLButtonElement>, token: string) {
|
||||
e.stopPropagation();
|
||||
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}</>;
|
||||
|
||||
return (
|
||||
<div className="note-invoice">
|
||||
<div className="flex f-between">
|
||||
<div>
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Cashu token" />
|
||||
</h4>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="Amount: {amount} sats"
|
||||
values={{
|
||||
amount: cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<small className="xs">
|
||||
<FormattedMessage defaultMessage="Mint: {url}" values={{ url: cashu.token[0].mint }} />
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={e => copyToken(e, token)} className="mr5">
|
||||
<FormattedMessage defaultMessage="Copy" description="Button: Copy Cashu token" />
|
||||
</button>
|
||||
<button onClick={e => redeemToken(e, token)}>
|
||||
<FormattedMessage defaultMessage="Redeem" description="Button: Redeem Cashu token" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,23 +1,37 @@
|
||||
.dm {
|
||||
padding: 8px;
|
||||
background-color: var(--gray);
|
||||
margin-bottom: 5px;
|
||||
border-radius: 5px;
|
||||
width: fit-content;
|
||||
margin-top: 16px;
|
||||
min-width: 100px;
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
white-space: pre-wrap;
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
.dm > div:first-child {
|
||||
.dm a {
|
||||
color: var(--font-color) !important;
|
||||
}
|
||||
|
||||
.dm > div:last-child {
|
||||
color: var(--gray-light);
|
||||
font-size: small;
|
||||
margin-bottom: 3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.dm.other > div:first-child {
|
||||
padding: 12px 16px;
|
||||
background: var(--gray-secondary);
|
||||
border-radius: 16px 16px 16px 0px;
|
||||
}
|
||||
|
||||
.dm.me {
|
||||
align-self: flex-end;
|
||||
background-color: var(--gray-secondary);
|
||||
}
|
||||
|
||||
.dm.me > div:first-child {
|
||||
padding: 12px 16px;
|
||||
background: var(--dm-gradient);
|
||||
border-radius: 16px 16px 0px 16px;
|
||||
}
|
||||
|
||||
.dm.me > div:last-child {
|
||||
text-align: end;
|
||||
}
|
||||
|
@ -2,56 +2,66 @@ import "./DM.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { TaggedRawEvent } from "@snort/nostr";
|
||||
import { EventKind, TaggedRawEvent } from "@snort/system";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import Text from "Element/Text";
|
||||
import { setLastReadDm } from "Pages/MessagesPage";
|
||||
import { unwrap } from "Util";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { Chat, ChatType, chatTo, setLastReadIn } from "chat";
|
||||
|
||||
import messages from "./messages";
|
||||
import ProfileImage from "./ProfileImage";
|
||||
|
||||
export type DMProps = {
|
||||
export interface DMProps {
|
||||
chat: Chat;
|
||||
data: TaggedRawEvent;
|
||||
};
|
||||
}
|
||||
|
||||
export default function DM(props: DMProps) {
|
||||
const pubKey = useLogin().publicKey;
|
||||
const publisher = useEventPublisher();
|
||||
const [content, setContent] = useState("Loading...");
|
||||
const ev = props.data;
|
||||
const needsDecryption = ev.kind === EventKind.DirectMessage;
|
||||
const [content, setContent] = useState(needsDecryption ? "Loading..." : ev.content);
|
||||
const [decrypted, setDecrypted] = useState(false);
|
||||
const { ref, inView } = useInView();
|
||||
const { formatMessage } = useIntl();
|
||||
const isMe = props.data.pubkey === pubKey;
|
||||
const otherPubkey = isMe ? pubKey : unwrap(props.data.tags.find(a => a[0] === "p")?.[1]);
|
||||
const isMe = ev.pubkey === pubKey;
|
||||
const otherPubkey = isMe ? pubKey : chatTo(ev);
|
||||
|
||||
async function decrypt() {
|
||||
if (publisher) {
|
||||
const decrypted = await publisher.decryptDm(props.data);
|
||||
const decrypted = await publisher.decryptDm(ev);
|
||||
setContent(decrypted || "<ERROR>");
|
||||
if (!isMe) {
|
||||
setLastReadDm(props.data.pubkey);
|
||||
setLastReadIn(ev.pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sender() {
|
||||
if (props.chat.type !== ChatType.DirectMessage && !isMe) {
|
||||
return <ProfileImage pubkey={ev.pubkey} />;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!decrypted && inView) {
|
||||
if (!decrypted && inView && needsDecryption) {
|
||||
setDecrypted(true);
|
||||
decrypt().catch(console.error);
|
||||
}
|
||||
}, [inView, props.data]);
|
||||
}, [inView, ev]);
|
||||
|
||||
return (
|
||||
<div className={`flex dm f-col${isMe ? " me" : ""}`} ref={ref}>
|
||||
<div className={isMe ? "dm me" : "dm other"} ref={ref}>
|
||||
<div>
|
||||
<NoteTime from={props.data.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
|
||||
</div>
|
||||
<div className="w-max">
|
||||
{sender()}
|
||||
<Text content={content} tags={[]} creator={otherPubkey} />
|
||||
</div>
|
||||
<div>
|
||||
<NoteTime from={ev.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
21
packages/app/src/Element/DmWindow.css
Normal file
@ -0,0 +1,21 @@
|
||||
.dm-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dm-window > div:nth-child(2) {
|
||||
overflow-y: auto;
|
||||
padding: 0 10px 10px 10px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.dm-window > div:nth-child(3) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-color);
|
||||
gap: 10px;
|
||||
padding: 5px 10px;
|
||||
}
|
62
packages/app/src/Element/DmWindow.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import "./DmWindow.css";
|
||||
import { useMemo } from "react";
|
||||
import { TaggedRawEvent } from "@snort/system";
|
||||
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import DM from "Element/DM";
|
||||
import NoteToSelf from "Element/NoteToSelf";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import WriteMessage from "Element/WriteMessage";
|
||||
import { Chat, ChatType, useChatSystem } from "chat";
|
||||
import { Nip4ChatSystem } from "chat/nip4";
|
||||
|
||||
export default function DmWindow({ id }: { id: string }) {
|
||||
const pubKey = useLogin().publicKey;
|
||||
const dms = useChatSystem();
|
||||
const chat = dms.find(a => a.id === id) ?? Nip4ChatSystem.createChatObj(id, []);
|
||||
|
||||
function sender() {
|
||||
if (id === pubKey) {
|
||||
return <NoteToSelf className="f-grow mb-10" pubkey={id} />;
|
||||
}
|
||||
if (chat?.type === ChatType.DirectMessage) {
|
||||
return <ProfileImage pubkey={id} className="f-grow mb10" />;
|
||||
}
|
||||
if (chat?.profile) {
|
||||
return <ProfileImage pubkey={id} className="f-grow mb10" profile={chat.profile} />;
|
||||
}
|
||||
return <ProfileImage pubkey={id ?? ""} className="f-grow mb10" overrideUsername={chat?.id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dm-window">
|
||||
<div>{sender()}</div>
|
||||
<div>
|
||||
<div className="flex f-col">{chat && <DmChatSelected chat={chat} />}</div>
|
||||
</div>
|
||||
<div>
|
||||
<WriteMessage chat={chat} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DmChatSelected({ chat }: { chat: Chat }) {
|
||||
const { publicKey: myPubKey } = useLogin();
|
||||
const sortedDms = useMemo(() => {
|
||||
const myDms = chat?.messages;
|
||||
if (myPubKey && myDms) {
|
||||
// filter dms in this chat, or dms to self
|
||||
return [...myDms].sort((a, b) => a.created_at - b.created_at);
|
||||
}
|
||||
return [];
|
||||
}, [chat, myPubKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sortedDms.map(a => (
|
||||
<DM data={a as TaggedRawEvent} key={a.id} chat={chat} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,11 +1,12 @@
|
||||
import "./FollowButton.css";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { HexKey } from "@snort/system";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { parseId } from "Util";
|
||||
import { parseId } from "SnortUtils";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import { System } from "index";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -23,7 +24,7 @@ export default function FollowButton(props: FollowButtonProps) {
|
||||
async function follow(pubkey: HexKey) {
|
||||
if (publisher) {
|
||||
const ev = await publisher.contactList([pubkey, ...follows.item], relays.item);
|
||||
publisher.broadcast(ev);
|
||||
System.BroadcastEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,7 +34,7 @@ export default function FollowButton(props: FollowButtonProps) {
|
||||
follows.item.filter(a => a !== pubkey),
|
||||
relays.item
|
||||
);
|
||||
publisher.broadcast(ev);
|
||||
System.BroadcastEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,35 +1,47 @@
|
||||
import { ReactNode } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/system";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { System } from "index";
|
||||
|
||||
import messages from "./messages";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
|
||||
export interface FollowListBaseProps {
|
||||
pubkeys: HexKey[];
|
||||
title?: ReactNode | string;
|
||||
title?: ReactNode;
|
||||
showFollowAll?: boolean;
|
||||
showAbout?: boolean;
|
||||
className?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
export default function FollowListBase({ pubkeys, title, showFollowAll, showAbout }: FollowListBaseProps) {
|
||||
|
||||
export default function FollowListBase({
|
||||
pubkeys,
|
||||
title,
|
||||
showFollowAll,
|
||||
showAbout,
|
||||
className,
|
||||
actions,
|
||||
}: FollowListBaseProps) {
|
||||
const publisher = useEventPublisher();
|
||||
const { follows, relays } = useLogin();
|
||||
|
||||
async function followAll() {
|
||||
if (publisher) {
|
||||
const ev = await publisher.contactList([...pubkeys, ...follows.item], relays.item);
|
||||
publisher.broadcast(ev);
|
||||
System.BroadcastEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<div className={className}>
|
||||
{(showFollowAll ?? true) && (
|
||||
<div className="flex mt10 mb10">
|
||||
<div className="f-grow bold">{title}</div>
|
||||
{actions}
|
||||
<button className="transparent" type="button" onClick={() => followAll()}>
|
||||
<FormattedMessage {...messages.FollowAll} />
|
||||
</button>
|
||||
|
@ -1,3 +1,7 @@
|
||||
.hashtag {
|
||||
color: var(--highlight);
|
||||
}
|
||||
|
||||
.hashtag > a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
NostrNestsRegex,
|
||||
WavlakeRegex,
|
||||
} from "Const";
|
||||
import { magnetURIDecode } from "Util";
|
||||
import { magnetURIDecode } from "SnortUtils";
|
||||
import SoundCloudEmbed from "Element/SoundCloudEmded";
|
||||
import MixCloudEmbed from "Element/MixCloudEmbed";
|
||||
import SpotifyEmbed from "Element/SpotifyEmbed";
|
||||
@ -26,7 +26,14 @@ import NostrLink from "Element/NostrLink";
|
||||
import RevealMedia from "Element/RevealMedia";
|
||||
import MagnetLink from "Element/MagnetLink";
|
||||
|
||||
export default function HyperText({ link, creator, depth }: { link: string; creator: string; depth?: number }) {
|
||||
interface HypeTextProps {
|
||||
link: string;
|
||||
creator: string;
|
||||
depth?: number;
|
||||
disableMediaSpotlight?: boolean;
|
||||
}
|
||||
|
||||
export default function HyperText({ link, creator, depth, disableMediaSpotlight }: HypeTextProps) {
|
||||
const a = link;
|
||||
try {
|
||||
const url = new URL(a);
|
||||
@ -42,7 +49,7 @@ export default function HyperText({ link, creator, depth }: { link: string; crea
|
||||
const isWavlakeLink = WavlakeRegex.test(a);
|
||||
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
|
||||
if (extension && !isAppleMusicLink) {
|
||||
return <RevealMedia link={a} creator={creator} />;
|
||||
return <RevealMedia link={a} creator={creator} disableSpotlight={disableMediaSpotlight} />;
|
||||
} else if (tweetId) {
|
||||
return (
|
||||
<div className="tweet" key={tweetId}>
|
||||
|
@ -2,11 +2,11 @@ import "./Invoice.css";
|
||||
import { useState } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { useMemo } from "react";
|
||||
import { decodeInvoice } from "@snort/shared";
|
||||
|
||||
import SendSats from "Element/SendSats";
|
||||
import Icon from "Icons/Icon";
|
||||
import { useWallet } from "Wallet";
|
||||
import { decodeInvoice } from "Util";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
|
@ -8,6 +8,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-preview-container > a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-preview-title {
|
||||
padding: 0 10px 10px 10px;
|
||||
}
|
||||
|
@ -4,11 +4,12 @@ import { CSSProperties, useEffect, useState } from "react";
|
||||
import Spinner from "Icons/Spinner";
|
||||
import SnortApi, { LinkPreviewData } from "SnortApi";
|
||||
import useImgProxy from "Hooks/useImgProxy";
|
||||
import { MediaElement } from "Element/MediaElement";
|
||||
|
||||
async function fetchUrlPreviewInfo(url: string) {
|
||||
const api = new SnortApi();
|
||||
try {
|
||||
return await api.linkPreview(url);
|
||||
return await api.linkPreview(url.endsWith(")") ? url.slice(0, -1) : url);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load link preview`, url);
|
||||
}
|
||||
@ -21,11 +22,16 @@ const LinkPreview = ({ url }: { url: string }) => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const data = await fetchUrlPreviewInfo(url);
|
||||
if (data && data.image) {
|
||||
setPreview(data);
|
||||
} else {
|
||||
setPreview(null);
|
||||
if (data) {
|
||||
const type = data.og_tags?.find(a => a[0].toLowerCase() === "og:type");
|
||||
const canPreviewType = type?.[1].startsWith("image") || type?.[1].startsWith("video") || false;
|
||||
if (canPreviewType || data.image) {
|
||||
setPreview(data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setPreview(null);
|
||||
})();
|
||||
}, [url]);
|
||||
|
||||
@ -36,14 +42,37 @@ const LinkPreview = ({ url }: { url: string }) => {
|
||||
</a>
|
||||
);
|
||||
|
||||
const backgroundImage = preview?.image ? `url(${proxy(preview?.image)})` : "";
|
||||
const style = { "--img-url": backgroundImage } as CSSProperties;
|
||||
function previewElement() {
|
||||
const type = preview?.og_tags?.find(a => a[0].toLowerCase() === "og:type")?.[1];
|
||||
if (type?.startsWith("video")) {
|
||||
const urlTags = ["og:video:secure_url", "og:video:url", "og:video"];
|
||||
const link = preview?.og_tags?.find(a => urlTags.includes(a[0].toLowerCase()))?.[1];
|
||||
const videoType = preview?.og_tags?.find(a => a[0].toLowerCase() === "og:video:type")?.[1] ?? "video/mp4";
|
||||
if (link) {
|
||||
return <MediaElement url={link} mime={videoType} />;
|
||||
}
|
||||
}
|
||||
if (type?.startsWith("image")) {
|
||||
const urlTags = ["og:image:secure_url", "og:image:url", "og:image"];
|
||||
const link = preview?.og_tags?.find(a => urlTags.includes(a[0].toLowerCase()))?.[1];
|
||||
const videoType = preview?.og_tags?.find(a => a[0].toLowerCase() === "og:image:type")?.[1] ?? "image/png";
|
||||
if (link) {
|
||||
return <MediaElement url={link} mime={videoType} />;
|
||||
}
|
||||
}
|
||||
if (preview?.image) {
|
||||
const backgroundImage = preview?.image ? `url(${proxy(preview?.image)})` : "";
|
||||
const style = { "--img-url": backgroundImage } as CSSProperties;
|
||||
return <div className="link-preview-image" style={style}></div>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="link-preview-container">
|
||||
{preview && (
|
||||
<a href={url} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
|
||||
{preview?.image && <div className="link-preview-image" style={style} />}
|
||||
{previewElement()}
|
||||
<p className="link-preview-title">
|
||||
{preview?.title}
|
||||
{preview?.description && (
|
||||
|
47
packages/app/src/Element/LiveChat.css
Normal file
@ -0,0 +1,47 @@
|
||||
.live-chat {
|
||||
height: calc(100vh - 100px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.live-chat > div:nth-child(1) {
|
||||
font-size: 24px;
|
||||
line-height: 29px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.live-chat > div:nth-child(2) {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-direction: column-reverse;
|
||||
margin-block-end: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.live-chat > div:nth-child(3) {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.live-chat .message {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-chat .message .name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--highlight);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.live-chat .message .avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
92
packages/app/src/Element/LiveChat.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import "./LiveChat.css";
|
||||
import { EventKind, NostrLink, TaggedRawEvent } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
import Textarea from "Element/Textarea";
|
||||
import { useLiveChatFeed } from "Feed/LiveChatFeed";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { getDisplayName } from "Element/ProfileImage";
|
||||
import Avatar from "Element/Avatar";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import Text from "Element/Text";
|
||||
import { System } from "index";
|
||||
import { profileLink } from "SnortUtils";
|
||||
|
||||
export function LiveChat({ ev, link }: { ev: TaggedRawEvent; link: NostrLink }) {
|
||||
const [chat, setChat] = useState("");
|
||||
const messages = useLiveChatFeed(link);
|
||||
const pub = useEventPublisher();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
async function sendChatMessage() {
|
||||
if (chat.length > 1) {
|
||||
const reply = await pub?.generic(eb => {
|
||||
return eb
|
||||
.kind(1311 as EventKind)
|
||||
.content(chat)
|
||||
.tag(["a", `${link.kind}:${link.author}:${link.id}`])
|
||||
.processContent();
|
||||
});
|
||||
if (reply) {
|
||||
console.debug(reply);
|
||||
System.BroadcastEvent(reply);
|
||||
}
|
||||
setChat("");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="live-chat">
|
||||
<div>
|
||||
<FormattedMessage defaultMessage="Stream Chat" />
|
||||
</div>
|
||||
<div>
|
||||
{[...(messages.data ?? [])]
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.map(a => (
|
||||
<ChatMessage ev={a} key={a.id} />
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Textarea
|
||||
autoFocus={false}
|
||||
className=""
|
||||
onChange={v => setChat(v.target.value)}
|
||||
value={chat}
|
||||
onFocus={() => {}}
|
||||
placeholder={formatMessage({
|
||||
defaultMessage: "Message...",
|
||||
})}
|
||||
onKeyDown={async e => {
|
||||
if (e.code === "Enter") {
|
||||
e.preventDefault();
|
||||
await sendChatMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AsyncButton onClick={sendChatMessage}>
|
||||
<FormattedMessage defaultMessage="Send" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ ev }: { ev: TaggedRawEvent }) {
|
||||
const profile = useUserProfile(System, ev.pubkey);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="message">
|
||||
<div className="name" onClick={() => navigate(profileLink(ev.pubkey, ev.relays))}>
|
||||
<Avatar user={profile} />
|
||||
{getDisplayName(profile, ev.pubkey)}:
|
||||
</div>
|
||||
<span>
|
||||
<Text disableMedia={true} content={ev.content} creator={ev.pubkey} tags={ev.tags} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
25
packages/app/src/Element/LiveEvent.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system";
|
||||
import { findTag, unwrap } from "SnortUtils";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function LiveEvent({ ev }: { ev: NostrEvent }) {
|
||||
const title = findTag(ev, "title");
|
||||
const d = unwrap(findTag(ev, "d"));
|
||||
return (
|
||||
<div className="text">
|
||||
<div className="flex card">
|
||||
<div className="f-grow">
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
<div>
|
||||
<Link to={`/live/${encodeTLV(NostrPrefix.Address, d, undefined, ev.kind, ev.pubkey)}`}>
|
||||
<button className="primary" type="button">
|
||||
<FormattedMessage defaultMessage="Watch Live!" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
19
packages/app/src/Element/LiveVideoPlayer.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import Hls from "hls.js";
|
||||
import { HTMLProps, useEffect, useRef } from "react";
|
||||
|
||||
export function LiveVideoPlayer(props: HTMLProps<HTMLVideoElement> & { stream: string }) {
|
||||
const video = useRef<HTMLVideoElement>(null);
|
||||
useEffect(() => {
|
||||
if (props.stream && video.current && !video.current.src && Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(props.stream);
|
||||
hls.attachMedia(video.current);
|
||||
return () => hls.destroy();
|
||||
}
|
||||
}, [video, props]);
|
||||
return (
|
||||
<div>
|
||||
<video ref={video} {...props} controls={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { Magnet } from "Util";
|
||||
import { Magnet } from "SnortUtils";
|
||||
|
||||
interface MagnetLinkProps {
|
||||
magnet: Magnet;
|
||||
|
@ -1,10 +1,19 @@
|
||||
import { ProxyImg } from "Element/ProxyImg";
|
||||
import React, { MouseEvent, useState } from "react";
|
||||
import React, { MouseEvent, useEffect, useState } from "react";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
import { decodeInvoice, InvoiceDetails } from "@snort/shared";
|
||||
|
||||
import "./MediaElement.css";
|
||||
import Modal from "Element/Modal";
|
||||
import Icon from "Icons/Icon";
|
||||
|
||||
import { kvToObject } from "SnortUtils";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import { useWallet } from "Wallet";
|
||||
import { PaymentsCache } from "Cache";
|
||||
import { Payment } from "Db";
|
||||
import PageSpinner from "Element/PageSpinner";
|
||||
import { LiveVideoPlayer } from "Element/LiveVideoPlayer";
|
||||
/*
|
||||
[
|
||||
"imeta",
|
||||
@ -19,23 +28,161 @@ interface MediaElementProps {
|
||||
magnet?: string;
|
||||
sha256?: string;
|
||||
blurHash?: string;
|
||||
disableSpotlight?: boolean;
|
||||
}
|
||||
|
||||
interface L402Object {
|
||||
macaroon: string;
|
||||
invoice: string;
|
||||
}
|
||||
|
||||
export function MediaElement(props: MediaElementProps) {
|
||||
const [invoice, setInvoice] = useState<InvoiceDetails>();
|
||||
const [l402, setL402] = useState<L402Object>();
|
||||
const [auth, setAuth] = useState<Payment>();
|
||||
const [error, setError] = useState("");
|
||||
const [url, setUrl] = useState(props.url);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const wallet = useWallet();
|
||||
|
||||
async function probeFor402() {
|
||||
const cached = await PaymentsCache.get(props.url);
|
||||
if (cached) {
|
||||
setAuth(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const req = new Request(props.url, {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
accept: "L402",
|
||||
},
|
||||
});
|
||||
const rsp = await fetch(req);
|
||||
if (rsp.status === 402) {
|
||||
const auth = rsp.headers.get("www-authenticate");
|
||||
if (auth?.startsWith("L402")) {
|
||||
const vals = kvToObject<L402Object>(auth.substring(5));
|
||||
console.debug(vals);
|
||||
setL402(vals);
|
||||
|
||||
if (vals.invoice) {
|
||||
const decoded = decodeInvoice(vals.invoice);
|
||||
setInvoice(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function payInvoice() {
|
||||
if (wallet.wallet && l402) {
|
||||
try {
|
||||
const res = await wallet.wallet.payInvoice(l402.invoice);
|
||||
console.debug(res);
|
||||
if (res.preimage) {
|
||||
const pmt = {
|
||||
pr: l402.invoice,
|
||||
url: props.url,
|
||||
macaroon: l402.macaroon,
|
||||
preimage: res.preimage,
|
||||
};
|
||||
await PaymentsCache.set(pmt);
|
||||
setAuth(pmt);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMedia() {
|
||||
if (!auth) return;
|
||||
setLoading(true);
|
||||
|
||||
const mediaReq = new Request(props.url, {
|
||||
headers: {
|
||||
Authorization: `L402 ${auth.macaroon}:${auth.preimage}`,
|
||||
},
|
||||
});
|
||||
const rsp = await fetch(mediaReq);
|
||||
if (rsp.ok) {
|
||||
const buf = await rsp.blob();
|
||||
setUrl(URL.createObjectURL(buf));
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (auth) {
|
||||
loadMedia().catch(console.error);
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
if (auth && loading) {
|
||||
return <PageSpinner />;
|
||||
}
|
||||
|
||||
if (invoice) {
|
||||
return (
|
||||
<div className="note-invoice">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Payment Required" />
|
||||
</h3>
|
||||
<div className="flex f-row">
|
||||
<div className="f-grow">
|
||||
<FormattedMessage
|
||||
defaultMessage="You must pay {n} sats to access this file."
|
||||
values={{
|
||||
n: <FormattedNumber value={(invoice.amount ?? 0) / 1000} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{wallet.wallet && (
|
||||
<AsyncButton onClick={() => payInvoice()}>
|
||||
<FormattedMessage defaultMessage="Pay Now" />
|
||||
</AsyncButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!wallet.wallet && (
|
||||
<b>
|
||||
<FormattedMessage
|
||||
defaultMessage="Please connect a wallet {here} to be able to pay this invoice"
|
||||
values={{
|
||||
here: (
|
||||
<Link to="/settings/wallet" onClick={e => e.stopPropagation()}>
|
||||
<FormattedMessage defaultMessage="here" description="Inline link text pointing to another page" />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</b>
|
||||
)}
|
||||
{error && <b className="error">{error}</b>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mime.startsWith("image/")) {
|
||||
return (
|
||||
<SpotlightMedia>
|
||||
<ProxyImg key={props.url} src={props.url} />
|
||||
</SpotlightMedia>
|
||||
);
|
||||
if (!(props.disableSpotlight ?? false)) {
|
||||
return (
|
||||
<SpotlightMedia>
|
||||
<ProxyImg key={props.url} src={url} onError={() => probeFor402()} />
|
||||
</SpotlightMedia>
|
||||
);
|
||||
} else {
|
||||
return <ProxyImg key={props.url} src={url} onError={() => probeFor402()} />;
|
||||
}
|
||||
} else if (props.mime.startsWith("audio/")) {
|
||||
return <audio key={props.url} src={props.url} controls />;
|
||||
return <audio key={props.url} src={url} controls onError={() => probeFor402()} />;
|
||||
} else if (props.mime.startsWith("video/")) {
|
||||
return (
|
||||
<SpotlightMedia>
|
||||
<video key={props.url} src={props.url} controls />
|
||||
</SpotlightMedia>
|
||||
);
|
||||
if (props.url.endsWith(".m3u8")) {
|
||||
return <LiveVideoPlayer stream={props.url} />;
|
||||
}
|
||||
return <video key={props.url} src={url} controls onError={() => probeFor402()} />;
|
||||
} else {
|
||||
return (
|
||||
<a
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { HexKey } from "@snort/system";
|
||||
|
||||
import { useUserProfile } from "Hooks/useUserProfile";
|
||||
import { profileLink } from "Util";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { profileLink } from "SnortUtils";
|
||||
import { getDisplayName } from "Element/ProfileImage";
|
||||
import { System } from "index";
|
||||
|
||||
export default function Mention({ pubkey, relays }: { pubkey: HexKey; relays?: Array<string> | string }) {
|
||||
const user = useUserProfile(pubkey);
|
||||
const user = useUserProfile(System, pubkey);
|
||||
|
||||
const name = useMemo(() => {
|
||||
return getDisplayName(user, pubkey);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { HexKey } from "@snort/system";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
import messages from "./messages";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { HexKey } from "@snort/system";
|
||||
import MuteButton from "Element/MuteButton";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
@ -1,59 +1,13 @@
|
||||
import "./Nip05.css";
|
||||
import { useQuery } from "react-query";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import DnsOverHttpResolver from "dns-over-http-resolver";
|
||||
import { HexKey } from "@snort/system";
|
||||
|
||||
import Icon from "Icons/Icon";
|
||||
import { bech32ToHex } from "Util";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { System } from "index";
|
||||
|
||||
interface NostrJson {
|
||||
names: Record<string, string>;
|
||||
}
|
||||
|
||||
const resolver = new DnsOverHttpResolver();
|
||||
async function fetchNip05Pubkey(name: string, domain: string) {
|
||||
if (!name || !domain) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`);
|
||||
const data: NostrJson = await res.json();
|
||||
const match = Object.keys(data.names).find(n => {
|
||||
return n.toLowerCase() === name.toLowerCase();
|
||||
});
|
||||
return match ? data.names[match] : undefined;
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
|
||||
// Check as DoH TXT entry
|
||||
try {
|
||||
const resDns = await resolver.resolveTxt(`${name}._nostr.${domain}`);
|
||||
return bech32ToHex(resDns[0][0]);
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const VERIFICATION_CACHE_TIME = 24 * 60 * 60 * 1000;
|
||||
const VERIFICATION_STALE_TIMEOUT = 10 * 60 * 1000;
|
||||
|
||||
export function useIsVerified(pubkey: HexKey, nip05?: string, bypassCheck?: boolean) {
|
||||
const [name, domain] = nip05 ? nip05.split("@") : [];
|
||||
const { isError, isSuccess, data } = useQuery(
|
||||
["nip05", nip05],
|
||||
() => (bypassCheck ? Promise.resolve(pubkey) : fetchNip05Pubkey(name, domain)),
|
||||
{
|
||||
retry: false,
|
||||
retryOnMount: false,
|
||||
cacheTime: VERIFICATION_CACHE_TIME,
|
||||
staleTime: VERIFICATION_STALE_TIMEOUT,
|
||||
}
|
||||
);
|
||||
const isVerified = isSuccess && data === pubkey;
|
||||
const cantVerify = isSuccess && data !== pubkey;
|
||||
return { isVerified, couldNotVerify: isError || cantVerify };
|
||||
export function useIsVerified(pubkey: HexKey, bypassCheck?: boolean) {
|
||||
const profile = useUserProfile(System, pubkey);
|
||||
return { isVerified: bypassCheck || profile?.isNostrAddressValid };
|
||||
}
|
||||
|
||||
export interface Nip05Params {
|
||||
@ -65,10 +19,10 @@ export interface Nip05Params {
|
||||
const Nip05 = ({ nip05, pubkey, verifyNip = true }: Nip05Params) => {
|
||||
const [name, domain] = nip05 ? nip05.split("@") : [];
|
||||
const isDefaultUser = name === "_";
|
||||
const { isVerified, couldNotVerify } = useIsVerified(pubkey, nip05, !verifyNip);
|
||||
const { isVerified } = useIsVerified(pubkey, !verifyNip);
|
||||
|
||||
return (
|
||||
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={ev => ev.stopPropagation()}>
|
||||
<div className={`flex nip05${!isVerified ? " failed" : ""}`}>
|
||||
{!isDefaultUser && isVerified && <span className="nick">{`${name}@`}</span>}
|
||||
{isVerified && (
|
||||
<>
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useState, ChangeEvent } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { UserMetadata } from "@snort/nostr";
|
||||
import { UserMetadata, mapEventToProfile } from "@snort/system";
|
||||
|
||||
import { unwrap } from "Util";
|
||||
import { unwrap } from "SnortUtils";
|
||||
import { formatShort } from "Number";
|
||||
import {
|
||||
ServiceProvider,
|
||||
@ -17,14 +17,15 @@ import {
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import SendSats from "Element/SendSats";
|
||||
import Copy from "Element/Copy";
|
||||
import { useUserProfile } from "Hooks/useUserProfile";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { debounce } from "Util";
|
||||
import { debounce } from "SnortUtils";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import SnortServiceProvider from "Nip05/SnortServiceProvider";
|
||||
import { mapEventToProfile, UserCache } from "Cache";
|
||||
import { UserCache } from "Cache";
|
||||
|
||||
import messages from "./messages";
|
||||
import { System } from "index";
|
||||
|
||||
type Nip05ServiceProps = {
|
||||
name: string;
|
||||
@ -43,7 +44,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
const { helpText = true } = props;
|
||||
const { formatMessage } = useIntl();
|
||||
const pubkey = useLogin().publicKey;
|
||||
const user = useUserProfile(pubkey);
|
||||
const user = useUserProfile(System, pubkey);
|
||||
const publisher = useEventPublisher();
|
||||
const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
|
||||
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
|
||||
@ -215,7 +216,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
nip05,
|
||||
} as UserMetadata;
|
||||
const ev = await publisher.metadata(newProfile);
|
||||
publisher.broadcast(ev);
|
||||
System.BroadcastEvent(ev);
|
||||
if (props.onSuccess) {
|
||||
props.onSuccess(nip05);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { RawEvent } from "@snort/nostr";
|
||||
import { NostrEvent, NostrLink } from "@snort/system";
|
||||
|
||||
import { findTag, NostrLink } from "Util";
|
||||
import { findTag } from "SnortUtils";
|
||||
import useEventFeed from "Feed/EventFeed";
|
||||
import PageSpinner from "Element/PageSpinner";
|
||||
import Reveal from "Element/Reveal";
|
||||
@ -14,7 +14,7 @@ export default function NostrFileHeader({ link }: { link: NostrLink }) {
|
||||
return <NostrFileElement ev={ev.data} />;
|
||||
}
|
||||
|
||||
export function NostrFileElement({ ev }: { ev: RawEvent }) {
|
||||
export function NostrFileElement({ ev }: { ev: NostrEvent }) {
|
||||
// assume image or embed which can be rendered by the hypertext kind
|
||||
// todo: make use of hash
|
||||
// todo: use magnet or other links if present
|
||||
|
@ -1,20 +1,15 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { EventKind, NostrPrefix } from "@snort/nostr";
|
||||
import { NostrPrefix, tryParseNostrLink } from "@snort/system";
|
||||
|
||||
import Mention from "Element/Mention";
|
||||
import NostrFileHeader from "Element/NostrFileHeader";
|
||||
import { parseNostrLink } from "Util";
|
||||
import NoteQuote from "Element/NoteQuote";
|
||||
|
||||
export default function NostrLink({ link, depth }: { link: string; depth?: number }) {
|
||||
const nav = parseNostrLink(link);
|
||||
const nav = tryParseNostrLink(link);
|
||||
|
||||
if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) {
|
||||
return <Mention pubkey={nav.id} relays={nav.relays} />;
|
||||
} else if (nav?.type === NostrPrefix.Note || nav?.type === NostrPrefix.Event || nav?.type === NostrPrefix.Address) {
|
||||
if (nav.kind === EventKind.FileHeader) {
|
||||
return <NostrFileHeader link={nav} />;
|
||||
}
|
||||
if ((depth ?? 0) > 0) {
|
||||
const evLink = nav.encode();
|
||||
return (
|
||||
|
@ -59,6 +59,8 @@
|
||||
|
||||
.note-quote {
|
||||
border: 1px solid var(--gray);
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.note-quote.note > .body {
|
||||
@ -180,6 +182,8 @@
|
||||
.note .poll-body > div > .progress {
|
||||
background-color: var(--gray);
|
||||
height: stretch;
|
||||
height: -webkit-fill-available;
|
||||
height: -moz-available;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
@ -3,11 +3,11 @@ import React, { useMemo, useState, useLayoutEffect, ReactNode } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { TaggedRawEvent, HexKey, EventKind, NostrPrefix, Lists } from "@snort/nostr";
|
||||
import { TaggedRawEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt, parseZap } from "@snort/system";
|
||||
|
||||
import { System } from "index";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import Icon from "Icons/Icon";
|
||||
import { parseZap } from "Element/Zap";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import Text from "Element/Text";
|
||||
import {
|
||||
@ -19,17 +19,19 @@ import {
|
||||
normalizeReaction,
|
||||
Reaction,
|
||||
profileLink,
|
||||
} from "Util";
|
||||
} from "SnortUtils";
|
||||
import NoteFooter, { Translation } from "Element/NoteFooter";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import Reveal from "Element/Reveal";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { UserCache } from "Cache/UserCache";
|
||||
import { UserCache } from "Cache";
|
||||
import Poll from "Element/Poll";
|
||||
import { EventExt } from "System/EventExt";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { setBookmarked, setPinned } from "Login";
|
||||
import { NostrFileElement } from "Element/NostrFileHeader";
|
||||
import ZapstrEmbed from "Element/ZapstrEmbed";
|
||||
import PubkeyList from "Element/PubkeyList";
|
||||
import { LiveEvent } from "Element/LiveEvent";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -73,12 +75,22 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => {
|
||||
};
|
||||
|
||||
export default function Note(props: NoteProps) {
|
||||
const { data: ev, related, highlight, options: opt, ignoreModeration = false } = props;
|
||||
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props;
|
||||
|
||||
if (ev.kind === EventKind.FileHeader) {
|
||||
return <NostrFileElement ev={ev} />;
|
||||
}
|
||||
if (ev.kind === EventKind.ZapstrTrack) {
|
||||
return <ZapstrEmbed ev={ev} />;
|
||||
}
|
||||
if (ev.kind === EventKind.PubkeyLists) {
|
||||
return <PubkeyList ev={ev} className={className} />;
|
||||
}
|
||||
if (ev.kind === EventKind.LiveEvent) {
|
||||
return <LiveEvent ev={ev} />;
|
||||
}
|
||||
|
||||
const baseClassName = `note card${className ? ` ${className}` : ""}`;
|
||||
const navigate = useNavigate();
|
||||
const [showReactions, setShowReactions] = useState(false);
|
||||
const deletions = useMemo(() => getReactions(related, ev.id, EventKind.Deletion), [related]);
|
||||
@ -87,7 +99,6 @@ export default function Note(props: NoteProps) {
|
||||
const { ref, inView, entry } = useInView({ triggerOnce: true });
|
||||
const [extendable, setExtendable] = useState<boolean>(false);
|
||||
const [showMore, setShowMore] = useState<boolean>(false);
|
||||
const baseClassName = `note card ${props.className ? props.className : ""}`;
|
||||
const login = useLogin();
|
||||
const { pinned, bookmarked } = login;
|
||||
const publisher = useEventPublisher();
|
||||
@ -123,7 +134,7 @@ export default function Note(props: NoteProps) {
|
||||
);
|
||||
const zaps = useMemo(() => {
|
||||
const sortedZaps = getReactions(related, ev.id, EventKind.ZapReceipt)
|
||||
.map(a => parseZap(a, ev))
|
||||
.map(a => parseZap(a, UserCache, ev))
|
||||
.filter(z => z.valid);
|
||||
sortedZaps.sort((a, b) => b.amount - a.amount);
|
||||
return sortedZaps;
|
||||
@ -144,7 +155,7 @@ export default function Note(props: NoteProps) {
|
||||
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
|
||||
const es = pinned.item.filter(e => e !== id);
|
||||
const ev = await publisher.noteList(es, Lists.Pinned);
|
||||
publisher.broadcast(ev);
|
||||
System.BroadcastEvent(ev);
|
||||
setPinned(login, es, ev.created_at * 1000);
|
||||
}
|
||||
}
|
||||
@ -155,7 +166,7 @@ export default function Note(props: NoteProps) {
|
||||
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
|
||||
const es = bookmarked.item.filter(e => e !== id);
|
||||
const ev = await publisher.noteList(es, Lists.Bookmarked);
|
||||
publisher.broadcast(ev);
|
||||
System.BroadcastEvent(ev);
|
||||
setBookmarked(login, es, ev.created_at * 1000);
|
||||
}
|
||||
}
|
||||
@ -239,8 +250,8 @@ export default function Note(props: NoteProps) {
|
||||
}
|
||||
|
||||
const maxMentions = 2;
|
||||
const replyId = thread?.replyTo?.Event ?? thread?.root?.Event;
|
||||
const replyRelayHints = thread?.replyTo?.Relay ?? thread.root?.Relay;
|
||||
const replyId = thread?.replyTo?.value ?? thread?.root?.value;
|
||||
const replyRelayHints = thread?.replyTo?.relay ?? thread.root?.relay;
|
||||
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
|
||||
for (const pk of thread?.pubKeys ?? []) {
|
||||
const u = UserCache.getFromCache(pk);
|
||||
@ -327,10 +338,9 @@ export default function Note(props: NoteProps) {
|
||||
{options.showHeader && (
|
||||
<div className="header flex">
|
||||
<ProfileImage
|
||||
autoWidth={false}
|
||||
pubkey={ev.pubkey}
|
||||
subHeader={replyTag() ?? undefined}
|
||||
linkToProfile={opt?.canClick === undefined}
|
||||
link={opt?.canClick === undefined ? undefined : ""}
|
||||
/>
|
||||
{(options.showTime || options.showBookmarked) && (
|
||||
<div className="info">
|
||||
|
@ -18,8 +18,9 @@
|
||||
background-color: var(--note-bg);
|
||||
border-radius: 10px 10px 0 0;
|
||||
min-height: 100px;
|
||||
max-width: stretch;
|
||||
min-width: stretch;
|
||||
width: stretch;
|
||||
width: -webkit-fill-available;
|
||||
width: -moz-available;
|
||||
max-height: 210px;
|
||||
}
|
||||
|
||||
@ -57,6 +58,8 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: stretch;
|
||||
width: -webkit-fill-available;
|
||||
width: -moz-available;
|
||||
}
|
||||
|
||||
.note-creator .insert > button {
|
||||
|
@ -1,11 +1,12 @@
|
||||
import "./NoteCreator.css";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { encodeTLV, EventKind, NostrPrefix, TaggedRawEvent } from "@snort/nostr";
|
||||
import { encodeTLV, EventKind, NostrPrefix, TaggedRawEvent, EventBuilder } from "@snort/system";
|
||||
import { LNURL } from "@snort/shared";
|
||||
|
||||
import Icon from "Icons/Icon";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { openFile } from "Util";
|
||||
import { openFile } from "SnortUtils";
|
||||
import Textarea from "Element/Textarea";
|
||||
import Modal from "Element/Modal";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
@ -26,16 +27,15 @@ import {
|
||||
setOtherEvents,
|
||||
} from "State/NoteCreator";
|
||||
import type { RootState } from "State/Store";
|
||||
import { LNURL } from "LNURL";
|
||||
|
||||
import messages from "./messages";
|
||||
import { ClipboardEventHandler, useState } from "react";
|
||||
import Spinner from "Icons/Spinner";
|
||||
import { EventBuilder } from "System";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import { LoginStore } from "Login";
|
||||
import { getCurrentSubscription } from "Subscription";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { System } from "index";
|
||||
|
||||
interface NotePreviewProps {
|
||||
note: TaggedRawEvent;
|
||||
@ -112,12 +112,12 @@ export function NoteCreator() {
|
||||
return eb;
|
||||
};
|
||||
const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk);
|
||||
if (selectedCustomRelays) publisher.broadcastAll(ev, selectedCustomRelays);
|
||||
else publisher.broadcast(ev);
|
||||
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, ev));
|
||||
else System.BroadcastEvent(ev);
|
||||
dispatch(reset());
|
||||
for (const oe of otherEvents) {
|
||||
if (selectedCustomRelays) publisher.broadcastAll(oe, selectedCustomRelays);
|
||||
else publisher.broadcast(oe);
|
||||
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, oe));
|
||||
else System.BroadcastEvent(oe);
|
||||
}
|
||||
dispatch(reset());
|
||||
}
|
||||
@ -142,7 +142,7 @@ export function NoteCreator() {
|
||||
if (file) {
|
||||
const rx = await uploader.upload(file, file.name);
|
||||
if (rx.header) {
|
||||
const link = `nostr:${encodeTLV(rx.header.id, NostrPrefix.Event, undefined, rx.header.kind)}`;
|
||||
const link = `nostr:${encodeTLV(NostrPrefix.Event, rx.header.id, undefined, rx.header.kind)}`;
|
||||
dispatch(setNote(`${note ? `${note}\n` : ""}${link}`));
|
||||
dispatch(setOtherEvents([...otherEvents, rx.header]));
|
||||
} else if (rx.url) {
|
||||
@ -293,7 +293,7 @@ export function NoteCreator() {
|
||||
ev.stopPropagation = true;
|
||||
LoginStore.switchAccount(a);
|
||||
}}>
|
||||
<ProfileImage pubkey={a} linkToProfile={false} />
|
||||
<ProfileImage pubkey={a} link={""} />
|
||||
</MenuItem>
|
||||
));
|
||||
}
|
||||
|
@ -3,20 +3,21 @@ import { useSelector, useDispatch } from "react-redux";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import { useLongPress } from "use-long-press";
|
||||
import { TaggedRawEvent, HexKey, u256, encodeTLV, NostrPrefix, Lists } from "@snort/nostr";
|
||||
import { TaggedRawEvent, HexKey, u256, encodeTLV, NostrPrefix, Lists, ParsedZap } from "@snort/system";
|
||||
import { LNURL } from "@snort/shared";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
import Icon from "Icons/Icon";
|
||||
import Spinner from "Icons/Spinner";
|
||||
|
||||
import { formatShort } from "Number";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { bech32ToHex, delay, normalizeReaction, unwrap } from "Util";
|
||||
import { delay, normalizeReaction, unwrap } from "SnortUtils";
|
||||
import { NoteCreator } from "Element/NoteCreator";
|
||||
import { ReBroadcaster } from "Element/ReBroadcaster";
|
||||
import Reactions from "Element/Reactions";
|
||||
import SendSats from "Element/SendSats";
|
||||
import { ParsedZap, ZapsSummary } from "Element/Zap";
|
||||
import { useUserProfile } from "Hooks/useUserProfile";
|
||||
import { ZapsSummary } from "Element/Zap";
|
||||
import { RootState } from "State/Store";
|
||||
import { setReplyTo, setShow, reset } from "State/NoteCreator";
|
||||
import {
|
||||
@ -25,13 +26,13 @@ import {
|
||||
reset as resetReBroadcast,
|
||||
} from "State/ReBroadcast";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { SnortPubKey, TranslateHost } from "Const";
|
||||
import { LNURL } from "LNURL";
|
||||
import { DonateLNURL } from "Pages/DonatePage";
|
||||
import { TranslateHost } from "Const";
|
||||
import { useWallet } from "Wallet";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { setBookmarked, setPinned } from "Login";
|
||||
import { useInteractionCache } from "Hooks/useInteractionCache";
|
||||
import { ZapPoolController } from "ZapPoolController";
|
||||
import { System } from "index";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -72,7 +73,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
const login = useLogin();
|
||||
const { pinned, bookmarked, publicKey, preferences: prefs, relays } = login;
|
||||
const { mute, block } = useModeration();
|
||||
const author = useUserProfile(ev.pubkey);
|
||||
const author = useUserProfile(System, ev.pubkey);
|
||||
const interactionCache = useInteractionCache(publicKey, ev.id);
|
||||
const publisher = useEventPublisher();
|
||||
const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show);
|
||||
@ -117,7 +118,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
async function react(content: string) {
|
||||
if (!hasReacted(content) && publisher) {
|
||||
const evLike = await publisher.react(ev, content);
|
||||
publisher.broadcast(evLike);
|
||||
System.BroadcastEvent(evLike);
|
||||
await interactionCache.react();
|
||||
}
|
||||
}
|
||||
@ -125,7 +126,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
async function deleteEvent() {
|
||||
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
|
||||
const evDelete = await publisher.delete(ev.id);
|
||||
publisher.broadcast(evDelete);
|
||||
System.BroadcastEvent(evDelete);
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,7 +134,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
if (!hasReposted() && publisher) {
|
||||
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
|
||||
const evRepost = await publisher.repost(ev);
|
||||
publisher.broadcast(evRepost);
|
||||
System.BroadcastEvent(evRepost);
|
||||
await interactionCache.repost();
|
||||
}
|
||||
}
|
||||
@ -160,7 +161,6 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
setZapping(true);
|
||||
try {
|
||||
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id);
|
||||
fastZapDonate();
|
||||
} catch (e) {
|
||||
console.warn("Fast zap failed", e);
|
||||
if (!(e instanceof Error) || e.message !== "User rejected") {
|
||||
@ -184,26 +184,12 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
const zap = handler.canZap && publisher ? await publisher.zap(amount * 1000, key, zr, id) : undefined;
|
||||
const invoice = await handler.getInvoice(amount, undefined, zap);
|
||||
await wallet?.payInvoice(unwrap(invoice.pr));
|
||||
ZapPoolController.allocate(amount);
|
||||
|
||||
await interactionCache.zap();
|
||||
});
|
||||
}
|
||||
|
||||
function fastZapDonate() {
|
||||
queueMicrotask(async () => {
|
||||
if (prefs.fastZapDonate > 0) {
|
||||
// spin off donate
|
||||
const donateAmount = Math.floor(prefs.defaultZapAmount * prefs.fastZapDonate);
|
||||
if (donateAmount > 0) {
|
||||
console.debug(`Donating ${donateAmount} sats to ${DonateLNURL}`);
|
||||
fastZapInner(DonateLNURL, donateAmount, bech32ToHex(SnortPubKey))
|
||||
.then(() => console.debug("Donation sent! Thank You!"))
|
||||
.catch(() => console.debug("Failed to donate"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (prefs.autoZap && !didZap && !isMine && !zapping) {
|
||||
const lnurl = getLNURL();
|
||||
@ -212,7 +198,6 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
queueMicrotask(async () => {
|
||||
try {
|
||||
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id);
|
||||
fastZapDonate();
|
||||
} catch {
|
||||
// ignored
|
||||
} finally {
|
||||
@ -264,7 +249,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
}
|
||||
|
||||
async function share() {
|
||||
const link = encodeTLV(ev.id, NostrPrefix.Event, ev.relays);
|
||||
const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
|
||||
const url = `${window.location.protocol}//${window.location.host}/e/${link}`;
|
||||
if ("share" in window.navigator) {
|
||||
await window.navigator.share({
|
||||
@ -300,7 +285,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
}
|
||||
|
||||
async function copyId() {
|
||||
const link = encodeTLV(ev.id, NostrPrefix.Event, ev.relays);
|
||||
const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
|
||||
await navigator.clipboard.writeText(link);
|
||||
}
|
||||
|
||||
@ -308,7 +293,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
if (publisher) {
|
||||
const es = [...pinned.item, id];
|
||||
const ev = await publisher.noteList(es, Lists.Pinned);
|
||||
publisher.broadcast(ev);
|
||||
System.BroadcastEvent(ev);
|
||||
setPinned(login, es, ev.created_at * 1000);
|
||||
}
|
||||
}
|
||||
@ -317,7 +302,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
if (publisher) {
|
||||
const es = [...bookmarked.item, id];
|
||||
const ev = await publisher.noteList(es, Lists.Bookmarked);
|
||||
publisher.broadcast(ev);
|
||||
System.BroadcastEvent(ev);
|
||||
setBookmarked(login, es, ev.created_at * 1000);
|
||||
}
|
||||
}
|
||||
@ -457,6 +442,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
author={author?.pubkey}
|
||||
target={getTargetName()}
|
||||
note={ev.id}
|
||||
allocatePool={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="zaps-container">
|
||||
|
@ -1,5 +1,5 @@
|
||||
import useEventFeed from "Feed/EventFeed";
|
||||
import { NostrLink } from "Util";
|
||||
import { NostrLink } from "@snort/system";
|
||||
import Note from "Element/Note";
|
||||
import PageSpinner from "Element/PageSpinner";
|
||||
|
||||
|
@ -1,14 +1,13 @@
|
||||
import "./NoteReaction.css";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMemo } from "react";
|
||||
import { EventKind, RawEvent, TaggedRawEvent, NostrPrefix } from "@snort/nostr";
|
||||
import { EventKind, NostrEvent, TaggedRawEvent, NostrPrefix, EventExt } from "@snort/system";
|
||||
|
||||
import Note from "Element/Note";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import { eventLink, hexToBech32 } from "Util";
|
||||
import { eventLink, hexToBech32 } from "SnortUtils";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { EventExt } from "System/EventExt";
|
||||
|
||||
export interface NoteReactionProps {
|
||||
data: TaggedRawEvent;
|
||||
@ -43,7 +42,7 @@ export default function NoteReaction(props: NoteReactionProps) {
|
||||
function extractRoot() {
|
||||
if (ev?.kind === EventKind.Repost && ev.content.length > 0 && ev.content !== "#[0]") {
|
||||
try {
|
||||
const r: RawEvent = JSON.parse(ev.content);
|
||||
const r: NostrEvent = JSON.parse(ev.content);
|
||||
return r as TaggedRawEvent;
|
||||
} catch (e) {
|
||||
console.error("Could not load reposted content", e);
|
||||
|
@ -28,7 +28,6 @@ export default function NoteTime(props: NoteTimeProps) {
|
||||
year: "2-digit",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
});
|
||||
} else if (absAgo > HourInMs) {
|
||||
return `${fromDate.getHours().toString().padStart(2, "0")}:${fromDate.getMinutes().toString().padStart(2, "0")}`;
|
||||
|
@ -3,20 +3,19 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note-to-self {
|
||||
margin-left: 5px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.nts .avatar-wrapper {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.nts .avatar {
|
||||
border-width: 1px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nts .avatar.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@ -1,11 +1,10 @@
|
||||
import "./NoteToSelf.css";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons";
|
||||
import { profileLink } from "Util";
|
||||
import { profileLink } from "SnortUtils";
|
||||
|
||||
import messages from "./messages";
|
||||
import Icon from "Icons/Icon";
|
||||
|
||||
export interface NoteToSelfProps {
|
||||
pubkey: string;
|
||||
@ -17,7 +16,7 @@ export interface NoteToSelfProps {
|
||||
function NoteLabel() {
|
||||
return (
|
||||
<div>
|
||||
<FormattedMessage {...messages.NoteToSelf} /> <FontAwesomeIcon icon={faCertificate} size="xs" />
|
||||
<FormattedMessage {...messages.NoteToSelf} /> <Icon name="badge" size={15} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -35,7 +34,7 @@ export default function NoteToSelf({ pubkey, clickable, className, link }: NoteT
|
||||
<div className={`nts${className ? ` ${className}` : ""}`}>
|
||||
<div className="avatar-wrapper">
|
||||
<div className={`avatar${clickable ? " clickable" : ""}`}>
|
||||
<FontAwesomeIcon onClick={clickLink} className="note-to-self" icon={faBook} size="2xl" />
|
||||
<Icon onClick={clickLink} name="book-closed" size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="f-grow">
|
||||
|
@ -1,18 +1,18 @@
|
||||
import { TaggedRawEvent } from "@snort/nostr";
|
||||
import { TaggedRawEvent, ParsedZap } from "@snort/system";
|
||||
import { LNURL } from "@snort/shared";
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
import { ParsedZap } from "Element/Zap";
|
||||
import Text from "Element/Text";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { useWallet } from "Wallet";
|
||||
import { useUserProfile } from "Hooks/useUserProfile";
|
||||
import { LNURL } from "LNURL";
|
||||
import { unwrap } from "Util";
|
||||
import { unwrap } from "SnortUtils";
|
||||
import { formatShort } from "Number";
|
||||
import Spinner from "Icons/Spinner";
|
||||
import SendSats from "Element/SendSats";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { System } from "index";
|
||||
|
||||
interface PollProps {
|
||||
ev: TaggedRawEvent;
|
||||
@ -24,7 +24,7 @@ export default function Poll(props: PollProps) {
|
||||
const publisher = useEventPublisher();
|
||||
const { wallet } = useWallet();
|
||||
const { preferences: prefs, publicKey: myPubKey, relays } = useLogin();
|
||||
const pollerProfile = useUserProfile(props.ev.pubkey);
|
||||
const pollerProfile = useUserProfile(System, props.ev.pubkey);
|
||||
const [error, setError] = useState("");
|
||||
const [invoice, setInvoice] = useState("");
|
||||
const [voting, setVoting] = useState<number>();
|
||||
@ -114,7 +114,11 @@ export default function Poll(props: PollProps) {
|
||||
return (
|
||||
<div key={a[1]} className="flex" onClick={e => zapVote(e, opt)}>
|
||||
<div className="f-grow">
|
||||
{opt === voting ? <Spinner /> : <Text content={desc} tags={props.ev.tags} creator={props.ev.pubkey} />}
|
||||
{opt === voting ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Text content={desc} tags={props.ev.tags} creator={props.ev.pubkey} disableMediaSpotlight={true} />
|
||||
)}
|
||||
</div>
|
||||
{showResults && (
|
||||
<>
|
||||
|
@ -1,6 +1,10 @@
|
||||
.pfp {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: min-content auto;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pfp .avatar-wrapper {
|
||||
@ -14,7 +18,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pfp a {
|
||||
a.pfp {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@ -25,6 +29,12 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pfp .subheader .about {
|
||||
max-width: calc(100vw - 140px);
|
||||
.pfp .profile-name {
|
||||
max-width: stretch;
|
||||
max-width: -webkit-fill-available;
|
||||
max-width: -moz-available;
|
||||
}
|
||||
|
||||
.pfp a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
@ -1,15 +1,14 @@
|
||||
import "./ProfileImage.css";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { HexKey, NostrPrefix } from "@snort/nostr";
|
||||
import React, { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { HexKey, NostrPrefix, UserMetadata } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
import { useUserProfile } from "Hooks/useUserProfile";
|
||||
import { hexToBech32, profileLink } from "Util";
|
||||
import { hexToBech32, profileLink } from "SnortUtils";
|
||||
import Avatar from "Element/Avatar";
|
||||
import Nip05 from "Element/Nip05";
|
||||
import { MetadataCache } from "Cache";
|
||||
import usePageWidth from "Hooks/usePageWidth";
|
||||
import { System } from "index";
|
||||
|
||||
export interface ProfileImageProps {
|
||||
pubkey: HexKey;
|
||||
@ -17,11 +16,10 @@ export interface ProfileImageProps {
|
||||
showUsername?: boolean;
|
||||
className?: string;
|
||||
link?: string;
|
||||
autoWidth?: boolean;
|
||||
defaultNip?: string;
|
||||
verifyNip?: boolean;
|
||||
linkToProfile?: boolean;
|
||||
overrideUsername?: string;
|
||||
profile?: UserMetadata;
|
||||
}
|
||||
|
||||
export default function ProfileImage({
|
||||
@ -30,54 +28,47 @@ export default function ProfileImage({
|
||||
showUsername = true,
|
||||
className,
|
||||
link,
|
||||
autoWidth = true,
|
||||
defaultNip,
|
||||
verifyNip,
|
||||
linkToProfile = true,
|
||||
overrideUsername,
|
||||
profile,
|
||||
}: ProfileImageProps) {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserProfile(pubkey);
|
||||
const user = profile ?? useUserProfile(System, pubkey);
|
||||
const nip05 = defaultNip ? defaultNip : user?.nip05;
|
||||
const width = usePageWidth();
|
||||
|
||||
const name = useMemo(() => {
|
||||
return overrideUsername ?? getDisplayName(user, pubkey);
|
||||
}, [user, pubkey, overrideUsername]);
|
||||
|
||||
if (!pubkey && !link) {
|
||||
link = "#";
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
if (link === "") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
const onAvatarClick = () => {
|
||||
if (linkToProfile) {
|
||||
navigate(link ?? profileLink(pubkey));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`pfp f-ellipsis${className ? ` ${className}` : ""}`} onClick={onAvatarClick}>
|
||||
<Link
|
||||
className={`pfp${className ? ` ${className}` : ""}`}
|
||||
to={link === undefined ? profileLink(pubkey) : link}
|
||||
onClick={handleClick}
|
||||
replace={true}>
|
||||
<div className="avatar-wrapper">
|
||||
<Avatar user={user} />
|
||||
</div>
|
||||
{showUsername && (
|
||||
<div className="profile-name">
|
||||
<div className="f-ellipsis">
|
||||
<div className="username">
|
||||
<div className="display-name">
|
||||
<div>{name.trim()}</div>
|
||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} verifyNip={verifyNip} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="subheader" style={{ width: autoWidth ? width - 190 : "" }}>
|
||||
{subHeader}
|
||||
<div>{name.trim()}</div>
|
||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} verifyNip={verifyNip} />}
|
||||
</div>
|
||||
<div className="subheader">{subHeader}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function getDisplayName(user: MetadataCache | undefined, pubkey: HexKey) {
|
||||
export function getDisplayName(user: UserMetadata | undefined, pubkey: HexKey) {
|
||||
let name = hexToBech32(NostrPrefix.PublicKey, pubkey).substring(0, 12);
|
||||
if (typeof user?.display_name === "string" && user.display_name.length > 0) {
|
||||
name = user.display_name;
|
||||
|
@ -1,7 +1,7 @@
|
||||
.profile-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
min-height: 59px;
|
||||
}
|
||||
|
||||
.profile-preview .pfp {
|
||||
|
@ -1,11 +1,12 @@
|
||||
import "./ProfilePreview.css";
|
||||
import { ReactNode } from "react";
|
||||
import { HexKey } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import FollowButton from "Element/FollowButton";
|
||||
import { useUserProfile } from "Hooks/useUserProfile";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { System } from "index";
|
||||
|
||||
export interface ProfilePreviewProps {
|
||||
pubkey: HexKey;
|
||||
@ -17,8 +18,8 @@ export interface ProfilePreviewProps {
|
||||
}
|
||||
export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||
const pubkey = props.pubkey;
|
||||
const user = useUserProfile(pubkey);
|
||||
const { ref, inView } = useInView({ triggerOnce: true });
|
||||
const user = useUserProfile(System, inView ? pubkey : undefined);
|
||||
const options = {
|
||||
about: true,
|
||||
...props.options,
|
||||
|
@ -1,21 +1,48 @@
|
||||
import useImgProxy from "Hooks/useImgProxy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { getUrlHostname } from "SnortUtils";
|
||||
|
||||
interface ProxyImgProps extends React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ProxyImg = (props: ProxyImgProps) => {
|
||||
const { src, size, ...rest } = props;
|
||||
const [url, setUrl] = useState<string>();
|
||||
const { proxy } = useImgProxy();
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [bypass, setBypass] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (src) {
|
||||
const url = proxy(src, size);
|
||||
setUrl(url);
|
||||
if (loadFailed) {
|
||||
if (bypass) {
|
||||
return <img {...props} width={props.size} height={props.size} />;
|
||||
}
|
||||
}, [src]);
|
||||
|
||||
return <img src={url} {...rest} />;
|
||||
return (
|
||||
<div
|
||||
className="note-invoice error"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setBypass(true);
|
||||
}}>
|
||||
<FormattedMessage
|
||||
defaultMessage="Failed to proxy image from {host}, click here to load directly"
|
||||
values={{
|
||||
host: getUrlHostname(props.src),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
src={props.src ? proxy(props.src, props.size) : ""}
|
||||
onError={e => {
|
||||
if (props.onError) {
|
||||
props.onError(e);
|
||||
} else {
|
||||
setLoadFailed(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
84
packages/app/src/Element/PubkeyList.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
import { LNURL } from "@snort/shared";
|
||||
|
||||
import { dedupe, hexToBech32, unixNow } from "SnortUtils";
|
||||
import FollowListBase from "Element/FollowListBase";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import { useWallet } from "Wallet";
|
||||
import { Toastore } from "Toaster";
|
||||
import { getDisplayName } from "Element/ProfileImage";
|
||||
import { UserCache } from "Cache";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { WalletInvoiceState } from "Wallet";
|
||||
|
||||
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
|
||||
const wallet = useWallet();
|
||||
const login = useLogin();
|
||||
const publisher = useEventPublisher();
|
||||
const ids = dedupe(ev.tags.filter(a => a[0] === "p").map(a => a[1]));
|
||||
|
||||
async function zapAll() {
|
||||
for (const pk of ids) {
|
||||
try {
|
||||
const profile = await UserCache.get(pk);
|
||||
const amtSend = login.preferences.defaultZapAmount;
|
||||
const lnurl = profile?.lud16 || profile?.lud06;
|
||||
if (lnurl) {
|
||||
const svc = new LNURL(lnurl);
|
||||
await svc.load();
|
||||
|
||||
const zap = await publisher?.zap(
|
||||
amtSend * 1000,
|
||||
pk,
|
||||
Object.keys(login.relays.item),
|
||||
undefined,
|
||||
`Zap from ${hexToBech32("note", ev.id)}`
|
||||
);
|
||||
const invoice = await svc.getInvoice(amtSend, undefined, zap);
|
||||
if (invoice.pr) {
|
||||
const rsp = await wallet.wallet?.payInvoice(invoice.pr);
|
||||
if (rsp?.state === WalletInvoiceState.Paid) {
|
||||
Toastore.push({
|
||||
element: (
|
||||
<FormattedMessage
|
||||
defaultMessage="Sent {n} sats to {name}"
|
||||
values={{
|
||||
n: amtSend,
|
||||
name: getDisplayName(profile, pk),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
icon: "zap",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("Failed to zap", pk, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FollowListBase
|
||||
pubkeys={ids}
|
||||
showAbout={true}
|
||||
className={className}
|
||||
title={ev.tags.find(a => a[0] === "d")?.[1]}
|
||||
actions={
|
||||
<>
|
||||
<AsyncButton className="mr5 transparent" onClick={() => zapAll()}>
|
||||
<FormattedMessage
|
||||
defaultMessage="Zap All {n} sats"
|
||||
values={{
|
||||
n: <FormattedNumber value={login.preferences.defaultZapAmount * ids.length} />,
|
||||
}}
|
||||
/>
|
||||
</AsyncButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
@ -6,6 +6,7 @@ import type { RootState } from "State/Store";
|
||||
import { setShow, reset, setSelectedCustomRelays } from "State/ReBroadcast";
|
||||
import messages from "./messages";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { System } from "index";
|
||||
|
||||
export function ReBroadcaster() {
|
||||
const publisher = useEventPublisher();
|
||||
@ -14,8 +15,8 @@ export function ReBroadcaster() {
|
||||
|
||||
async function sendReBroadcast() {
|
||||
if (note && publisher) {
|
||||
if (selectedCustomRelays) publisher.broadcastAll(note, selectedCustomRelays);
|
||||
else publisher.broadcast(note);
|
||||
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, note));
|
||||
else System.BroadcastEvent(note);
|
||||
dispatch(reset());
|
||||
}
|
||||
}
|
||||
|
@ -1,28 +1,23 @@
|
||||
.reactions-modal .modal-body {
|
||||
padding: 0;
|
||||
max-width: 586px;
|
||||
}
|
||||
|
||||
.reactions-view {
|
||||
padding: 24px 32px;
|
||||
background-color: #1b1b1b;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
min-height: 33vh;
|
||||
}
|
||||
|
||||
.light .reactions-view {
|
||||
.light .reactions-modal .modal-body {
|
||||
background-color: var(--note-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.reactions-view {
|
||||
.reactions-modal .modal-body {
|
||||
padding: 12px 16px;
|
||||
margin-top: -160px;
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
}
|
||||
|
||||
.reactions-view .close {
|
||||
.reactions-modal .modal-body .close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
@ -30,18 +25,18 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reactions-view .close:hover {
|
||||
.reactions-modal .modal-body .close:hover {
|
||||
color: var(--font-tertiary-color);
|
||||
}
|
||||
|
||||
.reactions-view .reactions-header {
|
||||
.reactions-modal .modal-body .reactions-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.reactions-view .reactions-header h2 {
|
||||
.reactions-modal .modal-body .reactions-header h2 {
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
font-weight: 600;
|
||||
@ -49,26 +44,25 @@
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.reactions-view .body {
|
||||
.reactions-modal .modal-body .reactions-body {
|
||||
overflow: scroll;
|
||||
height: 320px;
|
||||
height: 40vh;
|
||||
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.reactions-view .body::-webkit-scrollbar {
|
||||
.reactions-modal .modal-body .reactions-body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reactions-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
display: grid;
|
||||
grid-template-columns: 52px auto;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.reactions-item .reaction-icon {
|
||||
width: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -93,12 +87,8 @@
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.reactions-item .zap-comment {
|
||||
width: 332px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.reactions-view .tab.disabled {
|
||||
.reactions-modal .modal-body .tab.disabled {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@ -2,13 +2,11 @@ import "./Reactions.css";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
|
||||
import { TaggedRawEvent } from "@snort/nostr";
|
||||
import { TaggedRawEvent, ParsedZap } from "@snort/system";
|
||||
|
||||
import { formatShort } from "Number";
|
||||
import Icon from "Icons/Icon";
|
||||
import { Tab } from "Element/Tabs";
|
||||
import { ParsedZap } from "Element/Zap";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import Tabs from "Element/Tabs";
|
||||
import Modal from "Element/Modal";
|
||||
@ -75,72 +73,66 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
|
||||
|
||||
return show ? (
|
||||
<Modal className="reactions-modal" onClose={onClose}>
|
||||
<div className="reactions-view">
|
||||
<div className="close" onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</div>
|
||||
<div className="reactions-header">
|
||||
<h2>
|
||||
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
|
||||
</h2>
|
||||
</div>
|
||||
<Tabs tabs={tabs} tab={tab} setTab={setTab} />
|
||||
<div className="body" key={tab.value}>
|
||||
{tab.value === 0 &&
|
||||
likes.map(ev => {
|
||||
return (
|
||||
<div key={ev.id} className="reactions-item">
|
||||
<div className="reaction-icon">{ev.content === "+" ? <Icon name="heart" /> : ev.content}</div>
|
||||
<ProfileImage autoWidth={false} pubkey={ev.pubkey} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tab.value === 1 &&
|
||||
zaps.map(z => {
|
||||
return (
|
||||
z.sender && (
|
||||
<div key={z.id} className="reactions-item">
|
||||
<div className="zap-reaction-icon">
|
||||
<Icon name="zap" size={20} />
|
||||
<span className="zap-amount">{formatShort(z.amount)}</span>
|
||||
</div>
|
||||
<ProfileImage
|
||||
autoWidth={false}
|
||||
pubkey={z.anonZap ? "" : z.sender}
|
||||
subHeader={
|
||||
<div className="f-ellipsis zap-comment" title={z.content}>
|
||||
{z.content}
|
||||
</div>
|
||||
}
|
||||
overrideUsername={z.anonZap ? formatMessage({ defaultMessage: "Anonymous" }) : undefined}
|
||||
/>
|
||||
<div className="close" onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</div>
|
||||
<div className="reactions-header">
|
||||
<h2>
|
||||
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
|
||||
</h2>
|
||||
</div>
|
||||
<Tabs tabs={tabs} tab={tab} setTab={setTab} />
|
||||
<div className="reactions-body" key={tab.value}>
|
||||
{tab.value === 0 &&
|
||||
likes.map(ev => {
|
||||
return (
|
||||
<div key={ev.id} className="reactions-item">
|
||||
<div className="reaction-icon">{ev.content === "+" ? <Icon name="heart" /> : ev.content}</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tab.value === 1 &&
|
||||
zaps.map(z => {
|
||||
return (
|
||||
z.sender && (
|
||||
<div key={z.id} className="reactions-item">
|
||||
<div className="zap-reaction-icon">
|
||||
<Icon name="zap" size={20} />
|
||||
<span className="zap-amount">{formatShort(z.amount)}</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
{tab.value === 2 &&
|
||||
reposts.map(ev => {
|
||||
return (
|
||||
<div key={ev.id} className="reactions-item">
|
||||
<div className="reaction-icon">
|
||||
<Icon name="repost" size={16} />
|
||||
</div>
|
||||
<ProfileImage autoWidth={false} pubkey={ev.pubkey} />
|
||||
<ProfileImage
|
||||
pubkey={z.anonZap ? "" : z.sender}
|
||||
subHeader={<div title={z.content}>{z.content}</div>}
|
||||
link={z.anonZap ? "" : undefined}
|
||||
overrideUsername={z.anonZap ? formatMessage({ defaultMessage: "Anonymous" }) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tab.value === 3 &&
|
||||
dislikes.map(ev => {
|
||||
return (
|
||||
<div key={ev.id} className="reactions-item">
|
||||
<div className="reaction-icon">
|
||||
<Icon name="dislike" />
|
||||
</div>
|
||||
<ProfileImage autoWidth={false} pubkey={ev.pubkey} />
|
||||
)
|
||||
);
|
||||
})}
|
||||
{tab.value === 2 &&
|
||||
reposts.map(ev => {
|
||||
return (
|
||||
<div key={ev.id} className="reactions-item">
|
||||
<div className="reaction-icon">
|
||||
<Icon name="repost" size={16} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tab.value === 3 &&
|
||||
dislikes.map(ev => {
|
||||
return (
|
||||
<div key={ev.id} className="reactions-item f-ellipsis">
|
||||
<div className="reaction-icon">
|
||||
<Icon name="dislike" />
|
||||
</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null;
|
||||
|
@ -1,36 +1,28 @@
|
||||
import "./Relay.css";
|
||||
import { useMemo } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faPlug,
|
||||
faSquareCheck,
|
||||
faSquareXmark,
|
||||
faWifi,
|
||||
faPlugCircleXmark,
|
||||
faGear,
|
||||
faWarning,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { RelaySettings } from "@snort/nostr";
|
||||
import { RelaySettings } from "@snort/system";
|
||||
|
||||
import useRelayState from "Feed/RelayState";
|
||||
import { System } from "System";
|
||||
import { getRelayName, unixNowMs, unwrap } from "Util";
|
||||
|
||||
import messages from "./messages";
|
||||
import { System } from "index";
|
||||
import { getRelayName, unixNowMs, unwrap } from "SnortUtils";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { setRelays } from "Login";
|
||||
import Icon from "Icons/Icon";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface RelayProps {
|
||||
addr: string;
|
||||
}
|
||||
|
||||
export default function Relay(props: RelayProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const login = useLogin();
|
||||
const relaySettings = unwrap(login.relays.item[props.addr] ?? System.Sockets.get(props.addr)?.Settings ?? {});
|
||||
const relaySettings = unwrap(
|
||||
login.relays.item[props.addr] ?? System.Sockets.find(a => a.address === props.addr)?.settings ?? {}
|
||||
);
|
||||
const state = useRelayState(props.addr);
|
||||
const name = useMemo(() => getRelayName(props.addr), [props.addr]);
|
||||
|
||||
@ -45,12 +37,11 @@ export default function Relay(props: RelayProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const latency = Math.floor(state?.avgLatency ?? 0);
|
||||
return (
|
||||
<>
|
||||
<div className={`relay w-max`}>
|
||||
<div className={`flex ${state?.connected ? "bg-success" : "bg-error"}`}>
|
||||
<FontAwesomeIcon icon={faPlug} />
|
||||
<Icon name="wifi" />
|
||||
</div>
|
||||
<div className="f-grow f-col">
|
||||
<div className="flex mb10">
|
||||
@ -65,7 +56,7 @@ export default function Relay(props: RelayProps) {
|
||||
read: relaySettings.read,
|
||||
})
|
||||
}>
|
||||
<FontAwesomeIcon icon={relaySettings.write ? faSquareCheck : faSquareXmark} />
|
||||
<Icon name={relaySettings.write ? "check" : "close"} size={12} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="f-1">
|
||||
@ -78,28 +69,15 @@ export default function Relay(props: RelayProps) {
|
||||
read: !relaySettings.read,
|
||||
})
|
||||
}>
|
||||
<FontAwesomeIcon icon={relaySettings.read ? faSquareCheck : faSquareXmark} />
|
||||
<Icon name={relaySettings.read ? "check" : "close"} size={12} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="f-grow">
|
||||
<FontAwesomeIcon icon={faWifi} className="mr5 ml5" />
|
||||
{latency > 2000
|
||||
? formatMessage(messages.Seconds, {
|
||||
n: (latency / 1000).toFixed(0),
|
||||
})
|
||||
: formatMessage(messages.Milliseconds, {
|
||||
n: latency.toLocaleString(),
|
||||
})}
|
||||
|
||||
<FontAwesomeIcon icon={faPlugCircleXmark} className="mr5 ml5" /> {state?.disconnects}
|
||||
<FontAwesomeIcon icon={faWarning} className="mr5 ml5" />
|
||||
{state?.pendingRequests?.length}
|
||||
</div>
|
||||
<div className="f-grow"></div>
|
||||
<div>
|
||||
<span className="icon-btn" onClick={() => navigate(state?.id ?? "")}>
|
||||
<FontAwesomeIcon icon={faGear} />
|
||||
<Icon name="gear" size={12} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import "./RelaysMetadata.css";
|
||||
import Nostrich from "nostrich.webp";
|
||||
import Nostrich from "public/logo_256.png";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FullRelaySettings } from "@snort/nostr";
|
||||
import { FullRelaySettings } from "@snort/system";
|
||||
import Icon from "Icons/Icon";
|
||||
|
||||
const RelayFavicon = ({ url }: { url: string }) => {
|
||||
|
@ -8,6 +8,7 @@ import { MediaElement } from "Element/MediaElement";
|
||||
interface RevealMediaProps {
|
||||
creator: string;
|
||||
link: string;
|
||||
disableSpotlight?: boolean;
|
||||
}
|
||||
|
||||
export default function RevealMedia(props: RevealMediaProps) {
|
||||
@ -41,6 +42,7 @@ export default function RevealMedia(props: RevealMediaProps) {
|
||||
case "avi":
|
||||
case "m4v":
|
||||
case "webm":
|
||||
case "m3u8":
|
||||
return "video";
|
||||
default:
|
||||
return "unknown";
|
||||
@ -51,10 +53,12 @@ export default function RevealMedia(props: RevealMediaProps) {
|
||||
return (
|
||||
<Reveal
|
||||
message={<FormattedMessage defaultMessage="Click to load content from {link}" values={{ link: hostname }} />}>
|
||||
<MediaElement mime={`${type}/${extension}`} url={url.toString()} />
|
||||
<MediaElement mime={`${type}/${extension}`} url={url.toString()} disableSpotlight={props.disableSpotlight} />
|
||||
</Reveal>
|
||||
);
|
||||
} else {
|
||||
return <MediaElement mime={`${type}/${extension}`} url={url.toString()} />;
|
||||
return (
|
||||
<MediaElement mime={`${type}/${extension}`} url={url.toString()} disableSpotlight={props.disableSpotlight} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
import "./SendSats.css";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { HexKey, RawEvent } from "@snort/nostr";
|
||||
|
||||
import { HexKey, NostrEvent, EventPublisher } from "@snort/system";
|
||||
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "@snort/shared";
|
||||
|
||||
import { System } from "index";
|
||||
import { formatShort } from "Number";
|
||||
import Icon from "Icons/Icon";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
@ -10,12 +13,11 @@ import ProfileImage from "Element/ProfileImage";
|
||||
import Modal from "Element/Modal";
|
||||
import QrCode from "Element/QrCode";
|
||||
import Copy from "Element/Copy";
|
||||
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
|
||||
import { chunks, debounce } from "Util";
|
||||
import { chunks, debounce } from "SnortUtils";
|
||||
import { useWallet } from "Wallet";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { generateRandomKey } from "Login";
|
||||
import { EventPublisher } from "System/EventPublisher";
|
||||
import { ZapPoolController } from "ZapPoolController";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -36,6 +38,7 @@ export interface SendSatsProps {
|
||||
target?: string;
|
||||
note?: HexKey;
|
||||
author?: HexKey;
|
||||
allocatePool?: boolean;
|
||||
}
|
||||
|
||||
export default function SendSats(props: SendSatsProps) {
|
||||
@ -122,7 +125,7 @@ export default function SendSats(props: SendSatsProps) {
|
||||
async function loadInvoice() {
|
||||
if (!amount || !handler || !publisher) return null;
|
||||
|
||||
let zap: RawEvent | undefined;
|
||||
let zap: NostrEvent | undefined;
|
||||
if (author && zapType !== ZapType.NonZap) {
|
||||
const relays = Object.keys(login.relays.item);
|
||||
|
||||
@ -131,7 +134,7 @@ export default function SendSats(props: SendSatsProps) {
|
||||
const randomKey = generateRandomKey();
|
||||
console.debug("Generated new key for zap: ", randomKey);
|
||||
|
||||
const publisher = new EventPublisher(randomKey.publicKey, randomKey.privateKey);
|
||||
const publisher = EventPublisher.privateKey(randomKey.privateKey);
|
||||
zap = await publisher.zap(amount * 1000, author, relays, note, comment, eb => eb.tag(["anon", ""]));
|
||||
} else {
|
||||
zap = await publisher.zap(amount * 1000, author, relays, note, comment);
|
||||
@ -194,9 +197,12 @@ export default function SendSats(props: SendSatsProps) {
|
||||
|
||||
async function payWithWallet(invoice: LNURLInvoice) {
|
||||
try {
|
||||
if (wallet?.isReady) {
|
||||
if (wallet?.isReady()) {
|
||||
setPaying(true);
|
||||
const res = await wallet.payInvoice(invoice?.pr ?? "");
|
||||
if (props.allocatePool) {
|
||||
ZapPoolController.allocate(amount);
|
||||
}
|
||||
console.log(res);
|
||||
setSuccess(invoice?.successAction ?? {});
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import "./SubDebug.css";
|
||||
import { useState } from "react";
|
||||
import { ReqFilter } from "@snort/system";
|
||||
import { useSystemState } from "@snort/system-react";
|
||||
|
||||
import useRelayState from "Feed/RelayState";
|
||||
import Tabs, { Tab } from "Element/Tabs";
|
||||
import { System } from "System";
|
||||
import { unwrap } from "Util";
|
||||
import useSystemState from "Hooks/useSystemState";
|
||||
import { RawReqFilter } from "@snort/nostr";
|
||||
import { unwrap } from "SnortUtils";
|
||||
import { useCopy } from "useCopy";
|
||||
import { System } from "index";
|
||||
|
||||
function RelayInfo({ id }: { id: string }) {
|
||||
const state = useRelayState(id);
|
||||
@ -15,10 +15,10 @@ function RelayInfo({ id }: { id: string }) {
|
||||
}
|
||||
|
||||
function Queries() {
|
||||
const qs = useSystemState();
|
||||
const qs = useSystemState(System);
|
||||
const { copy } = useCopy();
|
||||
|
||||
function countElements(filters: Array<RawReqFilter>) {
|
||||
function countElements(filters: Array<ReqFilter>) {
|
||||
let total = 0;
|
||||
for (const f of filters) {
|
||||
for (const v of Object.values(f)) {
|
||||
@ -30,15 +30,10 @@ function Queries() {
|
||||
return total;
|
||||
}
|
||||
|
||||
function queryInfo(q: {
|
||||
id: string;
|
||||
filters: Array<RawReqFilter>;
|
||||
closing: boolean;
|
||||
subFilters: Array<RawReqFilter>;
|
||||
}) {
|
||||
function queryInfo(q: { id: string; filters: Array<ReqFilter>; subFilters: Array<ReqFilter> }) {
|
||||
return (
|
||||
<div key={q.id}>
|
||||
{q.closing ? <s>{q.id}</s> : <>{q.id}</>}
|
||||
{q.id}
|
||||
<br />
|
||||
<span onClick={() => copy(JSON.stringify(q.filters))} className="pointer">
|
||||
Filters: {q.filters.length} ({countElements(q.filters)} elements)
|
||||
@ -66,8 +61,8 @@ const SubDebug = () => {
|
||||
return (
|
||||
<>
|
||||
<b>Connections:</b>
|
||||
{[...System.Sockets.keys()].map(k => (
|
||||
<RelayInfo id={k} />
|
||||
{System.Sockets.map(k => (
|
||||
<RelayInfo id={k.address} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
72
packages/app/src/Element/SuggestedProfiles.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { HexKey, NostrPrefix } from "@snort/system";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import FollowListBase from "Element/FollowListBase";
|
||||
import PageSpinner from "Element/PageSpinner";
|
||||
import NostrBandApi from "External/NostrBand";
|
||||
import SemisolDevApi from "External/SemisolDev";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { hexToBech32 } from "SnortUtils";
|
||||
|
||||
enum Provider {
|
||||
NostrBand = 1,
|
||||
SemisolDev = 2,
|
||||
}
|
||||
|
||||
export default function SuggestedProfiles() {
|
||||
const login = useLogin();
|
||||
const [userList, setUserList] = useState<HexKey[]>();
|
||||
const [provider, setProvider] = useState(Provider.NostrBand);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function loadSuggestedProfiles() {
|
||||
if (!login.publicKey) return;
|
||||
setUserList(undefined);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
switch (provider) {
|
||||
case Provider.NostrBand: {
|
||||
const api = new NostrBandApi();
|
||||
const users = await api.sugguestedFollows(hexToBech32(NostrPrefix.PublicKey, login.publicKey));
|
||||
const keys = users.profiles.map(a => a.pubkey);
|
||||
setUserList(keys);
|
||||
break;
|
||||
}
|
||||
case Provider.SemisolDev: {
|
||||
const api = new SemisolDevApi();
|
||||
const users = await api.sugguestedFollows(login.publicKey, login.follows.item);
|
||||
const keys = users.recommendations.sort(a => a[1]).map(a => a[0]);
|
||||
setUserList(keys);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSuggestedProfiles().catch(console.error);
|
||||
}, [login, provider]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Suggested Follows" />
|
||||
</h3>
|
||||
<div className="card flex f-space">
|
||||
<FormattedMessage defaultMessage="Provider" />
|
||||
<select onChange={e => setProvider(Number(e.target.value))}>
|
||||
<option value={Provider.NostrBand}>nostr.band</option>
|
||||
<option value={Provider.SemisolDev}>semisol.dev</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <b className="error">{error}</b>}
|
||||
{userList ? <FollowListBase pubkeys={userList} showAbout={true} /> : <PageSpinner />}
|
||||
</>
|
||||
);
|
||||
}
|