Merge remote-tracking branch 'upstream/main' into feat/connect-wallet
This commit is contained in:
37
.drone.yaml
37
.drone.yaml
@ -23,3 +23,40 @@ 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/stream.git
|
||||
- yarn install
|
||||
- npx @crowdin/cli upload sources -b main -T $CTOKEN
|
||||
- npx @crowdin/cli pull -b main -T $CTOKEN
|
||||
- git add .
|
||||
- 'git commit -a -m "chore: Update translations"'
|
||||
- git push -u origin main
|
||||
volumes:
|
||||
- name: cache
|
||||
claim:
|
||||
name: docker-cache
|
@ -1 +0,0 @@
|
||||
{}
|
6
.vscode/extensions.json
vendored
6
.vscode/extensions.json
vendored
@ -1,7 +1,3 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"arcanis.vscode-zipfs",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
"recommendations": ["arcanis.vscode-zipfs", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
||||
|
File diff suppressed because one or more lines are too long
2
.yarn/sdks/eslint/package.json
vendored
2
.yarn/sdks/eslint/package.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint",
|
||||
"version": "8.45.0-sdk",
|
||||
"version": "8.48.0-sdk",
|
||||
"main": "./lib/api.js",
|
||||
"type": "commonjs"
|
||||
}
|
||||
|
50
.yarn/sdks/typescript/lib/tsserver.js
vendored
50
.yarn/sdks/typescript/lib/tsserver.js
vendored
@ -9,7 +9,7 @@ const relPnpApiPath = "../../../../.pnp.cjs";
|
||||
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
|
||||
const absRequire = createRequire(absPnpApiPath);
|
||||
|
||||
const moduleWrapper = (tsserver) => {
|
||||
const moduleWrapper = tsserver => {
|
||||
if (!process.versions.pnp) {
|
||||
return tsserver;
|
||||
}
|
||||
@ -17,12 +17,12 @@ const moduleWrapper = (tsserver) => {
|
||||
const { isAbsolute } = require(`path`);
|
||||
const pnpApi = require(`pnpapi`);
|
||||
|
||||
const isVirtual = (str) => str.match(/\/(\$\$virtual|__virtual__)\//);
|
||||
const isPortal = (str) => str.startsWith("portal:/");
|
||||
const normalize = (str) => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
|
||||
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
|
||||
const isPortal = str => str.startsWith("portal:/");
|
||||
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
|
||||
|
||||
const dependencyTreeRoots = new Set(
|
||||
pnpApi.getDependencyTreeRoots().map((locator) => {
|
||||
pnpApi.getDependencyTreeRoots().map(locator => {
|
||||
return `${locator.name}@${locator.reference}`;
|
||||
})
|
||||
);
|
||||
@ -33,11 +33,7 @@ const moduleWrapper = (tsserver) => {
|
||||
|
||||
function toEditorPath(str) {
|
||||
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
|
||||
if (
|
||||
isAbsolute(str) &&
|
||||
!str.match(/^\^?(zip:|\/zip\/)/) &&
|
||||
(str.match(/\.zip\//) || isVirtual(str))
|
||||
) {
|
||||
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
|
||||
// We also take the opportunity to turn virtual paths into physical ones;
|
||||
// this makes it much easier to work with workspaces that list peer
|
||||
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
|
||||
@ -53,8 +49,7 @@ const moduleWrapper = (tsserver) => {
|
||||
const locator = pnpApi.findPackageLocator(resolved);
|
||||
if (
|
||||
locator &&
|
||||
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) ||
|
||||
isPortal(locator.reference))
|
||||
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))
|
||||
) {
|
||||
str = resolved;
|
||||
}
|
||||
@ -149,9 +144,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
|
||||
// So in order to convert it back, we use .* to match all the thing
|
||||
// before `zipfile:`
|
||||
return process.platform === `win32`
|
||||
? str.replace(/^.*zipfile:\//, ``)
|
||||
: str.replace(/^.*zipfile:/, ``);
|
||||
return process.platform === `win32` ? str.replace(/^.*zipfile:\//, ``) : str.replace(/^.*zipfile:/, ``);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -166,10 +159,7 @@ const moduleWrapper = (tsserver) => {
|
||||
case `vscode`:
|
||||
default:
|
||||
{
|
||||
return str.replace(
|
||||
/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/,
|
||||
process.platform === `win32` ? `` : `/`
|
||||
);
|
||||
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -183,8 +173,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// TypeScript already does local loads and if this code is running the user trusts the workspace
|
||||
// https://github.com/microsoft/vscode/issues/45856
|
||||
const ConfiguredProject = tsserver.server.ConfiguredProject;
|
||||
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } =
|
||||
ConfiguredProject.prototype;
|
||||
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = ConfiguredProject.prototype;
|
||||
ConfiguredProject.prototype.enablePluginsWithOptions = function () {
|
||||
this.projectService.allowLocalPluginLoads = true;
|
||||
return originalEnablePluginsWithOptions.apply(this, arguments);
|
||||
@ -195,8 +184,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// like an absolute path of ours and normalize it.
|
||||
|
||||
const Session = tsserver.server.Session;
|
||||
const { onMessage: originalOnMessage, send: originalSend } =
|
||||
Session.prototype;
|
||||
const { onMessage: originalOnMessage, send: originalSend } = Session.prototype;
|
||||
let hostInfo = `unknown`;
|
||||
|
||||
Object.assign(Session.prototype, {
|
||||
@ -231,19 +219,11 @@ const moduleWrapper = (tsserver) => {
|
||||
}
|
||||
}
|
||||
|
||||
const processedMessageJSON = JSON.stringify(
|
||||
parsedMessage,
|
||||
(key, value) => {
|
||||
return typeof value === "string" ? fromEditorPath(value) : value;
|
||||
}
|
||||
);
|
||||
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
|
||||
return typeof value === "string" ? fromEditorPath(value) : value;
|
||||
});
|
||||
|
||||
return originalOnMessage.call(
|
||||
this,
|
||||
isStringMessage
|
||||
? processedMessageJSON
|
||||
: JSON.parse(processedMessageJSON)
|
||||
);
|
||||
return originalOnMessage.call(this, isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON));
|
||||
},
|
||||
|
||||
send(/** @type {any} */ msg) {
|
||||
|
50
.yarn/sdks/typescript/lib/tsserverlibrary.js
vendored
50
.yarn/sdks/typescript/lib/tsserverlibrary.js
vendored
@ -9,7 +9,7 @@ const relPnpApiPath = "../../../../.pnp.cjs";
|
||||
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
|
||||
const absRequire = createRequire(absPnpApiPath);
|
||||
|
||||
const moduleWrapper = (tsserver) => {
|
||||
const moduleWrapper = tsserver => {
|
||||
if (!process.versions.pnp) {
|
||||
return tsserver;
|
||||
}
|
||||
@ -17,12 +17,12 @@ const moduleWrapper = (tsserver) => {
|
||||
const { isAbsolute } = require(`path`);
|
||||
const pnpApi = require(`pnpapi`);
|
||||
|
||||
const isVirtual = (str) => str.match(/\/(\$\$virtual|__virtual__)\//);
|
||||
const isPortal = (str) => str.startsWith("portal:/");
|
||||
const normalize = (str) => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
|
||||
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
|
||||
const isPortal = str => str.startsWith("portal:/");
|
||||
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
|
||||
|
||||
const dependencyTreeRoots = new Set(
|
||||
pnpApi.getDependencyTreeRoots().map((locator) => {
|
||||
pnpApi.getDependencyTreeRoots().map(locator => {
|
||||
return `${locator.name}@${locator.reference}`;
|
||||
})
|
||||
);
|
||||
@ -33,11 +33,7 @@ const moduleWrapper = (tsserver) => {
|
||||
|
||||
function toEditorPath(str) {
|
||||
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
|
||||
if (
|
||||
isAbsolute(str) &&
|
||||
!str.match(/^\^?(zip:|\/zip\/)/) &&
|
||||
(str.match(/\.zip\//) || isVirtual(str))
|
||||
) {
|
||||
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
|
||||
// We also take the opportunity to turn virtual paths into physical ones;
|
||||
// this makes it much easier to work with workspaces that list peer
|
||||
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
|
||||
@ -53,8 +49,7 @@ const moduleWrapper = (tsserver) => {
|
||||
const locator = pnpApi.findPackageLocator(resolved);
|
||||
if (
|
||||
locator &&
|
||||
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) ||
|
||||
isPortal(locator.reference))
|
||||
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))
|
||||
) {
|
||||
str = resolved;
|
||||
}
|
||||
@ -149,9 +144,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
|
||||
// So in order to convert it back, we use .* to match all the thing
|
||||
// before `zipfile:`
|
||||
return process.platform === `win32`
|
||||
? str.replace(/^.*zipfile:\//, ``)
|
||||
: str.replace(/^.*zipfile:/, ``);
|
||||
return process.platform === `win32` ? str.replace(/^.*zipfile:\//, ``) : str.replace(/^.*zipfile:/, ``);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -166,10 +159,7 @@ const moduleWrapper = (tsserver) => {
|
||||
case `vscode`:
|
||||
default:
|
||||
{
|
||||
return str.replace(
|
||||
/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/,
|
||||
process.platform === `win32` ? `` : `/`
|
||||
);
|
||||
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -183,8 +173,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// TypeScript already does local loads and if this code is running the user trusts the workspace
|
||||
// https://github.com/microsoft/vscode/issues/45856
|
||||
const ConfiguredProject = tsserver.server.ConfiguredProject;
|
||||
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } =
|
||||
ConfiguredProject.prototype;
|
||||
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = ConfiguredProject.prototype;
|
||||
ConfiguredProject.prototype.enablePluginsWithOptions = function () {
|
||||
this.projectService.allowLocalPluginLoads = true;
|
||||
return originalEnablePluginsWithOptions.apply(this, arguments);
|
||||
@ -195,8 +184,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// like an absolute path of ours and normalize it.
|
||||
|
||||
const Session = tsserver.server.Session;
|
||||
const { onMessage: originalOnMessage, send: originalSend } =
|
||||
Session.prototype;
|
||||
const { onMessage: originalOnMessage, send: originalSend } = Session.prototype;
|
||||
let hostInfo = `unknown`;
|
||||
|
||||
Object.assign(Session.prototype, {
|
||||
@ -231,19 +219,11 @@ const moduleWrapper = (tsserver) => {
|
||||
}
|
||||
}
|
||||
|
||||
const processedMessageJSON = JSON.stringify(
|
||||
parsedMessage,
|
||||
(key, value) => {
|
||||
return typeof value === "string" ? fromEditorPath(value) : value;
|
||||
}
|
||||
);
|
||||
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
|
||||
return typeof value === "string" ? fromEditorPath(value) : value;
|
||||
});
|
||||
|
||||
return originalOnMessage.call(
|
||||
this,
|
||||
isStringMessage
|
||||
? processedMessageJSON
|
||||
: JSON.parse(processedMessageJSON)
|
||||
);
|
||||
return originalOnMessage.call(this, isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON));
|
||||
},
|
||||
|
||||
send(/** @type {any} */ msg) {
|
||||
|
2
.yarn/sdks/typescript/package.json
vendored
2
.yarn/sdks/typescript/package.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "typescript",
|
||||
"version": "5.1.3-sdk",
|
||||
"version": "5.2.2-sdk",
|
||||
"main": "./lib/typescript.js",
|
||||
"type": "commonjs"
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
yarnPath: .yarn/releases/yarn-3.6.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-3.6.3.cjs
|
||||
|
70
README.md
70
README.md
@ -1,70 +0,0 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `yarn build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
|
5
crowdin.yml
Normal file
5
crowdin.yml
Normal file
@ -0,0 +1,5 @@
|
||||
project_id: 610631
|
||||
preserve_hierarchy: true
|
||||
files:
|
||||
- source: src/lang.json
|
||||
translation: src/translations/%locale_with_underscore%.json
|
55
package.json
55
package.json
@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "stream_ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.1.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@ -16,8 +15,8 @@
|
||||
"@react-hook/resize-observer": "^1.2.6",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@snort/shared": "^1.0.4",
|
||||
"@snort/system": "^1.0.16",
|
||||
"@snort/system-react": "^1.0.11",
|
||||
"@snort/system": "^1.0.17",
|
||||
"@snort/system-react": "^1.0.12",
|
||||
"@szhsin/react-menu": "^4.0.2",
|
||||
"@testing-library/jest-dom": "^5.14.1",
|
||||
"@testing-library/react": "^13.0.0",
|
||||
@ -39,22 +38,26 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-intersection-observer": "^9.5.1",
|
||||
"react-intl": "^6.4.4",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-router-dom": "^6.13.0",
|
||||
"react-tag-input-component": "^2.0.2",
|
||||
"semantic-sdp": "^3.26.2",
|
||||
"semantic-sdp": "^3.26.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"web-vitals": "^2.1.0",
|
||||
"webrtc-adapter": "^8.2.3",
|
||||
"workbox-core": "^7.0.0",
|
||||
"workbox-precaching": "^7.0.0",
|
||||
"workbox-routing": "^7.0.0",
|
||||
"workbox-strategies": "^7.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "webpack serve",
|
||||
"build": "webpack --node-env=production",
|
||||
"start": "webpack serve --node-env=development --mode=development",
|
||||
"build": "webpack --node-env=production --mode=production",
|
||||
"deploy": "__XXX='false' && yarn build && npx wrangler pages publish --project-name nostr-live build",
|
||||
"deploy:xxzap": "__XXX='true' && yarn build && npx wrangler pages publish --project-name xxzap build"
|
||||
"deploy:xxzap": "__XXX='true' && yarn build && npx wrangler pages publish --project-name xxzap build",
|
||||
"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"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
@ -77,29 +80,26 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.9",
|
||||
"@babel/core": "^7.22.11",
|
||||
"@babel/plugin-syntax-import-assertions": "^7.20.0",
|
||||
"@babel/preset-env": "^7.21.5",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@babel/preset-typescript": "^7.22.5",
|
||||
"@formatjs/cli": "^6.0.1",
|
||||
"@formatjs/ts-transformer": "^3.13.1",
|
||||
"@formatjs/cli": "^6.1.3",
|
||||
"@formatjs/ts-transformer": "^3.13.3",
|
||||
"@testing-library/dom": "^9.3.1",
|
||||
"@types/lodash": "^4.14.195",
|
||||
"@types/lodash.uniqby": "^4.7.7",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react": "^18.2.21",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-helmet": "^6.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
||||
"@typescript-eslint/parser": "^6.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"@webbtc/webln-types": "^1.0.12",
|
||||
"babel-loader": "^9.1.2",
|
||||
"babel-plugin-formatjs": "^10.5.3",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"css-loader": "^6.7.3",
|
||||
"css-loader": "^6.8.1",
|
||||
"css-minimizer-webpack-plugin": "^5.0.0",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-formatjs": "^4.10.1",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-webpack-plugin": "^4.0.1",
|
||||
"html-webpack-plugin": "^5.5.1",
|
||||
"mini-css-extract-plugin": "^2.7.5",
|
||||
@ -108,12 +108,17 @@
|
||||
"source-map-loader": "^4.0.1",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"ts-loader": "^9.4.4",
|
||||
"typescript": "^5.1.3",
|
||||
"webpack": "^5.82.1",
|
||||
"typescript": "^5.2.2",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-bundle-analyzer": "^4.8.0",
|
||||
"webpack-cli": "^5.1.1",
|
||||
"webpack-dev-server": "^4.15.0",
|
||||
"workbox-webpack-plugin": "^6.5.4"
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^4.15.1",
|
||||
"workbox-webpack-plugin": "^7.0.0"
|
||||
},
|
||||
"packageManager": "yarn@3.6.1"
|
||||
"packageManager": "yarn@3.6.3",
|
||||
"prettier": {
|
||||
"printWidth": 120,
|
||||
"bracketSameLine": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,6 @@
|
||||
import "./event.css";
|
||||
|
||||
import {
|
||||
type NostrLink,
|
||||
type NostrEvent as NostrEventType,
|
||||
EventKind,
|
||||
} from "@snort/system";
|
||||
import { type NostrLink, type NostrEvent as NostrEventType, EventKind } from "@snort/system";
|
||||
|
||||
import { Icon } from "element/icon";
|
||||
import { Goal } from "element/goal";
|
||||
|
@ -2,8 +2,7 @@ import "./async-button.css";
|
||||
import { useState } from "react";
|
||||
import Spinner from "element/spinner";
|
||||
|
||||
interface AsyncButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
disabled?: boolean;
|
||||
onClick(e: React.MouseEvent): Promise<void> | void;
|
||||
children?: React.ReactNode;
|
||||
@ -29,15 +28,8 @@ export default function AsyncButton(props: AsyncButtonProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || props.disabled}
|
||||
{...props}
|
||||
onClick={handle}
|
||||
>
|
||||
<span style={{ visibility: loading ? "hidden" : "visible" }}>
|
||||
{props.children}
|
||||
</span>
|
||||
<button type="button" disabled={loading || props.disabled} {...props} onClick={handle}>
|
||||
<span style={{ visibility: loading ? "hidden" : "visible" }}>{props.children}</span>
|
||||
{loading && (
|
||||
<span className="spinner-wrapper">
|
||||
<Spinner />
|
||||
|
@ -1,17 +1,5 @@
|
||||
import { MetadataCache } from "@snort/system";
|
||||
|
||||
export function Avatar({
|
||||
user,
|
||||
avatarClassname,
|
||||
}: {
|
||||
user: MetadataCache;
|
||||
avatarClassname: string;
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
className={avatarClassname}
|
||||
alt={user?.name || user?.pubkey}
|
||||
src={user?.picture ?? ""}
|
||||
/>
|
||||
);
|
||||
export function Avatar({ user, avatarClassname }: { user: MetadataCache; avatarClassname: string }) {
|
||||
return <img className={avatarClassname} alt={user?.name || user?.pubkey} src={user?.picture ?? ""} />;
|
||||
}
|
||||
|
@ -12,9 +12,7 @@ export function Badge({ ev }: { ev: NostrEvent }) {
|
||||
<img className="badge-thumbnail" src={thumb || image} alt={name} />
|
||||
<div className="badge-details">
|
||||
<h4 className="badge-name">{name}</h4>
|
||||
{description?.length > 0 && (
|
||||
<p className="badge-description">{description}</p>
|
||||
)}
|
||||
{description?.length > 0 && <p className="badge-description">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,12 +1,7 @@
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { useUserProfile, SnortContext } from "@snort/system-react";
|
||||
import { NostrEvent, parseZap, EventKind } from "@snort/system";
|
||||
import React, { useRef, useState, useMemo } from "react";
|
||||
import {
|
||||
useMediaQuery,
|
||||
useHover,
|
||||
useOnClickOutside,
|
||||
useIntersectionObserver,
|
||||
} from "usehooks-ts";
|
||||
import React, { useRef, useState, useMemo, useContext } from "react";
|
||||
import { useMediaQuery, useHover, useOnClickOutside, useIntersectionObserver } from "usehooks-ts";
|
||||
|
||||
import { EmojiPicker } from "element/emoji-picker";
|
||||
import { Icon } from "element/icon";
|
||||
@ -20,7 +15,6 @@ import { useLogin } from "hooks/login";
|
||||
import { formatSats } from "number";
|
||||
import { findTag } from "utils";
|
||||
import type { Badge, Emoji, EmojiPack } from "types";
|
||||
import { System } from "index";
|
||||
|
||||
function emojifyReaction(reaction: string) {
|
||||
if (reaction === "+") {
|
||||
@ -60,36 +54,31 @@ export function ChatMessage({
|
||||
const [showZapDialog, setShowZapDialog] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const login = useLogin();
|
||||
const profile = useUserProfile(
|
||||
System,
|
||||
inView?.isIntersecting ? ev.pubkey : undefined
|
||||
);
|
||||
const shouldShowMuteButton =
|
||||
ev.pubkey !== streamer && ev.pubkey !== login?.pubkey;
|
||||
const profile = useUserProfile(inView?.isIntersecting ? ev.pubkey : undefined);
|
||||
const shouldShowMuteButton = ev.pubkey !== streamer && ev.pubkey !== login?.pubkey;
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
const system = useContext(SnortContext);
|
||||
const zaps = useMemo(() => {
|
||||
return reactions
|
||||
.filter((a) => a.kind === EventKind.ZapReceipt)
|
||||
.map((a) => parseZap(a, System.ProfileLoader.Cache))
|
||||
.filter((a) => a && a.valid);
|
||||
.filter(a => a.kind === EventKind.ZapReceipt)
|
||||
.map(a => parseZap(a, system.ProfileLoader.Cache))
|
||||
.filter(a => a && a.valid);
|
||||
}, [reactions]);
|
||||
const emojiReactions = useMemo(() => {
|
||||
const emojified = reactions
|
||||
.filter((e) => e.kind === EventKind.Reaction && findTag(e, "e") === ev.id)
|
||||
.map((ev) => emojifyReaction(ev.content));
|
||||
.filter(e => e.kind === EventKind.Reaction && findTag(e, "e") === ev.id)
|
||||
.map(ev => emojifyReaction(ev.content));
|
||||
return [...new Set(emojified)];
|
||||
}, [ev, reactions]);
|
||||
const emojiNames = emojiPacks.map((p) => p.emojis).flat();
|
||||
const emojiNames = emojiPacks.map(p => p.emojis).flat();
|
||||
|
||||
const hasReactions = emojiReactions.length > 0;
|
||||
const totalZaps = useMemo(() => {
|
||||
const messageZaps = zaps.filter((z) => z.event === ev.id);
|
||||
const messageZaps = zaps.filter(z => z.event === ev.id);
|
||||
return messageZaps.reduce((acc, z) => acc + z.amount, 0);
|
||||
}, [zaps, ev]);
|
||||
const hasZaps = totalZaps > 0;
|
||||
const awardedBadges = badges.filter(
|
||||
(b) => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey)
|
||||
);
|
||||
const awardedBadges = badges.filter(b => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey));
|
||||
|
||||
useOnClickOutside(ref, () => {
|
||||
setShowZapDialog(false);
|
||||
@ -100,7 +89,7 @@ export function ChatMessage({
|
||||
});
|
||||
|
||||
function getEmojiById(id: string) {
|
||||
return emojiNames.find((e) => e.at(1) === id);
|
||||
return emojiNames.find(e => e.at(1) === id);
|
||||
}
|
||||
|
||||
async function onEmojiSelect(emoji: Emoji) {
|
||||
@ -114,7 +103,7 @@ export function ChatMessage({
|
||||
} else if (emoji.id) {
|
||||
const e = getEmojiById(emoji.id);
|
||||
if (e) {
|
||||
reply = await pub?.generic((eb) => {
|
||||
reply = await pub?.generic(eb => {
|
||||
return eb
|
||||
.kind(EventKind.Reaction)
|
||||
.content(`:${emoji.id}:`)
|
||||
@ -126,7 +115,7 @@ export function ChatMessage({
|
||||
}
|
||||
if (reply) {
|
||||
console.debug(reply);
|
||||
System.BroadcastEvent(reply);
|
||||
system.BroadcastEvent(reply);
|
||||
}
|
||||
} catch {
|
||||
//ignore
|
||||
@ -148,23 +137,15 @@ export function ChatMessage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`message${streamer === ev.pubkey ? " streamer" : ""}`}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={`message${streamer === ev.pubkey ? " streamer" : ""}`} ref={ref}>
|
||||
<Profile
|
||||
icon={
|
||||
ev.pubkey === streamer ? (
|
||||
<Icon name="signal" size={16} />
|
||||
) : (
|
||||
awardedBadges.map((badge) => {
|
||||
awardedBadges.map(badge => {
|
||||
return (
|
||||
<img
|
||||
key={badge.name}
|
||||
className="badge-icon"
|
||||
src={badge.thumb || badge.image}
|
||||
alt={badge.name}
|
||||
/>
|
||||
<img key={badge.name} className="badge-icon" src={badge.thumb || badge.image} alt={badge.name} />
|
||||
);
|
||||
})
|
||||
)
|
||||
@ -172,11 +153,7 @@ export function ChatMessage({
|
||||
pubkey={ev.pubkey}
|
||||
profile={profile}
|
||||
/>
|
||||
<Text
|
||||
tags={ev.tags}
|
||||
content={ev.content}
|
||||
customComponents={customComponents}
|
||||
/>
|
||||
<Text tags={ev.tags} content={ev.content} customComponents={customComponents} />
|
||||
{(hasReactions || hasZaps) && (
|
||||
<div className="message-reactions">
|
||||
{hasZaps && (
|
||||
@ -185,9 +162,8 @@ export function ChatMessage({
|
||||
<span className="zap-pill-amount">{formatSats(totalZaps)}</span>
|
||||
</div>
|
||||
)}
|
||||
{emojiReactions.map((e) => {
|
||||
const isCustomEmojiReaction =
|
||||
e.length > 1 && e.startsWith(":") && e.endsWith(":");
|
||||
{emojiReactions.map(e => {
|
||||
const isCustomEmojiReaction = e.length > 1 && e.startsWith(":") && e.endsWith(":");
|
||||
const emojiName = e.replace(/:/g, "");
|
||||
const emoji = isCustomEmojiReaction && getEmojiById(emojiName);
|
||||
return (
|
||||
@ -217,11 +193,9 @@ export function ChatMessage({
|
||||
top: topOffset ? topOffset - 12 : 0,
|
||||
left: leftOffset ? leftOffset - 32 : 0,
|
||||
opacity: showZapDialog || isHovering ? 1 : 0,
|
||||
pointerEvents:
|
||||
showZapDialog || isHovering ? "auto" : "none",
|
||||
pointerEvents: showZapDialog || isHovering ? "auto" : "none",
|
||||
}
|
||||
}
|
||||
>
|
||||
}>
|
||||
{zapTarget && (
|
||||
<SendZapsDialog
|
||||
lnurl={zapTarget}
|
||||
|
@ -2,6 +2,7 @@ import "./collapsible.css";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
|
||||
@ -31,7 +32,7 @@ export function MediaURL({ url, children }: MediaURLProps) {
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button className="btn delete-button" aria-label="Close">
|
||||
Close
|
||||
<FormattedMessage defaultMessage="Close" />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</Dialog.Content>
|
||||
@ -46,29 +47,19 @@ export function CollapsibleEvent({ link }: { link: NostrLink }) {
|
||||
const author = event?.pubkey || link.author;
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
className="collapsible"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Collapsible.Root className="collapsible" open={open} onOpenChange={setOpen}>
|
||||
<div className="collapsed-event">
|
||||
<div className="collapsed-event-header">
|
||||
{event && <EventIcon kind={event.kind} />}
|
||||
{author && <Mention pubkey={author} />}
|
||||
</div>
|
||||
<Collapsible.Trigger asChild>
|
||||
<button
|
||||
className={`${
|
||||
open ? "btn btn-small delete-button" : "btn btn-small"
|
||||
}`}
|
||||
>
|
||||
{open ? "Hide" : "Show"}
|
||||
<button className={`${open ? "btn btn-small delete-button" : "btn btn-small"}`}>
|
||||
{open ? <FormattedMessage defaultMessage="Hide" /> : <FormattedMessage defaultMessage="Show" />}
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
{open && event && <NostrEvent ev={event} />}
|
||||
</Collapsible.Content>
|
||||
<Collapsible.Content>{open && event && <NostrEvent ev={event} />}</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function isContentWarningAccepted() {
|
||||
@ -17,14 +18,18 @@ export function ContentWarningOverlay() {
|
||||
|
||||
return (
|
||||
<div className="fullscreen-exclusive age-check">
|
||||
<h1>Sexually explicit material ahead!</h1>
|
||||
<h2>Confirm your age</h2>
|
||||
<h1>
|
||||
<FormattedMessage defaultMessage="Sexually explicit material ahead!" />
|
||||
</h1>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Confirm your age" />
|
||||
</h2>
|
||||
<div className="flex g24">
|
||||
<button className="btn btn-warning" onClick={grownUp}>
|
||||
Yes, I am over 18
|
||||
<FormattedMessage defaultMessage="Yes, I am over 18" />
|
||||
</button>
|
||||
<button className="btn" onClick={() => navigate("/")}>
|
||||
No, I am under 18
|
||||
<FormattedMessage defaultMessage="No, I am under 18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -11,26 +11,13 @@ export interface CopyProps {
|
||||
export default function Copy({ text, maxSize = 32, className, hideText }: CopyProps) {
|
||||
const { copy, copied } = useCopy();
|
||||
const sliceLength = maxSize / 2;
|
||||
const trimmed =
|
||||
text.length > maxSize
|
||||
? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}`
|
||||
: text;
|
||||
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`copy${className ? ` ${className}` : ""}`}
|
||||
onClick={() => copy(text)}
|
||||
>
|
||||
<div className={`copy${className ? ` ${className}` : ""}`} onClick={() => copy(text)}>
|
||||
{!hideText && <span className="body">{trimmed}</span>}
|
||||
<span
|
||||
className="icon"
|
||||
style={{ color: copied ? "var(--success)" : "var(--highlight)" }}
|
||||
>
|
||||
{copied ? (
|
||||
<Icon name="check" size={14} />
|
||||
) : (
|
||||
<Icon name="copy" size={14} />
|
||||
)}
|
||||
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
|
||||
{copied ? <Icon name="check" size={14} /> : <Icon name="copy" size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
@ -8,28 +8,24 @@ import { findTag } from "utils";
|
||||
import { USER_EMOJIS } from "const";
|
||||
import { Login, System } from "index";
|
||||
import type { EmojiPack as EmojiPackType } from "types";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function EmojiPack({ ev }: { ev: NostrEvent }) {
|
||||
const login = useLogin();
|
||||
const name = findTag(ev, "d");
|
||||
const isUsed = login?.emojis.find(
|
||||
(e) => e.author === ev.pubkey && e.name === name
|
||||
);
|
||||
const emoji = ev.tags.filter((e) => e.at(0) === "emoji");
|
||||
const isUsed = login?.emojis.find(e => e.author === ev.pubkey && e.name === name);
|
||||
const emoji = ev.tags.filter(e => e.at(0) === "emoji");
|
||||
|
||||
async function toggleEmojiPack() {
|
||||
let newPacks = [] as EmojiPackType[];
|
||||
if (isUsed) {
|
||||
newPacks =
|
||||
login?.emojis.filter(
|
||||
(e) => e.author !== ev.pubkey && e.name !== name
|
||||
) ?? [];
|
||||
newPacks = login?.emojis.filter(e => e.author !== ev.pubkey && e.name !== name) ?? [];
|
||||
} else {
|
||||
newPacks = [...(login?.emojis ?? []), toEmojiPack(ev)];
|
||||
}
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const ev = await pub.generic((eb) => {
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(USER_EMOJIS).content("");
|
||||
for (const e of newPacks) {
|
||||
eb.tag(["a", e.address]);
|
||||
@ -48,17 +44,14 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
|
||||
<h4>{name}</h4>
|
||||
{login?.pubkey && (
|
||||
<AsyncButton
|
||||
className={`btn btn-small btn-primary ${
|
||||
isUsed ? "delete-button" : ""
|
||||
}`}
|
||||
onClick={toggleEmojiPack}
|
||||
>
|
||||
{isUsed ? "Remove" : "Add"}
|
||||
className={`btn btn-small btn-primary ${isUsed ? "delete-button" : ""}`}
|
||||
onClick={toggleEmojiPack}>
|
||||
{isUsed ? <FormattedMessage defaultMessage="Remove" /> : <FormattedMessage defaultMessage="Add" />}
|
||||
</AsyncButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="emoji-pack-emojis">
|
||||
{emoji.map((e) => {
|
||||
{emoji.map(e => {
|
||||
const [, name, image] = e;
|
||||
return (
|
||||
<div className="emoji-definition">
|
||||
|
@ -22,11 +22,11 @@ export function EmojiPicker({
|
||||
height = 300,
|
||||
ref,
|
||||
}: EmojiPickerProps) {
|
||||
const customEmojiList = emojiPacks.map((pack) => {
|
||||
const customEmojiList = emojiPacks.map(pack => {
|
||||
return {
|
||||
id: pack.address,
|
||||
name: pack.name,
|
||||
emojis: pack.emojis.map((e) => {
|
||||
emojis: pack.emojis.map(e => {
|
||||
const [, name, url] = e;
|
||||
return {
|
||||
id: name,
|
||||
@ -45,8 +45,7 @@ export function EmojiPicker({
|
||||
left: leftOffset,
|
||||
zIndex: 1,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
ref={ref}>
|
||||
<style>
|
||||
{`
|
||||
em-emoji-picker { max-height: ${height}px; }
|
||||
|
@ -11,16 +11,10 @@ export function Emoji({ name, url }: EmojiProps) {
|
||||
return <img alt={name} src={url} className="emoji" />;
|
||||
}
|
||||
|
||||
export function Emojify({
|
||||
content,
|
||||
emoji,
|
||||
}: {
|
||||
content: string;
|
||||
emoji: EmojiTag[];
|
||||
}) {
|
||||
export function Emojify({ content, emoji }: { content: string; emoji: EmojiTag[] }) {
|
||||
const emojified = useMemo(() => {
|
||||
return content.split(/:(\w+):/g).map((i) => {
|
||||
const t = emoji.find((t) => t[1] === i);
|
||||
return content.split(/:(\w+):/g).map(i => {
|
||||
const t = emoji.find(t => t[1] === i);
|
||||
if (t) {
|
||||
return <Emoji name={t[1]} url={t[2]} />;
|
||||
} else {
|
||||
|
@ -19,19 +19,10 @@ interface ExternalIconLinkProps extends Omit<ExternalLinkProps, "children"> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function ExternalIconLink({
|
||||
size = 32,
|
||||
href,
|
||||
...rest
|
||||
}: ExternalIconLinkProps) {
|
||||
export function ExternalIconLink({ size = 32, href, ...rest }: ExternalIconLinkProps) {
|
||||
return (
|
||||
<span style={{ cursor: "pointer" }}>
|
||||
<Icon
|
||||
name="link"
|
||||
size={size}
|
||||
onClick={() => window.open(href, "_blank")}
|
||||
{...rest}
|
||||
/>
|
||||
<Icon name="link" size={size} onClick={() => window.open(href, "_blank")} {...rest} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import "./file-uploader.css";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { VoidApi } from "@void-cat/api";
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const voidCatHost = "https://void.cat";
|
||||
const fileExtensionRegex = /\.([\w]{1,7})$/i;
|
||||
@ -23,9 +24,7 @@ async function voidCatUpload(file: File | Blob): Promise<UploadResult> {
|
||||
if (rsp.file?.metadata?.mimeType === "image/webp") {
|
||||
ext = ["", "webp"];
|
||||
}
|
||||
const resultUrl =
|
||||
rsp.file?.metadata?.url ??
|
||||
`${voidCatHost}/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`;
|
||||
const resultUrl = rsp.file?.metadata?.url ?? `${voidCatHost}/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`;
|
||||
|
||||
const ret = {
|
||||
url: resultUrl,
|
||||
@ -45,11 +44,7 @@ interface FileUploaderProps {
|
||||
onFileUpload(url: string): void;
|
||||
}
|
||||
|
||||
export function FileUploader({
|
||||
defaultImage,
|
||||
onClear,
|
||||
onFileUpload,
|
||||
}: FileUploaderProps) {
|
||||
export function FileUploader({ defaultImage, onClear, onFileUpload }: FileUploaderProps) {
|
||||
const [img, setImg] = useState<string>(defaultImage ?? "");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
@ -88,7 +83,7 @@ export function FileUploader({
|
||||
<div className="file-uploader-preview">
|
||||
{img?.length > 0 && (
|
||||
<button className="btn btn-primary clear-button" onClick={clearImage}>
|
||||
Clear
|
||||
<FormattedMessage defaultMessage="Clear" />
|
||||
</button>
|
||||
)}
|
||||
{img && <img className="image-preview" src={img} />}
|
||||
|
@ -3,26 +3,21 @@ import { EventKind } from "@snort/system";
|
||||
import { useLogin } from "hooks/login";
|
||||
import AsyncButton from "element/async-button";
|
||||
import { Login, System } from "index";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function LoggedInFollowButton({
|
||||
tag,
|
||||
value,
|
||||
}: {
|
||||
tag: "p" | "t";
|
||||
value: string;
|
||||
}) {
|
||||
export function LoggedInFollowButton({ tag, value }: { tag: "p" | "t"; value: string }) {
|
||||
const login = useLogin();
|
||||
if (!login) return;
|
||||
|
||||
const { tags, content, timestamp } = login.follows;
|
||||
const follows = tags.filter((t) => t.at(0) === tag);
|
||||
const isFollowing = follows.find((t) => t.at(1) === value);
|
||||
const follows = tags.filter(t => t.at(0) === tag);
|
||||
const isFollowing = follows.find(t => t.at(1) === value);
|
||||
|
||||
async function unfollow() {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const newFollows = tags.filter((t) => t.at(1) !== value);
|
||||
const ev = await pub.generic((eb) => {
|
||||
const newFollows = tags.filter(t => t.at(1) !== value);
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(EventKind.ContactList).content(content ?? "");
|
||||
for (const t of newFollows) {
|
||||
eb.tag(t);
|
||||
@ -39,7 +34,7 @@ export function LoggedInFollowButton({
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const newFollows = [...tags, [tag, value]];
|
||||
const ev = await pub.generic((eb) => {
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(EventKind.ContactList).content(content ?? "");
|
||||
for (const tag of newFollows) {
|
||||
eb.tag(tag);
|
||||
@ -57,9 +52,8 @@ export function LoggedInFollowButton({
|
||||
disabled={timestamp ? timestamp === 0 : true}
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={isFollowing ? unfollow : follow}
|
||||
>
|
||||
{isFollowing ? "Unfollow" : "Follow"}
|
||||
onClick={isFollowing ? unfollow : follow}>
|
||||
{isFollowing ? <FormattedMessage defaultMessage="Unfollow" /> : <FormattedMessage defaultMessage="Follow" />}
|
||||
</AsyncButton>
|
||||
);
|
||||
}
|
||||
@ -71,7 +65,5 @@ export function FollowTagButton({ tag }: { tag: string }) {
|
||||
|
||||
export function FollowButton({ pubkey }: { pubkey: string }) {
|
||||
const login = useLogin();
|
||||
return login?.pubkey ? (
|
||||
<LoggedInFollowButton tag={"p"} value={pubkey} />
|
||||
) : null;
|
||||
return login?.pubkey ? <LoggedInFollowButton tag={"p"} value={pubkey} /> : null;
|
||||
}
|
||||
|
@ -12,11 +12,11 @@ import usePreviousValue from "hooks/usePreviousValue";
|
||||
import { SendZapsDialog } from "element/send-zap";
|
||||
import { useZaps } from "hooks/goals";
|
||||
import { getName } from "element/profile";
|
||||
import { System } from "index";
|
||||
import { Icon } from "./icon";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function Goal({ ev }: { ev: NostrEvent }) {
|
||||
const profile = useUserProfile(System, ev.pubkey);
|
||||
const profile = useUserProfile(ev.pubkey);
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
const zaps = useZaps(ev, true);
|
||||
const goalAmount = useMemo(() => {
|
||||
@ -29,9 +29,7 @@ export function Goal({ ev }: { ev: NostrEvent }) {
|
||||
}
|
||||
|
||||
const soFar = useMemo(() => {
|
||||
return zaps
|
||||
.filter((z) => z.receiver === ev.pubkey && z.event === ev.id)
|
||||
.reduce((acc, z) => acc + z.amount, 0);
|
||||
return zaps.filter(z => z.receiver === ev.pubkey && z.event === ev.id).reduce((acc, z) => acc + z.amount, 0);
|
||||
}, [zaps]);
|
||||
|
||||
const progress = Math.max(0, Math.min(100, (soFar / goalAmount) * 100));
|
||||
@ -43,26 +41,18 @@ export function Goal({ ev }: { ev: NostrEvent }) {
|
||||
{ev.content.length > 0 && <p>{ev.content}</p>}
|
||||
<div className={`progress-container ${isFinished ? "finished" : ""}`}>
|
||||
<Progress.Root className="progress-root" value={progress}>
|
||||
<Progress.Indicator
|
||||
className="progress-indicator"
|
||||
style={{ transform: `translateX(-${100 - progress}%)` }}
|
||||
>
|
||||
{!isFinished && (
|
||||
<span className="amount so-far">{formatSats(soFar)}</span>
|
||||
)}
|
||||
<Progress.Indicator className="progress-indicator" style={{ transform: `translateX(-${100 - progress}%)` }}>
|
||||
{!isFinished && <span className="amount so-far">{formatSats(soFar)}</span>}
|
||||
</Progress.Indicator>
|
||||
<span className="amount target">Goal: {formatSats(goalAmount)}</span>
|
||||
<span className="amount target">
|
||||
<FormattedMessage defaultMessage="Goal: {amount}" values={{ amount: formatSats(goalAmount) }} />
|
||||
</span>
|
||||
</Progress.Root>
|
||||
<div className="zap-circle">
|
||||
<Icon
|
||||
name="zap-filled"
|
||||
className={isFinished ? "goal-finished" : "goal-unfinished"}
|
||||
/>
|
||||
<Icon name="zap-filled" className={isFinished ? "goal-finished" : "goal-unfinished"} />
|
||||
</div>
|
||||
</div>
|
||||
{isFinished && previousValue === false && (
|
||||
<Confetti numberOfPieces={2100} recycle={false} />
|
||||
)}
|
||||
{isFinished && previousValue === false && <Confetti numberOfPieces={2100} recycle={false} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -12,8 +12,7 @@ interface HyperTextProps {
|
||||
export function HyperText({ link, children }: HyperTextProps) {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
const extension =
|
||||
FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
|
||||
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
|
||||
|
||||
if (extension) {
|
||||
switch (extension) {
|
||||
@ -25,11 +24,7 @@ export function HyperText({ link, children }: HyperTextProps) {
|
||||
case "webp": {
|
||||
return (
|
||||
<MediaURL url={url}>
|
||||
<img
|
||||
src={url.toString()}
|
||||
alt={url.toString()}
|
||||
style={{ objectFit: "contain" }}
|
||||
/>
|
||||
<img src={url.toString()} alt={url.toString()} style={{ objectFit: "contain" }} />
|
||||
</MediaURL>
|
||||
);
|
||||
}
|
||||
|
@ -12,12 +12,7 @@ export function Icon(props: Props) {
|
||||
const href = `/icons.svg#` + props.name;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={props.className}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<svg width={size} height={size} className={props.className} onClick={props.onClick}>
|
||||
<use href={href} />
|
||||
</svg>
|
||||
);
|
||||
|
@ -203,15 +203,7 @@
|
||||
}
|
||||
|
||||
.zap-container.big-zap:before {
|
||||
background: linear-gradient(
|
||||
60deg,
|
||||
#2bd9ff,
|
||||
#8c8ded,
|
||||
#f838d9,
|
||||
#f83838,
|
||||
#ff902b,
|
||||
#ddf838
|
||||
);
|
||||
background: linear-gradient(60deg, #2bd9ff, #8c8ded, #f838d9, #f83838, #ff902b, #ddf838);
|
||||
animation: animatedgradient 3s ease alternate infinite;
|
||||
background-size: 300% 300%;
|
||||
}
|
||||
|
@ -1,13 +1,5 @@
|
||||
import "./live-chat.css";
|
||||
import {
|
||||
EventKind,
|
||||
NostrPrefix,
|
||||
NostrLink,
|
||||
ParsedZap,
|
||||
NostrEvent,
|
||||
parseZap,
|
||||
encodeTLV,
|
||||
} from "@snort/system";
|
||||
import { EventKind, NostrPrefix, NostrLink, ParsedZap, NostrEvent, parseZap, encodeTLV } from "@snort/system";
|
||||
import { unixNow, unwrap } from "@snort/shared";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import uniqBy from "lodash.uniqby";
|
||||
@ -32,6 +24,7 @@ import { formatSats } from "number";
|
||||
import { WEEK, LIVE_STREAM_CHAT } from "const";
|
||||
import { findTag, getTagValues, getHost } from "utils";
|
||||
import { System } from "index";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export interface LiveChatOptions {
|
||||
canWrite?: boolean;
|
||||
@ -48,7 +41,7 @@ function BadgeAward({ ev }: { ev: NostrEvent }) {
|
||||
{event && <Badge ev={event} />}
|
||||
<p>awarded to</p>
|
||||
<div className="badge-awardees">
|
||||
{awardees.map((pk) => (
|
||||
{awardees.map(pk => (
|
||||
<Profile key={pk} pubkey={pk} />
|
||||
))}
|
||||
</div>
|
||||
@ -95,9 +88,7 @@ export function LiveChat({
|
||||
const feed = useLiveChatFeed(link, goal ? [goal.id] : undefined);
|
||||
const login = useLogin();
|
||||
useEffect(() => {
|
||||
const pubkeys = [
|
||||
...new Set(feed.zaps.flatMap((a) => [a.pubkey, unwrap(findTag(a, "p"))])),
|
||||
];
|
||||
const pubkeys = [...new Set(feed.zaps.flatMap(a => [a.pubkey, unwrap(findTag(a, "p"))]))];
|
||||
System.ProfileLoader.TrackMetadata(pubkeys);
|
||||
return () => System.ProfileLoader.UntrackMetadata(pubkeys);
|
||||
}, [feed.zaps]);
|
||||
@ -116,54 +107,40 @@ export function LiveChat({
|
||||
return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId);
|
||||
}, [userEmojiPacks, channelEmojiPacks]);
|
||||
|
||||
const zaps = feed.zaps
|
||||
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
||||
.filter((z) => z && z.valid);
|
||||
const zaps = feed.zaps.map(ev => parseZap(ev, System.ProfileLoader.Cache)).filter(z => z && z.valid);
|
||||
const events = useMemo(() => {
|
||||
return [...feed.messages, ...feed.zaps, ...awards].sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
return [...feed.messages, ...feed.zaps, ...awards].sort((a, b) => b.created_at - a.created_at);
|
||||
}, [feed.messages, feed.zaps, awards]);
|
||||
const streamer = getHost(ev);
|
||||
const naddr = useMemo(() => {
|
||||
if (ev) {
|
||||
return encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
findTag(ev, "d") ?? "",
|
||||
undefined,
|
||||
ev.kind,
|
||||
ev.pubkey
|
||||
);
|
||||
return encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev.kind, ev.pubkey);
|
||||
}
|
||||
}, [ev]);
|
||||
const filteredEvents = useMemo(() => {
|
||||
return events.filter(
|
||||
(e) => !mutedPubkeys.has(e.pubkey) && !hostMutedPubkeys.has(e.pubkey)
|
||||
);
|
||||
return events.filter(e => !mutedPubkeys.has(e.pubkey) && !hostMutedPubkeys.has(e.pubkey));
|
||||
}, [events, mutedPubkeys, hostMutedPubkeys]);
|
||||
|
||||
return (
|
||||
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
|
||||
{(options?.showHeader ?? true) && (
|
||||
<div className="header">
|
||||
<h2 className="title">Stream Chat</h2>
|
||||
<h2 className="title">
|
||||
<FormattedMessage defaultMessage="Stream Chat" />
|
||||
</h2>
|
||||
<Icon
|
||||
name="link"
|
||||
className="secondary"
|
||||
size={32}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/chat/${naddr}?chat=true`,
|
||||
"_blank",
|
||||
"popup,width=400,height=800"
|
||||
)
|
||||
}
|
||||
onClick={() => window.open(`/chat/${naddr}?chat=true`, "_blank", "popup,width=400,height=800")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{zaps.length > 0 && (
|
||||
<div className="top-zappers">
|
||||
<h3>Top zappers</h3>
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Top zappers" />
|
||||
</h3>
|
||||
<div className="top-zappers-container">
|
||||
<TopZappers zaps={zaps} />
|
||||
</div>
|
||||
@ -172,7 +149,7 @@ export function LiveChat({
|
||||
</div>
|
||||
)}
|
||||
<div className="messages">
|
||||
{filteredEvents.map((a) => {
|
||||
{filteredEvents.map(a => {
|
||||
switch (a.kind) {
|
||||
case EventKind.BadgeAward: {
|
||||
return <BadgeAward ev={a} />;
|
||||
@ -190,9 +167,7 @@ export function LiveChat({
|
||||
);
|
||||
}
|
||||
case EventKind.ZapReceipt: {
|
||||
const zap = zaps.find(
|
||||
(b) => b.id === a.id && b.receiver === streamer
|
||||
);
|
||||
const zap = zaps.find(b => b.id === a.id && b.receiver === streamer);
|
||||
if (zap) {
|
||||
return <ChatZap zap={zap} key={a.id} />;
|
||||
}
|
||||
@ -207,7 +182,9 @@ export function LiveChat({
|
||||
{login ? (
|
||||
<WriteMessage emojiPacks={allEmojiPacks} link={link} />
|
||||
) : (
|
||||
<p>Please login to write messages!</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Please login to write messages!" />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -227,16 +204,21 @@ function ChatZap({ zap }: { zap: ParsedZap }) {
|
||||
<div className={`zap-container ${isBig ? "big-zap" : ""}`}>
|
||||
<div className="zap">
|
||||
<Icon name="zap-filled" className="zap-icon" />
|
||||
<Profile
|
||||
pubkey={zap.anonZap ? "anon" : zap.sender ?? "anon"}
|
||||
options={{
|
||||
showAvatar: !zap.anonZap,
|
||||
overrideName: zap.anonZap ? "Anon" : undefined,
|
||||
<FormattedMessage
|
||||
defaultMessage="{person} zapped {amount} sats"
|
||||
values={{
|
||||
person: (
|
||||
<Profile
|
||||
pubkey={zap.anonZap ? "anon" : zap.sender ?? "anon"}
|
||||
options={{
|
||||
showAvatar: !zap.anonZap,
|
||||
overrideName: zap.anonZap ? "Anon" : undefined,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
amount: <span className="zap-amount">{formatSats(zap.amount)}</span>,
|
||||
}}
|
||||
/>
|
||||
zapped
|
||||
<span className="zap-amount">{formatSats(zap.amount)}</span>
|
||||
sats
|
||||
</div>
|
||||
{zap.content && (
|
||||
<div className="zap-content">
|
||||
|
@ -75,8 +75,7 @@ export function LiveVideoPlayer(props: VideoPlayerProps) {
|
||||
export function WebRTCPlayer(props: VideoPlayerProps) {
|
||||
const video = useRef<HTMLVideoElement>(null);
|
||||
const streamCached = useMemo(
|
||||
() =>
|
||||
"https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play",
|
||||
() => "https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play",
|
||||
[props.stream]
|
||||
);
|
||||
const [status] = useState<VideoStatus>();
|
||||
@ -90,7 +89,7 @@ export function WebRTCPlayer(props: VideoPlayerProps) {
|
||||
|
||||
client
|
||||
.Play()
|
||||
.then((s) => {
|
||||
.then(s => {
|
||||
if (video.current) {
|
||||
video.current.srcObject = s;
|
||||
}
|
||||
@ -107,12 +106,7 @@ export function WebRTCPlayer(props: VideoPlayerProps) {
|
||||
<div className={status}>
|
||||
<div>{status}</div>
|
||||
</div>
|
||||
<video
|
||||
ref={video}
|
||||
autoPlay={true}
|
||||
poster={props.poster}
|
||||
controls={status === VideoStatus.Online}
|
||||
/>
|
||||
<video ref={video} autoPlay={true} poster={props.poster} controls={status === VideoStatus.Online} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import Copy from "./copy";
|
||||
import { hexToBech32, openFile } from "utils";
|
||||
import { VoidApi } from "@void-cat/api";
|
||||
import { LoginType } from "login";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { bech32 } from "@scure/base";
|
||||
|
||||
enum Stage {
|
||||
@ -87,8 +88,7 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
"V-Strip-Metadata": "true",
|
||||
});
|
||||
if (result.ok) {
|
||||
const resultUrl =
|
||||
result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
|
||||
const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
|
||||
setAvatar(resultUrl);
|
||||
} else {
|
||||
setError(result.errorMessage ?? "Upload failed");
|
||||
@ -115,22 +115,16 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
case Stage.Login: {
|
||||
return (
|
||||
<>
|
||||
<h2>Login</h2>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Login" />
|
||||
</h2>
|
||||
{"nostr" in window && (
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={doLogin}
|
||||
>
|
||||
Nostr Extension
|
||||
<AsyncButton type="button" className="btn btn-primary" onClick={doLogin}>
|
||||
<FormattedMessage defaultMessage="Nostr Extension" />
|
||||
</AsyncButton>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={createAccount}
|
||||
>
|
||||
Create Account
|
||||
<button type="button" className="btn btn-primary" onClick={createAccount}>
|
||||
<FormattedMessage defaultMessage="Create Account" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -146,7 +140,9 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
case Stage.Details: {
|
||||
return (
|
||||
<>
|
||||
<h2>Setup Profile</h2>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Setup Profile" />
|
||||
</h2>
|
||||
<div className="flex f-center">
|
||||
<div
|
||||
className="avatar-input"
|
||||
@ -155,28 +151,20 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
{
|
||||
"--img": `url(${avatar})`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
}>
|
||||
<Icon name="camera-plus" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input type="text" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} />
|
||||
</div>
|
||||
<small>You can change this later</small>
|
||||
<small>
|
||||
<FormattedMessage defaultMessage="You can change this later" />
|
||||
</small>
|
||||
</div>
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={saveProfile}
|
||||
>
|
||||
Save
|
||||
<AsyncButton type="button" className="btn btn-primary" onClick={saveProfile}>
|
||||
<FormattedMessage defaultMessage="Save" />
|
||||
</AsyncButton>
|
||||
</>
|
||||
);
|
||||
@ -184,20 +172,17 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
case Stage.SaveKey: {
|
||||
return (
|
||||
<>
|
||||
<h2>Save Key</h2>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Save Key" />
|
||||
</h2>
|
||||
<p>
|
||||
Nostr uses private keys, please save yours, if you lose this key you
|
||||
wont be able to login to your account anymore!
|
||||
<FormattedMessage defaultMessage="Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore!" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<Copy text={hexToBech32("nsec", key)} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={loginWithKey}
|
||||
>
|
||||
Ok, it's safe
|
||||
<button type="button" className="btn btn-primary" onClick={loginWithKey}>
|
||||
<FormattedMessage defaultMessage="Ok, it's safe" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { System } from "index";
|
||||
import { hexToBech32 } from "utils";
|
||||
|
||||
interface MentionProps {
|
||||
@ -9,7 +8,7 @@ interface MentionProps {
|
||||
}
|
||||
|
||||
export function Mention({ pubkey }: MentionProps) {
|
||||
const user = useUserProfile(System, pubkey);
|
||||
const user = useUserProfile(pubkey);
|
||||
const npub = hexToBech32("npub", pubkey);
|
||||
return <Link to={`/p/${npub}`}>{user?.name || pubkey}</Link>;
|
||||
}
|
||||
|
@ -3,21 +3,19 @@ import { useLogin } from "hooks/login";
|
||||
import AsyncButton from "element/async-button";
|
||||
import { Login, System } from "index";
|
||||
import { MUTED } from "const";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function useMute(pubkey: string) {
|
||||
const login = useLogin();
|
||||
const { tags, content } = login?.muted ?? { tags: [] };
|
||||
const muted = useMemo(() => tags.filter((t) => t.at(0) === "p"), [tags]);
|
||||
const isMuted = useMemo(
|
||||
() => muted.find((t) => t.at(1) === pubkey),
|
||||
[pubkey, muted]
|
||||
);
|
||||
const muted = useMemo(() => tags.filter(t => t.at(0) === "p"), [tags]);
|
||||
const isMuted = useMemo(() => muted.find(t => t.at(1) === pubkey), [pubkey, muted]);
|
||||
|
||||
async function unmute() {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const newMuted = tags.filter((t) => t.at(1) !== pubkey);
|
||||
const ev = await pub.generic((eb) => {
|
||||
const newMuted = tags.filter(t => t.at(1) !== pubkey);
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(MUTED).content(content ?? "");
|
||||
for (const t of newMuted) {
|
||||
eb.tag(t);
|
||||
@ -34,7 +32,7 @@ export function useMute(pubkey: string) {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const newMuted = [...tags, ["p", pubkey]];
|
||||
const ev = await pub.generic((eb) => {
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(MUTED).content(content ?? "");
|
||||
for (const tag of newMuted) {
|
||||
eb.tag(tag);
|
||||
@ -54,12 +52,8 @@ export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
|
||||
const { isMuted, mute, unmute } = useMute(pubkey);
|
||||
|
||||
return (
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn delete-button"
|
||||
onClick={() => (isMuted ? unmute() : mute())}
|
||||
>
|
||||
{isMuted ? "Unmute" : "Mute"}
|
||||
<AsyncButton type="button" className="btn delete-button" onClick={() => (isMuted ? unmute() : mute())}>
|
||||
{isMuted ? <FormattedMessage defaultMessage="Unmute" /> : <FormattedMessage defaultMessage="Mute" />}
|
||||
</AsyncButton>
|
||||
);
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import { useState } from "react";
|
||||
import { System } from "index";
|
||||
import { GOAL } from "const";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface NewGoalDialogProps {
|
||||
link: NostrLink;
|
||||
@ -23,7 +24,7 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
|
||||
async function publishGoal() {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const evNew = await pub.generic((eb) => {
|
||||
const evNew = await pub.generic(eb => {
|
||||
eb.kind(GOAL)
|
||||
.tag(["a", `${link.kind}:${link.author}:${link.id}`])
|
||||
.tag(["amount", String(Number(goalAmount) * 1000)])
|
||||
@ -48,7 +49,9 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
|
||||
<button type="button" className="btn btn-primary">
|
||||
<span>
|
||||
<Icon name="zap-filled" size={12} />
|
||||
<span>Add stream goal</span>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Add stream goal" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
@ -57,26 +60,28 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
|
||||
<Dialog.Content className="dialog-content">
|
||||
<div className="new-goal">
|
||||
<div className="zap-goals">
|
||||
<Icon
|
||||
name="zap-filled"
|
||||
className="stream-zap-goals-icon"
|
||||
size={16}
|
||||
/>
|
||||
<h3>Stream Zap Goals</h3>
|
||||
<Icon name="zap-filled" className="stream-zap-goals-icon" size={16} />
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Stream Zap Goals" />
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<p>Name</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Name" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
value={goalName}
|
||||
placeholder="e.g. New Laptop"
|
||||
onChange={(e) => setGoalName(e.target.value)}
|
||||
onChange={e => setGoalName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Amount</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Amount" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="number"
|
||||
@ -84,18 +89,13 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
|
||||
min="1"
|
||||
max="2100000000000000"
|
||||
value={goalAmount}
|
||||
onChange={(e) => setGoalAmount(e.target.value)}
|
||||
onChange={e => setGoalAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="create-goal">
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-primary wide"
|
||||
disabled={!isValid}
|
||||
onClick={publishGoal}
|
||||
>
|
||||
Create goal
|
||||
<AsyncButton type="button" className="btn btn-primary wide" disabled={!isValid} onClick={publishGoal}>
|
||||
<FormattedMessage defaultMessage="Create Goal" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,6 +10,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { eventLink, findTag } from "utils";
|
||||
import { NostrProviderDialog } from "./nostr-provider-dialog";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
function NewStream({ ev, onFinish }: StreamEditorProps) {
|
||||
const providers = useStreamProvider();
|
||||
@ -19,9 +20,7 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
|
||||
useEffect(() => {
|
||||
if (!currentProvider) {
|
||||
setCurrentProvider(
|
||||
ev !== undefined
|
||||
? unwrap(providers.find((a) => a.name.toLowerCase() === "manual"))
|
||||
: providers.at(0)
|
||||
ev !== undefined ? unwrap(providers.find(a => a.name.toLowerCase() === "manual")) : providers.at(0)
|
||||
);
|
||||
}
|
||||
}, [providers, currentProvider]);
|
||||
@ -33,14 +32,10 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
|
||||
case StreamProviders.Manual: {
|
||||
return (
|
||||
<StreamEditor
|
||||
onFinish={(ex) => {
|
||||
onFinish={ex => {
|
||||
currentProvider.updateStreamInfo(ex);
|
||||
if (!ev) {
|
||||
if (
|
||||
findTag(ex, "content-warning") &&
|
||||
__XXX_HOST &&
|
||||
__XXX === false
|
||||
) {
|
||||
if (findTag(ex, "content-warning") && __XXX_HOST && __XXX === false) {
|
||||
location.href = `${__XXX_HOST}/${eventLink(ex)}`;
|
||||
} else {
|
||||
navigate(`/${eventLink(ex)}`, {
|
||||
@ -56,13 +51,7 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
|
||||
);
|
||||
}
|
||||
case StreamProviders.NostrType: {
|
||||
return (
|
||||
<NostrProviderDialog
|
||||
provider={currentProvider}
|
||||
onFinish={onFinish}
|
||||
ev={ev}
|
||||
/>
|
||||
);
|
||||
return <NostrProviderDialog provider={currentProvider} onFinish={onFinish} ev={ev} />;
|
||||
}
|
||||
case StreamProviders.Owncast: {
|
||||
return;
|
||||
@ -72,13 +61,12 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>Stream Providers</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Stream Providers" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
{providers.map((v) => (
|
||||
<span
|
||||
className={`pill${v === currentProvider ? " active" : ""}`}
|
||||
onClick={() => setCurrentProvider(v)}
|
||||
>
|
||||
{providers.map(v => (
|
||||
<span className={`pill${v === currentProvider ? " active" : ""}`} onClick={() => setCurrentProvider(v)}>
|
||||
{v.name}
|
||||
</span>
|
||||
))}
|
||||
@ -93,9 +81,7 @@ interface NewStreamDialogProps {
|
||||
btnClassName?: string;
|
||||
}
|
||||
|
||||
export function NewStreamDialog(
|
||||
props: NewStreamDialogProps & StreamEditorProps
|
||||
) {
|
||||
export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
@ -104,7 +90,9 @@ export function NewStreamDialog(
|
||||
{props.text && props.text}
|
||||
{!props.text && (
|
||||
<>
|
||||
<span className="hide-on-mobile">Stream</span>
|
||||
<span className="hide-on-mobile">
|
||||
<FormattedMessage defaultMessage="Stream" />
|
||||
</span>
|
||||
<Icon name="signal" />
|
||||
</>
|
||||
)}
|
||||
|
@ -3,10 +3,7 @@ import { Mention } from "./mention";
|
||||
|
||||
export function NostrLink({ link }: { link: string }) {
|
||||
const nav = tryParseNostrLink(link);
|
||||
if (
|
||||
nav?.type === NostrPrefix.PublicKey ||
|
||||
nav?.type === NostrPrefix.Profile
|
||||
) {
|
||||
if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) {
|
||||
return <Mention pubkey={nav.id} relays={nav.relays} />;
|
||||
} else {
|
||||
<a href={link} target="_blank" rel="noreferrer" className="ext">
|
||||
|
@ -1,19 +1,13 @@
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import {
|
||||
StreamProvider,
|
||||
StreamProviderEndpoint,
|
||||
StreamProviderInfo,
|
||||
} from "providers";
|
||||
import { StreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "providers";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SendZaps } from "./send-zap";
|
||||
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
||||
import Spinner from "./spinner";
|
||||
import AsyncButton from "./async-button";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function NostrProviderDialog({
|
||||
provider,
|
||||
...others
|
||||
}: { provider: StreamProvider } & StreamEditorProps) {
|
||||
export function NostrProviderDialog({ provider, ...others }: { provider: StreamProvider } & StreamEditorProps) {
|
||||
const [topup, setTopup] = useState(false);
|
||||
const [info, setInfo] = useState<StreamProviderInfo>();
|
||||
const [ep, setEndpoint] = useState<StreamProviderEndpoint>();
|
||||
@ -24,7 +18,7 @@ export function NostrProviderDialog({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
provider.info().then((v) => {
|
||||
provider.info().then(v => {
|
||||
setInfo(v);
|
||||
setTos(v.tosAccepted ?? true);
|
||||
setEndpoint(sortEndpoints(v.endpoints)[0]);
|
||||
@ -42,13 +36,13 @@ export function NostrProviderDialog({
|
||||
name: provider.name,
|
||||
canZap: false,
|
||||
maxCommentLength: 0,
|
||||
getInvoice: async (amount) => {
|
||||
getInvoice: async amount => {
|
||||
const pr = await provider.topup(amount);
|
||||
return { pr };
|
||||
},
|
||||
}}
|
||||
onFinish={() => {
|
||||
provider.info().then((v) => {
|
||||
provider.info().then(v => {
|
||||
setInfo(v);
|
||||
setTopup(false);
|
||||
});
|
||||
@ -92,33 +86,27 @@ export function NostrProviderDialog({
|
||||
<>
|
||||
<div>
|
||||
<div className="flex g12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tos}
|
||||
onChange={(e) => setTos(e.target.checked)}
|
||||
/>
|
||||
<input type="checkbox" checked={tos} onChange={e => setTos(e.target.checked)} />
|
||||
<p>
|
||||
I have read and agree with {info.name}'s{" "}
|
||||
<span
|
||||
className="tos-link"
|
||||
onClick={() =>
|
||||
window.open(info.tosLink, "popup", "width=400,height=800")
|
||||
}
|
||||
>
|
||||
terms and conditions
|
||||
</span>
|
||||
.
|
||||
<FormattedMessage
|
||||
defaultMessage="I have read and agree with {provider}'s {terms}."
|
||||
values={{
|
||||
provider: info.name,
|
||||
terms: (
|
||||
<span
|
||||
className="tos-link"
|
||||
onClick={() => window.open(info.tosLink, "popup", "width=400,height=800")}>
|
||||
<FormattedMessage defaultMessage="terms and conditions" />
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-primary wide"
|
||||
disabled={!tos}
|
||||
onClick={acceptTos}
|
||||
>
|
||||
Continue
|
||||
<AsyncButton type="button" className="btn btn-primary wide" disabled={!tos} onClick={acceptTos}>
|
||||
<FormattedMessage defaultMessage="Continue" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
@ -129,13 +117,12 @@ export function NostrProviderDialog({
|
||||
<>
|
||||
{info.endpoints.length > 1 && (
|
||||
<div>
|
||||
<p>Endpoint</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Endpoint" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
{sortEndpoints(info.endpoints).map((a) => (
|
||||
<span
|
||||
className={`pill${ep?.name === a.name ? " active" : ""}`}
|
||||
onClick={() => setEndpoint(a)}
|
||||
>
|
||||
{sortEndpoints(info.endpoints).map(a => (
|
||||
<span className={`pill${ep?.name === a.name ? " active" : ""}`} onClick={() => setEndpoint(a)}>
|
||||
{a.name}
|
||||
</span>
|
||||
))}
|
||||
@ -143,41 +130,48 @@ export function NostrProviderDialog({
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p>Stream Url</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Server Url" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input type="text" value={ep?.url} disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Stream Key</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Stream Key" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
<div className="paper f-grow">
|
||||
<input type="password" value={ep?.key} disabled />
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}
|
||||
>
|
||||
Copy
|
||||
<button className="btn btn-primary" onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}>
|
||||
<FormattedMessage defaultMessage="Copy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Balance</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Balance" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
<div className="paper f-grow">
|
||||
{info.balance?.toLocaleString()} sats
|
||||
<FormattedMessage defaultMessage="{amount} sats" values={{ amount: info.balance?.toLocaleString() }} />
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setTopup(true)}>
|
||||
Topup
|
||||
<FormattedMessage defaultMessage="Topup" />
|
||||
</button>
|
||||
</div>
|
||||
<small>About {calcEstimate()}</small>
|
||||
<small>
|
||||
<FormattedMessage defaultMessage="About {estimate}" values={{ estimate: calcEstimate() }} />
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<p>Resolutions</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Resolutions" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
{ep?.capabilities?.map((a) => (
|
||||
{ep?.capabilities?.map(a => (
|
||||
<span className="pill">{parseCapability(a)}</span>
|
||||
))}
|
||||
</div>
|
||||
@ -186,7 +180,7 @@ export function NostrProviderDialog({
|
||||
tosInput()
|
||||
) : (
|
||||
<StreamEditor
|
||||
onFinish={(ex) => {
|
||||
onFinish={ex => {
|
||||
provider.updateStreamInfo(ex);
|
||||
others.onFinish?.(ex);
|
||||
}}
|
||||
@ -196,10 +190,8 @@ export function NostrProviderDialog({
|
||||
["title", info.streamInfo?.title ?? ""],
|
||||
["summary", info.streamInfo?.summary ?? ""],
|
||||
["image", info.streamInfo?.image ?? ""],
|
||||
...(info.streamInfo?.content_warning
|
||||
? [["content-warning", info.streamInfo?.content_warning]]
|
||||
: []),
|
||||
...(info.streamInfo?.tags?.map((a) => ["t", a]) ?? []),
|
||||
...(info.streamInfo?.content_warning ? [["content-warning", info.streamInfo?.content_warning]] : []),
|
||||
...(info.streamInfo?.tags?.map(a => ["t", a]) ?? []),
|
||||
],
|
||||
} as NostrEvent
|
||||
}
|
||||
|
@ -14,10 +14,7 @@ export function Note({ ev }: { ev: NostrEvent }) {
|
||||
<ExternalIconLink
|
||||
size={24}
|
||||
className="note-link-icon"
|
||||
href={`https://snort.social/e/${hexToBech32(
|
||||
NostrPrefix.Event,
|
||||
ev.id
|
||||
)}`}
|
||||
href={`https://snort.social/e/${hexToBech32(NostrPrefix.Event, ev.id)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="note-content">
|
||||
|
@ -7,7 +7,6 @@ import { hexToBech32 } from "@snort/shared";
|
||||
|
||||
import { Icon } from "element/icon";
|
||||
import usePlaceholder from "hooks/placeholders";
|
||||
import { System } from "index";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
export interface ProfileOptions {
|
||||
@ -45,8 +44,7 @@ export function Profile({
|
||||
linkToProfile?: boolean;
|
||||
}) {
|
||||
const { inView, ref } = useInView();
|
||||
const pLoaded =
|
||||
useUserProfile(System, inView && !profile ? pubkey : undefined) || profile;
|
||||
const pLoaded = useUserProfile(inView && !profile ? pubkey : undefined) || profile;
|
||||
const showAvatar = options?.showAvatar ?? true;
|
||||
const showName = options?.showName ?? true;
|
||||
const placeholder = usePlaceholder(pubkey);
|
||||
@ -64,13 +62,7 @@ export function Profile({
|
||||
/>
|
||||
))}
|
||||
{icon}
|
||||
{showName && (
|
||||
<span>
|
||||
{options?.overrideName ?? pubkey === "anon"
|
||||
? "Anon"
|
||||
: getName(pubkey, pLoaded)}
|
||||
</span>
|
||||
)}
|
||||
{showName && <span>{options?.overrideName ?? pubkey === "anon" ? "Anon" : getName(pubkey, pLoaded)}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -79,11 +71,7 @@ export function Profile({
|
||||
{content}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to={`/p/${hexToBech32("npub", pubkey)}`}
|
||||
className="profile"
|
||||
ref={ref}
|
||||
>
|
||||
<Link to={`/p/${hexToBech32("npub", pubkey)}`} className="profile" ref={ref}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
|
@ -46,10 +46,5 @@ export default function QrCode(props: QrCodeProps) {
|
||||
}
|
||||
}, [props.data, props.link, props.width, props.height, props.avatar]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`qr${props.className ? ` ${props.className}` : ""}`}
|
||||
ref={qrRef}
|
||||
></div>
|
||||
);
|
||||
return <div className={`qr${props.className ? ` ${props.className}` : ""}`} ref={qrRef}></div>;
|
||||
}
|
||||
|
@ -13,16 +13,13 @@ import QrCode from "./qr-code";
|
||||
import { useLogin } from "hooks/login";
|
||||
import Copy from "./copy";
|
||||
import { defaultRelays } from "const";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export interface LNURLLike {
|
||||
get name(): string;
|
||||
get maxCommentLength(): number;
|
||||
get canZap(): boolean;
|
||||
getInvoice(
|
||||
amountInSats: number,
|
||||
comment?: string,
|
||||
zap?: NostrEvent
|
||||
): Promise<{ pr?: string }>;
|
||||
getInvoice(amountInSats: number, comment?: string, zap?: NostrEvent): Promise<{ pr?: string }>;
|
||||
}
|
||||
|
||||
export interface SendZapsProps {
|
||||
@ -35,19 +32,12 @@ export interface SendZapsProps {
|
||||
button?: ReactNode;
|
||||
}
|
||||
|
||||
export function SendZaps({
|
||||
lnurl,
|
||||
pubkey,
|
||||
aTag,
|
||||
eTag,
|
||||
targetName,
|
||||
onFinish,
|
||||
}: SendZapsProps) {
|
||||
export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: SendZapsProps) {
|
||||
const UsdRate = 28_000;
|
||||
|
||||
const satsAmounts = [
|
||||
21, 69, 121, 221, 420, 1_000, 2_100, 5_000, 6_666, 10_000, 21_000, 42_000,
|
||||
69_000, 100_000, 210_000, 500_000, 1_000_000,
|
||||
21, 69, 121, 221, 420, 1_000, 2_100, 5_000, 6_666, 10_000, 21_000, 42_000, 69_000, 100_000, 210_000, 500_000,
|
||||
1_000_000,
|
||||
];
|
||||
const usdAmounts = [0.05, 0.5, 2, 5, 10, 50, 100, 200];
|
||||
const [isFiat, setIsFiat] = useState(false);
|
||||
@ -79,34 +69,25 @@ export function SendZaps({
|
||||
let pub = login?.publisher();
|
||||
let isAnon = false;
|
||||
if (!pub) {
|
||||
pub = EventPublisher.privateKey(
|
||||
bytesToHex(secp256k1.utils.randomPrivateKey())
|
||||
);
|
||||
pub = EventPublisher.privateKey(bytesToHex(secp256k1.utils.randomPrivateKey()));
|
||||
isAnon = true;
|
||||
}
|
||||
|
||||
const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount;
|
||||
let zap: NostrEvent | undefined;
|
||||
if (pubkey) {
|
||||
zap = await pub.zap(
|
||||
amountInSats * 1000,
|
||||
pubkey,
|
||||
relays,
|
||||
undefined,
|
||||
comment,
|
||||
(eb) => {
|
||||
if (aTag) {
|
||||
eb.tag(["a", aTag]);
|
||||
}
|
||||
if (eTag) {
|
||||
eb.tag(["e", eTag]);
|
||||
}
|
||||
if (isAnon) {
|
||||
eb.tag(["anon", ""]);
|
||||
}
|
||||
return eb;
|
||||
zap = await pub.zap(amountInSats * 1000, pubkey, relays, undefined, comment, eb => {
|
||||
if (aTag) {
|
||||
eb.tag(["a", aTag]);
|
||||
}
|
||||
);
|
||||
if (eTag) {
|
||||
eb.tag(["e", eTag]);
|
||||
}
|
||||
if (isAnon) {
|
||||
eb.tag(["anon", ""]);
|
||||
}
|
||||
return eb;
|
||||
});
|
||||
}
|
||||
const invoice = await svc.getInvoice(amountInSats, comment, zap);
|
||||
if (!invoice.pr) return;
|
||||
@ -134,8 +115,7 @@ export function SendZaps({
|
||||
onClick={() => {
|
||||
setIsFiat(false);
|
||||
setAmount(satsAmounts[0]);
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
SATS
|
||||
</span>
|
||||
<span
|
||||
@ -143,20 +123,20 @@ export function SendZaps({
|
||||
onClick={() => {
|
||||
setIsFiat(true);
|
||||
setAmount(usdAmounts[0]);
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
USD
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<small>Zap amount in {isFiat ? "USD" : "sats"}</small>
|
||||
<small>
|
||||
<FormattedMessage
|
||||
defaultMessage="Zap amount in {currency}"
|
||||
values={{ amount: isFiat ? "USD" : "sats" }}
|
||||
/>
|
||||
</small>
|
||||
<div className="amounts">
|
||||
{(isFiat ? usdAmounts : satsAmounts).map((a) => (
|
||||
<span
|
||||
key={a}
|
||||
className={`pill${a === amount ? " active" : ""}`}
|
||||
onClick={() => setAmount(a)}
|
||||
>
|
||||
{(isFiat ? usdAmounts : satsAmounts).map(a => (
|
||||
<span key={a} className={`pill${a === amount ? " active" : ""}`} onClick={() => setAmount(a)}>
|
||||
{isFiat ? `$${a.toLocaleString()}` : formatSats(a)}
|
||||
</span>
|
||||
))}
|
||||
@ -164,19 +144,17 @@ export function SendZaps({
|
||||
</div>
|
||||
{svc && (svc.maxCommentLength > 0 || svc.canZap) && (
|
||||
<div>
|
||||
<small>Your comment for {name}</small>
|
||||
<small>
|
||||
<FormattedMessage defaultMessage="Your comment for {name}" values={{ name }} />
|
||||
</small>
|
||||
<div className="paper">
|
||||
<textarea
|
||||
placeholder="Nice!"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
<textarea placeholder="Nice!" value={comment} onChange={e => setComment(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<AsyncButton onClick={send} className="btn btn-primary">
|
||||
Zap!
|
||||
<FormattedMessage defaultMessage="Zap!" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
@ -194,7 +172,7 @@ export function SendZaps({
|
||||
<Copy text={invoice} />
|
||||
</div>
|
||||
<button className="btn btn-primary wide" onClick={() => onFinish()}>
|
||||
Back
|
||||
<FormattedMessage defaultMessage="Back" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
@ -203,7 +181,7 @@ export function SendZaps({
|
||||
return (
|
||||
<div className="send-zap">
|
||||
<h3>
|
||||
Zap {name}
|
||||
<FormattedMessage defaultMessage="Zap {name}" values={{ name }} />
|
||||
<Icon name="zap" />
|
||||
</h3>
|
||||
{input()}
|
||||
@ -221,7 +199,9 @@ export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
|
||||
props.button
|
||||
) : (
|
||||
<button className="btn btn-primary zap">
|
||||
<span className="hide-on-mobile">Zap</span>
|
||||
<span className="hide-on-mobile">
|
||||
<FormattedMessage defaultMessage="Zap" />
|
||||
</span>
|
||||
<Icon name="zap-filled" size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
@ -10,6 +10,7 @@ import { findTag } from "utils";
|
||||
import AsyncButton from "./async-button";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { System } from "index";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
type ShareOn = "nostr" | "twitter";
|
||||
|
||||
@ -18,13 +19,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
|
||||
const [message, setMessage] = useState("");
|
||||
const login = useLogin();
|
||||
|
||||
const naddr = encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
unwrap(findTag(ev, "d")),
|
||||
undefined,
|
||||
ev.kind,
|
||||
ev.pubkey
|
||||
);
|
||||
const naddr = encodeTLV(NostrPrefix.Address, unwrap(findTag(ev, "d")), undefined, ev.kind, ev.pubkey);
|
||||
const link = `https://zap.stream/${naddr}`;
|
||||
|
||||
async function sendMessage() {
|
||||
@ -45,35 +40,30 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
|
||||
menuClassName="ctx-menu"
|
||||
menuButton={
|
||||
<button type="button" className="btn btn-secondary">
|
||||
Share
|
||||
<FormattedMessage defaultMessage="Share" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setMessage(
|
||||
`Come check out my stream on zap.stream!\n\n${link}\n\nnostr:${naddr}`
|
||||
);
|
||||
setMessage(`Come check out my stream on zap.stream!\n\n${link}\n\nnostr:${naddr}`);
|
||||
setShare("nostr");
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
<Icon name="nostrich" size={24} />
|
||||
Broadcast on Nostr
|
||||
<FormattedMessage defaultMessage="Broadcast on Nostr" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<Dialog.Root
|
||||
open={Boolean(share)}
|
||||
onOpenChange={() => setShare(undefined)}
|
||||
>
|
||||
<Dialog.Root open={Boolean(share)} onOpenChange={() => setShare(undefined)}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-overlay" />
|
||||
<Dialog.Content className="dialog-content">
|
||||
<h2>Share</h2>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Share" />
|
||||
</h2>
|
||||
<div className="paper">
|
||||
<Textarea
|
||||
emojis={[]}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
onKeyDown={() => {
|
||||
//noop
|
||||
}}
|
||||
@ -81,7 +71,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
|
||||
/>
|
||||
</div>
|
||||
<AsyncButton className="btn btn-primary" onClick={sendMessage}>
|
||||
Send
|
||||
<FormattedMessage defaultMessage="Send" />
|
||||
</AsyncButton>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
|
@ -7,13 +7,7 @@ export interface IconProps {
|
||||
}
|
||||
|
||||
const Spinner = (props: IconProps) => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
{...props}
|
||||
>
|
||||
<svg width="20" height="20" stroke="currentColor" viewBox="0 0 20 20" {...props}>
|
||||
<g className="spinner_V8m1">
|
||||
<circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle>
|
||||
</g>
|
||||
|
@ -2,9 +2,5 @@ import "./state-pill.css";
|
||||
import { StreamState } from "index";
|
||||
|
||||
export function StatePill({ state }: { state: StreamState }) {
|
||||
return (
|
||||
<span className={`state pill${state === StreamState.Live ? " live" : ""}`}>
|
||||
{state}
|
||||
</span>
|
||||
);
|
||||
return <span className={`state pill${state === StreamState.Live ? " live" : ""}`}>{state}</span>;
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import "./stream-cards.css";
|
||||
|
||||
import { useState, forwardRef } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { DndProvider, useDrag, useDrop } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
|
||||
import type { TaggedRawEvent } from "@snort/system";
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
import { Toggle } from "element/toggle";
|
||||
import { Icon } from "element/icon";
|
||||
@ -37,35 +38,32 @@ interface CardPreviewProps extends NewCard {
|
||||
style: object;
|
||||
}
|
||||
|
||||
const CardPreview = forwardRef(
|
||||
({ style, title, link, image, content }: CardPreviewProps, ref) => {
|
||||
const isImageOnly = !isEmpty(image) && isEmpty(content) && isEmpty(title);
|
||||
return (
|
||||
<div
|
||||
className={`stream-card ${isImageOnly ? "image-card" : ""}`}
|
||||
// @ts-expect-error: Type 'ForwardRef<unknown>'
|
||||
ref={ref}
|
||||
style={style}
|
||||
>
|
||||
{title && <h1 className="card-title">{title}</h1>}
|
||||
{image &&
|
||||
(link && link?.length > 0 ? (
|
||||
<ExternalLink href={link}>
|
||||
<img className="card-image" src={image} alt={title} />
|
||||
</ExternalLink>
|
||||
) : (
|
||||
const CardPreview = forwardRef(({ style, title, link, image, content }: CardPreviewProps, ref) => {
|
||||
const isImageOnly = !isEmpty(image) && isEmpty(content) && isEmpty(title);
|
||||
return (
|
||||
<div
|
||||
className={`stream-card ${isImageOnly ? "image-card" : ""}`}
|
||||
// @ts-expect-error: Type 'ForwardRef<unknown>'
|
||||
ref={ref}
|
||||
style={style}>
|
||||
{title && <h1 className="card-title">{title}</h1>}
|
||||
{image &&
|
||||
(link && link?.length > 0 ? (
|
||||
<ExternalLink href={link}>
|
||||
<img className="card-image" src={image} alt={title} />
|
||||
))}
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
</ExternalLink>
|
||||
) : (
|
||||
<img className="card-image" src={image} alt={title} />
|
||||
))}
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface CardProps {
|
||||
canEdit?: boolean;
|
||||
ev: TaggedRawEvent;
|
||||
cards: TaggedRawEvent[];
|
||||
ev: TaggedNostrEvent;
|
||||
cards: TaggedNostrEvent[];
|
||||
}
|
||||
|
||||
interface CardItem {
|
||||
@ -88,7 +86,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
canDrag: () => {
|
||||
return Boolean(canEdit);
|
||||
},
|
||||
collect: (monitor) => {
|
||||
collect: monitor => {
|
||||
const isDragging = monitor.isDragging();
|
||||
return {
|
||||
opacity: isDragging ? 0.1 : 1,
|
||||
@ -100,7 +98,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
);
|
||||
|
||||
function findTagByIdentifier(d: string) {
|
||||
return tags.find((t) => t[1].endsWith(`:${d}`));
|
||||
return tags.find(t => t[1].endsWith(`:${d}`));
|
||||
}
|
||||
|
||||
const [dropStyle, dropRef] = useDrop(
|
||||
@ -109,7 +107,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
canDrop: () => {
|
||||
return Boolean(canEdit);
|
||||
},
|
||||
collect: (monitor) => {
|
||||
collect: monitor => {
|
||||
const isOvering = monitor.isOver({ shallow: true });
|
||||
return {
|
||||
opacity: isOvering ? 0.3 : 1,
|
||||
@ -123,7 +121,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
}
|
||||
const newItem = findTagByIdentifier(typed.identifier);
|
||||
const oldItem = findTagByIdentifier(identifier);
|
||||
const newTags = tags.map((t) => {
|
||||
const newTags = tags.map(t => {
|
||||
if (t === oldItem) {
|
||||
return newItem;
|
||||
}
|
||||
@ -134,7 +132,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
}) as Tags;
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const userCardsEv = await pub.generic((eb) => {
|
||||
const userCardsEv = await pub.generic(eb => {
|
||||
eb.kind(USER_CARDS).content("");
|
||||
for (const tag of newTags) {
|
||||
eb.tag(tag);
|
||||
@ -151,14 +149,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
);
|
||||
|
||||
const card = (
|
||||
<CardPreview
|
||||
ref={dropRef}
|
||||
title={title}
|
||||
link={link}
|
||||
image={image}
|
||||
content={content}
|
||||
style={dropStyle}
|
||||
/>
|
||||
<CardPreview ref={dropRef} title={title} link={link} image={image} content={content} style={dropStyle} />
|
||||
);
|
||||
const editor = canEdit && (
|
||||
<div className="editor-buttons">
|
||||
@ -184,14 +175,7 @@ interface CardDialogProps {
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function CardDialog({
|
||||
header,
|
||||
cta,
|
||||
cancelCta,
|
||||
card,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: CardDialogProps) {
|
||||
function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: CardDialogProps) {
|
||||
const [title, setTitle] = useState(card?.title ?? "");
|
||||
const [image, setImage] = useState(card?.image ?? "");
|
||||
const [content, setContent] = useState(card?.content ?? "");
|
||||
@ -199,58 +183,63 @@ function CardDialog({
|
||||
|
||||
return (
|
||||
<div className="new-card">
|
||||
<h3>{header || "Add card"}</h3>
|
||||
<h3>
|
||||
{header || <FormattedMessage defaultMessage="Add card" />}
|
||||
</h3>
|
||||
<div className="form-control">
|
||||
<label htmlFor="card-title">Title</label>
|
||||
<label htmlFor="card-title">
|
||||
<FormattedMessage defaultMessage="Title" />
|
||||
</label>
|
||||
<input
|
||||
id="card-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="e.g. about me"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label htmlFor="card-image">Image</label>
|
||||
<FileUploader
|
||||
defaultImage={image}
|
||||
onFileUpload={setImage}
|
||||
onClear={() => setImage("")}
|
||||
/>
|
||||
<label htmlFor="card-image">
|
||||
<FormattedMessage defaultMessage="Image" />
|
||||
</label>
|
||||
<FileUploader defaultImage={image} onFileUpload={setImage} onClear={() => setImage("")} />
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label htmlFor="card-image-link">Image Link</label>
|
||||
<label htmlFor="card-image-link">
|
||||
<FormattedMessage defaultMessage="Image Link" />
|
||||
</label>
|
||||
<input
|
||||
id="card-image-link"
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
onChange={e => setLink(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label htmlFor="card-content">Content</label>
|
||||
<textarea
|
||||
placeholder="Start typing..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="card-content">
|
||||
<FormattedMessage defaultMessage="Content" />
|
||||
</label>
|
||||
<textarea placeholder="Start typing..." value={content} onChange={e => setContent(e.target.value)} />
|
||||
<span className="help-text">
|
||||
Supports{" "}
|
||||
<ExternalLink href="https://www.markdownguide.org/cheat-sheet">
|
||||
Markdown
|
||||
</ExternalLink>
|
||||
<FormattedMessage
|
||||
defaultMessage="Supports {markdown}"
|
||||
values={{
|
||||
markdown: (
|
||||
<ExternalLink href="https://www.markdownguide.org/cheat-sheet">
|
||||
<FormattedMessage defaultMessage="Markdown" />
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="new-card-buttons">
|
||||
<button
|
||||
className="btn btn-primary add-button"
|
||||
onClick={() => onSave({ title, image, content, link })}
|
||||
>
|
||||
{cta || "Add Card"}
|
||||
<button className="btn btn-primary add-button" onClick={() => onSave({ title, image, content, link })}>
|
||||
{cta || <FormattedMessage defaultMessage="Add Card" />}
|
||||
</button>
|
||||
<button className="btn delete-button" onClick={onCancel}>
|
||||
{cancelCta || "Cancel"}
|
||||
{cancelCta || <FormattedMessage defaultMessage="Cancel" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -259,7 +248,7 @@ function CardDialog({
|
||||
|
||||
interface EditCardProps {
|
||||
card: CardType;
|
||||
cards: TaggedRawEvent[];
|
||||
cards: TaggedNostrEvent[];
|
||||
}
|
||||
|
||||
function EditCard({ card, cards }: EditCardProps) {
|
||||
@ -267,11 +256,12 @@ function EditCard({ card, cards }: EditCardProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const identifier = card.identifier;
|
||||
const tags = cards.map(toTag);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
async function editCard({ title, image, link, content }: CardType) {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const ev = await pub.generic((eb) => {
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(CARD).content(content).tag(["d", card.identifier]);
|
||||
if (title && title?.length > 0) {
|
||||
eb.tag(["title", title]);
|
||||
@ -293,8 +283,8 @@ function EditCard({ card, cards }: EditCardProps) {
|
||||
async function onCancel() {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const newTags = tags.filter((t) => !t[1].endsWith(`:${identifier}`));
|
||||
const userCardsEv = await pub.generic((eb) => {
|
||||
const newTags = tags.filter(t => !t[1].endsWith(`:${identifier}`));
|
||||
const userCardsEv = await pub.generic(eb => {
|
||||
eb.kind(USER_CARDS).content("");
|
||||
for (const tag of newTags) {
|
||||
eb.tag(tag);
|
||||
@ -312,15 +302,17 @@ function EditCard({ card, cards }: EditCardProps) {
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button className="btn btn-primary">Edit</button>
|
||||
<button className="btn btn-primary">
|
||||
<FormattedMessage defaultMessage="Edit" />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-overlay" />
|
||||
<Dialog.Content className="dialog-content">
|
||||
<CardDialog
|
||||
header="Edit card"
|
||||
cta="Save Card"
|
||||
cancelCta="Delete"
|
||||
header={formatMessage({ defaultMessage: "Edit card" })}
|
||||
cta={formatMessage({ defaultMessage: "Save card" })}
|
||||
cancelCta={formatMessage({ defaultMessage: "Delete" })}
|
||||
card={card}
|
||||
onSave={editCard}
|
||||
onCancel={onCancel}
|
||||
@ -332,7 +324,7 @@ function EditCard({ card, cards }: EditCardProps) {
|
||||
}
|
||||
|
||||
interface AddCardProps {
|
||||
cards: TaggedRawEvent[];
|
||||
cards: TaggedNostrEvent[];
|
||||
}
|
||||
|
||||
function AddCard({ cards }: AddCardProps) {
|
||||
@ -343,7 +335,7 @@ function AddCard({ cards }: AddCardProps) {
|
||||
async function createCard({ title, image, link, content }: NewCard) {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const ev = await pub.generic((eb) => {
|
||||
const ev = await pub.generic(eb => {
|
||||
const d = String(Date.now());
|
||||
eb.kind(CARD).content(content).tag(["d", d]);
|
||||
if (title && title?.length > 0) {
|
||||
@ -357,7 +349,7 @@ function AddCard({ cards }: AddCardProps) {
|
||||
}
|
||||
return eb;
|
||||
});
|
||||
const userCardsEv = await pub.generic((eb) => {
|
||||
const userCardsEv = await pub.generic(eb => {
|
||||
eb.kind(USER_CARDS).content("");
|
||||
for (const tag of tags) {
|
||||
eb.tag(tag);
|
||||
@ -407,18 +399,13 @@ export function StreamCardEditor({ pubkey, tags }: StreamCardEditorProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="stream-cards">
|
||||
{cards.map((ev) => (
|
||||
{cards.map(ev => (
|
||||
<Card canEdit={isEditing} cards={cards} key={ev.id} ev={ev} />
|
||||
))}
|
||||
{isEditing && <AddCard cards={cards} />}
|
||||
</div>
|
||||
<div className="edit-container">
|
||||
<Toggle
|
||||
pressed={isEditing}
|
||||
onPressedChange={setIsEditing}
|
||||
label="Toggle edit mode"
|
||||
text="Edit cards"
|
||||
/>
|
||||
<Toggle pressed={isEditing} onPressedChange={setIsEditing} label="Toggle edit mode" text="Edit cards" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@ -432,7 +419,7 @@ export function ReadOnlyStreamCards({ host }: StreamCardsProps) {
|
||||
const cards = useCards(host);
|
||||
return (
|
||||
<div className="stream-cards">
|
||||
{cards.map((ev) => (
|
||||
{cards.map(ev => (
|
||||
<Card cards={cards} key={ev.id} ev={ev} />
|
||||
))}
|
||||
</div>
|
||||
|
@ -8,6 +8,7 @@ import AsyncButton from "./async-button";
|
||||
import { StreamState } from "../index";
|
||||
import { findTag } from "../utils";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
export interface StreamEditorProps {
|
||||
ev?: NostrEvent;
|
||||
@ -34,6 +35,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
const [contentWarning, setContentWarning] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const login = useLogin();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(findTag(ev, "title") ?? "");
|
||||
@ -42,7 +44,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
setStream(findTag(ev, "streaming") ?? "");
|
||||
setStatus(findTag(ev, "status") ?? StreamState.Live);
|
||||
setStart(findTag(ev, "starts"));
|
||||
setTags(ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? []);
|
||||
setTags(ev?.tags.filter(a => a[0] === "t").map(a => a[1]) ?? []);
|
||||
setContentWarning(findTag(ev, "content-warning") !== undefined);
|
||||
}, [ev?.id]);
|
||||
|
||||
@ -66,7 +68,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
async function publishStream() {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const evNew = await pub.generic((eb) => {
|
||||
const evNew = await pub.generic(eb => {
|
||||
const now = unixNow();
|
||||
const dTag = findTag(ev, "d") ?? now.toString();
|
||||
const starts = start ?? now.toString();
|
||||
@ -108,85 +110,81 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
|
||||
{(options?.canSetTitle ?? true) && (
|
||||
<div>
|
||||
<p>Title</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Title" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="What are we steaming today?"
|
||||
placeholder={formatMessage({ defaultMessage: "What are we steaming today?" })}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(options?.canSetSummary ?? true) && (
|
||||
<div>
|
||||
<p>Summary</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Summary" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="A short description of the content"
|
||||
placeholder={formatMessage({ defaultMessage: "A short description of the content" })}
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
onChange={e => setSummary(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(options?.canSetImage ?? true) && (
|
||||
<div>
|
||||
<p>Cover image</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Cover Image" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
/>
|
||||
<input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(options?.canSetStream ?? true) && (
|
||||
<div>
|
||||
<p>Stream Url</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Stream URL" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
value={stream}
|
||||
onChange={(e) => setStream(e.target.value)}
|
||||
/>
|
||||
<input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
|
||||
</div>
|
||||
<small>Stream type should be HLS</small>
|
||||
<small>
|
||||
<FormattedMessage defaultMessage="Stream type should be HLS" />
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{(options?.canSetStatus ?? true) && (
|
||||
<>
|
||||
<div>
|
||||
<p>Status</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Status" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(
|
||||
(v) => (
|
||||
<span
|
||||
className={`pill${status === v ? " active" : ""}`}
|
||||
onClick={() => setStatus(v)}
|
||||
key={v}
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => (
|
||||
<span className={`pill${status === v ? " active" : ""}`} onClick={() => setStatus(v)} key={v}>
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{status === StreamState.Planned && (
|
||||
<div>
|
||||
<p>Start Time</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Start Time" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toDateTimeString(Number(start ?? "0"))}
|
||||
onChange={(e) =>
|
||||
setStart(fromDateTimeString(e.target.value).toString())
|
||||
}
|
||||
onChange={e => setStart(fromDateTimeString(e.target.value).toString())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -195,40 +193,30 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
)}
|
||||
{(options?.canSetTags ?? true) && (
|
||||
<div>
|
||||
<p>Tags</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Tags" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<TagsInput
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
placeHolder="Music,DJ,English"
|
||||
separators={["Enter", ","]}
|
||||
/>
|
||||
<TagsInput value={tags} onChange={setTags} placeHolder="Music,DJ,English" separators={["Enter", ","]} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(options?.canSetContentWarning ?? true) && (
|
||||
<div className="flex g12 content-warning">
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contentWarning}
|
||||
onChange={(e) => setContentWarning(e.target.checked)}
|
||||
/>
|
||||
<input type="checkbox" checked={contentWarning} onChange={e => setContentWarning(e.target.checked)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="warning">NSFW Content</div>
|
||||
Check here if this stream contains nudity or pornographic content.
|
||||
<div className="warning">
|
||||
<FormattedMessage defaultMessage="NSFW Content" />
|
||||
</div>
|
||||
<FormattedMessage defaultMessage="Check here if this stream contains nudity or pornographic content." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-primary wide"
|
||||
disabled={!isValid}
|
||||
onClick={publishStream}
|
||||
>
|
||||
{ev ? "Save" : "Start Stream"}
|
||||
<AsyncButton type="button" className="btn btn-primary wide" disabled={!isValid} onClick={publishStream}>
|
||||
{ev ? <FormattedMessage defaultMessage="Save" /> : <FormattedMessage defaultMessage="Start Stream" />}
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
|
@ -11,9 +11,7 @@ export function StreamTimer({ ev }: { ev?: NostrEvent }) {
|
||||
const diff = unixNow() - starts;
|
||||
const hours = Number(diff / 60.0 / 60.0);
|
||||
const mins = Number((diff / 60) % 60);
|
||||
setTime(
|
||||
`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}`
|
||||
);
|
||||
setTime(`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import moment from "moment";
|
||||
|
||||
import { NostrEvent } from "@snort/system";
|
||||
@ -6,15 +7,7 @@ import { NostrEvent } from "@snort/system";
|
||||
import { StreamState } from "index";
|
||||
import { findTag, getTagValues } from "utils";
|
||||
|
||||
export function Tags({
|
||||
children,
|
||||
max,
|
||||
ev,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
max?: number;
|
||||
ev: NostrEvent;
|
||||
}) {
|
||||
export function Tags({ children, max, ev }: { children?: ReactNode; max?: number; ev: NostrEvent }) {
|
||||
const status = findTag(ev, "status");
|
||||
const start = findTag(ev, "starts");
|
||||
const hashtags = getTagValues(ev.tags, "t");
|
||||
@ -25,11 +18,11 @@ export function Tags({
|
||||
{children}
|
||||
{status === StreamState.Planned && (
|
||||
<span className="pill">
|
||||
{status === StreamState.Planned ? "Starts " : ""}
|
||||
{status === StreamState.Planned ? <FormattedMessage defaultMessage="Starts " /> : ""}
|
||||
{moment(Number(start) * 1000).fromNow()}
|
||||
</span>
|
||||
)}
|
||||
{tags.map((a) => (
|
||||
{tags.map(a => (
|
||||
<a href={`/t/${encodeURIComponent(a)}`} className="pill" key={a}>
|
||||
{a}
|
||||
</a>
|
||||
|
@ -1,10 +1,6 @@
|
||||
import { useMemo, type ReactNode, type FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
type NostrLink,
|
||||
parseNostrLink,
|
||||
validateNostrLink,
|
||||
} from "@snort/system";
|
||||
import { type NostrLink, parseNostrLink, validateNostrLink } from "@snort/system";
|
||||
|
||||
import { Event } from "element/Event";
|
||||
import { Mention } from "element/mention";
|
||||
@ -20,23 +16,17 @@ const EmojiRegex = /:([\w-]+):/g;
|
||||
|
||||
function extractLinks(fragments: Fragment[]) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return splitByUrl(f).map((a) => {
|
||||
return splitByUrl(f).map(a => {
|
||||
const validateLink = () => {
|
||||
const normalizedStr = a.toLowerCase();
|
||||
|
||||
if (
|
||||
normalizedStr.startsWith("web+nostr:") ||
|
||||
normalizedStr.startsWith("nostr:")
|
||||
) {
|
||||
if (normalizedStr.startsWith("web+nostr:") || normalizedStr.startsWith("nostr:")) {
|
||||
return validateNostrLink(normalizedStr);
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedStr.startsWith("http:") ||
|
||||
normalizedStr.startsWith("https:")
|
||||
);
|
||||
return normalizedStr.startsWith("http:") || normalizedStr.startsWith("https:");
|
||||
};
|
||||
|
||||
if (validateLink()) {
|
||||
@ -52,10 +42,10 @@ function extractLinks(fragments: Fragment[]) {
|
||||
|
||||
function extractEmoji(fragments: Fragment[], tags: string[][]) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(EmojiRegex).map((i) => {
|
||||
const t = tags.find((a) => a[0] === "emoji" && a[1] === i);
|
||||
return f.split(EmojiRegex).map(i => {
|
||||
const t = tags.find(a => a[0] === "emoji" && a[1] === i);
|
||||
if (t) {
|
||||
return <Emoji name={t[1]} url={t[2]} />;
|
||||
} else {
|
||||
@ -70,9 +60,9 @@ function extractEmoji(fragments: Fragment[], tags: string[][]) {
|
||||
|
||||
function extractNprofiles(fragments: Fragment[]) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(/(nostr:nprofile1[a-z0-9]+)/g).map((i) => {
|
||||
return f.split(/(nostr:nprofile1[a-z0-9]+)/g).map(i => {
|
||||
if (i.startsWith("nostr:nprofile1")) {
|
||||
try {
|
||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||
@ -92,9 +82,9 @@ function extractNprofiles(fragments: Fragment[]) {
|
||||
|
||||
function extractNpubs(fragments: Fragment[]) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(/(nostr:npub1[a-z0-9]+)/g).map((i) => {
|
||||
return f.split(/(nostr:npub1[a-z0-9]+)/g).map(i => {
|
||||
if (i.startsWith("nostr:npub1")) {
|
||||
try {
|
||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||
@ -114,9 +104,9 @@ function extractNpubs(fragments: Fragment[]) {
|
||||
|
||||
function extractNevents(fragments: Fragment[], Event: NostrComponent) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(/(nostr:nevent1[a-z0-9]+)/g).map((i) => {
|
||||
return f.split(/(nostr:nevent1[a-z0-9]+)/g).map(i => {
|
||||
if (i.startsWith("nostr:nevent1")) {
|
||||
try {
|
||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||
@ -136,9 +126,9 @@ function extractNevents(fragments: Fragment[], Event: NostrComponent) {
|
||||
|
||||
function extractNaddrs(fragments: Fragment[], Address: NostrComponent) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(/(nostr:naddr1[a-z0-9]+)/g).map((i) => {
|
||||
return f.split(/(nostr:naddr1[a-z0-9]+)/g).map(i => {
|
||||
if (i.startsWith("nostr:naddr1")) {
|
||||
try {
|
||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||
@ -159,9 +149,9 @@ function extractNaddrs(fragments: Fragment[], Address: NostrComponent) {
|
||||
|
||||
function extractNoteIds(fragments: Fragment[], Event: NostrComponent) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(/(nostr:note1[a-z0-9]+)/g).map((i) => {
|
||||
return f.split(/(nostr:note1[a-z0-9]+)/g).map(i => {
|
||||
if (i.startsWith("nostr:note1")) {
|
||||
try {
|
||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||
@ -189,11 +179,7 @@ const components: NostrComponents = {
|
||||
Event,
|
||||
};
|
||||
|
||||
export function transformText(
|
||||
ps: Fragment[],
|
||||
tags: Array<string[]>,
|
||||
customComponents = components
|
||||
) {
|
||||
export function transformText(ps: Fragment[], tags: Array<string[]>, customComponents = components) {
|
||||
let fragments = extractEmoji(ps, tags);
|
||||
fragments = extractNprofiles(fragments);
|
||||
fragments = extractNevents(fragments, customComponents.Event);
|
||||
@ -214,11 +200,7 @@ interface TextProps {
|
||||
export function Text({ content, tags, customComponents }: TextProps) {
|
||||
// todo: RTL langugage support
|
||||
const element = useMemo(() => {
|
||||
return (
|
||||
<span className="text">
|
||||
{transformText([content], tags, customComponents)}
|
||||
</span>
|
||||
);
|
||||
return <span className="text">{transformText([content], tags, customComponents)}</span>;
|
||||
}, [content, tags]);
|
||||
|
||||
return <>{element}</>;
|
||||
|
@ -1,8 +1,6 @@
|
||||
import "./textarea.css";
|
||||
import type { KeyboardEvent, ChangeEvent } from "react";
|
||||
import ReactTextareaAutocomplete, {
|
||||
TriggerType,
|
||||
} from "@webscopeio/react-textarea-autocomplete";
|
||||
import ReactTextareaAutocomplete, { TriggerType } from "@webscopeio/react-textarea-autocomplete";
|
||||
import "@webscopeio/react-textarea-autocomplete/style.css";
|
||||
import uniqWith from "lodash/uniqWith";
|
||||
import isEqual from "lodash/isEqual";
|
||||
@ -59,7 +57,7 @@ export function Textarea({ emojis, ...props }: TextareaProps) {
|
||||
|
||||
const emojiDataProvider = (token: string) => {
|
||||
const results = emojis
|
||||
.map((t) => {
|
||||
.map(t => {
|
||||
return {
|
||||
name: t.at(1) || "",
|
||||
url: t.at(2) || "",
|
||||
@ -78,11 +76,8 @@ export function Textarea({ emojis, ...props }: TextareaProps) {
|
||||
"@": {
|
||||
afterWhitespace: true,
|
||||
dataProvider: userDataProvider,
|
||||
component: (props: { entity: MetadataCache }) => (
|
||||
<UserItem {...props.entity} />
|
||||
),
|
||||
output: (item: { pubkey: string }) =>
|
||||
`@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`,
|
||||
component: (props: { entity: MetadataCache }) => <UserItem {...props.entity} />,
|
||||
output: (item: { pubkey: string }) => `@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`,
|
||||
},
|
||||
} as TriggerType<string | object>;
|
||||
|
||||
|
@ -10,6 +10,7 @@ import { formatSats } from "number";
|
||||
import ZapStream from "../../public/zap-stream.svg";
|
||||
import { isContentWarningAccepted } from "./content-warning";
|
||||
import { Tags } from "element/tags";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function VideoTile({
|
||||
ev,
|
||||
@ -26,37 +27,22 @@ export function VideoTile({
|
||||
const image = findTag(ev, "image");
|
||||
const status = findTag(ev, "status");
|
||||
const viewers = findTag(ev, "current_participants");
|
||||
const contentWarning =
|
||||
findTag(ev, "content-warning") && !isContentWarningAccepted();
|
||||
const contentWarning = findTag(ev, "content-warning") && !isContentWarningAccepted();
|
||||
const host = getHost(ev);
|
||||
|
||||
const link = encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
id,
|
||||
undefined,
|
||||
ev.kind,
|
||||
ev.pubkey
|
||||
);
|
||||
const link = encodeTLV(NostrPrefix.Address, id, undefined, ev.kind, ev.pubkey);
|
||||
return (
|
||||
<div className="video-tile-container">
|
||||
<Link
|
||||
to={`/${link}`}
|
||||
className={`video-tile${contentWarning ? " nsfw" : ""}`}
|
||||
ref={ref}
|
||||
state={ev}
|
||||
>
|
||||
<Link to={`/${link}`} className={`video-tile${contentWarning ? " nsfw" : ""}`} ref={ref} state={ev}>
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(${
|
||||
inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : ""
|
||||
})`,
|
||||
}}
|
||||
></div>
|
||||
backgroundImage: `url(${inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : ""})`,
|
||||
}}></div>
|
||||
<span className="pill-box">
|
||||
{showStatus && <StatePill state={status as StreamState} />}
|
||||
{viewers && (
|
||||
<span className="pill viewers">
|
||||
{formatSats(Number(viewers))} viewers
|
||||
<FormattedMessage defaultMessage="{n} viewers" values={{ n: formatSats(Number(viewers)) }} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { NostrLink, EventKind } from "@snort/system";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { useLogin } from "hooks/login";
|
||||
import AsyncButton from "element/async-button";
|
||||
@ -10,20 +11,14 @@ import type { EmojiPack, Emoji } from "types";
|
||||
import { System } from "index";
|
||||
import { LIVE_STREAM_CHAT } from "const";
|
||||
|
||||
export function WriteMessage({
|
||||
link,
|
||||
emojiPacks,
|
||||
}: {
|
||||
link: NostrLink;
|
||||
emojiPacks: EmojiPack[];
|
||||
}) {
|
||||
export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks: EmojiPack[] }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const emojiRef = useRef(null);
|
||||
const [chat, setChat] = useState("");
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const login = useLogin();
|
||||
const emojis = emojiPacks.map((pack) => pack.emojis).flat();
|
||||
const names = emojis.map((t) => t.at(1));
|
||||
const emojis = emojiPacks.map(pack => pack.emojis).flat();
|
||||
const names = emojis.map(t => t.at(1));
|
||||
|
||||
const topOffset = ref.current?.getBoundingClientRect().top;
|
||||
const leftOffset = ref.current?.getBoundingClientRect().left;
|
||||
@ -39,10 +34,8 @@ export function WriteMessage({
|
||||
}
|
||||
}
|
||||
|
||||
const reply = await pub?.generic((eb) => {
|
||||
const emoji = [...emojiNames].map((name) =>
|
||||
emojis.find((e) => e.at(1) === name)
|
||||
);
|
||||
const reply = await pub?.generic(eb => {
|
||||
const emoji = [...emojiNames].map(name => emojis.find(e => e.at(1) === name));
|
||||
eb.kind(LIVE_STREAM_CHAT as EventKind)
|
||||
.content(chat)
|
||||
.tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"])
|
||||
@ -86,12 +79,7 @@ export function WriteMessage({
|
||||
return (
|
||||
<>
|
||||
<div className="paper" ref={ref}>
|
||||
<Textarea
|
||||
emojis={emojis}
|
||||
value={chat}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={(e) => setChat(e.target.value)}
|
||||
/>
|
||||
<Textarea emojis={emojis} value={chat} onKeyDown={onKeyDown} onChange={e => setChat(e.target.value)} />
|
||||
<div onClick={pickEmoji}>
|
||||
<Icon name="face" className="write-emoji-button" />
|
||||
</div>
|
||||
@ -107,7 +95,7 @@ export function WriteMessage({
|
||||
)}
|
||||
</div>
|
||||
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
|
||||
Send
|
||||
<FormattedMessage defaultMessage="Send" />
|
||||
</AsyncButton>
|
||||
</>
|
||||
);
|
||||
|
@ -5,8 +5,8 @@
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(outfit_400_latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
@ -15,9 +15,8 @@
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(outfit_400_latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
@ -26,8 +25,8 @@
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(outfit_500_latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
@ -36,9 +35,8 @@
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(outfit_500_latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
@ -47,8 +45,8 @@
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(outfit_600_latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
@ -57,9 +55,8 @@
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(outfit_600_latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
@ -68,8 +65,8 @@
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(outfit_700_latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
@ -78,7 +75,6 @@
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(outfit_700_latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
@ -1,50 +1,35 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
TaggedRawEvent,
|
||||
EventKind,
|
||||
NoteCollection,
|
||||
RequestBuilder,
|
||||
} from "@snort/system";
|
||||
import { TaggedNostrEvent, EventKind, NoteCollection, RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
import { findTag, toAddress, getTagValues } from "utils";
|
||||
import { System } from "index";
|
||||
import type { Badge } from "types";
|
||||
|
||||
export function useBadges(
|
||||
pubkey: string,
|
||||
since: number,
|
||||
leaveOpen = true
|
||||
): { badges: Badge[]; awards: TaggedRawEvent[] } {
|
||||
): { badges: Badge[]; awards: TaggedNostrEvent[] } {
|
||||
const rb = useMemo(() => {
|
||||
const rb = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`);
|
||||
rb.withOptions({ leaveOpen });
|
||||
rb.withFilter().authors([pubkey]).kinds([EventKind.Badge]);
|
||||
rb.withFilter()
|
||||
.authors([pubkey])
|
||||
.kinds([EventKind.BadgeAward])
|
||||
.since(since);
|
||||
rb.withFilter().authors([pubkey]).kinds([EventKind.BadgeAward]).since(since);
|
||||
return rb;
|
||||
}, [pubkey, since]);
|
||||
|
||||
const { data: badgeEvents } = useRequestBuilder<NoteCollection>(
|
||||
System,
|
||||
NoteCollection,
|
||||
rb
|
||||
);
|
||||
const { data: badgeEvents } = useRequestBuilder(NoteCollection, rb);
|
||||
|
||||
const rawBadges = useMemo(() => {
|
||||
if (badgeEvents) {
|
||||
return badgeEvents
|
||||
.filter((e) => e.kind === EventKind.Badge)
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
return badgeEvents.filter(e => e.kind === EventKind.Badge).sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
return [];
|
||||
}, [badgeEvents]);
|
||||
const badgeAwards = useMemo(() => {
|
||||
if (badgeEvents) {
|
||||
return badgeEvents.filter((e) => e.kind === EventKind.BadgeAward);
|
||||
return badgeEvents.filter(e => e.kind === EventKind.BadgeAward);
|
||||
}
|
||||
return [];
|
||||
}, [badgeEvents]);
|
||||
@ -52,37 +37,24 @@ export function useBadges(
|
||||
const acceptedSub = useMemo(() => {
|
||||
if (rawBadges.length === 0) return null;
|
||||
const rb = new RequestBuilder(`accepted-badges:${pubkey.slice(0, 12)}`);
|
||||
rb.withFilter()
|
||||
.kinds([EventKind.ProfileBadges])
|
||||
.tag("d", ["profile_badges"])
|
||||
.tag("a", rawBadges.map(toAddress));
|
||||
rb.withFilter().kinds([EventKind.ProfileBadges]).tag("d", ["profile_badges"]).tag("a", rawBadges.map(toAddress));
|
||||
return rb;
|
||||
}, [rawBadges]);
|
||||
|
||||
const acceptedStream = useRequestBuilder<NoteCollection>(
|
||||
System,
|
||||
NoteCollection,
|
||||
acceptedSub
|
||||
);
|
||||
const acceptedStream = useRequestBuilder(NoteCollection, acceptedSub);
|
||||
const acceptedEvents = acceptedStream.data ?? [];
|
||||
|
||||
const badges = useMemo(() => {
|
||||
return rawBadges.map((e) => {
|
||||
return rawBadges.map(e => {
|
||||
const name = findTag(e, "d") ?? "";
|
||||
const address = toAddress(e);
|
||||
const awardEvents = badgeAwards.filter(
|
||||
(b) => findTag(b, "a") === address
|
||||
);
|
||||
const awardees = new Set(
|
||||
awardEvents.map((e) => getTagValues(e.tags, "p")).flat()
|
||||
);
|
||||
const awardEvents = badgeAwards.filter(b => findTag(b, "a") === address);
|
||||
const awardees = new Set(awardEvents.map(e => getTagValues(e.tags, "p")).flat());
|
||||
const accepted = new Set(
|
||||
acceptedEvents
|
||||
.filter((pb) => awardees.has(pb.pubkey))
|
||||
.filter((pb) =>
|
||||
pb.tags.find((t) => t.at(0) === "a" && t.at(1) === address)
|
||||
)
|
||||
.map((pb) => pb.pubkey)
|
||||
.filter(pb => awardees.has(pb.pubkey))
|
||||
.filter(pb => pb.tags.find(t => t.at(0) === "a" && t.at(1) === address))
|
||||
.map(pb => pb.pubkey)
|
||||
);
|
||||
const thumb = findTag(e, "thumb");
|
||||
const image = findTag(e, "image");
|
||||
|
@ -1,80 +1,55 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
TaggedRawEvent,
|
||||
ReplaceableNoteStore,
|
||||
NoteCollection,
|
||||
RequestBuilder,
|
||||
} from "@snort/system";
|
||||
import { TaggedNostrEvent, ReplaceableNoteStore, NoteCollection, RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
import { USER_CARDS, CARD } from "const";
|
||||
import { findTag } from "utils";
|
||||
import { System } from "index";
|
||||
|
||||
export function useUserCards(
|
||||
pubkey: string,
|
||||
userCards: Array<string[]>,
|
||||
leaveOpen = false
|
||||
): TaggedRawEvent[] {
|
||||
export function useUserCards(pubkey: string, userCards: Array<string[]>, leaveOpen = false): TaggedNostrEvent[] {
|
||||
const related = useMemo(() => {
|
||||
// filtering to only show CARD kinds for now, but in the future we could link and render anything
|
||||
if (userCards?.length > 0) {
|
||||
return userCards.filter(
|
||||
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`)
|
||||
);
|
||||
return userCards.filter(t => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`));
|
||||
}
|
||||
return [];
|
||||
}, [userCards]);
|
||||
|
||||
const subRelated = useMemo(() => {
|
||||
if (!pubkey) return null;
|
||||
const splitted = related.map((t) => t[1].split(":"));
|
||||
const splitted = related.map(t => t[1].split(":"));
|
||||
const authors = splitted
|
||||
.map((s) => s.at(1))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(1))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
const identifiers = splitted
|
||||
.map((s) => s.at(2))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(2))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
|
||||
const rb = new RequestBuilder(`cards:${pubkey}`);
|
||||
rb.withOptions({ leaveOpen })
|
||||
.withFilter()
|
||||
.kinds([CARD])
|
||||
.authors(authors)
|
||||
.tag("d", identifiers);
|
||||
rb.withOptions({ leaveOpen }).withFilter().kinds([CARD]).authors(authors).tag("d", identifiers);
|
||||
|
||||
return rb;
|
||||
}, [pubkey, related]);
|
||||
|
||||
const { data } = useRequestBuilder<NoteCollection>(
|
||||
System,
|
||||
NoteCollection,
|
||||
subRelated
|
||||
);
|
||||
const { data } = useRequestBuilder(NoteCollection, subRelated);
|
||||
|
||||
const cards = useMemo(() => {
|
||||
return related
|
||||
.map((t) => {
|
||||
.map(t => {
|
||||
const [k, pubkey, identifier] = t[1].split(":");
|
||||
const kind = Number(k);
|
||||
return (data ?? []).find(
|
||||
(e) =>
|
||||
e.kind === kind &&
|
||||
e.pubkey === pubkey &&
|
||||
findTag(e, "d") === identifier
|
||||
);
|
||||
return (data ?? []).find(e => e.kind === kind && e.pubkey === pubkey && findTag(e, "d") === identifier);
|
||||
})
|
||||
.filter((e) => e)
|
||||
.map((e) => e as TaggedRawEvent);
|
||||
.filter(e => e)
|
||||
.map(e => e as TaggedNostrEvent);
|
||||
}, [related, data]);
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function useCards(pubkey: string, leaveOpen = false): TaggedRawEvent[] {
|
||||
export function useCards(pubkey: string, leaveOpen = false): TaggedNostrEvent[] {
|
||||
const sub = useMemo(() => {
|
||||
const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`);
|
||||
b.withOptions({
|
||||
@ -86,65 +61,46 @@ export function useCards(pubkey: string, leaveOpen = false): TaggedRawEvent[] {
|
||||
return b;
|
||||
}, [pubkey, leaveOpen]);
|
||||
|
||||
const { data: userCards } = useRequestBuilder<ReplaceableNoteStore>(
|
||||
System,
|
||||
ReplaceableNoteStore,
|
||||
sub
|
||||
);
|
||||
const { data: userCards } = useRequestBuilder(ReplaceableNoteStore, sub);
|
||||
|
||||
const related = useMemo(() => {
|
||||
// filtering to only show CARD kinds for now, but in the future we could link and render anything
|
||||
if (userCards) {
|
||||
return userCards.tags.filter(
|
||||
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`)
|
||||
);
|
||||
return userCards.tags.filter(t => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`));
|
||||
}
|
||||
return [];
|
||||
}, [userCards]);
|
||||
|
||||
const subRelated = useMemo(() => {
|
||||
if (!pubkey) return null;
|
||||
const splitted = related.map((t) => t[1].split(":"));
|
||||
const splitted = related.map(t => t[1].split(":"));
|
||||
const authors = splitted
|
||||
.map((s) => s.at(1))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(1))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
const identifiers = splitted
|
||||
.map((s) => s.at(2))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(2))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
|
||||
const rb = new RequestBuilder(`cards:${pubkey}`);
|
||||
rb.withOptions({ leaveOpen })
|
||||
.withFilter()
|
||||
.kinds([CARD])
|
||||
.authors(authors)
|
||||
.tag("d", identifiers);
|
||||
rb.withOptions({ leaveOpen }).withFilter().kinds([CARD]).authors(authors).tag("d", identifiers);
|
||||
|
||||
return rb;
|
||||
}, [pubkey, related]);
|
||||
|
||||
const { data } = useRequestBuilder<NoteCollection>(
|
||||
System,
|
||||
NoteCollection,
|
||||
subRelated
|
||||
);
|
||||
const { data } = useRequestBuilder(NoteCollection, subRelated);
|
||||
const cardEvents = data ?? [];
|
||||
|
||||
const cards = useMemo(() => {
|
||||
return related
|
||||
.map((t) => {
|
||||
.map(t => {
|
||||
const [k, pubkey, identifier] = t[1].split(":");
|
||||
const kind = Number(k);
|
||||
return cardEvents.find(
|
||||
(e) =>
|
||||
e.kind === kind &&
|
||||
e.pubkey === pubkey &&
|
||||
findTag(e, "d") === identifier
|
||||
);
|
||||
return cardEvents.find(e => e.kind === kind && e.pubkey === pubkey && findTag(e, "d") === identifier);
|
||||
})
|
||||
.filter((e) => e)
|
||||
.map((e) => e as TaggedRawEvent);
|
||||
.filter(e => e)
|
||||
.map(e => e as TaggedNostrEvent);
|
||||
}, [related, cardEvents]);
|
||||
|
||||
return cards;
|
||||
|
@ -1,33 +1,17 @@
|
||||
import { unwrap } from "@snort/shared";
|
||||
import {
|
||||
NostrEvent,
|
||||
NostrLink,
|
||||
NostrPrefix,
|
||||
NoteCollection,
|
||||
RequestBuilder,
|
||||
TaggedRawEvent,
|
||||
} from "@snort/system";
|
||||
import { NostrEvent, NostrLink, NostrPrefix, NoteCollection, RequestBuilder, TaggedNostrEvent } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { LIVE_STREAM } from "const";
|
||||
import { System } from "index";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function useCurrentStreamFeed(
|
||||
link: NostrLink,
|
||||
leaveOpen = false,
|
||||
evPreload?: NostrEvent
|
||||
) {
|
||||
const author =
|
||||
link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
|
||||
export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: NostrEvent) {
|
||||
const author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
|
||||
const sub = useMemo(() => {
|
||||
const b = new RequestBuilder(`current-event:${link.id}`);
|
||||
b.withOptions({
|
||||
leaveOpen,
|
||||
});
|
||||
if (
|
||||
link.type === NostrPrefix.PublicKey ||
|
||||
link.type === NostrPrefix.Profile
|
||||
) {
|
||||
if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) {
|
||||
b.withFilter().authors([link.id]).kinds([LIVE_STREAM]).limit(1);
|
||||
b.withFilter().tag("p", [link.id]).kinds([LIVE_STREAM]).limit(1);
|
||||
} else if (link.type === NostrPrefix.Address) {
|
||||
@ -42,20 +26,16 @@ export function useCurrentStreamFeed(
|
||||
return b;
|
||||
}, [link.id, leaveOpen]);
|
||||
|
||||
const q = useRequestBuilder(System, NoteCollection, sub);
|
||||
const q = useRequestBuilder(NoteCollection, sub);
|
||||
|
||||
if (evPreload) {
|
||||
q.add(evPreload as TaggedRawEvent);
|
||||
q.add(evPreload as TaggedNostrEvent);
|
||||
}
|
||||
|
||||
return useMemo(() => {
|
||||
const hosting = q.data?.filter(
|
||||
(a) =>
|
||||
a.pubkey === author ||
|
||||
a.tags.some((b) => b[0] === "p" && b[1] === author && b[3] === "host")
|
||||
a => a.pubkey === author || a.tags.some(b => b[0] === "p" && b[1] === author && b[3] === "host")
|
||||
);
|
||||
return [...(hosting ?? [])]
|
||||
.sort((a, b) => (b.created_at > a.created_at ? 1 : -1))
|
||||
.at(0);
|
||||
return [...(hosting ?? [])].sort((a, b) => (b.created_at > a.created_at ? 1 : -1)).at(0);
|
||||
}, [q.data]);
|
||||
}
|
||||
|
@ -1,14 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import uniqBy from "lodash.uniqby";
|
||||
|
||||
import {
|
||||
RequestBuilder,
|
||||
ReplaceableNoteStore,
|
||||
NoteCollection,
|
||||
NostrEvent,
|
||||
} from "@snort/system";
|
||||
import { RequestBuilder, ReplaceableNoteStore, NoteCollection, NostrEvent } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { System } from "index";
|
||||
import { findTag } from "utils";
|
||||
import { EMOJI_PACK, USER_EMOJIS } from "const";
|
||||
import type { EmojiPack, Tags, EmojiTag } from "types";
|
||||
@ -24,8 +18,8 @@ export function toEmojiPack(ev: NostrEvent): EmojiPack {
|
||||
name: d,
|
||||
author: ev.pubkey,
|
||||
emojis: ev.tags
|
||||
.filter((t) => t.at(0) === "emoji")
|
||||
.map((t) => ["emoji", cleanShortcode(t.at(1)), t.at(2)]) as EmojiTag[],
|
||||
.filter(t => t.at(0) === "emoji")
|
||||
.map(t => ["emoji", cleanShortcode(t.at(1)), t.at(2)]) as EmojiTag[],
|
||||
};
|
||||
}
|
||||
|
||||
@ -36,24 +30,22 @@ export function packId(pack: EmojiPack): string {
|
||||
export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) {
|
||||
const related = useMemo(() => {
|
||||
if (userEmoji) {
|
||||
return userEmoji?.filter(
|
||||
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`)
|
||||
);
|
||||
return userEmoji?.filter(t => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`));
|
||||
}
|
||||
return [];
|
||||
}, [userEmoji]);
|
||||
|
||||
const subRelated = useMemo(() => {
|
||||
if (!pubkey) return null;
|
||||
const splitted = related.map((t) => t[1].split(":"));
|
||||
const splitted = related.map(t => t[1].split(":"));
|
||||
const authors = splitted
|
||||
.map((s) => s.at(1))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(1))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
const identifiers = splitted
|
||||
.map((s) => s.at(2))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(2))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
|
||||
const rb = new RequestBuilder(`emoji-related:${pubkey}`);
|
||||
|
||||
@ -64,11 +56,7 @@ export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) {
|
||||
return rb;
|
||||
}, [pubkey, related]);
|
||||
|
||||
const { data: relatedData } = useRequestBuilder<NoteCollection>(
|
||||
System,
|
||||
NoteCollection,
|
||||
subRelated
|
||||
);
|
||||
const { data: relatedData } = useRequestBuilder(NoteCollection, subRelated);
|
||||
|
||||
const emojiPacks = useMemo(() => {
|
||||
return relatedData ?? [];
|
||||
@ -92,11 +80,7 @@ export default function useEmoji(pubkey?: string) {
|
||||
return rb;
|
||||
}, [pubkey]);
|
||||
|
||||
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>(
|
||||
System,
|
||||
ReplaceableNoteStore,
|
||||
sub
|
||||
);
|
||||
const { data: userEmoji } = useRequestBuilder(ReplaceableNoteStore, sub);
|
||||
|
||||
const emojis = useUserEmojiPacks(pubkey, userEmoji?.tags ?? []);
|
||||
return emojis;
|
||||
|
@ -1,14 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
NostrPrefix,
|
||||
RequestBuilder,
|
||||
ReplaceableNoteStore,
|
||||
NostrLink,
|
||||
} from "@snort/system";
|
||||
import { NostrPrefix, RequestBuilder, ReplaceableNoteStore, NostrLink } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
import { System } from "index";
|
||||
|
||||
export default function useEventFeed(link: NostrLink, leaveOpen = false) {
|
||||
const sub = useMemo(() => {
|
||||
const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`);
|
||||
@ -26,7 +19,7 @@ export default function useEventFeed(link: NostrLink, leaveOpen = false) {
|
||||
} else {
|
||||
const f = b.withFilter().ids([link.id]);
|
||||
if (link.relays) {
|
||||
link.relays.slice(0, 2).forEach((r) => f.relay(r));
|
||||
link.relays.slice(0, 2).forEach(r => f.relay(r));
|
||||
}
|
||||
if (link.author) {
|
||||
f.authors([link.author]);
|
||||
@ -35,9 +28,5 @@ export default function useEventFeed(link: NostrLink, leaveOpen = false) {
|
||||
return b;
|
||||
}, [link, leaveOpen]);
|
||||
|
||||
return useRequestBuilder<ReplaceableNoteStore>(
|
||||
System,
|
||||
ReplaceableNoteStore,
|
||||
sub
|
||||
);
|
||||
return useRequestBuilder(ReplaceableNoteStore, sub);
|
||||
}
|
||||
|
@ -1,15 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
NostrPrefix,
|
||||
ReplaceableNoteStore,
|
||||
RequestBuilder,
|
||||
type NostrLink,
|
||||
} from "@snort/system";
|
||||
import { NostrPrefix, ReplaceableNoteStore, RequestBuilder, type NostrLink } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
import { System } from "index";
|
||||
|
||||
export function useAddress(kind: number, pubkey: string, identifier: string) {
|
||||
const sub = useMemo(() => {
|
||||
const b = new RequestBuilder(`event:${kind}:${identifier}`);
|
||||
@ -17,11 +10,7 @@ export function useAddress(kind: number, pubkey: string, identifier: string) {
|
||||
return b;
|
||||
}, [kind, pubkey, identifier]);
|
||||
|
||||
const { data } = useRequestBuilder<ReplaceableNoteStore>(
|
||||
System,
|
||||
ReplaceableNoteStore,
|
||||
sub
|
||||
);
|
||||
const { data } = useRequestBuilder(ReplaceableNoteStore, sub);
|
||||
|
||||
return data;
|
||||
}
|
||||
@ -40,7 +29,7 @@ export function useEvent(link: NostrLink) {
|
||||
} else {
|
||||
const f = b.withFilter().ids([link.id]);
|
||||
if (link.relays) {
|
||||
link.relays.slice(0, 2).forEach((r) => f.relay(r));
|
||||
link.relays.slice(0, 2).forEach(r => f.relay(r));
|
||||
}
|
||||
if (link.author) {
|
||||
f.authors([link.author]);
|
||||
@ -49,11 +38,7 @@ export function useEvent(link: NostrLink) {
|
||||
return b;
|
||||
}, [link]);
|
||||
|
||||
const { data } = useRequestBuilder<ReplaceableNoteStore>(
|
||||
System,
|
||||
ReplaceableNoteStore,
|
||||
sub
|
||||
);
|
||||
const { data } = useRequestBuilder(ReplaceableNoteStore, sub);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
@ -17,24 +17,13 @@ export function useZaps(goal: NostrEvent, leaveOpen = false) {
|
||||
const sub = useMemo(() => {
|
||||
const b = new RequestBuilder(`goal-zaps:${goal.id.slice(0, 12)}`);
|
||||
b.withOptions({ leaveOpen });
|
||||
b.withFilter()
|
||||
.kinds([EventKind.ZapReceipt])
|
||||
.tag("e", [goal.id])
|
||||
.since(goal.created_at);
|
||||
b.withFilter().kinds([EventKind.ZapReceipt]).tag("e", [goal.id]).since(goal.created_at);
|
||||
return b;
|
||||
}, [goal, leaveOpen]);
|
||||
|
||||
const { data } = useRequestBuilder<NoteCollection>(
|
||||
System,
|
||||
NoteCollection,
|
||||
sub
|
||||
);
|
||||
const { data } = useRequestBuilder(NoteCollection, sub);
|
||||
|
||||
return (
|
||||
data
|
||||
?.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
||||
.filter((z) => z && z.valid) ?? []
|
||||
);
|
||||
return data?.map(ev => parseZap(ev, System.ProfileLoader.Cache)).filter(z => z && z.valid) ?? [];
|
||||
}
|
||||
|
||||
export function useZapGoal(host: string, link?: NostrLink, leaveOpen = false) {
|
||||
@ -49,11 +38,7 @@ export function useZapGoal(host: string, link?: NostrLink, leaveOpen = false) {
|
||||
return b;
|
||||
}, [link, leaveOpen]);
|
||||
|
||||
const { data } = useRequestBuilder<ReplaceableNoteStore>(
|
||||
System,
|
||||
ReplaceableNoteStore,
|
||||
sub
|
||||
);
|
||||
const { data } = useRequestBuilder(ReplaceableNoteStore, sub);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
import { MUTED } from "const";
|
||||
import { getTagValues } from "utils";
|
||||
import { System } from "index";
|
||||
|
||||
export function useMutedPubkeys(host?: string, leaveOpen = false) {
|
||||
const mutedSub = useMemo(() => {
|
||||
@ -16,11 +15,7 @@ export function useMutedPubkeys(host?: string, leaveOpen = false) {
|
||||
return rb;
|
||||
}, [host]);
|
||||
|
||||
const { data: muted } = useRequestBuilder<ReplaceableNoteStore>(
|
||||
System,
|
||||
ReplaceableNoteStore,
|
||||
mutedSub
|
||||
);
|
||||
const { data: muted } = useRequestBuilder(ReplaceableNoteStore, mutedSub);
|
||||
const mutedPubkeys = useMemo(() => {
|
||||
return new Set(getTagValues(muted?.tags ?? [], "p"));
|
||||
}, [muted]);
|
||||
|
@ -1,12 +1,6 @@
|
||||
import {
|
||||
NostrLink,
|
||||
RequestBuilder,
|
||||
EventKind,
|
||||
FlatNoteStore,
|
||||
} from "@snort/system";
|
||||
import { NostrLink, RequestBuilder, EventKind, NoteCollection } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { System } from "index";
|
||||
import { useMemo } from "react";
|
||||
import { LIVE_STREAM_CHAT, WEEK } from "const";
|
||||
|
||||
@ -27,17 +21,17 @@ export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
|
||||
return rb;
|
||||
}, [link.id, since, eZaps]);
|
||||
|
||||
const feed = useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
|
||||
const feed = useRequestBuilder(NoteCollection, sub);
|
||||
|
||||
const messages = useMemo(() => {
|
||||
return (feed.data ?? []).filter((ev) => ev.kind === LIVE_STREAM_CHAT);
|
||||
return (feed.data ?? []).filter(ev => ev.kind === LIVE_STREAM_CHAT);
|
||||
}, [feed.data]);
|
||||
const zaps = useMemo(() => {
|
||||
return (feed.data ?? []).filter((ev) => ev.kind === EventKind.ZapReceipt);
|
||||
return (feed.data ?? []).filter(ev => ev.kind === EventKind.ZapReceipt);
|
||||
}, [feed.data]);
|
||||
|
||||
const etags = useMemo(() => {
|
||||
return messages.map((e) => e.id);
|
||||
return messages.map(e => e.id);
|
||||
}, [messages]);
|
||||
|
||||
const esub = useMemo(() => {
|
||||
@ -46,17 +40,11 @@ export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
|
||||
rb.withOptions({
|
||||
leaveOpen: true,
|
||||
});
|
||||
rb.withFilter()
|
||||
.kinds([EventKind.Reaction, EventKind.ZapReceipt])
|
||||
.tag("e", etags);
|
||||
rb.withFilter().kinds([EventKind.Reaction, EventKind.ZapReceipt]).tag("e", etags);
|
||||
return rb;
|
||||
}, [etags]);
|
||||
|
||||
const reactionsSub = useRequestBuilder<FlatNoteStore>(
|
||||
System,
|
||||
FlatNoteStore,
|
||||
esub
|
||||
);
|
||||
const reactionsSub = useRequestBuilder(NoteCollection, esub);
|
||||
|
||||
const reactions = reactionsSub.data ?? [];
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { LIVE_STREAM } from "const";
|
||||
import { System, StreamState } from "index";
|
||||
import { StreamState } from "index";
|
||||
import { findTag } from "utils";
|
||||
import { WEEK } from "const";
|
||||
|
||||
@ -34,30 +34,22 @@ export function useStreamsFeed(tag?: string) {
|
||||
return bStart > aStart ? 1 : -1;
|
||||
}
|
||||
|
||||
const feed = useRequestBuilder<NoteCollection>(System, NoteCollection, rb);
|
||||
const feed = useRequestBuilder(NoteCollection, rb);
|
||||
const feedSorted = useMemo(() => {
|
||||
if (feed.data) {
|
||||
if (__XXX) {
|
||||
return [...feed.data].filter(
|
||||
(a) => findTag(a, "content-warning") !== undefined
|
||||
);
|
||||
return [...feed.data].filter(a => findTag(a, "content-warning") !== undefined);
|
||||
} else {
|
||||
return [...feed.data].filter(
|
||||
(a) => findTag(a, "content-warning") === undefined
|
||||
);
|
||||
return [...feed.data].filter(a => findTag(a, "content-warning") === undefined);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}, [feed.data]);
|
||||
|
||||
const live = feedSorted
|
||||
.filter((a) => findTag(a, "status") === StreamState.Live)
|
||||
.sort(sortStarts);
|
||||
const planned = feedSorted
|
||||
.filter((a) => findTag(a, "status") === StreamState.Planned)
|
||||
.sort(sortStarts);
|
||||
const live = feedSorted.filter(a => findTag(a, "status") === StreamState.Live).sort(sortStarts);
|
||||
const planned = feedSorted.filter(a => findTag(a, "status") === StreamState.Planned).sort(sortStarts);
|
||||
const ended = feedSorted
|
||||
.filter((a) => {
|
||||
.filter(a => {
|
||||
const hasEnded = findTag(a, "status") === StreamState.Ended;
|
||||
const recording = findTag(a, "recording") ?? "";
|
||||
return hasEnded && recording?.length > 0;
|
||||
|
@ -6,12 +6,12 @@ import { useRequestBuilder } from "@snort/system-react";
|
||||
import { useUserEmojiPacks } from "hooks/emoji";
|
||||
import { MUTED, USER_CARDS, USER_EMOJIS } from "const";
|
||||
import type { Tags } from "types";
|
||||
import { System, Login } from "index";
|
||||
import { getPublisher } from "login";
|
||||
import { Login } from "index";
|
||||
|
||||
export function useLogin() {
|
||||
const session = useSyncExternalStore(
|
||||
(c) => Login.hook(c),
|
||||
c => Login.hook(c),
|
||||
() => Login.snapshot()
|
||||
);
|
||||
if (!session) return;
|
||||
@ -26,7 +26,7 @@ export function useLogin() {
|
||||
export function useLoginEvents(pubkey?: string, leaveOpen = false) {
|
||||
const [userEmojis, setUserEmojis] = useState<Tags>([]);
|
||||
const session = useSyncExternalStore(
|
||||
(c) => Login.hook(c),
|
||||
c => Login.hook(c),
|
||||
() => Login.snapshot()
|
||||
);
|
||||
|
||||
@ -42,11 +42,7 @@ export function useLoginEvents(pubkey?: string, leaveOpen = false) {
|
||||
return b;
|
||||
}, [pubkey, leaveOpen]);
|
||||
|
||||
const { data } = useRequestBuilder<NoteCollection>(
|
||||
System,
|
||||
NoteCollection,
|
||||
sub
|
||||
);
|
||||
const { data } = useRequestBuilder(NoteCollection, sub);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
|
@ -1,9 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function usePlaceholder(pubkey: string) {
|
||||
const url = useMemo(
|
||||
() => `https://robohash.v0l.io/${pubkey}.png?set=2`,
|
||||
[pubkey]
|
||||
);
|
||||
const url = useMemo(() => `https://robohash.v0l.io/${pubkey}.png?set=2`, [pubkey]);
|
||||
return url;
|
||||
}
|
||||
|
@ -1,12 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
RequestBuilder,
|
||||
FlatNoteStore,
|
||||
NoteCollection,
|
||||
NostrLink,
|
||||
EventKind,
|
||||
parseZap,
|
||||
} from "@snort/system";
|
||||
import { RequestBuilder, NoteCollection, NostrLink, EventKind, parseZap } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { LIVE_STREAM } from "const";
|
||||
import { findTag } from "utils";
|
||||
@ -27,16 +20,12 @@ export function useProfile(link: NostrLink, leaveOpen = false) {
|
||||
return b;
|
||||
}, [link, leaveOpen]);
|
||||
|
||||
const { data: streamsData } = useRequestBuilder<NoteCollection>(
|
||||
System,
|
||||
NoteCollection,
|
||||
sub
|
||||
);
|
||||
const { data: streamsData } = useRequestBuilder(NoteCollection, sub);
|
||||
const streams = streamsData ?? [];
|
||||
|
||||
const addresses = useMemo(() => {
|
||||
if (streamsData) {
|
||||
return streamsData.map((e) => `${e.kind}:${e.pubkey}:${findTag(e, "d")}`);
|
||||
return streamsData.map(e => `${e.kind}:${e.pubkey}:${findTag(e, "d")}`);
|
||||
}
|
||||
return [];
|
||||
}, [streamsData]);
|
||||
@ -52,14 +41,10 @@ export function useProfile(link: NostrLink, leaveOpen = false) {
|
||||
return b;
|
||||
}, [link, addresses, leaveOpen]);
|
||||
|
||||
const { data: zapsData } = useRequestBuilder<FlatNoteStore>(
|
||||
System,
|
||||
FlatNoteStore,
|
||||
zapsSub
|
||||
);
|
||||
const { data: zapsData } = useRequestBuilder(NoteCollection, zapsSub);
|
||||
const zaps = (zapsData ?? [])
|
||||
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
||||
.filter((z) => z && z.valid && z.receiver === link.id);
|
||||
.map(ev => parseZap(ev, System.ProfileLoader.Cache))
|
||||
.filter(z => z && z.valid && z.receiver === link.id);
|
||||
|
||||
const sortedStreams = useMemo(() => {
|
||||
const sorted = [...streams];
|
||||
|
@ -3,7 +3,7 @@ import { useSyncExternalStore } from "react";
|
||||
|
||||
export function useStreamProvider() {
|
||||
return useSyncExternalStore(
|
||||
(c) => StreamProviderStore.hook(c),
|
||||
c => StreamProviderStore.hook(c),
|
||||
() => StreamProviderStore.snapshot()
|
||||
);
|
||||
}
|
||||
|
@ -2,19 +2,15 @@ import { useMemo } from "react";
|
||||
import { ParsedZap } from "@snort/system";
|
||||
|
||||
function totalZapped(pubkey: string, zaps: ParsedZap[]) {
|
||||
return zaps
|
||||
.filter((z) => (z.anonZap ? pubkey === "anon" : z.sender === pubkey))
|
||||
.reduce((acc, z) => acc + z.amount, 0);
|
||||
return zaps.filter(z => (z.anonZap ? pubkey === "anon" : z.sender === pubkey)).reduce((acc, z) => acc + z.amount, 0);
|
||||
}
|
||||
|
||||
export default function useTopZappers(zaps: ParsedZap[]) {
|
||||
const zappers = zaps
|
||||
.map((z) => (z.anonZap ? "anon" : z.sender))
|
||||
.map((p) => p as string);
|
||||
const zappers = zaps.map(z => (z.anonZap ? "anon" : z.sender)).map(p => p as string);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const pubkeys = [...new Set([...zappers])];
|
||||
const result = pubkeys.map((pubkey) => {
|
||||
const result = pubkeys.map(pubkey => {
|
||||
return { pubkey, total: totalZapped(pubkey, zaps) };
|
||||
});
|
||||
result.sort((a, b) => b.total - a.total);
|
||||
|
@ -19,11 +19,7 @@ body {
|
||||
--border: #171717;
|
||||
--gradient-purple: linear-gradient(135deg, #882bff 0%, #f83838 100%);
|
||||
--gradient-yellow: linear-gradient(270deg, #adff27 0%, #ffd027 100%);
|
||||
--gradient-orange: linear-gradient(
|
||||
270deg,
|
||||
#ff5b27 0%,
|
||||
rgba(255, 182, 39, 0.99) 100%
|
||||
);
|
||||
--gradient-orange: linear-gradient(270deg, #ff5b27 0%, rgba(255, 182, 39, 0.99) 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
@ -35,8 +31,7 @@ body {
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
a {
|
||||
@ -119,14 +114,12 @@ a {
|
||||
.btn-border {
|
||||
border: 1px solid transparent;
|
||||
color: inherit;
|
||||
background: linear-gradient(black, black) padding-box,
|
||||
linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
|
||||
background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.btn-border:hover {
|
||||
background: linear-gradient(black, black) padding-box,
|
||||
linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
|
||||
background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
|
@ -5,6 +5,7 @@ import "./fonts/outfit/outfit.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { NostrSystem } from "@snort/system";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||
|
||||
import { RootPage } from "pages/root";
|
||||
@ -18,6 +19,8 @@ import { StreamProvidersPage } from "pages/providers";
|
||||
import { defaultRelays } from "const";
|
||||
import { CatchAllRoutePage } from "pages/catch-all";
|
||||
import { SettingsPage } from "pages/settings-page";
|
||||
import { register } from "serviceWorker";
|
||||
import { IntlProvider } from "intl";
|
||||
|
||||
export enum StreamState {
|
||||
Live = "live",
|
||||
@ -28,7 +31,9 @@ export enum StreamState {
|
||||
export const System = new NostrSystem({});
|
||||
export const Login = new LoginStore();
|
||||
|
||||
Object.entries(defaultRelays).forEach((params) => {
|
||||
register();
|
||||
|
||||
Object.entries(defaultRelays).forEach(params => {
|
||||
const [relay, settings] = params;
|
||||
System.ConnectToRelay(relay, settings);
|
||||
});
|
||||
@ -76,11 +81,13 @@ const router = createBrowserRouter([
|
||||
element: <ChatPopout />,
|
||||
},
|
||||
]);
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLDivElement
|
||||
);
|
||||
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLDivElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<SnortContext.Provider value={System}>
|
||||
<IntlProvider>
|
||||
<RouterProvider router={router} />
|
||||
</IntlProvider>
|
||||
</SnortContext.Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
40
src/intl.tsx
Normal file
40
src/intl.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { IntlProvider as ReactIntlProvider } from "react-intl";
|
||||
|
||||
import enMessages from "translations/en.json";
|
||||
|
||||
const DefaultLocale = "en-US";
|
||||
|
||||
const getMessages = (locale: string) => {
|
||||
const truncatedLocale = locale.toLowerCase().split(/[_-]+/)[0];
|
||||
|
||||
const matchLang = (lng: string) => {
|
||||
switch (lng) {
|
||||
case DefaultLocale:
|
||||
case "en":
|
||||
return enMessages;
|
||||
}
|
||||
};
|
||||
|
||||
return matchLang(locale) ?? matchLang(truncatedLocale) ?? enMessages;
|
||||
};
|
||||
|
||||
export const IntlProvider = ({ children }: { children: ReactNode }) => {
|
||||
const locale = getLocale();
|
||||
const [messages, setMessages] = useState<Record<string, string>>(enMessages);
|
||||
|
||||
useEffect(() => {
|
||||
const msg = getMessages(locale);
|
||||
setMessages(msg);
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<ReactIntlProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
</ReactIntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const getLocale = () => {
|
||||
return (navigator.languages && navigator.languages[0]) ?? navigator.language ?? DefaultLocale;
|
||||
};
|
281
src/lang.json
Normal file
281
src/lang.json
Normal file
@ -0,0 +1,281 @@
|
||||
{
|
||||
"+0zv6g": {
|
||||
"defaultMessage": "Image"
|
||||
},
|
||||
"+vVZ/G": {
|
||||
"defaultMessage": "Connect"
|
||||
},
|
||||
"/0TOL5": {
|
||||
"defaultMessage": "Amount"
|
||||
},
|
||||
"/GCoTA": {
|
||||
"defaultMessage": "Clear"
|
||||
},
|
||||
"04lmFi": {
|
||||
"defaultMessage": "Save Key"
|
||||
},
|
||||
"0GfNiL": {
|
||||
"defaultMessage": "Stream Zap Goals"
|
||||
},
|
||||
"1EYCdR": {
|
||||
"defaultMessage": "Tags"
|
||||
},
|
||||
"2/2yg+": {
|
||||
"defaultMessage": "Add"
|
||||
},
|
||||
"2CGh/0": {
|
||||
"defaultMessage": "live"
|
||||
},
|
||||
"3HwrQo": {
|
||||
"defaultMessage": "Zap!"
|
||||
},
|
||||
"3adEeb": {
|
||||
"defaultMessage": "{n} viewers"
|
||||
},
|
||||
"47FYwb": {
|
||||
"defaultMessage": "Cancel"
|
||||
},
|
||||
"4l6vz1": {
|
||||
"defaultMessage": "Copy"
|
||||
},
|
||||
"4uI538": {
|
||||
"defaultMessage": "Resolutions"
|
||||
},
|
||||
"5JcXdV": {
|
||||
"defaultMessage": "Create Account"
|
||||
},
|
||||
"5QYdPU": {
|
||||
"defaultMessage": "Start Time"
|
||||
},
|
||||
"5kx+2v": {
|
||||
"defaultMessage": "Server Url"
|
||||
},
|
||||
"6Z2pvJ": {
|
||||
"defaultMessage": "Stream Providers"
|
||||
},
|
||||
"9WRlF4": {
|
||||
"defaultMessage": "Send"
|
||||
},
|
||||
"9a9+ww": {
|
||||
"defaultMessage": "Title"
|
||||
},
|
||||
"9anxhq": {
|
||||
"defaultMessage": "Starts"
|
||||
},
|
||||
"AIHaPH": {
|
||||
"defaultMessage": "{person} zapped {amount} sats"
|
||||
},
|
||||
"Atr2p4": {
|
||||
"defaultMessage": "NSFW Content"
|
||||
},
|
||||
"AyGauy": {
|
||||
"defaultMessage": "Login"
|
||||
},
|
||||
"BGxpTN": {
|
||||
"defaultMessage": "Stream Chat"
|
||||
},
|
||||
"C81/uG": {
|
||||
"defaultMessage": "Logout"
|
||||
},
|
||||
"ESyhzp": {
|
||||
"defaultMessage": "Your comment for {name}"
|
||||
},
|
||||
"G/yZLu": {
|
||||
"defaultMessage": "Remove"
|
||||
},
|
||||
"Gq6x9o": {
|
||||
"defaultMessage": "Cover Image"
|
||||
},
|
||||
"H5+NAX": {
|
||||
"defaultMessage": "Balance"
|
||||
},
|
||||
"HAlOn1": {
|
||||
"defaultMessage": "Name"
|
||||
},
|
||||
"I1kjHI": {
|
||||
"defaultMessage": "Supports {markdown}"
|
||||
},
|
||||
"IJDKz3": {
|
||||
"defaultMessage": "Zap amount in {currency}"
|
||||
},
|
||||
"Jq3FDz": {
|
||||
"defaultMessage": "Content"
|
||||
},
|
||||
"K3r6DQ": {
|
||||
"defaultMessage": "Delete"
|
||||
},
|
||||
"K3uH1C": {
|
||||
"defaultMessage": "offline"
|
||||
},
|
||||
"K7AkdL": {
|
||||
"defaultMessage": "Show"
|
||||
},
|
||||
"KkIL3s": {
|
||||
"defaultMessage": "No, I am under 18"
|
||||
},
|
||||
"Ld5LAE": {
|
||||
"defaultMessage": "Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore!"
|
||||
},
|
||||
"LknBsU": {
|
||||
"defaultMessage": "Stream Key"
|
||||
},
|
||||
"My6HwN": {
|
||||
"defaultMessage": "Ok, it's safe"
|
||||
},
|
||||
"O2Cy6m": {
|
||||
"defaultMessage": "Yes, I am over 18"
|
||||
},
|
||||
"OKhRC6": {
|
||||
"defaultMessage": "Share"
|
||||
},
|
||||
"OWgHbg": {
|
||||
"defaultMessage": "Edit card"
|
||||
},
|
||||
"Q3au2v": {
|
||||
"defaultMessage": "About {estimate}"
|
||||
},
|
||||
"QRHNuF": {
|
||||
"defaultMessage": "What are we steaming today?"
|
||||
},
|
||||
"QRRCp0": {
|
||||
"defaultMessage": "Stream URL"
|
||||
},
|
||||
"QceMQZ": {
|
||||
"defaultMessage": "Goal: {amount}"
|
||||
},
|
||||
"RJOmzk": {
|
||||
"defaultMessage": "I have read and agree with {provider}''s {terms}."
|
||||
},
|
||||
"RXQdxR": {
|
||||
"defaultMessage": "Please login to write messages!"
|
||||
},
|
||||
"RrCui3": {
|
||||
"defaultMessage": "Summary"
|
||||
},
|
||||
"TaTRKo": {
|
||||
"defaultMessage": "Start Stream"
|
||||
},
|
||||
"UJBFYK": {
|
||||
"defaultMessage": "Add Card"
|
||||
},
|
||||
"UfSot5": {
|
||||
"defaultMessage": "Past Streams"
|
||||
},
|
||||
"VA/Z1S": {
|
||||
"defaultMessage": "Hide"
|
||||
},
|
||||
"W9355R": {
|
||||
"defaultMessage": "Unmute"
|
||||
},
|
||||
"X2PZ7D": {
|
||||
"defaultMessage": "Create Goal"
|
||||
},
|
||||
"ZmqxZs": {
|
||||
"defaultMessage": "You can change this later"
|
||||
},
|
||||
"acrOoz": {
|
||||
"defaultMessage": "Continue"
|
||||
},
|
||||
"cvAsEh": {
|
||||
"defaultMessage": "Streamed on {date}"
|
||||
},
|
||||
"cyR7Kh": {
|
||||
"defaultMessage": "Back"
|
||||
},
|
||||
"dVD/AR": {
|
||||
"defaultMessage": "Top Zappers"
|
||||
},
|
||||
"ebmhes": {
|
||||
"defaultMessage": "Nostr Extension"
|
||||
},
|
||||
"fBI91o": {
|
||||
"defaultMessage": "Zap"
|
||||
},
|
||||
"hGQqkW": {
|
||||
"defaultMessage": "Schedule"
|
||||
},
|
||||
"ieGrWo": {
|
||||
"defaultMessage": "Follow"
|
||||
},
|
||||
"itPgxd": {
|
||||
"defaultMessage": "Profile"
|
||||
},
|
||||
"izWS4J": {
|
||||
"defaultMessage": "Unfollow"
|
||||
},
|
||||
"jr4+vD": {
|
||||
"defaultMessage": "Markdown"
|
||||
},
|
||||
"jvo0vs": {
|
||||
"defaultMessage": "Save"
|
||||
},
|
||||
"lZpRMR": {
|
||||
"defaultMessage": "Check here if this stream contains nudity or pornographic content."
|
||||
},
|
||||
"ljmS5P": {
|
||||
"defaultMessage": "Endpoint"
|
||||
},
|
||||
"mtNGwh": {
|
||||
"defaultMessage": "A short description of the content"
|
||||
},
|
||||
"nBCvvJ": {
|
||||
"defaultMessage": "Topup"
|
||||
},
|
||||
"nOaArs": {
|
||||
"defaultMessage": "Setup Profile"
|
||||
},
|
||||
"nwA8Os": {
|
||||
"defaultMessage": "Add card"
|
||||
},
|
||||
"oHPB8Q": {
|
||||
"defaultMessage": "Zap {name}"
|
||||
},
|
||||
"oZrFyI": {
|
||||
"defaultMessage": "Stream type should be HLS"
|
||||
},
|
||||
"pO/lPX": {
|
||||
"defaultMessage": "Scheduled for {date}"
|
||||
},
|
||||
"rWBFZA": {
|
||||
"defaultMessage": "Sexually explicit material ahead!"
|
||||
},
|
||||
"rbrahO": {
|
||||
"defaultMessage": "Close"
|
||||
},
|
||||
"rfC1Zq": {
|
||||
"defaultMessage": "Save card"
|
||||
},
|
||||
"s5ksS7": {
|
||||
"defaultMessage": "Image Link"
|
||||
},
|
||||
"s7V+5p": {
|
||||
"defaultMessage": "Confirm your age"
|
||||
},
|
||||
"thsiMl": {
|
||||
"defaultMessage": "terms and conditions"
|
||||
},
|
||||
"tzMNF3": {
|
||||
"defaultMessage": "Status"
|
||||
},
|
||||
"uYw2LD": {
|
||||
"defaultMessage": "Stream"
|
||||
},
|
||||
"vrTOHJ": {
|
||||
"defaultMessage": "{amount} sats"
|
||||
},
|
||||
"wCIL7o": {
|
||||
"defaultMessage": "Broadcast on Nostr"
|
||||
},
|
||||
"wEQDC6": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
"wOy57k": {
|
||||
"defaultMessage": "Add stream goal"
|
||||
},
|
||||
"wzWWzV": {
|
||||
"defaultMessage": "Top zappers"
|
||||
},
|
||||
"x82IOl": {
|
||||
"defaultMessage": "Mute"
|
||||
}
|
||||
}
|
@ -130,10 +130,7 @@ export function getPublisher(session: LoginSession) {
|
||||
return new EventPublisher(new Nip7Signer(), session.pubkey);
|
||||
}
|
||||
case LoginType.PrivateKey: {
|
||||
return new EventPublisher(
|
||||
new PrivateKeySigner(unwrap(session.privateKey)),
|
||||
session.pubkey
|
||||
);
|
||||
return new EventPublisher(new PrivateKeySigner(unwrap(session.privateKey)), session.pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,15 +11,7 @@ export function ChatPopout() {
|
||||
const link = parseNostrLink(unwrap(params.id));
|
||||
const ev = useCurrentStreamFeed(link, true);
|
||||
|
||||
const lnk = parseNostrLink(
|
||||
encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
findTag(ev, "d") ?? "",
|
||||
undefined,
|
||||
ev?.kind,
|
||||
ev?.pubkey
|
||||
)
|
||||
);
|
||||
const lnk = parseNostrLink(encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev?.kind, ev?.pubkey));
|
||||
const chat = Boolean(new URL(window.location.href).searchParams.get("chat"));
|
||||
return (
|
||||
<div className={`popout-chat${chat ? "" : " embed"}`}>
|
||||
|
@ -12,6 +12,7 @@ import { LoginSignup } from "element/login-signup";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import { hexToBech32 } from "@snort/shared";
|
||||
import { Login } from "index";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function LayoutPage() {
|
||||
const navigate = useNavigate();
|
||||
@ -40,13 +41,10 @@ export function LayoutPage() {
|
||||
</div>
|
||||
}
|
||||
align="end"
|
||||
gap={5}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => navigate(`/p/${hexToBech32("npub", login.pubkey)}`)}
|
||||
>
|
||||
gap={5}>
|
||||
<MenuItem onClick={() => navigate(`/p/${hexToBech32("npub", login.pubkey)}`)}>
|
||||
<Icon name="user" size={24} />
|
||||
Profile
|
||||
<FormattedMessage defaultMessage="Profile" />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => navigate("/settings")}>
|
||||
<Icon name="settings" size={24} />
|
||||
@ -54,7 +52,7 @@ export function LayoutPage() {
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => Login.logout()}>
|
||||
<Icon name="logout" size={24} />
|
||||
Logout
|
||||
<FormattedMessage defaultMessage="Logout" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
@ -67,12 +65,8 @@ export function LayoutPage() {
|
||||
return (
|
||||
<Dialog.Root open={showLogin} onOpenChange={setShowLogin}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-border"
|
||||
onClick={() => setShowLogin(true)}
|
||||
>
|
||||
Login
|
||||
<button type="button" className="btn btn-border" onClick={() => setShowLogin(true)}>
|
||||
<FormattedMessage defaultMessage="Login" />
|
||||
<Icon name="login" />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
@ -87,11 +81,7 @@ export function LayoutPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`page${
|
||||
location.pathname.startsWith("/naddr1") ? " stream" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`page${location.pathname.startsWith("/naddr1") ? " stream" : ""}`}>
|
||||
<Helmet>
|
||||
<title>Home - zap.stream</title>
|
||||
</Helmet>
|
||||
|
@ -162,12 +162,7 @@
|
||||
|
||||
.tabs-tab[data-state="active"] .tab-border {
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
94.73deg,
|
||||
#2bd9ff 0%,
|
||||
#8c8ded 47.4%,
|
||||
#f838d9 100%
|
||||
);
|
||||
background: linear-gradient(94.73deg, #2bd9ff 0%, #8c8ded 47.4%, #f838d9 100%);
|
||||
}
|
||||
|
||||
.tabs-content {
|
||||
|
@ -3,12 +3,7 @@ import { useMemo } from "react";
|
||||
import moment from "moment";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import {
|
||||
parseNostrLink,
|
||||
NostrPrefix,
|
||||
ParsedZap,
|
||||
encodeTLV,
|
||||
} from "@snort/system";
|
||||
import { parseNostrLink, NostrPrefix, ParsedZap, encodeTLV } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { Profile } from "element/profile";
|
||||
@ -21,9 +16,10 @@ import { useProfile } from "hooks/profile";
|
||||
import useTopZappers from "hooks/top-zappers";
|
||||
import usePlaceholder from "hooks/placeholders";
|
||||
import { Text } from "element/text";
|
||||
import { StreamState, System } from "index";
|
||||
import { StreamState } from "index";
|
||||
import { findTag } from "utils";
|
||||
import { formatSats } from "number";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
function Zapper({ pubkey, total }: { pubkey: string; total: number }) {
|
||||
return (
|
||||
@ -41,7 +37,7 @@ function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
|
||||
const zappers = useTopZappers(zaps);
|
||||
return (
|
||||
<section className="profile-top-zappers">
|
||||
{zappers.map((z) => (
|
||||
{zappers.map(z => (
|
||||
<Zapper key={z.pubkey} pubkey={z.pubkey} total={z.total} />
|
||||
))}
|
||||
</section>
|
||||
@ -55,32 +51,24 @@ export function ProfilePage() {
|
||||
const params = useParams();
|
||||
const link = parseNostrLink(unwrap(params.npub));
|
||||
const placeholder = usePlaceholder(link.id);
|
||||
const profile = useUserProfile(System, link.id);
|
||||
const profile = useUserProfile(link.id);
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
const { streams, zaps } = useProfile(link, true);
|
||||
const liveEvent = useMemo(() => {
|
||||
return streams.find((ev) => findTag(ev, "status") === StreamState.Live);
|
||||
return streams.find(ev => findTag(ev, "status") === StreamState.Live);
|
||||
}, [streams]);
|
||||
const pastStreams = useMemo(() => {
|
||||
return streams.filter((ev) => findTag(ev, "status") === StreamState.Ended);
|
||||
return streams.filter(ev => findTag(ev, "status") === StreamState.Ended);
|
||||
}, [streams]);
|
||||
const futureStreams = useMemo(() => {
|
||||
return streams.filter(
|
||||
(ev) => findTag(ev, "status") === StreamState.Planned
|
||||
);
|
||||
return streams.filter(ev => findTag(ev, "status") === StreamState.Planned);
|
||||
}, [streams]);
|
||||
const isLive = Boolean(liveEvent);
|
||||
|
||||
function goToLive() {
|
||||
if (liveEvent) {
|
||||
const d = findTag(liveEvent, "d") || "";
|
||||
const naddr = encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
d,
|
||||
undefined,
|
||||
liveEvent.kind,
|
||||
liveEvent.pubkey
|
||||
);
|
||||
const naddr = encodeTLV(NostrPrefix.Address, d, undefined, liveEvent.kind, liveEvent.pubkey);
|
||||
navigate(`/${naddr}`);
|
||||
}
|
||||
}
|
||||
@ -88,52 +76,39 @@ export function ProfilePage() {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-container">
|
||||
<img
|
||||
className="banner"
|
||||
alt={profile?.name || link.id}
|
||||
src={profile?.banner || defaultBanner}
|
||||
/>
|
||||
<img className="banner" alt={profile?.name || link.id} src={profile?.banner || defaultBanner} />
|
||||
<div className="profile-content">
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
className="avatar"
|
||||
alt={profile.name || link.id}
|
||||
src={profile.picture}
|
||||
/>
|
||||
<img className="avatar" alt={profile.name || link.id} src={profile.picture} />
|
||||
) : (
|
||||
<img
|
||||
className="avatar"
|
||||
alt={profile?.name || link.id}
|
||||
src={placeholder}
|
||||
/>
|
||||
<img className="avatar" alt={profile?.name || link.id} src={placeholder} />
|
||||
)}
|
||||
<div className="status-indicator">
|
||||
{isLive ? (
|
||||
<div className="live-button pill live" onClick={goToLive}>
|
||||
<Icon name="signal" />
|
||||
<span>live</span>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="live" />
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="pill offline">offline</span>
|
||||
<span className="pill offline">
|
||||
<FormattedMessage defaultMessage="offline" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-actions">
|
||||
{zapTarget && (
|
||||
<SendZapsDialog
|
||||
aTag={
|
||||
liveEvent
|
||||
? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(
|
||||
liveEvent,
|
||||
"d"
|
||||
)}`
|
||||
: undefined
|
||||
}
|
||||
aTag={liveEvent ? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(liveEvent, "d")}` : undefined}
|
||||
lnurl={zapTarget}
|
||||
button={
|
||||
<button className="btn">
|
||||
<div className="zap-button">
|
||||
<Icon name="zap-filled" className="zap-button-icon" />
|
||||
<span>Zap</span>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Zap" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
@ -152,22 +127,17 @@ export function ProfilePage() {
|
||||
)}
|
||||
</div>
|
||||
<Tabs.Root className="tabs-root" defaultValue="top-zappers">
|
||||
<Tabs.List
|
||||
className="tabs-list"
|
||||
aria-label={`Information about ${
|
||||
profile ? profile.name : link.id
|
||||
}`}
|
||||
>
|
||||
<Tabs.List className="tabs-list" aria-label={`Information about ${profile ? profile.name : link.id}`}>
|
||||
<Tabs.Trigger className="tabs-tab" value="top-zappers">
|
||||
Top Zappers
|
||||
<FormattedMessage defaultMessage="Top Zappers" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger className="tabs-tab" value="past-streams">
|
||||
Past Streams
|
||||
<FormattedMessage defaultMessage="Past Streams" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger className="tabs-tab" value="schedule">
|
||||
Schedule
|
||||
<FormattedMessage defaultMessage="Schedule" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
@ -176,14 +146,16 @@ export function ProfilePage() {
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="tabs-content" value="past-streams">
|
||||
<div className="stream-list">
|
||||
{pastStreams.map((ev) => (
|
||||
{pastStreams.map(ev => (
|
||||
<div key={ev.id} className="stream-item">
|
||||
<VideoTile ev={ev} showAuthor={false} showStatus={false} />
|
||||
<span className="timestamp">
|
||||
Streamed on{" "}
|
||||
{moment(Number(ev.created_at) * 1000).format(
|
||||
"MMM DD, YYYY"
|
||||
)}
|
||||
<FormattedMessage
|
||||
defaultMessage="Streamed on {date}"
|
||||
values={{
|
||||
date: moment(Number(ev.created_at) * 1000).format("MMM DD, YYYY"),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@ -191,14 +163,16 @@ export function ProfilePage() {
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="tabs-content" value="schedule">
|
||||
<div className="stream-list">
|
||||
{futureStreams.map((ev) => (
|
||||
{futureStreams.map(ev => (
|
||||
<div key={ev.id} className="stream-item">
|
||||
<VideoTile ev={ev} showAuthor={false} showStatus={false} />
|
||||
<span className="timestamp">
|
||||
Scheduled for{" "}
|
||||
{moment(Number(ev.created_at) * 1000).format(
|
||||
"MMM DD, YYYY h:mm:ss a"
|
||||
)}
|
||||
<FormattedMessage
|
||||
defaultMessage="Scheduled for {date}"
|
||||
values={{
|
||||
date: moment(Number(ev.created_at) * 1000).format("MMM DD, YYYY h:mm:ss a"),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
@ -48,16 +48,9 @@ export function StreamProvidersPage() {
|
||||
return (
|
||||
<div className="stream-providers-page">
|
||||
<h1>Providers</h1>
|
||||
<p>
|
||||
Stream providers streamline the process of streaming on Nostr, some
|
||||
event accept lightning payments!
|
||||
</p>
|
||||
<p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p>
|
||||
<div className="stream-providers-grid">
|
||||
{[
|
||||
StreamProviders.NostrType,
|
||||
StreamProviders.Owncast,
|
||||
StreamProviders.Cloudflare,
|
||||
].map((v) => providerLink(v))}
|
||||
{[StreamProviders.NostrType, StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -6,6 +6,7 @@ import { StatePill } from "element/state-pill";
|
||||
import { StreamState } from "index";
|
||||
import { StreamProviderInfo, StreamProviderStore } from "providers";
|
||||
import { Nip103StreamProvider } from "providers/zsz";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function ConfigureNostrType() {
|
||||
const [url, setUrl] = useState("");
|
||||
@ -59,9 +60,8 @@ export function ConfigureNostrType() {
|
||||
onClick={() => {
|
||||
StreamProviderStore.add(new Nip103StreamProvider(url));
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
Save
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Save" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@ -74,16 +74,11 @@ export function ConfigureNostrType() {
|
||||
<div>
|
||||
<p>Nostr streaming provider URL</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
|
||||
Connect
|
||||
<FormattedMessage defaultMessage="Connect" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
<div>{status()}</div>
|
||||
|
@ -59,8 +59,7 @@ export function ConfigureOwncast() {
|
||||
onClick={() => {
|
||||
StreamProviderStore.add(new OwncastProvider(url, token));
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
@ -74,22 +73,13 @@ export function ConfigureOwncast() {
|
||||
<div>
|
||||
<p>Owncast instance url</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>API token</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
<input type="password" value={token} onChange={e => setToken(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
|
||||
|
@ -15,19 +15,17 @@ export function RootPage() {
|
||||
const tags = login?.follows.tags ?? [];
|
||||
const followsHost = useCallback(
|
||||
(ev: NostrEvent) => {
|
||||
return tags.find((t) => t.at(1) === getHost(ev));
|
||||
return tags.find(t => t.at(1) === getHost(ev));
|
||||
},
|
||||
[tags]
|
||||
);
|
||||
const hashtags = getTagValues(tags, "t");
|
||||
const following = live.filter(followsHost);
|
||||
const liveNow = live.filter((e) => !following.includes(e));
|
||||
const liveNow = live.filter(e => !following.includes(e));
|
||||
const hasFollowingLive = following.length > 0;
|
||||
|
||||
const plannedEvents = planned
|
||||
.filter((e) => !mutedHosts.has(getHost(e)))
|
||||
.filter(followsHost);
|
||||
const endedEvents = ended.filter((e) => !mutedHosts.has(getHost(e)));
|
||||
const plannedEvents = planned.filter(e => !mutedHosts.has(getHost(e))).filter(followsHost);
|
||||
const endedEvents = ended.filter(e => !mutedHosts.has(getHost(e)));
|
||||
|
||||
return (
|
||||
<div className="homepage">
|
||||
@ -35,7 +33,7 @@ export function RootPage() {
|
||||
<>
|
||||
<h2 className="divider line one-line">Following</h2>
|
||||
<div className="video-grid">
|
||||
{following.map((e) => (
|
||||
{following.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
@ -44,23 +42,23 @@ export function RootPage() {
|
||||
{!hasFollowingLive && (
|
||||
<div className="video-grid">
|
||||
{live
|
||||
.filter((e) => !mutedHosts.has(getHost(e)))
|
||||
.map((e) => (
|
||||
.filter(e => !mutedHosts.has(getHost(e)))
|
||||
.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hashtags.map((t) => (
|
||||
{hashtags.map(t => (
|
||||
<>
|
||||
<h2 className="divider line one-line">#{t}</h2>
|
||||
<div className="video-grid">
|
||||
{live
|
||||
.filter((e) => !mutedHosts.has(getHost(e)))
|
||||
.filter((e) => {
|
||||
.filter(e => !mutedHosts.has(getHost(e)))
|
||||
.filter(e => {
|
||||
const evTags = getTagValues(e.tags, "t");
|
||||
return evTags.includes(t);
|
||||
})
|
||||
.map((e) => (
|
||||
.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
@ -71,8 +69,8 @@ export function RootPage() {
|
||||
<h2 className="divider line one-line">Live</h2>
|
||||
<div className="video-grid">
|
||||
{liveNow
|
||||
.filter((e) => !mutedHosts.has(getHost(e)))
|
||||
.map((e) => (
|
||||
.filter(e => !mutedHosts.has(getHost(e)))
|
||||
.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
@ -82,7 +80,7 @@ export function RootPage() {
|
||||
<>
|
||||
<h2 className="divider line one-line">Planned</h2>
|
||||
<div className="video-grid">
|
||||
{plannedEvents.map((e) => (
|
||||
{plannedEvents.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
@ -92,7 +90,7 @@ export function RootPage() {
|
||||
<>
|
||||
<h2 className="divider line one-line">Ended</h2>
|
||||
<div className="video-grid">
|
||||
{endedEvents.map((e) => (
|
||||
{endedEvents.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
|
@ -2,9 +2,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: auto 450px;
|
||||
gap: var(--gap-m);
|
||||
height: calc(
|
||||
100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s)
|
||||
);
|
||||
height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s));
|
||||
}
|
||||
|
||||
.stream-page .video-content {
|
||||
@ -33,19 +31,14 @@
|
||||
padding: 24px 16px 8px 24px;
|
||||
border: 1px solid #171717;
|
||||
border-radius: 24px;
|
||||
height: calc(
|
||||
100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s) -
|
||||
24px - 8px
|
||||
);
|
||||
height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s) - 24px - 8px);
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.stream-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(
|
||||
100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s)
|
||||
);
|
||||
height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s));
|
||||
}
|
||||
|
||||
.stream-page .video-content {
|
||||
|
@ -1,17 +1,11 @@
|
||||
import "./stream-page.css";
|
||||
import { NostrLink, NostrPrefix, TaggedRawEvent, tryParseNostrLink } from "@snort/system";
|
||||
import { NostrLink, NostrPrefix, TaggedNostrEvent, tryParseNostrLink } from "@snort/system";
|
||||
import { fetchNip05Pubkey } from "@snort/shared";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
import { LiveVideoPlayer } from "element/live-video-player";
|
||||
import {
|
||||
createNostrLink,
|
||||
findTag,
|
||||
getEventFromLocationState,
|
||||
getHost,
|
||||
hexToBech32,
|
||||
} from "utils";
|
||||
import { createNostrLink, findTag, getEventFromLocationState, getHost, hexToBech32 } from "utils";
|
||||
import { Profile, getName } from "element/profile";
|
||||
import { LiveChat } from "element/live-chat";
|
||||
import AsyncButton from "element/async-button";
|
||||
@ -28,18 +22,15 @@ import { StreamCards } from "element/stream-cards";
|
||||
import { formatSats } from "number";
|
||||
import { StreamTimer } from "element/stream-time";
|
||||
import { ShareMenu } from "element/share-menu";
|
||||
import {
|
||||
ContentWarningOverlay,
|
||||
isContentWarningAccepted,
|
||||
} from "element/content-warning";
|
||||
import { ContentWarningOverlay, isContentWarningAccepted } from "element/content-warning";
|
||||
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) {
|
||||
function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedNostrEvent }) {
|
||||
const login = useLogin();
|
||||
const navigate = useNavigate();
|
||||
const host = getHost(ev);
|
||||
const profile = useUserProfile(System, host);
|
||||
const profile = useUserProfile(host);
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
|
||||
const status = findTag(ev, "status") ?? "";
|
||||
@ -64,11 +55,7 @@ function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) {
|
||||
<p>{findTag(ev, "summary")}</p>
|
||||
<div className="tags">
|
||||
<StatePill state={status as StreamState} />
|
||||
{viewers > 0 && (
|
||||
<span className="pill viewers">
|
||||
{formatSats(viewers)} viewers
|
||||
</span>
|
||||
)}
|
||||
{viewers > 0 && <span className="pill viewers">{formatSats(viewers)} viewers</span>}
|
||||
{status === StreamState.Live && (
|
||||
<span className="pill">
|
||||
<StreamTimer ev={ev} />
|
||||
@ -79,11 +66,7 @@ function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) {
|
||||
{isMine && (
|
||||
<div className="actions">
|
||||
{ev && <NewStreamDialog text="Edit" ev={ev} btnClassName="btn" />}
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-warning"
|
||||
onClick={deleteStream}
|
||||
>
|
||||
<AsyncButton type="button" className="btn btn-warning" onClick={deleteStream}>
|
||||
Delete
|
||||
</AsyncButton>
|
||||
</div>
|
||||
@ -131,20 +114,20 @@ export function StreamPageHandler() {
|
||||
setLink({
|
||||
id: d,
|
||||
type: NostrPrefix.PublicKey,
|
||||
encode: () => hexToBech32(NostrPrefix.PublicKey, d)
|
||||
encode: () => hexToBech32(NostrPrefix.PublicKey, d),
|
||||
} as NostrLink);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [params.id]);
|
||||
|
||||
if (link) {
|
||||
return <StreamPage link={link} evPreload={evPreload} />
|
||||
return <StreamPage link={link} evPreload={evPreload} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent, link: NostrLink }) {
|
||||
export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link: NostrLink }) {
|
||||
const ev = useCurrentStreamFeed(link, true, evPreload);
|
||||
const host = getHost(ev);
|
||||
const goal = useZapGoal(host, createNostrLink(ev), true);
|
||||
@ -153,31 +136,21 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent, link:
|
||||
const summary = findTag(ev, "summary");
|
||||
const image = findTag(ev, "image");
|
||||
const status = findTag(ev, "status");
|
||||
const stream =
|
||||
status === StreamState.Live
|
||||
? findTag(ev, "streaming")
|
||||
: findTag(ev, "recording");
|
||||
const stream = status === StreamState.Live ? findTag(ev, "streaming") : findTag(ev, "recording");
|
||||
const contentWarning = findTag(ev, "content-warning");
|
||||
const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? [];
|
||||
const tags = ev?.tags.filter(a => a[0] === "t").map(a => a[1]) ?? [];
|
||||
|
||||
if (contentWarning && !isContentWarningAccepted()) {
|
||||
return <ContentWarningOverlay />;
|
||||
}
|
||||
|
||||
const descriptionContent = [
|
||||
title,
|
||||
(summary?.length ?? 0) > 0 ? summary : "Nostr live streaming",
|
||||
...tags,
|
||||
].join(", ");
|
||||
const descriptionContent = [title, (summary?.length ?? 0) > 0 ? summary : "Nostr live streaming", ...tags].join(", ");
|
||||
return (
|
||||
<div className="stream-page">
|
||||
<Helmet>
|
||||
<title>{`${title} - zap.stream`}</title>
|
||||
<meta name="description" content={descriptionContent} />
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`https://${window.location.host}/${link.encode()}`}
|
||||
/>
|
||||
<meta property="og:url" content={`https://${window.location.host}/${link.encode()}`} />
|
||||
<meta property="og:type" content="video" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={descriptionContent} />
|
||||
|
@ -16,7 +16,7 @@ export function TagPage() {
|
||||
<FollowTagButton tag={unwrap(tag)} />
|
||||
</div>
|
||||
<div className="video-grid">
|
||||
{live.map((e) => (
|
||||
{live.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
|
@ -80,8 +80,7 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
|
||||
super();
|
||||
const cache = window.localStorage.getItem("providers");
|
||||
if (cache) {
|
||||
const cached: Array<{ type: StreamProviders } & Record<string, unknown>> =
|
||||
JSON.parse(cache);
|
||||
const cached: Array<{ type: StreamProviders } & Record<string, unknown>> = JSON.parse(cache);
|
||||
for (const c of cached) {
|
||||
switch (c.type) {
|
||||
case StreamProviders.Manual: {
|
||||
@ -93,9 +92,7 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
|
||||
break;
|
||||
}
|
||||
case StreamProviders.Owncast: {
|
||||
this.#providers.push(
|
||||
new OwncastProvider(c.url as string, c.token as string)
|
||||
);
|
||||
this.#providers.push(new OwncastProvider(c.url as string, c.token as string));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -110,14 +107,12 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
|
||||
}
|
||||
|
||||
takeSnapshot() {
|
||||
const defaultProvider = new Nip103StreamProvider(
|
||||
"https://api.zap.stream/api/nostr/"
|
||||
);
|
||||
const defaultProvider = new Nip103StreamProvider("https://api.zap.stream/api/nostr/");
|
||||
return [defaultProvider, new ManualProvider(), ...this.#providers];
|
||||
}
|
||||
|
||||
#save() {
|
||||
const cfg = this.#providers.map((a) => a.createConfig());
|
||||
const cfg = this.#providers.map(a => a.createConfig());
|
||||
window.localStorage.setItem("providers", JSON.stringify(cfg));
|
||||
}
|
||||
}
|
||||
|
@ -52,11 +52,7 @@ export class OwncastProvider implements StreamProvider {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async #getJson<T>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
async #getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||
const rsp = await fetch(`${this.#url}${path}`, {
|
||||
method,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
|
@ -36,7 +36,7 @@ export class Nip103StreamProvider implements StreamProvider {
|
||||
balance: rsp.balance,
|
||||
tosAccepted: rsp.tos?.accepted,
|
||||
tosLink: rsp.tos?.link,
|
||||
endpoints: rsp.endpoints.map((a) => {
|
||||
endpoints: rsp.endpoints.map(a => {
|
||||
return {
|
||||
name: a.name,
|
||||
url: a.url,
|
||||
@ -60,7 +60,7 @@ export class Nip103StreamProvider implements StreamProvider {
|
||||
const title = findTag(ev, "title");
|
||||
const summary = findTag(ev, "summary");
|
||||
const image = findTag(ev, "image");
|
||||
const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]);
|
||||
const tags = ev?.tags.filter(a => a[0] === "t").map(a => a[1]);
|
||||
const contentWarning = findTag(ev, "content-warning");
|
||||
await this.#getJson("PATCH", "event", {
|
||||
title,
|
||||
@ -72,10 +72,7 @@ export class Nip103StreamProvider implements StreamProvider {
|
||||
}
|
||||
|
||||
async topup(amount: number): Promise<string> {
|
||||
const rsp = await this.#getJson<TopUpResponse>(
|
||||
"GET",
|
||||
`topup?amount=${amount}`
|
||||
);
|
||||
const rsp = await this.#getJson<TopUpResponse>("GET", `topup?amount=${amount}`);
|
||||
return rsp.pr;
|
||||
}
|
||||
|
||||
@ -85,22 +82,14 @@ export class Nip103StreamProvider implements StreamProvider {
|
||||
});
|
||||
}
|
||||
|
||||
async #getJson<T>(
|
||||
method: "GET" | "POST" | "PATCH",
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
async #getJson<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T> {
|
||||
const login = Login.snapshot();
|
||||
const pub = login && getPublisher(login);
|
||||
if (!pub) throw new Error("No signer");
|
||||
|
||||
const u = `${this.#url}${path}`;
|
||||
const token = await pub.generic((eb) => {
|
||||
return eb
|
||||
.kind(EventKind.HttpAuthentication)
|
||||
.content("")
|
||||
.tag(["u", u])
|
||||
.tag(["method", method]);
|
||||
const token = await pub.generic(eb => {
|
||||
return eb.kind(EventKind.HttpAuthentication).content("").tag(["u", u]).tag(["method", method]);
|
||||
});
|
||||
const rsp = await fetch(u, {
|
||||
method,
|
||||
|
@ -1,40 +1,15 @@
|
||||
/// <reference lib="webworker" />
|
||||
import {} from ".";
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: (string | PrecacheEntry)[];
|
||||
};
|
||||
|
||||
import { clientsClaim } from "workbox-core";
|
||||
import { registerRoute } from "workbox-routing";
|
||||
import { CacheFirst } from "workbox-strategies";
|
||||
import { PrecacheEntry, precacheAndRoute } from "workbox-precaching";
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
clientsClaim();
|
||||
|
||||
const staticTypes = ["image", "video", "audio", "script", "style", "font"];
|
||||
registerRoute(
|
||||
({ request, url }) =>
|
||||
url.origin === self.location.origin &&
|
||||
staticTypes.includes(request.destination),
|
||||
new CacheFirst({
|
||||
cacheName: "static-content",
|
||||
})
|
||||
);
|
||||
|
||||
// External media domains which have unique urls (never changing content) and can be cached forever
|
||||
const externalMediaHosts = [
|
||||
"void.cat",
|
||||
"nostr.build",
|
||||
"imgur.com",
|
||||
"i.imgur.com",
|
||||
"pbs.twimg.com",
|
||||
"i.ibb.co",
|
||||
];
|
||||
registerRoute(
|
||||
({ url }) => externalMediaHosts.includes(url.host),
|
||||
new CacheFirst({
|
||||
cacheName: "ext-content-hosts",
|
||||
})
|
||||
);
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
self.addEventListener("message", event => {
|
||||
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||
self.skipWaiting();
|
||||
}
|
||||
|
30
src/serviceWorker.ts
Normal file
30
src/serviceWorker.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export function register() {
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
registerValidSW("/service-worker.js");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function registerValidSW(swUrl: string) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(swUrl);
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (installingWorker === null) {
|
||||
return;
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === "installed") {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("Service worker updated, pending reload");
|
||||
} else {
|
||||
console.log("Content is cached for offline use.");
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error during service worker registration:", e);
|
||||
}
|
||||
}
|
95
src/translations/en.json
Normal file
95
src/translations/en.json
Normal file
@ -0,0 +1,95 @@
|
||||
{
|
||||
"+0zv6g": "Image",
|
||||
"+vVZ/G": "Connect",
|
||||
"/0TOL5": "Amount",
|
||||
"/GCoTA": "Clear",
|
||||
"04lmFi": "Save Key",
|
||||
"0GfNiL": "Stream Zap Goals",
|
||||
"1EYCdR": "Tags",
|
||||
"2/2yg+": "Add",
|
||||
"2CGh/0": "live",
|
||||
"3HwrQo": "Zap!",
|
||||
"3adEeb": "{n} viewers",
|
||||
"47FYwb": "Cancel",
|
||||
"4l6vz1": "Copy",
|
||||
"4uI538": "Resolutions",
|
||||
"5JcXdV": "Create Account",
|
||||
"5QYdPU": "Start Time",
|
||||
"5kx+2v": "Server Url",
|
||||
"6Z2pvJ": "Stream Providers",
|
||||
"9WRlF4": "Send",
|
||||
"9a9+ww": "Title",
|
||||
"9anxhq": "Starts",
|
||||
"AIHaPH": "{person} zapped {amount} sats",
|
||||
"Atr2p4": "NSFW Content",
|
||||
"AyGauy": "Login",
|
||||
"BGxpTN": "Stream Chat",
|
||||
"C81/uG": "Logout",
|
||||
"ESyhzp": "Your comment for {name}",
|
||||
"G/yZLu": "Remove",
|
||||
"Gq6x9o": "Cover Image",
|
||||
"H5+NAX": "Balance",
|
||||
"HAlOn1": "Name",
|
||||
"I1kjHI": "Supports {markdown}",
|
||||
"IJDKz3": "Zap amount in {currency}",
|
||||
"Jq3FDz": "Content",
|
||||
"K3r6DQ": "Delete",
|
||||
"K3uH1C": "offline",
|
||||
"K7AkdL": "Show",
|
||||
"KkIL3s": "No, I am under 18",
|
||||
"Ld5LAE": "Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore!",
|
||||
"LknBsU": "Stream Key",
|
||||
"My6HwN": "Ok, it's safe",
|
||||
"O2Cy6m": "Yes, I am over 18",
|
||||
"OKhRC6": "Share",
|
||||
"OWgHbg": "Edit card",
|
||||
"Q3au2v": "About {estimate}",
|
||||
"QRHNuF": "What are we steaming today?",
|
||||
"QRRCp0": "Stream URL",
|
||||
"QceMQZ": "Goal: {amount}",
|
||||
"RJOmzk": "I have read and agree with {provider}''s {terms}.",
|
||||
"RXQdxR": "Please login to write messages!",
|
||||
"RrCui3": "Summary",
|
||||
"TaTRKo": "Start Stream",
|
||||
"UJBFYK": "Add Card",
|
||||
"UfSot5": "Past Streams",
|
||||
"VA/Z1S": "Hide",
|
||||
"W9355R": "Unmute",
|
||||
"X2PZ7D": "Create Goal",
|
||||
"ZmqxZs": "You can change this later",
|
||||
"acrOoz": "Continue",
|
||||
"cvAsEh": "Streamed on {date}",
|
||||
"cyR7Kh": "Back",
|
||||
"dVD/AR": "Top Zappers",
|
||||
"ebmhes": "Nostr Extension",
|
||||
"fBI91o": "Zap",
|
||||
"hGQqkW": "Schedule",
|
||||
"ieGrWo": "Follow",
|
||||
"itPgxd": "Profile",
|
||||
"izWS4J": "Unfollow",
|
||||
"jr4+vD": "Markdown",
|
||||
"jvo0vs": "Save",
|
||||
"lZpRMR": "Check here if this stream contains nudity or pornographic content.",
|
||||
"ljmS5P": "Endpoint",
|
||||
"mtNGwh": "A short description of the content",
|
||||
"nBCvvJ": "Topup",
|
||||
"nOaArs": "Setup Profile",
|
||||
"nwA8Os": "Add card",
|
||||
"oHPB8Q": "Zap {name}",
|
||||
"oZrFyI": "Stream type should be HLS",
|
||||
"pO/lPX": "Scheduled for {date}",
|
||||
"rWBFZA": "Sexually explicit material ahead!",
|
||||
"rbrahO": "Close",
|
||||
"rfC1Zq": "Save card",
|
||||
"s5ksS7": "Image Link",
|
||||
"s7V+5p": "Confirm your age",
|
||||
"thsiMl": "terms and conditions",
|
||||
"tzMNF3": "Status",
|
||||
"uYw2LD": "Stream",
|
||||
"vrTOHJ": "{amount} sats",
|
||||
"wCIL7o": "Broadcast on Nostr",
|
||||
"wEQDC6": "Edit",
|
||||
"wOy57k": "Add stream goal",
|
||||
"wzWWzV": "Top zappers",
|
||||
"x82IOl": "Mute"
|
||||
}
|
53
src/utils.ts
53
src/utils.ts
@ -1,10 +1,4 @@
|
||||
import {
|
||||
NostrEvent,
|
||||
NostrPrefix,
|
||||
TaggedRawEvent,
|
||||
encodeTLV,
|
||||
parseNostrLink,
|
||||
} from "@snort/system";
|
||||
import { NostrEvent, NostrPrefix, TaggedNostrEvent, encodeTLV, parseNostrLink } from "@snort/system";
|
||||
import * as utils from "@noble/curves/abstract/utils";
|
||||
import { bech32 } from "@scure/base";
|
||||
import type { Tag, Tags } from "types";
|
||||
@ -39,7 +33,7 @@ export function toTag(e: NostrEvent): Tag {
|
||||
}
|
||||
|
||||
export function findTag(e: NostrEvent | undefined, tag: string) {
|
||||
const maybeTag = e?.tags.find((evTag) => {
|
||||
const maybeTag = e?.tags.find(evTag => {
|
||||
return evTag[0] === tag;
|
||||
});
|
||||
return maybeTag && maybeTag[1];
|
||||
@ -54,11 +48,7 @@ export function hexToBech32(hrp: string, hex?: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
hrp === NostrPrefix.Note ||
|
||||
hrp === NostrPrefix.PrivateKey ||
|
||||
hrp === NostrPrefix.PublicKey
|
||||
) {
|
||||
if (hrp === NostrPrefix.Note || hrp === NostrPrefix.PrivateKey || hrp === NostrPrefix.PublicKey) {
|
||||
const buf = utils.hexToBytes(hex);
|
||||
return bech32.encode(hrp, bech32.toWords(buf));
|
||||
} else {
|
||||
@ -77,22 +67,12 @@ export function splitByUrl(str: string) {
|
||||
return str.split(urlRegex);
|
||||
}
|
||||
|
||||
export function eventLink(ev: NostrEvent | TaggedRawEvent) {
|
||||
export function eventLink(ev: NostrEvent | TaggedNostrEvent) {
|
||||
if (ev.kind && ev.kind >= 30000 && ev.kind <= 40000) {
|
||||
const d = findTag(ev, "d") ?? "";
|
||||
return encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
d,
|
||||
"relays" in ev ? ev.relays : undefined,
|
||||
ev.kind,
|
||||
ev.pubkey
|
||||
);
|
||||
return encodeTLV(NostrPrefix.Address, d, "relays" in ev ? ev.relays : undefined, ev.kind, ev.pubkey);
|
||||
} else {
|
||||
return encodeTLV(
|
||||
NostrPrefix.Event,
|
||||
ev.id,
|
||||
"relays" in ev ? ev.relays : undefined
|
||||
);
|
||||
return encodeTLV(NostrPrefix.Event, ev.id, "relays" in ev ? ev.relays : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,15 +82,11 @@ export function createNostrLink(ev?: NostrEvent) {
|
||||
}
|
||||
|
||||
export function getHost(ev?: NostrEvent) {
|
||||
return (
|
||||
ev?.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ??
|
||||
ev?.pubkey ??
|
||||
""
|
||||
);
|
||||
return ev?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev?.pubkey ?? "";
|
||||
}
|
||||
|
||||
export function openFile(): Promise<File | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise(resolve => {
|
||||
const elm = document.createElement("input");
|
||||
elm.type = "file";
|
||||
elm.onchange = (e: Event) => {
|
||||
@ -127,17 +103,14 @@ export function openFile(): Promise<File | undefined> {
|
||||
|
||||
export function getTagValues(tags: Tags, tag: string): Array<string> {
|
||||
return tags
|
||||
.filter((t) => t.at(0) === tag)
|
||||
.map((t) => t.at(1))
|
||||
.filter((t) => t)
|
||||
.map((t) => t as string);
|
||||
.filter(t => t.at(0) === tag)
|
||||
.map(t => t.at(1))
|
||||
.filter(t => t)
|
||||
.map(t => t as string);
|
||||
}
|
||||
|
||||
export function getEventFromLocationState(state: unknown | undefined | null) {
|
||||
return state &&
|
||||
typeof state === "object" &&
|
||||
"kind" in state &&
|
||||
state.kind === LIVE_STREAM
|
||||
return state && typeof state === "object" && "kind" in state && state.kind === LIVE_STREAM
|
||||
? (state as NostrEvent)
|
||||
: undefined;
|
||||
}
|
||||
|
@ -3,10 +3,7 @@ import { CandidateInfo, SDPInfo } from "semantic-sdp";
|
||||
import { TypedEventTarget, type StatusEvent, type LogEvent } from "./events";
|
||||
import { parserLinkHeader } from "./parser";
|
||||
|
||||
export const DEFAULT_ICE_SERVERS = [
|
||||
"stun:stun.cloudflare.com:3478",
|
||||
"stun:stun.l.google.com:19302",
|
||||
];
|
||||
export const DEFAULT_ICE_SERVERS = ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"];
|
||||
|
||||
export const TRICKLE_BATCH_INTERVAL = 50;
|
||||
|
||||
@ -49,9 +46,7 @@ export class WISH extends TypedEventTarget {
|
||||
if (iceServers) {
|
||||
this.iceServers = iceServers ? iceServers : DEFAULT_ICE_SERVERS;
|
||||
}
|
||||
this.logMessage(
|
||||
`Enabling webrtc-adapter for ${adapter.browserDetails.browser}@${adapter.browserDetails.version}`
|
||||
);
|
||||
this.logMessage(`Enabling webrtc-adapter for ${adapter.browserDetails.browser}@${adapter.browserDetails.version}`);
|
||||
this.newResolvers();
|
||||
}
|
||||
|
||||
@ -99,7 +94,7 @@ export class WISH extends TypedEventTarget {
|
||||
this.connectedResolver = resolve;
|
||||
this.connectedRejector = reject;
|
||||
});
|
||||
this.gatherPromise = new Promise((resolve) => {
|
||||
this.gatherPromise = new Promise(resolve => {
|
||||
this.gatherResolver = resolve;
|
||||
});
|
||||
}
|
||||
@ -108,36 +103,19 @@ export class WISH extends TypedEventTarget {
|
||||
if (!this.peerConnection) {
|
||||
return;
|
||||
}
|
||||
this.peerConnection.addEventListener(
|
||||
"connectionstatechange",
|
||||
this.onConnectionStateChange.bind(this)
|
||||
);
|
||||
this.peerConnection.addEventListener(
|
||||
"iceconnectionstatechange",
|
||||
this.onICEConnectionStateChange.bind(this)
|
||||
);
|
||||
this.peerConnection.addEventListener(
|
||||
"icegatheringstatechange",
|
||||
this.onGatheringStateChange.bind(this)
|
||||
);
|
||||
this.peerConnection.addEventListener(
|
||||
"icecandidate",
|
||||
this.onICECandidate.bind(this)
|
||||
);
|
||||
this.peerConnection.addEventListener("connectionstatechange", this.onConnectionStateChange.bind(this));
|
||||
this.peerConnection.addEventListener("iceconnectionstatechange", this.onICEConnectionStateChange.bind(this));
|
||||
this.peerConnection.addEventListener("icegatheringstatechange", this.onGatheringStateChange.bind(this));
|
||||
this.peerConnection.addEventListener("icecandidate", this.onICECandidate.bind(this));
|
||||
this.peerConnection.addEventListener("track", this.onTrack.bind(this));
|
||||
this.peerConnection.addEventListener(
|
||||
"signalingstatechange",
|
||||
this.onSignalingStateChange.bind(this)
|
||||
);
|
||||
this.peerConnection.addEventListener("signalingstatechange", this.onSignalingStateChange.bind(this));
|
||||
}
|
||||
|
||||
private onGatheringStateChange() {
|
||||
if (!this.peerConnection) {
|
||||
return;
|
||||
}
|
||||
this.logMessage(
|
||||
`ICE Gathering State changed: ${this.peerConnection.iceGatheringState}`
|
||||
);
|
||||
this.logMessage(`ICE Gathering State changed: ${this.peerConnection.iceGatheringState}`);
|
||||
switch (this.peerConnection.iceGatheringState) {
|
||||
case "complete":
|
||||
this.gatherResolver();
|
||||
@ -149,13 +127,8 @@ export class WISH extends TypedEventTarget {
|
||||
if (!this.peerConnection) {
|
||||
return;
|
||||
}
|
||||
this.logMessage(
|
||||
`Peer Connection State changed: ${this.peerConnection.connectionState}`
|
||||
);
|
||||
const transportHandler = (
|
||||
track: MediaStreamTrack,
|
||||
transport: RTCDtlsTransport
|
||||
) => {
|
||||
this.logMessage(`Peer Connection State changed: ${this.peerConnection.connectionState}`);
|
||||
const transportHandler = (track: MediaStreamTrack, transport: RTCDtlsTransport) => {
|
||||
const ice = transport.iceTransport;
|
||||
if (!ice) {
|
||||
return;
|
||||
@ -217,9 +190,7 @@ export class WISH extends TypedEventTarget {
|
||||
if (!candidate.candidate) {
|
||||
return;
|
||||
}
|
||||
this.logMessage(
|
||||
`Got ICE candidate: ${candidate.candidate.replace("candidate:", "")}`
|
||||
);
|
||||
this.logMessage(`Got ICE candidate: ${candidate.candidate.replace("candidate:", "")}`);
|
||||
if (!this.parsedOffer) {
|
||||
return;
|
||||
}
|
||||
@ -240,13 +211,8 @@ export class WISH extends TypedEventTarget {
|
||||
if (this.trickleBatchingJob) {
|
||||
clearInterval(this.trickleBatchingJob);
|
||||
}
|
||||
this.logMessage(
|
||||
`Starting batching job to trickle candidates every ${TRICKLE_BATCH_INTERVAL}ms`
|
||||
);
|
||||
this.trickleBatchingJob = setInterval(
|
||||
this.trickleBatch.bind(this),
|
||||
TRICKLE_BATCH_INTERVAL
|
||||
);
|
||||
this.logMessage(`Starting batching job to trickle candidates every ${TRICKLE_BATCH_INTERVAL}ms`);
|
||||
this.trickleBatchingJob = setInterval(this.trickleBatch.bind(this), TRICKLE_BATCH_INTERVAL);
|
||||
}
|
||||
|
||||
private stopTrickleBatching() {
|
||||
@ -281,8 +247,7 @@ export class WISH extends TypedEventTarget {
|
||||
type: candidate.type || "host",
|
||||
relAddr: candidate.relatedAddress || undefined,
|
||||
relPort:
|
||||
typeof candidate.relatedPort !== "undefined" &&
|
||||
candidate.relatedPort !== null
|
||||
typeof candidate.relatedPort !== "undefined" && candidate.relatedPort !== null
|
||||
? candidate.relatedPort.toString()
|
||||
: undefined,
|
||||
});
|
||||
@ -307,18 +272,14 @@ export class WISH extends TypedEventTarget {
|
||||
if (!this.peerConnection) {
|
||||
return;
|
||||
}
|
||||
this.logMessage(
|
||||
`Signaling State changed: ${this.peerConnection.signalingState}`
|
||||
);
|
||||
this.logMessage(`Signaling State changed: ${this.peerConnection.signalingState}`);
|
||||
}
|
||||
|
||||
private onICEConnectionStateChange() {
|
||||
if (!this.peerConnection) {
|
||||
return;
|
||||
}
|
||||
this.logMessage(
|
||||
`ICE Connection State changed: ${this.peerConnection.iceConnectionState}`
|
||||
);
|
||||
this.logMessage(`ICE Connection State changed: ${this.peerConnection.iceConnectionState}`);
|
||||
switch (this.peerConnection.iceConnectionState) {
|
||||
case "checking":
|
||||
this.iceStartTime = performance.now();
|
||||
@ -327,19 +288,11 @@ export class WISH extends TypedEventTarget {
|
||||
const connected = performance.now();
|
||||
if (this.connectStartTime) {
|
||||
const delta = connected - this.connectStartTime;
|
||||
this.logMessage(
|
||||
`Took ${(delta / 1000).toFixed(
|
||||
2
|
||||
)} seconds to establish PeerConnection (end-to-end)`
|
||||
);
|
||||
this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to establish PeerConnection (end-to-end)`);
|
||||
}
|
||||
if (this.iceStartTime) {
|
||||
const delta = connected - this.iceStartTime;
|
||||
this.logMessage(
|
||||
`Took ${(delta / 1000).toFixed(
|
||||
2
|
||||
)} seconds to establish PeerConnection (ICE)`
|
||||
);
|
||||
this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to establish PeerConnection (ICE)`);
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<StatusEvent>("status", {
|
||||
@ -421,19 +374,12 @@ export class WISH extends TypedEventTarget {
|
||||
}
|
||||
|
||||
private setVideoCodecPreference(transceiver: RTCRtpTransceiver) {
|
||||
if (
|
||||
typeof RTCRtpSender.getCapabilities === "undefined" ||
|
||||
typeof transceiver.setCodecPreferences === "undefined"
|
||||
) {
|
||||
if (typeof RTCRtpSender.getCapabilities === "undefined" || typeof transceiver.setCodecPreferences === "undefined") {
|
||||
return;
|
||||
}
|
||||
const capability = RTCRtpSender.getCapabilities("video");
|
||||
const codecs = capability ? capability.codecs : [];
|
||||
this.logMessage(
|
||||
`Available codecs for outbound video: ${codecs
|
||||
.map((c) => c.mimeType)
|
||||
.join(", ")}`
|
||||
);
|
||||
this.logMessage(`Available codecs for outbound video: ${codecs.map(c => c.mimeType).join(", ")}`);
|
||||
for (let i = 0; i < codecs.length; i++) {
|
||||
const codec = codecs[i];
|
||||
if (codec.mimeType === "video/VP9") {
|
||||
@ -486,10 +432,7 @@ export class WISH extends TypedEventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
private async doSignalingPOST(
|
||||
sdp: string,
|
||||
useLink?: boolean
|
||||
): Promise<string> {
|
||||
private async doSignalingPOST(sdp: string, useLink?: boolean): Promise<string> {
|
||||
if (!this.endpoint) {
|
||||
throw new Error("No WHIP/WHEP endpoint has been set");
|
||||
}
|
||||
@ -528,14 +471,10 @@ export class WISH extends TypedEventTarget {
|
||||
if (resp.headers.get("accept-post") || resp.headers.get("accept-patch")) {
|
||||
switch (this.mode) {
|
||||
case Mode.Publisher:
|
||||
this.logMessage(
|
||||
`WHIP version draft-ietf-wish-whip-05 (Accept-Post/Accept-Patch)`
|
||||
);
|
||||
this.logMessage(`WHIP version draft-ietf-wish-whip-05 (Accept-Post/Accept-Patch)`);
|
||||
break;
|
||||
case Mode.Player:
|
||||
this.logMessage(
|
||||
`WHEP version draft-murillo-whep-01 (Accept-Post/Accept-Patch)`
|
||||
);
|
||||
this.logMessage(`WHEP version draft-murillo-whep-01 (Accept-Post/Accept-Patch)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -560,9 +499,7 @@ export class WISH extends TypedEventTarget {
|
||||
|
||||
const signaled = performance.now();
|
||||
const delta = signaled - signalStartTime;
|
||||
this.logMessage(
|
||||
`Took ${(delta / 1000).toFixed(2)} seconds to exchange SDP`
|
||||
);
|
||||
this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to exchange SDP`);
|
||||
|
||||
return body;
|
||||
}
|
||||
|
@ -8,16 +8,14 @@ const ESLintPlugin = require("eslint-webpack-plugin");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
|
||||
const CopyPlugin = require("copy-webpack-plugin");
|
||||
const WorkboxPlugin = require("workbox-webpack-plugin");
|
||||
const IntlTsTransformer = require("@formatjs/ts-transformer");
|
||||
|
||||
const isProduction = process.env.NODE_ENV == "production";
|
||||
|
||||
const config = {
|
||||
entry: {
|
||||
main: "./src/index.tsx",
|
||||
sw: {
|
||||
import: "./src/service-worker.ts",
|
||||
filename: "service-worker.js",
|
||||
},
|
||||
},
|
||||
target: "browserslist",
|
||||
mode: isProduction ? "production" : "development",
|
||||
@ -25,12 +23,7 @@ const config = {
|
||||
output: {
|
||||
publicPath: "/",
|
||||
path: path.resolve(__dirname, "build"),
|
||||
filename: ({ runtime }) => {
|
||||
if (runtime === "sw") {
|
||||
return "[name].js";
|
||||
}
|
||||
return isProduction ? "[name].[chunkhash].js" : "[name].js";
|
||||
},
|
||||
filename: isProduction ? "[name].[chunkhash].js" : "[name].js",
|
||||
clean: isProduction,
|
||||
},
|
||||
devServer: {
|
||||
@ -51,12 +44,11 @@ const config = {
|
||||
new HtmlWebpackPlugin({
|
||||
template: "public/index.html",
|
||||
favicon: "public/favicon.ico",
|
||||
excludeChunks: ["sw"],
|
||||
}),
|
||||
new ESLintPlugin({
|
||||
extensions: ["js", "mjs", "jsx", "ts", "tsx"],
|
||||
eslintPath: require.resolve("eslint"),
|
||||
failOnError: !isProduction,
|
||||
failOnError: true,
|
||||
cache: true,
|
||||
}),
|
||||
new MiniCssExtractPlugin({
|
||||
@ -70,6 +62,9 @@ const config = {
|
||||
__XXX: process.env["__XXX"] || JSON.stringify(false),
|
||||
__XXX_HOST: JSON.stringify("https://xxzap.com"),
|
||||
}),
|
||||
new WorkboxPlugin.InjectManifest({
|
||||
swSrc: "./src/service-worker.ts",
|
||||
}),
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
@ -97,28 +92,24 @@ const config = {
|
||||
options: {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
presets: [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
targets: "defaults",
|
||||
},
|
||||
],
|
||||
["@babel/preset-react", { runtime: "automatic" }],
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
plugins: [
|
||||
[
|
||||
"formatjs",
|
||||
{
|
||||
idInterpolationPattern: "[sha512:contenthash:base64:6]",
|
||||
ast: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
presets: [["@babel/preset-env"], ["@babel/preset-react", { runtime: "automatic" }]],
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: require.resolve("ts-loader"),
|
||||
options: {
|
||||
getCustomTransformers() {
|
||||
return {
|
||||
before: [
|
||||
IntlTsTransformer.transform({
|
||||
overrideIdFn: "[sha512:contenthash:base64:6]",
|
||||
ast: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
require.resolve("ts-loader"),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
Reference in New Issue
Block a user