Merge remote-tracking branch 'upstream/main' into feat/connect-wallet

This commit is contained in:
Roland Bewick
2023-08-28 18:36:21 +07:00
96 changed files with 2569 additions and 3264 deletions

View File

@ -23,3 +23,40 @@ volumes:
- name: cache - name: cache
claim: claim:
name: docker-cache 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

View File

@ -1 +0,0 @@
{}

View File

@ -1,7 +1,3 @@
{ {
"recommendations": [ "recommendations": ["arcanis.vscode-zipfs", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
"arcanis.vscode-zipfs",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
} }

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{ {
"name": "eslint", "name": "eslint",
"version": "8.45.0-sdk", "version": "8.48.0-sdk",
"main": "./lib/api.js", "main": "./lib/api.js",
"type": "commonjs" "type": "commonjs"
} }

View File

@ -9,7 +9,7 @@ const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath); const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath); const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = (tsserver) => { const moduleWrapper = tsserver => {
if (!process.versions.pnp) { if (!process.versions.pnp) {
return tsserver; return tsserver;
} }
@ -17,12 +17,12 @@ const moduleWrapper = (tsserver) => {
const { isAbsolute } = require(`path`); const { isAbsolute } = require(`path`);
const pnpApi = require(`pnpapi`); const pnpApi = require(`pnpapi`);
const isVirtual = (str) => str.match(/\/(\$\$virtual|__virtual__)\//); const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = (str) => str.startsWith("portal:/"); const isPortal = str => str.startsWith("portal:/");
const normalize = (str) => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set( const dependencyTreeRoots = new Set(
pnpApi.getDependencyTreeRoots().map((locator) => { pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`; return `${locator.name}@${locator.reference}`;
}) })
); );
@ -33,11 +33,7 @@ const moduleWrapper = (tsserver) => {
function toEditorPath(str) { function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths // We add the `zip:` prefix to both `.zip/` paths and virtual paths
if ( if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
isAbsolute(str) &&
!str.match(/^\^?(zip:|\/zip\/)/) &&
(str.match(/\.zip\//) || isVirtual(str))
) {
// We also take the opportunity to turn virtual paths into physical ones; // We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer // this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual // dependencies, since otherwise Ctrl+Click would bring us to the virtual
@ -53,8 +49,7 @@ const moduleWrapper = (tsserver) => {
const locator = pnpApi.findPackageLocator(resolved); const locator = pnpApi.findPackageLocator(resolved);
if ( if (
locator && locator &&
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))
isPortal(locator.reference))
) { ) {
str = resolved; str = resolved;
} }
@ -149,9 +144,7 @@ const moduleWrapper = (tsserver) => {
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/... // 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 // So in order to convert it back, we use .* to match all the thing
// before `zipfile:` // before `zipfile:`
return process.platform === `win32` return process.platform === `win32` ? str.replace(/^.*zipfile:\//, ``) : str.replace(/^.*zipfile:/, ``);
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} }
break; break;
@ -166,10 +159,7 @@ const moduleWrapper = (tsserver) => {
case `vscode`: case `vscode`:
default: default:
{ {
return str.replace( return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`);
/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/,
process.platform === `win32` ? `` : `/`
);
} }
break; break;
} }
@ -183,8 +173,7 @@ const moduleWrapper = (tsserver) => {
// TypeScript already does local loads and if this code is running the user trusts the workspace // TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856 // https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject; const ConfiguredProject = tsserver.server.ConfiguredProject;
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = ConfiguredProject.prototype;
ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function () { ConfiguredProject.prototype.enablePluginsWithOptions = function () {
this.projectService.allowLocalPluginLoads = true; this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments); return originalEnablePluginsWithOptions.apply(this, arguments);
@ -195,8 +184,7 @@ const moduleWrapper = (tsserver) => {
// like an absolute path of ours and normalize it. // like an absolute path of ours and normalize it.
const Session = tsserver.server.Session; const Session = tsserver.server.Session;
const { onMessage: originalOnMessage, send: originalSend } = const { onMessage: originalOnMessage, send: originalSend } = Session.prototype;
Session.prototype;
let hostInfo = `unknown`; let hostInfo = `unknown`;
Object.assign(Session.prototype, { Object.assign(Session.prototype, {
@ -231,19 +219,11 @@ const moduleWrapper = (tsserver) => {
} }
} }
const processedMessageJSON = JSON.stringify( const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
parsedMessage, return typeof value === "string" ? fromEditorPath(value) : value;
(key, value) => { });
return typeof value === "string" ? fromEditorPath(value) : value;
}
);
return originalOnMessage.call( return originalOnMessage.call(this, isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON));
this,
isStringMessage
? processedMessageJSON
: JSON.parse(processedMessageJSON)
);
}, },
send(/** @type {any} */ msg) { send(/** @type {any} */ msg) {

View File

@ -9,7 +9,7 @@ const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath); const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath); const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = (tsserver) => { const moduleWrapper = tsserver => {
if (!process.versions.pnp) { if (!process.versions.pnp) {
return tsserver; return tsserver;
} }
@ -17,12 +17,12 @@ const moduleWrapper = (tsserver) => {
const { isAbsolute } = require(`path`); const { isAbsolute } = require(`path`);
const pnpApi = require(`pnpapi`); const pnpApi = require(`pnpapi`);
const isVirtual = (str) => str.match(/\/(\$\$virtual|__virtual__)\//); const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = (str) => str.startsWith("portal:/"); const isPortal = str => str.startsWith("portal:/");
const normalize = (str) => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set( const dependencyTreeRoots = new Set(
pnpApi.getDependencyTreeRoots().map((locator) => { pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`; return `${locator.name}@${locator.reference}`;
}) })
); );
@ -33,11 +33,7 @@ const moduleWrapper = (tsserver) => {
function toEditorPath(str) { function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths // We add the `zip:` prefix to both `.zip/` paths and virtual paths
if ( if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
isAbsolute(str) &&
!str.match(/^\^?(zip:|\/zip\/)/) &&
(str.match(/\.zip\//) || isVirtual(str))
) {
// We also take the opportunity to turn virtual paths into physical ones; // We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer // this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual // dependencies, since otherwise Ctrl+Click would bring us to the virtual
@ -53,8 +49,7 @@ const moduleWrapper = (tsserver) => {
const locator = pnpApi.findPackageLocator(resolved); const locator = pnpApi.findPackageLocator(resolved);
if ( if (
locator && locator &&
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))
isPortal(locator.reference))
) { ) {
str = resolved; str = resolved;
} }
@ -149,9 +144,7 @@ const moduleWrapper = (tsserver) => {
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/... // 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 // So in order to convert it back, we use .* to match all the thing
// before `zipfile:` // before `zipfile:`
return process.platform === `win32` return process.platform === `win32` ? str.replace(/^.*zipfile:\//, ``) : str.replace(/^.*zipfile:/, ``);
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} }
break; break;
@ -166,10 +159,7 @@ const moduleWrapper = (tsserver) => {
case `vscode`: case `vscode`:
default: default:
{ {
return str.replace( return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`);
/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/,
process.platform === `win32` ? `` : `/`
);
} }
break; break;
} }
@ -183,8 +173,7 @@ const moduleWrapper = (tsserver) => {
// TypeScript already does local loads and if this code is running the user trusts the workspace // TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856 // https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject; const ConfiguredProject = tsserver.server.ConfiguredProject;
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = ConfiguredProject.prototype;
ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function () { ConfiguredProject.prototype.enablePluginsWithOptions = function () {
this.projectService.allowLocalPluginLoads = true; this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments); return originalEnablePluginsWithOptions.apply(this, arguments);
@ -195,8 +184,7 @@ const moduleWrapper = (tsserver) => {
// like an absolute path of ours and normalize it. // like an absolute path of ours and normalize it.
const Session = tsserver.server.Session; const Session = tsserver.server.Session;
const { onMessage: originalOnMessage, send: originalSend } = const { onMessage: originalOnMessage, send: originalSend } = Session.prototype;
Session.prototype;
let hostInfo = `unknown`; let hostInfo = `unknown`;
Object.assign(Session.prototype, { Object.assign(Session.prototype, {
@ -231,19 +219,11 @@ const moduleWrapper = (tsserver) => {
} }
} }
const processedMessageJSON = JSON.stringify( const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
parsedMessage, return typeof value === "string" ? fromEditorPath(value) : value;
(key, value) => { });
return typeof value === "string" ? fromEditorPath(value) : value;
}
);
return originalOnMessage.call( return originalOnMessage.call(this, isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON));
this,
isStringMessage
? processedMessageJSON
: JSON.parse(processedMessageJSON)
);
}, },
send(/** @type {any} */ msg) { send(/** @type {any} */ msg) {

View File

@ -1,6 +1,6 @@
{ {
"name": "typescript", "name": "typescript",
"version": "5.1.3-sdk", "version": "5.2.2-sdk",
"main": "./lib/typescript.js", "main": "./lib/typescript.js",
"type": "commonjs" "type": "commonjs"
} }

View File

@ -1 +1 @@
yarnPath: .yarn/releases/yarn-3.6.1.cjs yarnPath: .yarn/releases/yarn-3.6.3.cjs

View File

@ -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
View File

@ -0,0 +1,5 @@
project_id: 610631
preserve_hierarchy: true
files:
- source: src/lang.json
translation: src/translations/%locale_with_underscore%.json

View File

@ -1,7 +1,6 @@
{ {
"name": "stream_ui", "name": "stream_ui",
"version": "0.1.0", "version": "0.1.0",
"private": true,
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.1.2", "@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
@ -16,8 +15,8 @@
"@react-hook/resize-observer": "^1.2.6", "@react-hook/resize-observer": "^1.2.6",
"@scure/base": "^1.1.1", "@scure/base": "^1.1.1",
"@snort/shared": "^1.0.4", "@snort/shared": "^1.0.4",
"@snort/system": "^1.0.16", "@snort/system": "^1.0.17",
"@snort/system-react": "^1.0.11", "@snort/system-react": "^1.0.12",
"@szhsin/react-menu": "^4.0.2", "@szhsin/react-menu": "^4.0.2",
"@testing-library/jest-dom": "^5.14.1", "@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0", "@testing-library/react": "^13.0.0",
@ -39,22 +38,26 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-intersection-observer": "^9.5.1", "react-intersection-observer": "^9.5.1",
"react-intl": "^6.4.4",
"react-markdown": "^8.0.7", "react-markdown": "^8.0.7",
"react-router-dom": "^6.13.0", "react-router-dom": "^6.13.0",
"react-tag-input-component": "^2.0.2", "react-tag-input-component": "^2.0.2",
"semantic-sdp": "^3.26.2", "semantic-sdp": "^3.26.3",
"usehooks-ts": "^2.9.1", "usehooks-ts": "^2.9.1",
"web-vitals": "^2.1.0", "web-vitals": "^2.1.0",
"webrtc-adapter": "^8.2.3", "webrtc-adapter": "^8.2.3",
"workbox-core": "^7.0.0", "workbox-core": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0", "workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0" "workbox-strategies": "^7.0.0"
}, },
"scripts": { "scripts": {
"start": "webpack serve", "start": "webpack serve --node-env=development --mode=development",
"build": "webpack --node-env=production", "build": "webpack --node-env=production --mode=production",
"deploy": "__XXX='false' && yarn build && npx wrangler pages publish --project-name nostr-live build", "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": { "eslintConfig": {
"extends": [ "extends": [
@ -77,29 +80,26 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.22.9", "@babel/core": "^7.22.11",
"@babel/plugin-syntax-import-assertions": "^7.20.0", "@babel/plugin-syntax-import-assertions": "^7.20.0",
"@babel/preset-env": "^7.21.5", "@babel/preset-env": "^7.21.5",
"@babel/preset-react": "^7.18.6", "@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.22.5", "@formatjs/cli": "^6.1.3",
"@formatjs/cli": "^6.0.1", "@formatjs/ts-transformer": "^3.13.3",
"@formatjs/ts-transformer": "^3.13.1",
"@testing-library/dom": "^9.3.1", "@testing-library/dom": "^9.3.1",
"@types/lodash": "^4.14.195", "@types/lodash": "^4.14.195",
"@types/lodash.uniqby": "^4.7.7", "@types/lodash.uniqby": "^4.7.7",
"@types/react": "^18.2.15", "@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/react-helmet": "^6.1.6", "@types/react-helmet": "^6.1.6",
"@typescript-eslint/eslint-plugin": "^6.1.0", "@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.1.0", "@typescript-eslint/parser": "^6.4.1",
"@webbtc/webln-types": "^1.0.12", "@webbtc/webln-types": "^1.0.12",
"babel-loader": "^9.1.2", "babel-loader": "^9.1.3",
"babel-plugin-formatjs": "^10.5.3",
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3", "css-loader": "^6.8.1",
"css-minimizer-webpack-plugin": "^5.0.0", "css-minimizer-webpack-plugin": "^5.0.0",
"eslint": "^8.45.0", "eslint": "^8.48.0",
"eslint-plugin-formatjs": "^4.10.1",
"eslint-webpack-plugin": "^4.0.1", "eslint-webpack-plugin": "^4.0.1",
"html-webpack-plugin": "^5.5.1", "html-webpack-plugin": "^5.5.1",
"mini-css-extract-plugin": "^2.7.5", "mini-css-extract-plugin": "^2.7.5",
@ -108,12 +108,17 @@
"source-map-loader": "^4.0.1", "source-map-loader": "^4.0.1",
"terser-webpack-plugin": "^5.3.9", "terser-webpack-plugin": "^5.3.9",
"ts-loader": "^9.4.4", "ts-loader": "^9.4.4",
"typescript": "^5.1.3", "typescript": "^5.2.2",
"webpack": "^5.82.1", "webpack": "^5.88.2",
"webpack-bundle-analyzer": "^4.8.0", "webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^5.1.1", "webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.0", "webpack-dev-server": "^4.15.1",
"workbox-webpack-plugin": "^6.5.4" "workbox-webpack-plugin": "^7.0.0"
}, },
"packageManager": "yarn@3.6.1" "packageManager": "yarn@3.6.3",
"prettier": {
"printWidth": 120,
"bracketSameLine": true,
"arrowParens": "avoid"
}
} }

View File

@ -1,10 +1,6 @@
import "./event.css"; import "./event.css";
import { import { type NostrLink, type NostrEvent as NostrEventType, EventKind } from "@snort/system";
type NostrLink,
type NostrEvent as NostrEventType,
EventKind,
} from "@snort/system";
import { Icon } from "element/icon"; import { Icon } from "element/icon";
import { Goal } from "element/goal"; import { Goal } from "element/goal";

View File

@ -2,8 +2,7 @@ import "./async-button.css";
import { useState } from "react"; import { useState } from "react";
import Spinner from "element/spinner"; import Spinner from "element/spinner";
interface AsyncButtonProps interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
disabled?: boolean; disabled?: boolean;
onClick(e: React.MouseEvent): Promise<void> | void; onClick(e: React.MouseEvent): Promise<void> | void;
children?: React.ReactNode; children?: React.ReactNode;
@ -29,15 +28,8 @@ export default function AsyncButton(props: AsyncButtonProps) {
} }
return ( return (
<button <button type="button" disabled={loading || props.disabled} {...props} onClick={handle}>
type="button" <span style={{ visibility: loading ? "hidden" : "visible" }}>{props.children}</span>
disabled={loading || props.disabled}
{...props}
onClick={handle}
>
<span style={{ visibility: loading ? "hidden" : "visible" }}>
{props.children}
</span>
{loading && ( {loading && (
<span className="spinner-wrapper"> <span className="spinner-wrapper">
<Spinner /> <Spinner />

View File

@ -1,17 +1,5 @@
import { MetadataCache } from "@snort/system"; import { MetadataCache } from "@snort/system";
export function Avatar({ export function Avatar({ user, avatarClassname }: { user: MetadataCache; avatarClassname: string }) {
user, return <img className={avatarClassname} alt={user?.name || user?.pubkey} src={user?.picture ?? ""} />;
avatarClassname,
}: {
user: MetadataCache;
avatarClassname: string;
}) {
return (
<img
className={avatarClassname}
alt={user?.name || user?.pubkey}
src={user?.picture ?? ""}
/>
);
} }

View File

@ -12,9 +12,7 @@ export function Badge({ ev }: { ev: NostrEvent }) {
<img className="badge-thumbnail" src={thumb || image} alt={name} /> <img className="badge-thumbnail" src={thumb || image} alt={name} />
<div className="badge-details"> <div className="badge-details">
<h4 className="badge-name">{name}</h4> <h4 className="badge-name">{name}</h4>
{description?.length > 0 && ( {description?.length > 0 && <p className="badge-description">{description}</p>}
<p className="badge-description">{description}</p>
)}
</div> </div>
</div> </div>
); );

View File

@ -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 { NostrEvent, parseZap, EventKind } from "@snort/system";
import React, { useRef, useState, useMemo } from "react"; import React, { useRef, useState, useMemo, useContext } from "react";
import { import { useMediaQuery, useHover, useOnClickOutside, useIntersectionObserver } from "usehooks-ts";
useMediaQuery,
useHover,
useOnClickOutside,
useIntersectionObserver,
} from "usehooks-ts";
import { EmojiPicker } from "element/emoji-picker"; import { EmojiPicker } from "element/emoji-picker";
import { Icon } from "element/icon"; import { Icon } from "element/icon";
@ -20,7 +15,6 @@ import { useLogin } from "hooks/login";
import { formatSats } from "number"; import { formatSats } from "number";
import { findTag } from "utils"; import { findTag } from "utils";
import type { Badge, Emoji, EmojiPack } from "types"; import type { Badge, Emoji, EmojiPack } from "types";
import { System } from "index";
function emojifyReaction(reaction: string) { function emojifyReaction(reaction: string) {
if (reaction === "+") { if (reaction === "+") {
@ -60,36 +54,31 @@ export function ChatMessage({
const [showZapDialog, setShowZapDialog] = useState(false); const [showZapDialog, setShowZapDialog] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const login = useLogin(); const login = useLogin();
const profile = useUserProfile( const profile = useUserProfile(inView?.isIntersecting ? ev.pubkey : undefined);
System, const shouldShowMuteButton = ev.pubkey !== streamer && ev.pubkey !== login?.pubkey;
inView?.isIntersecting ? ev.pubkey : undefined
);
const shouldShowMuteButton =
ev.pubkey !== streamer && ev.pubkey !== login?.pubkey;
const zapTarget = profile?.lud16 ?? profile?.lud06; const zapTarget = profile?.lud16 ?? profile?.lud06;
const system = useContext(SnortContext);
const zaps = useMemo(() => { const zaps = useMemo(() => {
return reactions return reactions
.filter((a) => a.kind === EventKind.ZapReceipt) .filter(a => a.kind === EventKind.ZapReceipt)
.map((a) => parseZap(a, System.ProfileLoader.Cache)) .map(a => parseZap(a, system.ProfileLoader.Cache))
.filter((a) => a && a.valid); .filter(a => a && a.valid);
}, [reactions]); }, [reactions]);
const emojiReactions = useMemo(() => { const emojiReactions = useMemo(() => {
const emojified = reactions const emojified = reactions
.filter((e) => e.kind === EventKind.Reaction && findTag(e, "e") === ev.id) .filter(e => e.kind === EventKind.Reaction && findTag(e, "e") === ev.id)
.map((ev) => emojifyReaction(ev.content)); .map(ev => emojifyReaction(ev.content));
return [...new Set(emojified)]; return [...new Set(emojified)];
}, [ev, reactions]); }, [ev, reactions]);
const emojiNames = emojiPacks.map((p) => p.emojis).flat(); const emojiNames = emojiPacks.map(p => p.emojis).flat();
const hasReactions = emojiReactions.length > 0; const hasReactions = emojiReactions.length > 0;
const totalZaps = useMemo(() => { 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); return messageZaps.reduce((acc, z) => acc + z.amount, 0);
}, [zaps, ev]); }, [zaps, ev]);
const hasZaps = totalZaps > 0; const hasZaps = totalZaps > 0;
const awardedBadges = badges.filter( const awardedBadges = badges.filter(b => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey));
(b) => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey)
);
useOnClickOutside(ref, () => { useOnClickOutside(ref, () => {
setShowZapDialog(false); setShowZapDialog(false);
@ -100,7 +89,7 @@ export function ChatMessage({
}); });
function getEmojiById(id: string) { 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) { async function onEmojiSelect(emoji: Emoji) {
@ -114,7 +103,7 @@ export function ChatMessage({
} else if (emoji.id) { } else if (emoji.id) {
const e = getEmojiById(emoji.id); const e = getEmojiById(emoji.id);
if (e) { if (e) {
reply = await pub?.generic((eb) => { reply = await pub?.generic(eb => {
return eb return eb
.kind(EventKind.Reaction) .kind(EventKind.Reaction)
.content(`:${emoji.id}:`) .content(`:${emoji.id}:`)
@ -126,7 +115,7 @@ export function ChatMessage({
} }
if (reply) { if (reply) {
console.debug(reply); console.debug(reply);
System.BroadcastEvent(reply); system.BroadcastEvent(reply);
} }
} catch { } catch {
//ignore //ignore
@ -148,23 +137,15 @@ export function ChatMessage({
return ( return (
<> <>
<div <div className={`message${streamer === ev.pubkey ? " streamer" : ""}`} ref={ref}>
className={`message${streamer === ev.pubkey ? " streamer" : ""}`}
ref={ref}
>
<Profile <Profile
icon={ icon={
ev.pubkey === streamer ? ( ev.pubkey === streamer ? (
<Icon name="signal" size={16} /> <Icon name="signal" size={16} />
) : ( ) : (
awardedBadges.map((badge) => { awardedBadges.map(badge => {
return ( return (
<img <img key={badge.name} className="badge-icon" src={badge.thumb || badge.image} alt={badge.name} />
key={badge.name}
className="badge-icon"
src={badge.thumb || badge.image}
alt={badge.name}
/>
); );
}) })
) )
@ -172,11 +153,7 @@ export function ChatMessage({
pubkey={ev.pubkey} pubkey={ev.pubkey}
profile={profile} profile={profile}
/> />
<Text <Text tags={ev.tags} content={ev.content} customComponents={customComponents} />
tags={ev.tags}
content={ev.content}
customComponents={customComponents}
/>
{(hasReactions || hasZaps) && ( {(hasReactions || hasZaps) && (
<div className="message-reactions"> <div className="message-reactions">
{hasZaps && ( {hasZaps && (
@ -185,9 +162,8 @@ export function ChatMessage({
<span className="zap-pill-amount">{formatSats(totalZaps)}</span> <span className="zap-pill-amount">{formatSats(totalZaps)}</span>
</div> </div>
)} )}
{emojiReactions.map((e) => { {emojiReactions.map(e => {
const isCustomEmojiReaction = const isCustomEmojiReaction = e.length > 1 && e.startsWith(":") && e.endsWith(":");
e.length > 1 && e.startsWith(":") && e.endsWith(":");
const emojiName = e.replace(/:/g, ""); const emojiName = e.replace(/:/g, "");
const emoji = isCustomEmojiReaction && getEmojiById(emojiName); const emoji = isCustomEmojiReaction && getEmojiById(emojiName);
return ( return (
@ -217,11 +193,9 @@ export function ChatMessage({
top: topOffset ? topOffset - 12 : 0, top: topOffset ? topOffset - 12 : 0,
left: leftOffset ? leftOffset - 32 : 0, left: leftOffset ? leftOffset - 32 : 0,
opacity: showZapDialog || isHovering ? 1 : 0, opacity: showZapDialog || isHovering ? 1 : 0,
pointerEvents: pointerEvents: showZapDialog || isHovering ? "auto" : "none",
showZapDialog || isHovering ? "auto" : "none",
} }
} }>
>
{zapTarget && ( {zapTarget && (
<SendZapsDialog <SendZapsDialog
lnurl={zapTarget} lnurl={zapTarget}

View File

@ -2,6 +2,7 @@ import "./collapsible.css";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useState } from "react"; import { useState } from "react";
import { FormattedMessage } from "react-intl";
import * as Dialog from "@radix-ui/react-dialog"; import * as Dialog from "@radix-ui/react-dialog";
import * as Collapsible from "@radix-ui/react-collapsible"; import * as Collapsible from "@radix-ui/react-collapsible";
@ -31,7 +32,7 @@ export function MediaURL({ url, children }: MediaURLProps) {
</div> </div>
<Dialog.Close asChild> <Dialog.Close asChild>
<button className="btn delete-button" aria-label="Close"> <button className="btn delete-button" aria-label="Close">
Close <FormattedMessage defaultMessage="Close" />
</button> </button>
</Dialog.Close> </Dialog.Close>
</Dialog.Content> </Dialog.Content>
@ -46,29 +47,19 @@ export function CollapsibleEvent({ link }: { link: NostrLink }) {
const author = event?.pubkey || link.author; const author = event?.pubkey || link.author;
return ( return (
<Collapsible.Root <Collapsible.Root className="collapsible" open={open} onOpenChange={setOpen}>
className="collapsible"
open={open}
onOpenChange={setOpen}
>
<div className="collapsed-event"> <div className="collapsed-event">
<div className="collapsed-event-header"> <div className="collapsed-event-header">
{event && <EventIcon kind={event.kind} />} {event && <EventIcon kind={event.kind} />}
{author && <Mention pubkey={author} />} {author && <Mention pubkey={author} />}
</div> </div>
<Collapsible.Trigger asChild> <Collapsible.Trigger asChild>
<button <button className={`${open ? "btn btn-small delete-button" : "btn btn-small"}`}>
className={`${ {open ? <FormattedMessage defaultMessage="Hide" /> : <FormattedMessage defaultMessage="Show" />}
open ? "btn btn-small delete-button" : "btn btn-small"
}`}
>
{open ? "Hide" : "Show"}
</button> </button>
</Collapsible.Trigger> </Collapsible.Trigger>
</div> </div>
<Collapsible.Content> <Collapsible.Content>{open && event && <NostrEvent ev={event} />}</Collapsible.Content>
{open && event && <NostrEvent ev={event} />}
</Collapsible.Content>
</Collapsible.Root> </Collapsible.Root>
); );
} }

View File

@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export function isContentWarningAccepted() { export function isContentWarningAccepted() {
@ -17,14 +18,18 @@ export function ContentWarningOverlay() {
return ( return (
<div className="fullscreen-exclusive age-check"> <div className="fullscreen-exclusive age-check">
<h1>Sexually explicit material ahead!</h1> <h1>
<h2>Confirm your age</h2> <FormattedMessage defaultMessage="Sexually explicit material ahead!" />
</h1>
<h2>
<FormattedMessage defaultMessage="Confirm your age" />
</h2>
<div className="flex g24"> <div className="flex g24">
<button className="btn btn-warning" onClick={grownUp}> <button className="btn btn-warning" onClick={grownUp}>
Yes, I am over 18 <FormattedMessage defaultMessage="Yes, I am over 18" />
</button> </button>
<button className="btn" onClick={() => navigate("/")}> <button className="btn" onClick={() => navigate("/")}>
No, I am under 18 <FormattedMessage defaultMessage="No, I am under 18" />
</button> </button>
</div> </div>
</div> </div>

View File

@ -11,26 +11,13 @@ export interface CopyProps {
export default function Copy({ text, maxSize = 32, className, hideText }: CopyProps) { export default function Copy({ text, maxSize = 32, className, hideText }: CopyProps) {
const { copy, copied } = useCopy(); const { copy, copied } = useCopy();
const sliceLength = maxSize / 2; const sliceLength = maxSize / 2;
const trimmed = const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text;
text.length > maxSize
? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}`
: text;
return ( return (
<div <div className={`copy${className ? ` ${className}` : ""}`} onClick={() => copy(text)}>
className={`copy${className ? ` ${className}` : ""}`}
onClick={() => copy(text)}
>
{!hideText && <span className="body">{trimmed}</span>} {!hideText && <span className="body">{trimmed}</span>}
<span <span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
className="icon" {copied ? <Icon name="check" size={14} /> : <Icon name="copy" size={14} />}
style={{ color: copied ? "var(--success)" : "var(--highlight)" }}
>
{copied ? (
<Icon name="check" size={14} />
) : (
<Icon name="copy" size={14} />
)}
</span> </span>
</div> </div>
); );

View File

@ -8,28 +8,24 @@ import { findTag } from "utils";
import { USER_EMOJIS } from "const"; import { USER_EMOJIS } from "const";
import { Login, System } from "index"; import { Login, System } from "index";
import type { EmojiPack as EmojiPackType } from "types"; import type { EmojiPack as EmojiPackType } from "types";
import { FormattedMessage } from "react-intl";
export function EmojiPack({ ev }: { ev: NostrEvent }) { export function EmojiPack({ ev }: { ev: NostrEvent }) {
const login = useLogin(); const login = useLogin();
const name = findTag(ev, "d"); const name = findTag(ev, "d");
const isUsed = login?.emojis.find( const isUsed = login?.emojis.find(e => e.author === ev.pubkey && e.name === name);
(e) => e.author === ev.pubkey && e.name === name const emoji = ev.tags.filter(e => e.at(0) === "emoji");
);
const emoji = ev.tags.filter((e) => e.at(0) === "emoji");
async function toggleEmojiPack() { async function toggleEmojiPack() {
let newPacks = [] as EmojiPackType[]; let newPacks = [] as EmojiPackType[];
if (isUsed) { if (isUsed) {
newPacks = newPacks = login?.emojis.filter(e => e.author !== ev.pubkey && e.name !== name) ?? [];
login?.emojis.filter(
(e) => e.author !== ev.pubkey && e.name !== name
) ?? [];
} else { } else {
newPacks = [...(login?.emojis ?? []), toEmojiPack(ev)]; newPacks = [...(login?.emojis ?? []), toEmojiPack(ev)];
} }
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
const ev = await pub.generic((eb) => { const ev = await pub.generic(eb => {
eb.kind(USER_EMOJIS).content(""); eb.kind(USER_EMOJIS).content("");
for (const e of newPacks) { for (const e of newPacks) {
eb.tag(["a", e.address]); eb.tag(["a", e.address]);
@ -48,17 +44,14 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
<h4>{name}</h4> <h4>{name}</h4>
{login?.pubkey && ( {login?.pubkey && (
<AsyncButton <AsyncButton
className={`btn btn-small btn-primary ${ className={`btn btn-small btn-primary ${isUsed ? "delete-button" : ""}`}
isUsed ? "delete-button" : "" onClick={toggleEmojiPack}>
}`} {isUsed ? <FormattedMessage defaultMessage="Remove" /> : <FormattedMessage defaultMessage="Add" />}
onClick={toggleEmojiPack}
>
{isUsed ? "Remove" : "Add"}
</AsyncButton> </AsyncButton>
)} )}
</div> </div>
<div className="emoji-pack-emojis"> <div className="emoji-pack-emojis">
{emoji.map((e) => { {emoji.map(e => {
const [, name, image] = e; const [, name, image] = e;
return ( return (
<div className="emoji-definition"> <div className="emoji-definition">

View File

@ -22,11 +22,11 @@ export function EmojiPicker({
height = 300, height = 300,
ref, ref,
}: EmojiPickerProps) { }: EmojiPickerProps) {
const customEmojiList = emojiPacks.map((pack) => { const customEmojiList = emojiPacks.map(pack => {
return { return {
id: pack.address, id: pack.address,
name: pack.name, name: pack.name,
emojis: pack.emojis.map((e) => { emojis: pack.emojis.map(e => {
const [, name, url] = e; const [, name, url] = e;
return { return {
id: name, id: name,
@ -45,8 +45,7 @@ export function EmojiPicker({
left: leftOffset, left: leftOffset,
zIndex: 1, zIndex: 1,
}} }}
ref={ref} ref={ref}>
>
<style> <style>
{` {`
em-emoji-picker { max-height: ${height}px; } em-emoji-picker { max-height: ${height}px; }

View File

@ -11,16 +11,10 @@ export function Emoji({ name, url }: EmojiProps) {
return <img alt={name} src={url} className="emoji" />; return <img alt={name} src={url} className="emoji" />;
} }
export function Emojify({ export function Emojify({ content, emoji }: { content: string; emoji: EmojiTag[] }) {
content,
emoji,
}: {
content: string;
emoji: EmojiTag[];
}) {
const emojified = useMemo(() => { const emojified = useMemo(() => {
return content.split(/:(\w+):/g).map((i) => { return content.split(/:(\w+):/g).map(i => {
const t = emoji.find((t) => t[1] === i); const t = emoji.find(t => t[1] === i);
if (t) { if (t) {
return <Emoji name={t[1]} url={t[2]} />; return <Emoji name={t[1]} url={t[2]} />;
} else { } else {

View File

@ -19,19 +19,10 @@ interface ExternalIconLinkProps extends Omit<ExternalLinkProps, "children"> {
size?: number; size?: number;
} }
export function ExternalIconLink({ export function ExternalIconLink({ size = 32, href, ...rest }: ExternalIconLinkProps) {
size = 32,
href,
...rest
}: ExternalIconLinkProps) {
return ( return (
<span style={{ cursor: "pointer" }}> <span style={{ cursor: "pointer" }}>
<Icon <Icon name="link" size={size} onClick={() => window.open(href, "_blank")} {...rest} />
name="link"
size={size}
onClick={() => window.open(href, "_blank")}
{...rest}
/>
</span> </span>
); );
} }

View File

@ -2,6 +2,7 @@ import "./file-uploader.css";
import type { ChangeEvent } from "react"; import type { ChangeEvent } from "react";
import { VoidApi } from "@void-cat/api"; import { VoidApi } from "@void-cat/api";
import { useState } from "react"; import { useState } from "react";
import { FormattedMessage } from "react-intl";
const voidCatHost = "https://void.cat"; const voidCatHost = "https://void.cat";
const fileExtensionRegex = /\.([\w]{1,7})$/i; 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") { if (rsp.file?.metadata?.mimeType === "image/webp") {
ext = ["", "webp"]; ext = ["", "webp"];
} }
const resultUrl = const resultUrl = rsp.file?.metadata?.url ?? `${voidCatHost}/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`;
rsp.file?.metadata?.url ??
`${voidCatHost}/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`;
const ret = { const ret = {
url: resultUrl, url: resultUrl,
@ -45,11 +44,7 @@ interface FileUploaderProps {
onFileUpload(url: string): void; onFileUpload(url: string): void;
} }
export function FileUploader({ export function FileUploader({ defaultImage, onClear, onFileUpload }: FileUploaderProps) {
defaultImage,
onClear,
onFileUpload,
}: FileUploaderProps) {
const [img, setImg] = useState<string>(defaultImage ?? ""); const [img, setImg] = useState<string>(defaultImage ?? "");
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
@ -88,7 +83,7 @@ export function FileUploader({
<div className="file-uploader-preview"> <div className="file-uploader-preview">
{img?.length > 0 && ( {img?.length > 0 && (
<button className="btn btn-primary clear-button" onClick={clearImage}> <button className="btn btn-primary clear-button" onClick={clearImage}>
Clear <FormattedMessage defaultMessage="Clear" />
</button> </button>
)} )}
{img && <img className="image-preview" src={img} />} {img && <img className="image-preview" src={img} />}

View File

@ -3,26 +3,21 @@ import { EventKind } from "@snort/system";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import AsyncButton from "element/async-button"; import AsyncButton from "element/async-button";
import { Login, System } from "index"; import { Login, System } from "index";
import { FormattedMessage } from "react-intl";
export function LoggedInFollowButton({ export function LoggedInFollowButton({ tag, value }: { tag: "p" | "t"; value: string }) {
tag,
value,
}: {
tag: "p" | "t";
value: string;
}) {
const login = useLogin(); const login = useLogin();
if (!login) return; if (!login) return;
const { tags, content, timestamp } = login.follows; const { tags, content, timestamp } = login.follows;
const follows = tags.filter((t) => t.at(0) === tag); const follows = tags.filter(t => t.at(0) === tag);
const isFollowing = follows.find((t) => t.at(1) === value); const isFollowing = follows.find(t => t.at(1) === value);
async function unfollow() { async function unfollow() {
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
const newFollows = tags.filter((t) => t.at(1) !== value); const newFollows = tags.filter(t => t.at(1) !== value);
const ev = await pub.generic((eb) => { const ev = await pub.generic(eb => {
eb.kind(EventKind.ContactList).content(content ?? ""); eb.kind(EventKind.ContactList).content(content ?? "");
for (const t of newFollows) { for (const t of newFollows) {
eb.tag(t); eb.tag(t);
@ -39,7 +34,7 @@ export function LoggedInFollowButton({
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
const newFollows = [...tags, [tag, value]]; const newFollows = [...tags, [tag, value]];
const ev = await pub.generic((eb) => { const ev = await pub.generic(eb => {
eb.kind(EventKind.ContactList).content(content ?? ""); eb.kind(EventKind.ContactList).content(content ?? "");
for (const tag of newFollows) { for (const tag of newFollows) {
eb.tag(tag); eb.tag(tag);
@ -57,9 +52,8 @@ export function LoggedInFollowButton({
disabled={timestamp ? timestamp === 0 : true} disabled={timestamp ? timestamp === 0 : true}
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
onClick={isFollowing ? unfollow : follow} onClick={isFollowing ? unfollow : follow}>
> {isFollowing ? <FormattedMessage defaultMessage="Unfollow" /> : <FormattedMessage defaultMessage="Follow" />}
{isFollowing ? "Unfollow" : "Follow"}
</AsyncButton> </AsyncButton>
); );
} }
@ -71,7 +65,5 @@ export function FollowTagButton({ tag }: { tag: string }) {
export function FollowButton({ pubkey }: { pubkey: string }) { export function FollowButton({ pubkey }: { pubkey: string }) {
const login = useLogin(); const login = useLogin();
return login?.pubkey ? ( return login?.pubkey ? <LoggedInFollowButton tag={"p"} value={pubkey} /> : null;
<LoggedInFollowButton tag={"p"} value={pubkey} />
) : null;
} }

View File

@ -12,11 +12,11 @@ import usePreviousValue from "hooks/usePreviousValue";
import { SendZapsDialog } from "element/send-zap"; import { SendZapsDialog } from "element/send-zap";
import { useZaps } from "hooks/goals"; import { useZaps } from "hooks/goals";
import { getName } from "element/profile"; import { getName } from "element/profile";
import { System } from "index";
import { Icon } from "./icon"; import { Icon } from "./icon";
import { FormattedMessage } from "react-intl";
export function Goal({ ev }: { ev: NostrEvent }) { export function Goal({ ev }: { ev: NostrEvent }) {
const profile = useUserProfile(System, ev.pubkey); const profile = useUserProfile(ev.pubkey);
const zapTarget = profile?.lud16 ?? profile?.lud06; const zapTarget = profile?.lud16 ?? profile?.lud06;
const zaps = useZaps(ev, true); const zaps = useZaps(ev, true);
const goalAmount = useMemo(() => { const goalAmount = useMemo(() => {
@ -29,9 +29,7 @@ export function Goal({ ev }: { ev: NostrEvent }) {
} }
const soFar = useMemo(() => { const soFar = useMemo(() => {
return zaps return zaps.filter(z => z.receiver === ev.pubkey && z.event === ev.id).reduce((acc, z) => acc + z.amount, 0);
.filter((z) => z.receiver === ev.pubkey && z.event === ev.id)
.reduce((acc, z) => acc + z.amount, 0);
}, [zaps]); }, [zaps]);
const progress = Math.max(0, Math.min(100, (soFar / goalAmount) * 100)); 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>} {ev.content.length > 0 && <p>{ev.content}</p>}
<div className={`progress-container ${isFinished ? "finished" : ""}`}> <div className={`progress-container ${isFinished ? "finished" : ""}`}>
<Progress.Root className="progress-root" value={progress}> <Progress.Root className="progress-root" value={progress}>
<Progress.Indicator <Progress.Indicator className="progress-indicator" style={{ transform: `translateX(-${100 - progress}%)` }}>
className="progress-indicator" {!isFinished && <span className="amount so-far">{formatSats(soFar)}</span>}
style={{ transform: `translateX(-${100 - progress}%)` }}
>
{!isFinished && (
<span className="amount so-far">{formatSats(soFar)}</span>
)}
</Progress.Indicator> </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> </Progress.Root>
<div className="zap-circle"> <div className="zap-circle">
<Icon <Icon name="zap-filled" className={isFinished ? "goal-finished" : "goal-unfinished"} />
name="zap-filled"
className={isFinished ? "goal-finished" : "goal-unfinished"}
/>
</div> </div>
</div> </div>
{isFinished && previousValue === false && ( {isFinished && previousValue === false && <Confetti numberOfPieces={2100} recycle={false} />}
<Confetti numberOfPieces={2100} recycle={false} />
)}
</div> </div>
); );

View File

@ -12,8 +12,7 @@ interface HyperTextProps {
export function HyperText({ link, children }: HyperTextProps) { export function HyperText({ link, children }: HyperTextProps) {
try { try {
const url = new URL(link); const url = new URL(link);
const extension = const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
if (extension) { if (extension) {
switch (extension) { switch (extension) {
@ -25,11 +24,7 @@ export function HyperText({ link, children }: HyperTextProps) {
case "webp": { case "webp": {
return ( return (
<MediaURL url={url}> <MediaURL url={url}>
<img <img src={url.toString()} alt={url.toString()} style={{ objectFit: "contain" }} />
src={url.toString()}
alt={url.toString()}
style={{ objectFit: "contain" }}
/>
</MediaURL> </MediaURL>
); );
} }

View File

@ -12,12 +12,7 @@ export function Icon(props: Props) {
const href = `/icons.svg#` + props.name; const href = `/icons.svg#` + props.name;
return ( return (
<svg <svg width={size} height={size} className={props.className} onClick={props.onClick}>
width={size}
height={size}
className={props.className}
onClick={props.onClick}
>
<use href={href} /> <use href={href} />
</svg> </svg>
); );

View File

@ -203,15 +203,7 @@
} }
.zap-container.big-zap:before { .zap-container.big-zap:before {
background: linear-gradient( background: linear-gradient(60deg, #2bd9ff, #8c8ded, #f838d9, #f83838, #ff902b, #ddf838);
60deg,
#2bd9ff,
#8c8ded,
#f838d9,
#f83838,
#ff902b,
#ddf838
);
animation: animatedgradient 3s ease alternate infinite; animation: animatedgradient 3s ease alternate infinite;
background-size: 300% 300%; background-size: 300% 300%;
} }

View File

@ -1,13 +1,5 @@
import "./live-chat.css"; import "./live-chat.css";
import { import { EventKind, NostrPrefix, NostrLink, ParsedZap, NostrEvent, parseZap, encodeTLV } from "@snort/system";
EventKind,
NostrPrefix,
NostrLink,
ParsedZap,
NostrEvent,
parseZap,
encodeTLV,
} from "@snort/system";
import { unixNow, unwrap } from "@snort/shared"; import { unixNow, unwrap } from "@snort/shared";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import uniqBy from "lodash.uniqby"; import uniqBy from "lodash.uniqby";
@ -32,6 +24,7 @@ import { formatSats } from "number";
import { WEEK, LIVE_STREAM_CHAT } from "const"; import { WEEK, LIVE_STREAM_CHAT } from "const";
import { findTag, getTagValues, getHost } from "utils"; import { findTag, getTagValues, getHost } from "utils";
import { System } from "index"; import { System } from "index";
import { FormattedMessage } from "react-intl";
export interface LiveChatOptions { export interface LiveChatOptions {
canWrite?: boolean; canWrite?: boolean;
@ -48,7 +41,7 @@ function BadgeAward({ ev }: { ev: NostrEvent }) {
{event && <Badge ev={event} />} {event && <Badge ev={event} />}
<p>awarded to</p> <p>awarded to</p>
<div className="badge-awardees"> <div className="badge-awardees">
{awardees.map((pk) => ( {awardees.map(pk => (
<Profile key={pk} pubkey={pk} /> <Profile key={pk} pubkey={pk} />
))} ))}
</div> </div>
@ -95,9 +88,7 @@ export function LiveChat({
const feed = useLiveChatFeed(link, goal ? [goal.id] : undefined); const feed = useLiveChatFeed(link, goal ? [goal.id] : undefined);
const login = useLogin(); const login = useLogin();
useEffect(() => { useEffect(() => {
const pubkeys = [ const pubkeys = [...new Set(feed.zaps.flatMap(a => [a.pubkey, unwrap(findTag(a, "p"))]))];
...new Set(feed.zaps.flatMap((a) => [a.pubkey, unwrap(findTag(a, "p"))])),
];
System.ProfileLoader.TrackMetadata(pubkeys); System.ProfileLoader.TrackMetadata(pubkeys);
return () => System.ProfileLoader.UntrackMetadata(pubkeys); return () => System.ProfileLoader.UntrackMetadata(pubkeys);
}, [feed.zaps]); }, [feed.zaps]);
@ -116,54 +107,40 @@ export function LiveChat({
return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId); return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId);
}, [userEmojiPacks, channelEmojiPacks]); }, [userEmojiPacks, channelEmojiPacks]);
const zaps = feed.zaps const zaps = feed.zaps.map(ev => parseZap(ev, System.ProfileLoader.Cache)).filter(z => z && z.valid);
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid);
const events = useMemo(() => { const events = useMemo(() => {
return [...feed.messages, ...feed.zaps, ...awards].sort( return [...feed.messages, ...feed.zaps, ...awards].sort((a, b) => b.created_at - a.created_at);
(a, b) => b.created_at - a.created_at
);
}, [feed.messages, feed.zaps, awards]); }, [feed.messages, feed.zaps, awards]);
const streamer = getHost(ev); const streamer = getHost(ev);
const naddr = useMemo(() => { const naddr = useMemo(() => {
if (ev) { if (ev) {
return encodeTLV( return encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev.kind, ev.pubkey);
NostrPrefix.Address,
findTag(ev, "d") ?? "",
undefined,
ev.kind,
ev.pubkey
);
} }
}, [ev]); }, [ev]);
const filteredEvents = useMemo(() => { const filteredEvents = useMemo(() => {
return events.filter( return events.filter(e => !mutedPubkeys.has(e.pubkey) && !hostMutedPubkeys.has(e.pubkey));
(e) => !mutedPubkeys.has(e.pubkey) && !hostMutedPubkeys.has(e.pubkey)
);
}, [events, mutedPubkeys, hostMutedPubkeys]); }, [events, mutedPubkeys, hostMutedPubkeys]);
return ( return (
<div className="live-chat" style={height ? { height: `${height}px` } : {}}> <div className="live-chat" style={height ? { height: `${height}px` } : {}}>
{(options?.showHeader ?? true) && ( {(options?.showHeader ?? true) && (
<div className="header"> <div className="header">
<h2 className="title">Stream Chat</h2> <h2 className="title">
<FormattedMessage defaultMessage="Stream Chat" />
</h2>
<Icon <Icon
name="link" name="link"
className="secondary" className="secondary"
size={32} size={32}
onClick={() => onClick={() => window.open(`/chat/${naddr}?chat=true`, "_blank", "popup,width=400,height=800")}
window.open(
`/chat/${naddr}?chat=true`,
"_blank",
"popup,width=400,height=800"
)
}
/> />
</div> </div>
)} )}
{zaps.length > 0 && ( {zaps.length > 0 && (
<div className="top-zappers"> <div className="top-zappers">
<h3>Top zappers</h3> <h3>
<FormattedMessage defaultMessage="Top zappers" />
</h3>
<div className="top-zappers-container"> <div className="top-zappers-container">
<TopZappers zaps={zaps} /> <TopZappers zaps={zaps} />
</div> </div>
@ -172,7 +149,7 @@ export function LiveChat({
</div> </div>
)} )}
<div className="messages"> <div className="messages">
{filteredEvents.map((a) => { {filteredEvents.map(a => {
switch (a.kind) { switch (a.kind) {
case EventKind.BadgeAward: { case EventKind.BadgeAward: {
return <BadgeAward ev={a} />; return <BadgeAward ev={a} />;
@ -190,9 +167,7 @@ export function LiveChat({
); );
} }
case EventKind.ZapReceipt: { case EventKind.ZapReceipt: {
const zap = zaps.find( const zap = zaps.find(b => b.id === a.id && b.receiver === streamer);
(b) => b.id === a.id && b.receiver === streamer
);
if (zap) { if (zap) {
return <ChatZap zap={zap} key={a.id} />; return <ChatZap zap={zap} key={a.id} />;
} }
@ -207,7 +182,9 @@ export function LiveChat({
{login ? ( {login ? (
<WriteMessage emojiPacks={allEmojiPacks} link={link} /> <WriteMessage emojiPacks={allEmojiPacks} link={link} />
) : ( ) : (
<p>Please login to write messages!</p> <p>
<FormattedMessage defaultMessage="Please login to write messages!" />
</p>
)} )}
</div> </div>
)} )}
@ -227,16 +204,21 @@ function ChatZap({ zap }: { zap: ParsedZap }) {
<div className={`zap-container ${isBig ? "big-zap" : ""}`}> <div className={`zap-container ${isBig ? "big-zap" : ""}`}>
<div className="zap"> <div className="zap">
<Icon name="zap-filled" className="zap-icon" /> <Icon name="zap-filled" className="zap-icon" />
<Profile <FormattedMessage
pubkey={zap.anonZap ? "anon" : zap.sender ?? "anon"} defaultMessage="{person} zapped {amount} sats"
options={{ values={{
showAvatar: !zap.anonZap, person: (
overrideName: zap.anonZap ? "Anon" : undefined, <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> </div>
{zap.content && ( {zap.content && (
<div className="zap-content"> <div className="zap-content">

View File

@ -75,8 +75,7 @@ export function LiveVideoPlayer(props: VideoPlayerProps) {
export function WebRTCPlayer(props: VideoPlayerProps) { export function WebRTCPlayer(props: VideoPlayerProps) {
const video = useRef<HTMLVideoElement>(null); const video = useRef<HTMLVideoElement>(null);
const streamCached = useMemo( const streamCached = useMemo(
() => () => "https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play",
"https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play",
[props.stream] [props.stream]
); );
const [status] = useState<VideoStatus>(); const [status] = useState<VideoStatus>();
@ -90,7 +89,7 @@ export function WebRTCPlayer(props: VideoPlayerProps) {
client client
.Play() .Play()
.then((s) => { .then(s => {
if (video.current) { if (video.current) {
video.current.srcObject = s; video.current.srcObject = s;
} }
@ -107,12 +106,7 @@ export function WebRTCPlayer(props: VideoPlayerProps) {
<div className={status}> <div className={status}>
<div>{status}</div> <div>{status}</div>
</div> </div>
<video <video ref={video} autoPlay={true} poster={props.poster} controls={status === VideoStatus.Online} />
ref={video}
autoPlay={true}
poster={props.poster}
controls={status === VideoStatus.Online}
/>
</div> </div>
); );
} }

View File

@ -11,6 +11,7 @@ import Copy from "./copy";
import { hexToBech32, openFile } from "utils"; import { hexToBech32, openFile } from "utils";
import { VoidApi } from "@void-cat/api"; import { VoidApi } from "@void-cat/api";
import { LoginType } from "login"; import { LoginType } from "login";
import { FormattedMessage } from "react-intl";
import { bech32 } from "@scure/base"; import { bech32 } from "@scure/base";
enum Stage { enum Stage {
@ -87,8 +88,7 @@ export function LoginSignup({ close }: { close: () => void }) {
"V-Strip-Metadata": "true", "V-Strip-Metadata": "true",
}); });
if (result.ok) { if (result.ok) {
const resultUrl = const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
setAvatar(resultUrl); setAvatar(resultUrl);
} else { } else {
setError(result.errorMessage ?? "Upload failed"); setError(result.errorMessage ?? "Upload failed");
@ -115,22 +115,16 @@ export function LoginSignup({ close }: { close: () => void }) {
case Stage.Login: { case Stage.Login: {
return ( return (
<> <>
<h2>Login</h2> <h2>
<FormattedMessage defaultMessage="Login" />
</h2>
{"nostr" in window && ( {"nostr" in window && (
<AsyncButton <AsyncButton type="button" className="btn btn-primary" onClick={doLogin}>
type="button" <FormattedMessage defaultMessage="Nostr Extension" />
className="btn btn-primary"
onClick={doLogin}
>
Nostr Extension
</AsyncButton> </AsyncButton>
)} )}
<button <button type="button" className="btn btn-primary" onClick={createAccount}>
type="button" <FormattedMessage defaultMessage="Create Account" />
className="btn btn-primary"
onClick={createAccount}
>
Create Account
</button> </button>
<button <button
type="button" type="button"
@ -146,7 +140,9 @@ export function LoginSignup({ close }: { close: () => void }) {
case Stage.Details: { case Stage.Details: {
return ( return (
<> <>
<h2>Setup Profile</h2> <h2>
<FormattedMessage defaultMessage="Setup Profile" />
</h2>
<div className="flex f-center"> <div className="flex f-center">
<div <div
className="avatar-input" className="avatar-input"
@ -155,28 +151,20 @@ export function LoginSignup({ close }: { close: () => void }) {
{ {
"--img": `url(${avatar})`, "--img": `url(${avatar})`,
} as CSSProperties } as CSSProperties
} }>
>
<Icon name="camera-plus" /> <Icon name="camera-plus" />
</div> </div>
</div> </div>
<div> <div>
<div className="paper"> <div className="paper">
<input <input type="text" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} />
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div> </div>
<small>You can change this later</small> <small>
<FormattedMessage defaultMessage="You can change this later" />
</small>
</div> </div>
<AsyncButton <AsyncButton type="button" className="btn btn-primary" onClick={saveProfile}>
type="button" <FormattedMessage defaultMessage="Save" />
className="btn btn-primary"
onClick={saveProfile}
>
Save
</AsyncButton> </AsyncButton>
</> </>
); );
@ -184,20 +172,17 @@ export function LoginSignup({ close }: { close: () => void }) {
case Stage.SaveKey: { case Stage.SaveKey: {
return ( return (
<> <>
<h2>Save Key</h2> <h2>
<FormattedMessage defaultMessage="Save Key" />
</h2>
<p> <p>
Nostr uses private keys, please save yours, if you lose this key you <FormattedMessage defaultMessage="Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore!" />
wont be able to login to your account anymore!
</p> </p>
<div className="paper"> <div className="paper">
<Copy text={hexToBech32("nsec", key)} /> <Copy text={hexToBech32("nsec", key)} />
</div> </div>
<button <button type="button" className="btn btn-primary" onClick={loginWithKey}>
type="button" <FormattedMessage defaultMessage="Ok, it's safe" />
className="btn btn-primary"
onClick={loginWithKey}
>
Ok, it's safe
</button> </button>
</> </>
); );

View File

@ -1,6 +1,5 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { System } from "index";
import { hexToBech32 } from "utils"; import { hexToBech32 } from "utils";
interface MentionProps { interface MentionProps {
@ -9,7 +8,7 @@ interface MentionProps {
} }
export function Mention({ pubkey }: MentionProps) { export function Mention({ pubkey }: MentionProps) {
const user = useUserProfile(System, pubkey); const user = useUserProfile(pubkey);
const npub = hexToBech32("npub", pubkey); const npub = hexToBech32("npub", pubkey);
return <Link to={`/p/${npub}`}>{user?.name || pubkey}</Link>; return <Link to={`/p/${npub}`}>{user?.name || pubkey}</Link>;
} }

View File

@ -3,21 +3,19 @@ import { useLogin } from "hooks/login";
import AsyncButton from "element/async-button"; import AsyncButton from "element/async-button";
import { Login, System } from "index"; import { Login, System } from "index";
import { MUTED } from "const"; import { MUTED } from "const";
import { FormattedMessage } from "react-intl";
export function useMute(pubkey: string) { export function useMute(pubkey: string) {
const login = useLogin(); const login = useLogin();
const { tags, content } = login?.muted ?? { tags: [] }; const { tags, content } = login?.muted ?? { tags: [] };
const muted = useMemo(() => tags.filter((t) => t.at(0) === "p"), [tags]); const muted = useMemo(() => tags.filter(t => t.at(0) === "p"), [tags]);
const isMuted = useMemo( const isMuted = useMemo(() => muted.find(t => t.at(1) === pubkey), [pubkey, muted]);
() => muted.find((t) => t.at(1) === pubkey),
[pubkey, muted]
);
async function unmute() { async function unmute() {
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
const newMuted = tags.filter((t) => t.at(1) !== pubkey); const newMuted = tags.filter(t => t.at(1) !== pubkey);
const ev = await pub.generic((eb) => { const ev = await pub.generic(eb => {
eb.kind(MUTED).content(content ?? ""); eb.kind(MUTED).content(content ?? "");
for (const t of newMuted) { for (const t of newMuted) {
eb.tag(t); eb.tag(t);
@ -34,7 +32,7 @@ export function useMute(pubkey: string) {
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
const newMuted = [...tags, ["p", pubkey]]; const newMuted = [...tags, ["p", pubkey]];
const ev = await pub.generic((eb) => { const ev = await pub.generic(eb => {
eb.kind(MUTED).content(content ?? ""); eb.kind(MUTED).content(content ?? "");
for (const tag of newMuted) { for (const tag of newMuted) {
eb.tag(tag); eb.tag(tag);
@ -54,12 +52,8 @@ export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
const { isMuted, mute, unmute } = useMute(pubkey); const { isMuted, mute, unmute } = useMute(pubkey);
return ( return (
<AsyncButton <AsyncButton type="button" className="btn delete-button" onClick={() => (isMuted ? unmute() : mute())}>
type="button" {isMuted ? <FormattedMessage defaultMessage="Unmute" /> : <FormattedMessage defaultMessage="Mute" />}
className="btn delete-button"
onClick={() => (isMuted ? unmute() : mute())}
>
{isMuted ? "Unmute" : "Mute"}
</AsyncButton> </AsyncButton>
); );
} }

View File

@ -8,6 +8,7 @@ import { useState } from "react";
import { System } from "index"; import { System } from "index";
import { GOAL } from "const"; import { GOAL } from "const";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import { FormattedMessage } from "react-intl";
interface NewGoalDialogProps { interface NewGoalDialogProps {
link: NostrLink; link: NostrLink;
@ -23,7 +24,7 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
async function publishGoal() { async function publishGoal() {
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
const evNew = await pub.generic((eb) => { const evNew = await pub.generic(eb => {
eb.kind(GOAL) eb.kind(GOAL)
.tag(["a", `${link.kind}:${link.author}:${link.id}`]) .tag(["a", `${link.kind}:${link.author}:${link.id}`])
.tag(["amount", String(Number(goalAmount) * 1000)]) .tag(["amount", String(Number(goalAmount) * 1000)])
@ -48,7 +49,9 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
<button type="button" className="btn btn-primary"> <button type="button" className="btn btn-primary">
<span> <span>
<Icon name="zap-filled" size={12} /> <Icon name="zap-filled" size={12} />
<span>Add stream goal</span> <span>
<FormattedMessage defaultMessage="Add stream goal" />
</span>
</span> </span>
</button> </button>
</Dialog.Trigger> </Dialog.Trigger>
@ -57,26 +60,28 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
<Dialog.Content className="dialog-content"> <Dialog.Content className="dialog-content">
<div className="new-goal"> <div className="new-goal">
<div className="zap-goals"> <div className="zap-goals">
<Icon <Icon name="zap-filled" className="stream-zap-goals-icon" size={16} />
name="zap-filled" <h3>
className="stream-zap-goals-icon" <FormattedMessage defaultMessage="Stream Zap Goals" />
size={16} </h3>
/>
<h3>Stream Zap Goals</h3>
</div> </div>
<div> <div>
<p>Name</p> <p>
<FormattedMessage defaultMessage="Name" />
</p>
<div className="paper"> <div className="paper">
<input <input
type="text" type="text"
value={goalName} value={goalName}
placeholder="e.g. New Laptop" placeholder="e.g. New Laptop"
onChange={(e) => setGoalName(e.target.value)} onChange={e => setGoalName(e.target.value)}
/> />
</div> </div>
</div> </div>
<div> <div>
<p>Amount</p> <p>
<FormattedMessage defaultMessage="Amount" />
</p>
<div className="paper"> <div className="paper">
<input <input
type="number" type="number"
@ -84,18 +89,13 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
min="1" min="1"
max="2100000000000000" max="2100000000000000"
value={goalAmount} value={goalAmount}
onChange={(e) => setGoalAmount(e.target.value)} onChange={e => setGoalAmount(e.target.value)}
/> />
</div> </div>
</div> </div>
<div className="create-goal"> <div className="create-goal">
<AsyncButton <AsyncButton type="button" className="btn btn-primary wide" disabled={!isValid} onClick={publishGoal}>
type="button" <FormattedMessage defaultMessage="Create Goal" />
className="btn btn-primary wide"
disabled={!isValid}
onClick={publishGoal}
>
Create goal
</AsyncButton> </AsyncButton>
</div> </div>
</div> </div>

View File

@ -10,6 +10,7 @@ import { useNavigate } from "react-router-dom";
import { eventLink, findTag } from "utils"; import { eventLink, findTag } from "utils";
import { NostrProviderDialog } from "./nostr-provider-dialog"; import { NostrProviderDialog } from "./nostr-provider-dialog";
import { unwrap } from "@snort/shared"; import { unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl";
function NewStream({ ev, onFinish }: StreamEditorProps) { function NewStream({ ev, onFinish }: StreamEditorProps) {
const providers = useStreamProvider(); const providers = useStreamProvider();
@ -19,9 +20,7 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
useEffect(() => { useEffect(() => {
if (!currentProvider) { if (!currentProvider) {
setCurrentProvider( setCurrentProvider(
ev !== undefined ev !== undefined ? unwrap(providers.find(a => a.name.toLowerCase() === "manual")) : providers.at(0)
? unwrap(providers.find((a) => a.name.toLowerCase() === "manual"))
: providers.at(0)
); );
} }
}, [providers, currentProvider]); }, [providers, currentProvider]);
@ -33,14 +32,10 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
case StreamProviders.Manual: { case StreamProviders.Manual: {
return ( return (
<StreamEditor <StreamEditor
onFinish={(ex) => { onFinish={ex => {
currentProvider.updateStreamInfo(ex); currentProvider.updateStreamInfo(ex);
if (!ev) { if (!ev) {
if ( if (findTag(ex, "content-warning") && __XXX_HOST && __XXX === false) {
findTag(ex, "content-warning") &&
__XXX_HOST &&
__XXX === false
) {
location.href = `${__XXX_HOST}/${eventLink(ex)}`; location.href = `${__XXX_HOST}/${eventLink(ex)}`;
} else { } else {
navigate(`/${eventLink(ex)}`, { navigate(`/${eventLink(ex)}`, {
@ -56,13 +51,7 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
); );
} }
case StreamProviders.NostrType: { case StreamProviders.NostrType: {
return ( return <NostrProviderDialog provider={currentProvider} onFinish={onFinish} ev={ev} />;
<NostrProviderDialog
provider={currentProvider}
onFinish={onFinish}
ev={ev}
/>
);
} }
case StreamProviders.Owncast: { case StreamProviders.Owncast: {
return; return;
@ -72,13 +61,12 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
return ( return (
<> <>
<p>Stream Providers</p> <p>
<FormattedMessage defaultMessage="Stream Providers" />
</p>
<div className="flex g12"> <div className="flex g12">
{providers.map((v) => ( {providers.map(v => (
<span <span className={`pill${v === currentProvider ? " active" : ""}`} onClick={() => setCurrentProvider(v)}>
className={`pill${v === currentProvider ? " active" : ""}`}
onClick={() => setCurrentProvider(v)}
>
{v.name} {v.name}
</span> </span>
))} ))}
@ -93,9 +81,7 @@ interface NewStreamDialogProps {
btnClassName?: string; btnClassName?: string;
} }
export function NewStreamDialog( export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps) {
props: NewStreamDialogProps & StreamEditorProps
) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<Dialog.Root open={open} onOpenChange={setOpen}> <Dialog.Root open={open} onOpenChange={setOpen}>
@ -104,7 +90,9 @@ export function NewStreamDialog(
{props.text && props.text} {props.text && 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" /> <Icon name="signal" />
</> </>
)} )}

View File

@ -3,10 +3,7 @@ import { Mention } from "./mention";
export function NostrLink({ link }: { link: string }) { export function NostrLink({ link }: { link: string }) {
const nav = tryParseNostrLink(link); const nav = tryParseNostrLink(link);
if ( if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) {
nav?.type === NostrPrefix.PublicKey ||
nav?.type === NostrPrefix.Profile
) {
return <Mention pubkey={nav.id} relays={nav.relays} />; return <Mention pubkey={nav.id} relays={nav.relays} />;
} else { } else {
<a href={link} target="_blank" rel="noreferrer" className="ext"> <a href={link} target="_blank" rel="noreferrer" className="ext">

View File

@ -1,19 +1,13 @@
import { NostrEvent } from "@snort/system"; import { NostrEvent } from "@snort/system";
import { import { StreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "providers";
StreamProvider,
StreamProviderEndpoint,
StreamProviderInfo,
} from "providers";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { SendZaps } from "./send-zap"; import { SendZaps } from "./send-zap";
import { StreamEditor, StreamEditorProps } from "./stream-editor"; import { StreamEditor, StreamEditorProps } from "./stream-editor";
import Spinner from "./spinner"; import Spinner from "./spinner";
import AsyncButton from "./async-button"; import AsyncButton from "./async-button";
import { FormattedMessage } from "react-intl";
export function NostrProviderDialog({ export function NostrProviderDialog({ provider, ...others }: { provider: StreamProvider } & StreamEditorProps) {
provider,
...others
}: { provider: StreamProvider } & StreamEditorProps) {
const [topup, setTopup] = useState(false); const [topup, setTopup] = useState(false);
const [info, setInfo] = useState<StreamProviderInfo>(); const [info, setInfo] = useState<StreamProviderInfo>();
const [ep, setEndpoint] = useState<StreamProviderEndpoint>(); const [ep, setEndpoint] = useState<StreamProviderEndpoint>();
@ -24,7 +18,7 @@ export function NostrProviderDialog({
} }
useEffect(() => { useEffect(() => {
provider.info().then((v) => { provider.info().then(v => {
setInfo(v); setInfo(v);
setTos(v.tosAccepted ?? true); setTos(v.tosAccepted ?? true);
setEndpoint(sortEndpoints(v.endpoints)[0]); setEndpoint(sortEndpoints(v.endpoints)[0]);
@ -42,13 +36,13 @@ export function NostrProviderDialog({
name: provider.name, name: provider.name,
canZap: false, canZap: false,
maxCommentLength: 0, maxCommentLength: 0,
getInvoice: async (amount) => { getInvoice: async amount => {
const pr = await provider.topup(amount); const pr = await provider.topup(amount);
return { pr }; return { pr };
}, },
}} }}
onFinish={() => { onFinish={() => {
provider.info().then((v) => { provider.info().then(v => {
setInfo(v); setInfo(v);
setTopup(false); setTopup(false);
}); });
@ -92,33 +86,27 @@ export function NostrProviderDialog({
<> <>
<div> <div>
<div className="flex g12"> <div className="flex g12">
<input <input type="checkbox" checked={tos} onChange={e => setTos(e.target.checked)} />
type="checkbox"
checked={tos}
onChange={(e) => setTos(e.target.checked)}
/>
<p> <p>
I have read and agree with {info.name}'s{" "} <FormattedMessage
<span defaultMessage="I have read and agree with {provider}'s {terms}."
className="tos-link" values={{
onClick={() => provider: info.name,
window.open(info.tosLink, "popup", "width=400,height=800") terms: (
} <span
> className="tos-link"
terms and conditions onClick={() => window.open(info.tosLink, "popup", "width=400,height=800")}>
</span> <FormattedMessage defaultMessage="terms and conditions" />
. </span>
),
}}
/>
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<AsyncButton <AsyncButton type="button" className="btn btn-primary wide" disabled={!tos} onClick={acceptTos}>
type="button" <FormattedMessage defaultMessage="Continue" />
className="btn btn-primary wide"
disabled={!tos}
onClick={acceptTos}
>
Continue
</AsyncButton> </AsyncButton>
</div> </div>
</> </>
@ -129,13 +117,12 @@ export function NostrProviderDialog({
<> <>
{info.endpoints.length > 1 && ( {info.endpoints.length > 1 && (
<div> <div>
<p>Endpoint</p> <p>
<FormattedMessage defaultMessage="Endpoint" />
</p>
<div className="flex g12"> <div className="flex g12">
{sortEndpoints(info.endpoints).map((a) => ( {sortEndpoints(info.endpoints).map(a => (
<span <span className={`pill${ep?.name === a.name ? " active" : ""}`} onClick={() => setEndpoint(a)}>
className={`pill${ep?.name === a.name ? " active" : ""}`}
onClick={() => setEndpoint(a)}
>
{a.name} {a.name}
</span> </span>
))} ))}
@ -143,41 +130,48 @@ export function NostrProviderDialog({
</div> </div>
)} )}
<div> <div>
<p>Stream Url</p> <p>
<FormattedMessage defaultMessage="Server Url" />
</p>
<div className="paper"> <div className="paper">
<input type="text" value={ep?.url} disabled /> <input type="text" value={ep?.url} disabled />
</div> </div>
</div> </div>
<div> <div>
<p>Stream Key</p> <p>
<FormattedMessage defaultMessage="Stream Key" />
</p>
<div className="flex g12"> <div className="flex g12">
<div className="paper f-grow"> <div className="paper f-grow">
<input type="password" value={ep?.key} disabled /> <input type="password" value={ep?.key} disabled />
</div> </div>
<button <button className="btn btn-primary" onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}>
className="btn btn-primary" <FormattedMessage defaultMessage="Copy" />
onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}
>
Copy
</button> </button>
</div> </div>
</div> </div>
<div> <div>
<p>Balance</p> <p>
<FormattedMessage defaultMessage="Balance" />
</p>
<div className="flex g12"> <div className="flex g12">
<div className="paper f-grow"> <div className="paper f-grow">
{info.balance?.toLocaleString()} sats <FormattedMessage defaultMessage="{amount} sats" values={{ amount: info.balance?.toLocaleString() }} />
</div> </div>
<button className="btn btn-primary" onClick={() => setTopup(true)}> <button className="btn btn-primary" onClick={() => setTopup(true)}>
Topup <FormattedMessage defaultMessage="Topup" />
</button> </button>
</div> </div>
<small>About {calcEstimate()}</small> <small>
<FormattedMessage defaultMessage="About {estimate}" values={{ estimate: calcEstimate() }} />
</small>
</div> </div>
<div> <div>
<p>Resolutions</p> <p>
<FormattedMessage defaultMessage="Resolutions" />
</p>
<div className="flex g12"> <div className="flex g12">
{ep?.capabilities?.map((a) => ( {ep?.capabilities?.map(a => (
<span className="pill">{parseCapability(a)}</span> <span className="pill">{parseCapability(a)}</span>
))} ))}
</div> </div>
@ -186,7 +180,7 @@ export function NostrProviderDialog({
tosInput() tosInput()
) : ( ) : (
<StreamEditor <StreamEditor
onFinish={(ex) => { onFinish={ex => {
provider.updateStreamInfo(ex); provider.updateStreamInfo(ex);
others.onFinish?.(ex); others.onFinish?.(ex);
}} }}
@ -196,10 +190,8 @@ export function NostrProviderDialog({
["title", info.streamInfo?.title ?? ""], ["title", info.streamInfo?.title ?? ""],
["summary", info.streamInfo?.summary ?? ""], ["summary", info.streamInfo?.summary ?? ""],
["image", info.streamInfo?.image ?? ""], ["image", info.streamInfo?.image ?? ""],
...(info.streamInfo?.content_warning ...(info.streamInfo?.content_warning ? [["content-warning", info.streamInfo?.content_warning]] : []),
? [["content-warning", info.streamInfo?.content_warning]] ...(info.streamInfo?.tags?.map(a => ["t", a]) ?? []),
: []),
...(info.streamInfo?.tags?.map((a) => ["t", a]) ?? []),
], ],
} as NostrEvent } as NostrEvent
} }

View File

@ -14,10 +14,7 @@ export function Note({ ev }: { ev: NostrEvent }) {
<ExternalIconLink <ExternalIconLink
size={24} size={24}
className="note-link-icon" className="note-link-icon"
href={`https://snort.social/e/${hexToBech32( href={`https://snort.social/e/${hexToBech32(NostrPrefix.Event, ev.id)}`}
NostrPrefix.Event,
ev.id
)}`}
/> />
</div> </div>
<div className="note-content"> <div className="note-content">

View File

@ -7,7 +7,6 @@ import { hexToBech32 } from "@snort/shared";
import { Icon } from "element/icon"; import { Icon } from "element/icon";
import usePlaceholder from "hooks/placeholders"; import usePlaceholder from "hooks/placeholders";
import { System } from "index";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
export interface ProfileOptions { export interface ProfileOptions {
@ -45,8 +44,7 @@ export function Profile({
linkToProfile?: boolean; linkToProfile?: boolean;
}) { }) {
const { inView, ref } = useInView(); const { inView, ref } = useInView();
const pLoaded = const pLoaded = useUserProfile(inView && !profile ? pubkey : undefined) || profile;
useUserProfile(System, inView && !profile ? pubkey : undefined) || profile;
const showAvatar = options?.showAvatar ?? true; const showAvatar = options?.showAvatar ?? true;
const showName = options?.showName ?? true; const showName = options?.showName ?? true;
const placeholder = usePlaceholder(pubkey); const placeholder = usePlaceholder(pubkey);
@ -64,13 +62,7 @@ export function Profile({
/> />
))} ))}
{icon} {icon}
{showName && ( {showName && <span>{options?.overrideName ?? pubkey === "anon" ? "Anon" : getName(pubkey, pLoaded)}</span>}
<span>
{options?.overrideName ?? pubkey === "anon"
? "Anon"
: getName(pubkey, pLoaded)}
</span>
)}
</> </>
); );
@ -79,11 +71,7 @@ export function Profile({
{content} {content}
</div> </div>
) : ( ) : (
<Link <Link to={`/p/${hexToBech32("npub", pubkey)}`} className="profile" ref={ref}>
to={`/p/${hexToBech32("npub", pubkey)}`}
className="profile"
ref={ref}
>
{content} {content}
</Link> </Link>
); );

View File

@ -46,10 +46,5 @@ export default function QrCode(props: QrCodeProps) {
} }
}, [props.data, props.link, props.width, props.height, props.avatar]); }, [props.data, props.link, props.width, props.height, props.avatar]);
return ( return <div className={`qr${props.className ? ` ${props.className}` : ""}`} ref={qrRef}></div>;
<div
className={`qr${props.className ? ` ${props.className}` : ""}`}
ref={qrRef}
></div>
);
} }

View File

@ -13,16 +13,13 @@ import QrCode from "./qr-code";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import Copy from "./copy"; import Copy from "./copy";
import { defaultRelays } from "const"; import { defaultRelays } from "const";
import { FormattedMessage } from "react-intl";
export interface LNURLLike { export interface LNURLLike {
get name(): string; get name(): string;
get maxCommentLength(): number; get maxCommentLength(): number;
get canZap(): boolean; get canZap(): boolean;
getInvoice( getInvoice(amountInSats: number, comment?: string, zap?: NostrEvent): Promise<{ pr?: string }>;
amountInSats: number,
comment?: string,
zap?: NostrEvent
): Promise<{ pr?: string }>;
} }
export interface SendZapsProps { export interface SendZapsProps {
@ -35,19 +32,12 @@ export interface SendZapsProps {
button?: ReactNode; button?: ReactNode;
} }
export function SendZaps({ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: SendZapsProps) {
lnurl,
pubkey,
aTag,
eTag,
targetName,
onFinish,
}: SendZapsProps) {
const UsdRate = 28_000; const UsdRate = 28_000;
const satsAmounts = [ const satsAmounts = [
21, 69, 121, 221, 420, 1_000, 2_100, 5_000, 6_666, 10_000, 21_000, 42_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,
69_000, 100_000, 210_000, 500_000, 1_000_000, 1_000_000,
]; ];
const usdAmounts = [0.05, 0.5, 2, 5, 10, 50, 100, 200]; const usdAmounts = [0.05, 0.5, 2, 5, 10, 50, 100, 200];
const [isFiat, setIsFiat] = useState(false); const [isFiat, setIsFiat] = useState(false);
@ -79,34 +69,25 @@ export function SendZaps({
let pub = login?.publisher(); let pub = login?.publisher();
let isAnon = false; let isAnon = false;
if (!pub) { if (!pub) {
pub = EventPublisher.privateKey( pub = EventPublisher.privateKey(bytesToHex(secp256k1.utils.randomPrivateKey()));
bytesToHex(secp256k1.utils.randomPrivateKey())
);
isAnon = true; isAnon = true;
} }
const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount; const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount;
let zap: NostrEvent | undefined; let zap: NostrEvent | undefined;
if (pubkey) { if (pubkey) {
zap = await pub.zap( zap = await pub.zap(amountInSats * 1000, pubkey, relays, undefined, comment, eb => {
amountInSats * 1000, if (aTag) {
pubkey, eb.tag(["a", aTag]);
relays,
undefined,
comment,
(eb) => {
if (aTag) {
eb.tag(["a", aTag]);
}
if (eTag) {
eb.tag(["e", eTag]);
}
if (isAnon) {
eb.tag(["anon", ""]);
}
return eb;
} }
); if (eTag) {
eb.tag(["e", eTag]);
}
if (isAnon) {
eb.tag(["anon", ""]);
}
return eb;
});
} }
const invoice = await svc.getInvoice(amountInSats, comment, zap); const invoice = await svc.getInvoice(amountInSats, comment, zap);
if (!invoice.pr) return; if (!invoice.pr) return;
@ -134,8 +115,7 @@ export function SendZaps({
onClick={() => { onClick={() => {
setIsFiat(false); setIsFiat(false);
setAmount(satsAmounts[0]); setAmount(satsAmounts[0]);
}} }}>
>
SATS SATS
</span> </span>
<span <span
@ -143,20 +123,20 @@ export function SendZaps({
onClick={() => { onClick={() => {
setIsFiat(true); setIsFiat(true);
setAmount(usdAmounts[0]); setAmount(usdAmounts[0]);
}} }}>
>
USD USD
</span> </span>
</div> </div>
<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"> <div className="amounts">
{(isFiat ? usdAmounts : satsAmounts).map((a) => ( {(isFiat ? usdAmounts : satsAmounts).map(a => (
<span <span key={a} className={`pill${a === amount ? " active" : ""}`} onClick={() => setAmount(a)}>
key={a}
className={`pill${a === amount ? " active" : ""}`}
onClick={() => setAmount(a)}
>
{isFiat ? `$${a.toLocaleString()}` : formatSats(a)} {isFiat ? `$${a.toLocaleString()}` : formatSats(a)}
</span> </span>
))} ))}
@ -164,19 +144,17 @@ export function SendZaps({
</div> </div>
{svc && (svc.maxCommentLength > 0 || svc.canZap) && ( {svc && (svc.maxCommentLength > 0 || svc.canZap) && (
<div> <div>
<small>Your comment for {name}</small> <small>
<FormattedMessage defaultMessage="Your comment for {name}" values={{ name }} />
</small>
<div className="paper"> <div className="paper">
<textarea <textarea placeholder="Nice!" value={comment} onChange={e => setComment(e.target.value)} />
placeholder="Nice!"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div> </div>
</div> </div>
)} )}
<div> <div>
<AsyncButton onClick={send} className="btn btn-primary"> <AsyncButton onClick={send} className="btn btn-primary">
Zap! <FormattedMessage defaultMessage="Zap!" />
</AsyncButton> </AsyncButton>
</div> </div>
</> </>
@ -194,7 +172,7 @@ export function SendZaps({
<Copy text={invoice} /> <Copy text={invoice} />
</div> </div>
<button className="btn btn-primary wide" onClick={() => onFinish()}> <button className="btn btn-primary wide" onClick={() => onFinish()}>
Back <FormattedMessage defaultMessage="Back" />
</button> </button>
</> </>
); );
@ -203,7 +181,7 @@ export function SendZaps({
return ( return (
<div className="send-zap"> <div className="send-zap">
<h3> <h3>
Zap {name} <FormattedMessage defaultMessage="Zap {name}" values={{ name }} />
<Icon name="zap" /> <Icon name="zap" />
</h3> </h3>
{input()} {input()}
@ -221,7 +199,9 @@ export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
props.button props.button
) : ( ) : (
<button className="btn btn-primary zap"> <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} /> <Icon name="zap-filled" size={16} />
</button> </button>
)} )}

View File

@ -10,6 +10,7 @@ import { findTag } from "utils";
import AsyncButton from "./async-button"; import AsyncButton from "./async-button";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import { System } from "index"; import { System } from "index";
import { FormattedMessage } from "react-intl";
type ShareOn = "nostr" | "twitter"; type ShareOn = "nostr" | "twitter";
@ -18,13 +19,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const login = useLogin(); const login = useLogin();
const naddr = encodeTLV( const naddr = encodeTLV(NostrPrefix.Address, unwrap(findTag(ev, "d")), undefined, ev.kind, ev.pubkey);
NostrPrefix.Address,
unwrap(findTag(ev, "d")),
undefined,
ev.kind,
ev.pubkey
);
const link = `https://zap.stream/${naddr}`; const link = `https://zap.stream/${naddr}`;
async function sendMessage() { async function sendMessage() {
@ -45,35 +40,30 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
menuClassName="ctx-menu" menuClassName="ctx-menu"
menuButton={ menuButton={
<button type="button" className="btn btn-secondary"> <button type="button" className="btn btn-secondary">
Share <FormattedMessage defaultMessage="Share" />
</button> </button>
} }>
>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
setMessage( setMessage(`Come check out my stream on zap.stream!\n\n${link}\n\nnostr:${naddr}`);
`Come check out my stream on zap.stream!\n\n${link}\n\nnostr:${naddr}`
);
setShare("nostr"); setShare("nostr");
}} }}>
>
<Icon name="nostrich" size={24} /> <Icon name="nostrich" size={24} />
Broadcast on Nostr <FormattedMessage defaultMessage="Broadcast on Nostr" />
</MenuItem> </MenuItem>
</Menu> </Menu>
<Dialog.Root <Dialog.Root open={Boolean(share)} onOpenChange={() => setShare(undefined)}>
open={Boolean(share)}
onOpenChange={() => setShare(undefined)}
>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" /> <Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content"> <Dialog.Content className="dialog-content">
<h2>Share</h2> <h2>
<FormattedMessage defaultMessage="Share" />
</h2>
<div className="paper"> <div className="paper">
<Textarea <Textarea
emojis={[]} emojis={[]}
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={e => setMessage(e.target.value)}
onKeyDown={() => { onKeyDown={() => {
//noop //noop
}} }}
@ -81,7 +71,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
/> />
</div> </div>
<AsyncButton className="btn btn-primary" onClick={sendMessage}> <AsyncButton className="btn btn-primary" onClick={sendMessage}>
Send <FormattedMessage defaultMessage="Send" />
</AsyncButton> </AsyncButton>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>

View File

@ -7,13 +7,7 @@ export interface IconProps {
} }
const Spinner = (props: IconProps) => ( const Spinner = (props: IconProps) => (
<svg <svg width="20" height="20" stroke="currentColor" viewBox="0 0 20 20" {...props}>
width="20"
height="20"
stroke="currentColor"
viewBox="0 0 20 20"
{...props}
>
<g className="spinner_V8m1"> <g className="spinner_V8m1">
<circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle> <circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle>
</g> </g>

View File

@ -2,9 +2,5 @@ import "./state-pill.css";
import { StreamState } from "index"; import { StreamState } from "index";
export function StatePill({ state }: { state: StreamState }) { export function StatePill({ state }: { state: StreamState }) {
return ( return <span className={`state pill${state === StreamState.Live ? " live" : ""}`}>{state}</span>;
<span className={`state pill${state === StreamState.Live ? " live" : ""}`}>
{state}
</span>
);
} }

View File

@ -1,11 +1,12 @@
import "./stream-cards.css"; import "./stream-cards.css";
import { useState, forwardRef } from "react"; import { useState, forwardRef } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import * as Dialog from "@radix-ui/react-dialog"; import * as Dialog from "@radix-ui/react-dialog";
import { DndProvider, useDrag, useDrop } from "react-dnd"; import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend"; import { HTML5Backend } from "react-dnd-html5-backend";
import type { TaggedRawEvent } from "@snort/system"; import { TaggedNostrEvent } from "@snort/system";
import { Toggle } from "element/toggle"; import { Toggle } from "element/toggle";
import { Icon } from "element/icon"; import { Icon } from "element/icon";
@ -37,35 +38,32 @@ interface CardPreviewProps extends NewCard {
style: object; style: object;
} }
const CardPreview = forwardRef( const CardPreview = forwardRef(({ style, title, link, image, content }: CardPreviewProps, ref) => {
({ style, title, link, image, content }: CardPreviewProps, ref) => { const isImageOnly = !isEmpty(image) && isEmpty(content) && isEmpty(title);
const isImageOnly = !isEmpty(image) && isEmpty(content) && isEmpty(title); return (
return ( <div
<div className={`stream-card ${isImageOnly ? "image-card" : ""}`}
className={`stream-card ${isImageOnly ? "image-card" : ""}`} // @ts-expect-error: Type 'ForwardRef<unknown>'
// @ts-expect-error: Type 'ForwardRef<unknown>' ref={ref}
ref={ref} style={style}>
style={style} {title && <h1 className="card-title">{title}</h1>}
> {image &&
{title && <h1 className="card-title">{title}</h1>} (link && link?.length > 0 ? (
{image && <ExternalLink href={link}>
(link && link?.length > 0 ? (
<ExternalLink href={link}>
<img className="card-image" src={image} alt={title} />
</ExternalLink>
) : (
<img className="card-image" src={image} alt={title} /> <img className="card-image" src={image} alt={title} />
))} </ExternalLink>
<Markdown content={content} /> ) : (
</div> <img className="card-image" src={image} alt={title} />
); ))}
} <Markdown content={content} />
); </div>
);
});
interface CardProps { interface CardProps {
canEdit?: boolean; canEdit?: boolean;
ev: TaggedRawEvent; ev: TaggedNostrEvent;
cards: TaggedRawEvent[]; cards: TaggedNostrEvent[];
} }
interface CardItem { interface CardItem {
@ -88,7 +86,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
canDrag: () => { canDrag: () => {
return Boolean(canEdit); return Boolean(canEdit);
}, },
collect: (monitor) => { collect: monitor => {
const isDragging = monitor.isDragging(); const isDragging = monitor.isDragging();
return { return {
opacity: isDragging ? 0.1 : 1, opacity: isDragging ? 0.1 : 1,
@ -100,7 +98,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
); );
function findTagByIdentifier(d: string) { function findTagByIdentifier(d: string) {
return tags.find((t) => t[1].endsWith(`:${d}`)); return tags.find(t => t[1].endsWith(`:${d}`));
} }
const [dropStyle, dropRef] = useDrop( const [dropStyle, dropRef] = useDrop(
@ -109,7 +107,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
canDrop: () => { canDrop: () => {
return Boolean(canEdit); return Boolean(canEdit);
}, },
collect: (monitor) => { collect: monitor => {
const isOvering = monitor.isOver({ shallow: true }); const isOvering = monitor.isOver({ shallow: true });
return { return {
opacity: isOvering ? 0.3 : 1, opacity: isOvering ? 0.3 : 1,
@ -123,7 +121,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
} }
const newItem = findTagByIdentifier(typed.identifier); const newItem = findTagByIdentifier(typed.identifier);
const oldItem = findTagByIdentifier(identifier); const oldItem = findTagByIdentifier(identifier);
const newTags = tags.map((t) => { const newTags = tags.map(t => {
if (t === oldItem) { if (t === oldItem) {
return newItem; return newItem;
} }
@ -134,7 +132,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
}) as Tags; }) as Tags;
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
const userCardsEv = await pub.generic((eb) => { const userCardsEv = await pub.generic(eb => {
eb.kind(USER_CARDS).content(""); eb.kind(USER_CARDS).content("");
for (const tag of newTags) { for (const tag of newTags) {
eb.tag(tag); eb.tag(tag);
@ -151,14 +149,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
); );
const card = ( const card = (
<CardPreview <CardPreview ref={dropRef} title={title} link={link} image={image} content={content} style={dropStyle} />
ref={dropRef}
title={title}
link={link}
image={image}
content={content}
style={dropStyle}
/>
); );
const editor = canEdit && ( const editor = canEdit && (
<div className="editor-buttons"> <div className="editor-buttons">
@ -184,14 +175,7 @@ interface CardDialogProps {
onCancel(): void; onCancel(): void;
} }
function CardDialog({ function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: CardDialogProps) {
header,
cta,
cancelCta,
card,
onSave,
onCancel,
}: CardDialogProps) {
const [title, setTitle] = useState(card?.title ?? ""); const [title, setTitle] = useState(card?.title ?? "");
const [image, setImage] = useState(card?.image ?? ""); const [image, setImage] = useState(card?.image ?? "");
const [content, setContent] = useState(card?.content ?? ""); const [content, setContent] = useState(card?.content ?? "");
@ -199,58 +183,63 @@ function CardDialog({
return ( return (
<div className="new-card"> <div className="new-card">
<h3>{header || "Add card"}</h3> <h3>
{header || <FormattedMessage defaultMessage="Add card" />}
</h3>
<div className="form-control"> <div className="form-control">
<label htmlFor="card-title">Title</label> <label htmlFor="card-title">
<FormattedMessage defaultMessage="Title" />
</label>
<input <input
id="card-title" id="card-title"
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={e => setTitle(e.target.value)}
placeholder="e.g. about me" placeholder="e.g. about me"
/> />
</div> </div>
<div className="form-control"> <div className="form-control">
<label htmlFor="card-image">Image</label> <label htmlFor="card-image">
<FileUploader <FormattedMessage defaultMessage="Image" />
defaultImage={image} </label>
onFileUpload={setImage} <FileUploader defaultImage={image} onFileUpload={setImage} onClear={() => setImage("")} />
onClear={() => setImage("")}
/>
</div> </div>
<div className="form-control"> <div className="form-control">
<label htmlFor="card-image-link">Image Link</label> <label htmlFor="card-image-link">
<FormattedMessage defaultMessage="Image Link" />
</label>
<input <input
id="card-image-link" id="card-image-link"
type="text" type="text"
placeholder="https://" placeholder="https://"
value={link} value={link}
onChange={(e) => setLink(e.target.value)} onChange={e => setLink(e.target.value)}
/> />
</div> </div>
<div className="form-control"> <div className="form-control">
<label htmlFor="card-content">Content</label> <label htmlFor="card-content">
<textarea <FormattedMessage defaultMessage="Content" />
placeholder="Start typing..." </label>
value={content} <textarea placeholder="Start typing..." value={content} onChange={e => setContent(e.target.value)} />
onChange={(e) => setContent(e.target.value)}
/>
<span className="help-text"> <span className="help-text">
Supports{" "} <FormattedMessage
<ExternalLink href="https://www.markdownguide.org/cheat-sheet"> defaultMessage="Supports {markdown}"
Markdown values={{
</ExternalLink> markdown: (
<ExternalLink href="https://www.markdownguide.org/cheat-sheet">
<FormattedMessage defaultMessage="Markdown" />
</ExternalLink>
),
}}
/>
</span> </span>
</div> </div>
<div className="new-card-buttons"> <div className="new-card-buttons">
<button <button className="btn btn-primary add-button" onClick={() => onSave({ title, image, content, link })}>
className="btn btn-primary add-button" {cta || <FormattedMessage defaultMessage="Add Card" />}
onClick={() => onSave({ title, image, content, link })}
>
{cta || "Add Card"}
</button> </button>
<button className="btn delete-button" onClick={onCancel}> <button className="btn delete-button" onClick={onCancel}>
{cancelCta || "Cancel"} {cancelCta || <FormattedMessage defaultMessage="Cancel" />}
</button> </button>
</div> </div>
</div> </div>
@ -259,7 +248,7 @@ function CardDialog({
interface EditCardProps { interface EditCardProps {
card: CardType; card: CardType;
cards: TaggedRawEvent[]; cards: TaggedNostrEvent[];
} }
function EditCard({ card, cards }: EditCardProps) { function EditCard({ card, cards }: EditCardProps) {
@ -267,11 +256,12 @@ function EditCard({ card, cards }: EditCardProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const identifier = card.identifier; const identifier = card.identifier;
const tags = cards.map(toTag); const tags = cards.map(toTag);
const { formatMessage } = useIntl();
async function editCard({ title, image, link, content }: CardType) { async function editCard({ title, image, link, content }: CardType) {
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
const ev = await pub.generic((eb) => { const ev = await pub.generic(eb => {
eb.kind(CARD).content(content).tag(["d", card.identifier]); eb.kind(CARD).content(content).tag(["d", card.identifier]);
if (title && title?.length > 0) { if (title && title?.length > 0) {
eb.tag(["title", title]); eb.tag(["title", title]);
@ -293,8 +283,8 @@ function EditCard({ card, cards }: EditCardProps) {
async function onCancel() { async function onCancel() {
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
const newTags = tags.filter((t) => !t[1].endsWith(`:${identifier}`)); const newTags = tags.filter(t => !t[1].endsWith(`:${identifier}`));
const userCardsEv = await pub.generic((eb) => { const userCardsEv = await pub.generic(eb => {
eb.kind(USER_CARDS).content(""); eb.kind(USER_CARDS).content("");
for (const tag of newTags) { for (const tag of newTags) {
eb.tag(tag); eb.tag(tag);
@ -312,15 +302,17 @@ function EditCard({ card, cards }: EditCardProps) {
return ( return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}> <Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild> <Dialog.Trigger asChild>
<button className="btn btn-primary">Edit</button> <button className="btn btn-primary">
<FormattedMessage defaultMessage="Edit" />
</button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" /> <Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content"> <Dialog.Content className="dialog-content">
<CardDialog <CardDialog
header="Edit card" header={formatMessage({ defaultMessage: "Edit card" })}
cta="Save Card" cta={formatMessage({ defaultMessage: "Save card" })}
cancelCta="Delete" cancelCta={formatMessage({ defaultMessage: "Delete" })}
card={card} card={card}
onSave={editCard} onSave={editCard}
onCancel={onCancel} onCancel={onCancel}
@ -332,7 +324,7 @@ function EditCard({ card, cards }: EditCardProps) {
} }
interface AddCardProps { interface AddCardProps {
cards: TaggedRawEvent[]; cards: TaggedNostrEvent[];
} }
function AddCard({ cards }: AddCardProps) { function AddCard({ cards }: AddCardProps) {
@ -343,7 +335,7 @@ function AddCard({ cards }: AddCardProps) {
async function createCard({ title, image, link, content }: NewCard) { async function createCard({ title, image, link, content }: NewCard) {
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
const ev = await pub.generic((eb) => { const ev = await pub.generic(eb => {
const d = String(Date.now()); const d = String(Date.now());
eb.kind(CARD).content(content).tag(["d", d]); eb.kind(CARD).content(content).tag(["d", d]);
if (title && title?.length > 0) { if (title && title?.length > 0) {
@ -357,7 +349,7 @@ function AddCard({ cards }: AddCardProps) {
} }
return eb; return eb;
}); });
const userCardsEv = await pub.generic((eb) => { const userCardsEv = await pub.generic(eb => {
eb.kind(USER_CARDS).content(""); eb.kind(USER_CARDS).content("");
for (const tag of tags) { for (const tag of tags) {
eb.tag(tag); eb.tag(tag);
@ -407,18 +399,13 @@ export function StreamCardEditor({ pubkey, tags }: StreamCardEditorProps) {
return ( return (
<> <>
<div className="stream-cards"> <div className="stream-cards">
{cards.map((ev) => ( {cards.map(ev => (
<Card canEdit={isEditing} cards={cards} key={ev.id} ev={ev} /> <Card canEdit={isEditing} cards={cards} key={ev.id} ev={ev} />
))} ))}
{isEditing && <AddCard cards={cards} />} {isEditing && <AddCard cards={cards} />}
</div> </div>
<div className="edit-container"> <div className="edit-container">
<Toggle <Toggle pressed={isEditing} onPressedChange={setIsEditing} label="Toggle edit mode" text="Edit cards" />
pressed={isEditing}
onPressedChange={setIsEditing}
label="Toggle edit mode"
text="Edit cards"
/>
</div> </div>
</> </>
); );
@ -432,7 +419,7 @@ export function ReadOnlyStreamCards({ host }: StreamCardsProps) {
const cards = useCards(host); const cards = useCards(host);
return ( return (
<div className="stream-cards"> <div className="stream-cards">
{cards.map((ev) => ( {cards.map(ev => (
<Card cards={cards} key={ev.id} ev={ev} /> <Card cards={cards} key={ev.id} ev={ev} />
))} ))}
</div> </div>

View File

@ -8,6 +8,7 @@ import AsyncButton from "./async-button";
import { StreamState } from "../index"; import { StreamState } from "../index";
import { findTag } from "../utils"; import { findTag } from "../utils";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import { FormattedMessage, useIntl } from "react-intl";
export interface StreamEditorProps { export interface StreamEditorProps {
ev?: NostrEvent; ev?: NostrEvent;
@ -34,6 +35,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const [contentWarning, setContentWarning] = useState(false); const [contentWarning, setContentWarning] = useState(false);
const [isValid, setIsValid] = useState(false); const [isValid, setIsValid] = useState(false);
const login = useLogin(); const login = useLogin();
const { formatMessage } = useIntl();
useEffect(() => { useEffect(() => {
setTitle(findTag(ev, "title") ?? ""); setTitle(findTag(ev, "title") ?? "");
@ -42,7 +44,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
setStream(findTag(ev, "streaming") ?? ""); setStream(findTag(ev, "streaming") ?? "");
setStatus(findTag(ev, "status") ?? StreamState.Live); setStatus(findTag(ev, "status") ?? StreamState.Live);
setStart(findTag(ev, "starts")); 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); setContentWarning(findTag(ev, "content-warning") !== undefined);
}, [ev?.id]); }, [ev?.id]);
@ -66,7 +68,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
async function publishStream() { async function publishStream() {
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
const evNew = await pub.generic((eb) => { const evNew = await pub.generic(eb => {
const now = unixNow(); const now = unixNow();
const dTag = findTag(ev, "d") ?? now.toString(); const dTag = findTag(ev, "d") ?? now.toString();
const starts = start ?? 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> <h3>{ev ? "Edit Stream" : "New Stream"}</h3>
{(options?.canSetTitle ?? true) && ( {(options?.canSetTitle ?? true) && (
<div> <div>
<p>Title</p> <p>
<FormattedMessage defaultMessage="Title" />
</p>
<div className="paper"> <div className="paper">
<input <input
type="text" type="text"
placeholder="What are we steaming today?" placeholder={formatMessage({ defaultMessage: "What are we steaming today?" })}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={e => setTitle(e.target.value)}
/> />
</div> </div>
</div> </div>
)} )}
{(options?.canSetSummary ?? true) && ( {(options?.canSetSummary ?? true) && (
<div> <div>
<p>Summary</p> <p>
<FormattedMessage defaultMessage="Summary" />
</p>
<div className="paper"> <div className="paper">
<input <input
type="text" type="text"
placeholder="A short description of the content" placeholder={formatMessage({ defaultMessage: "A short description of the content" })}
value={summary} value={summary}
onChange={(e) => setSummary(e.target.value)} onChange={e => setSummary(e.target.value)}
/> />
</div> </div>
</div> </div>
)} )}
{(options?.canSetImage ?? true) && ( {(options?.canSetImage ?? true) && (
<div> <div>
<p>Cover image</p> <p>
<FormattedMessage defaultMessage="Cover Image" />
</p>
<div className="paper"> <div className="paper">
<input <input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} />
type="text"
placeholder="https://"
value={image}
onChange={(e) => setImage(e.target.value)}
/>
</div> </div>
</div> </div>
)} )}
{(options?.canSetStream ?? true) && ( {(options?.canSetStream ?? true) && (
<div> <div>
<p>Stream Url</p> <p>
<FormattedMessage defaultMessage="Stream URL" />
</p>
<div className="paper"> <div className="paper">
<input <input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
type="text"
placeholder="https://"
value={stream}
onChange={(e) => setStream(e.target.value)}
/>
</div> </div>
<small>Stream type should be HLS</small> <small>
<FormattedMessage defaultMessage="Stream type should be HLS" />
</small>
</div> </div>
)} )}
{(options?.canSetStatus ?? true) && ( {(options?.canSetStatus ?? true) && (
<> <>
<div> <div>
<p>Status</p> <p>
<FormattedMessage defaultMessage="Status" />
</p>
<div className="flex g12"> <div className="flex g12">
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map( {[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => (
(v) => ( <span className={`pill${status === v ? " active" : ""}`} onClick={() => setStatus(v)} key={v}>
<span {v}
className={`pill${status === v ? " active" : ""}`} </span>
onClick={() => setStatus(v)} ))}
key={v}
>
{v}
</span>
)
)}
</div> </div>
</div> </div>
{status === StreamState.Planned && ( {status === StreamState.Planned && (
<div> <div>
<p>Start Time</p> <p>
<FormattedMessage defaultMessage="Start Time" />
</p>
<div className="paper"> <div className="paper">
<input <input
type="datetime-local" type="datetime-local"
value={toDateTimeString(Number(start ?? "0"))} value={toDateTimeString(Number(start ?? "0"))}
onChange={(e) => onChange={e => setStart(fromDateTimeString(e.target.value).toString())}
setStart(fromDateTimeString(e.target.value).toString())
}
/> />
</div> </div>
</div> </div>
@ -195,40 +193,30 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
)} )}
{(options?.canSetTags ?? true) && ( {(options?.canSetTags ?? true) && (
<div> <div>
<p>Tags</p> <p>
<FormattedMessage defaultMessage="Tags" />
</p>
<div className="paper"> <div className="paper">
<TagsInput <TagsInput value={tags} onChange={setTags} placeHolder="Music,DJ,English" separators={["Enter", ","]} />
value={tags}
onChange={setTags}
placeHolder="Music,DJ,English"
separators={["Enter", ","]}
/>
</div> </div>
</div> </div>
)} )}
{(options?.canSetContentWarning ?? true) && ( {(options?.canSetContentWarning ?? true) && (
<div className="flex g12 content-warning"> <div className="flex g12 content-warning">
<div> <div>
<input <input type="checkbox" checked={contentWarning} onChange={e => setContentWarning(e.target.checked)} />
type="checkbox"
checked={contentWarning}
onChange={(e) => setContentWarning(e.target.checked)}
/>
</div> </div>
<div> <div>
<div className="warning">NSFW Content</div> <div className="warning">
Check here if this stream contains nudity or pornographic content. <FormattedMessage defaultMessage="NSFW Content" />
</div>
<FormattedMessage defaultMessage="Check here if this stream contains nudity or pornographic content." />
</div> </div>
</div> </div>
)} )}
<div> <div>
<AsyncButton <AsyncButton type="button" className="btn btn-primary wide" disabled={!isValid} onClick={publishStream}>
type="button" {ev ? <FormattedMessage defaultMessage="Save" /> : <FormattedMessage defaultMessage="Start Stream" />}
className="btn btn-primary wide"
disabled={!isValid}
onClick={publishStream}
>
{ev ? "Save" : "Start Stream"}
</AsyncButton> </AsyncButton>
</div> </div>
</> </>

View File

@ -11,9 +11,7 @@ export function StreamTimer({ ev }: { ev?: NostrEvent }) {
const diff = unixNow() - starts; const diff = unixNow() - starts;
const hours = Number(diff / 60.0 / 60.0); const hours = Number(diff / 60.0 / 60.0);
const mins = Number((diff / 60) % 60); const mins = Number((diff / 60) % 60);
setTime( setTime(`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}`);
`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}`
);
} }
useEffect(() => { useEffect(() => {

View File

@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { FormattedMessage } from "react-intl";
import moment from "moment"; import moment from "moment";
import { NostrEvent } from "@snort/system"; import { NostrEvent } from "@snort/system";
@ -6,15 +7,7 @@ import { NostrEvent } from "@snort/system";
import { StreamState } from "index"; import { StreamState } from "index";
import { findTag, getTagValues } from "utils"; import { findTag, getTagValues } from "utils";
export function Tags({ export function Tags({ children, max, ev }: { children?: ReactNode; max?: number; ev: NostrEvent }) {
children,
max,
ev,
}: {
children?: ReactNode;
max?: number;
ev: NostrEvent;
}) {
const status = findTag(ev, "status"); const status = findTag(ev, "status");
const start = findTag(ev, "starts"); const start = findTag(ev, "starts");
const hashtags = getTagValues(ev.tags, "t"); const hashtags = getTagValues(ev.tags, "t");
@ -25,11 +18,11 @@ export function Tags({
{children} {children}
{status === StreamState.Planned && ( {status === StreamState.Planned && (
<span className="pill"> <span className="pill">
{status === StreamState.Planned ? "Starts " : ""} {status === StreamState.Planned ? <FormattedMessage defaultMessage="Starts " /> : ""}
{moment(Number(start) * 1000).fromNow()} {moment(Number(start) * 1000).fromNow()}
</span> </span>
)} )}
{tags.map((a) => ( {tags.map(a => (
<a href={`/t/${encodeURIComponent(a)}`} className="pill" key={a}> <a href={`/t/${encodeURIComponent(a)}`} className="pill" key={a}>
{a} {a}
</a> </a>

View File

@ -1,10 +1,6 @@
import { useMemo, type ReactNode, type FunctionComponent } from "react"; import { useMemo, type ReactNode, type FunctionComponent } from "react";
import { import { type NostrLink, parseNostrLink, validateNostrLink } from "@snort/system";
type NostrLink,
parseNostrLink,
validateNostrLink,
} from "@snort/system";
import { Event } from "element/Event"; import { Event } from "element/Event";
import { Mention } from "element/mention"; import { Mention } from "element/mention";
@ -20,23 +16,17 @@ const EmojiRegex = /:([\w-]+):/g;
function extractLinks(fragments: Fragment[]) { function extractLinks(fragments: Fragment[]) {
return fragments return fragments
.map((f) => { .map(f => {
if (typeof f === "string") { if (typeof f === "string") {
return splitByUrl(f).map((a) => { return splitByUrl(f).map(a => {
const validateLink = () => { const validateLink = () => {
const normalizedStr = a.toLowerCase(); const normalizedStr = a.toLowerCase();
if ( if (normalizedStr.startsWith("web+nostr:") || normalizedStr.startsWith("nostr:")) {
normalizedStr.startsWith("web+nostr:") ||
normalizedStr.startsWith("nostr:")
) {
return validateNostrLink(normalizedStr); return validateNostrLink(normalizedStr);
} }
return ( return normalizedStr.startsWith("http:") || normalizedStr.startsWith("https:");
normalizedStr.startsWith("http:") ||
normalizedStr.startsWith("https:")
);
}; };
if (validateLink()) { if (validateLink()) {
@ -52,10 +42,10 @@ function extractLinks(fragments: Fragment[]) {
function extractEmoji(fragments: Fragment[], tags: string[][]) { function extractEmoji(fragments: Fragment[], tags: string[][]) {
return fragments return fragments
.map((f) => { .map(f => {
if (typeof f === "string") { if (typeof f === "string") {
return f.split(EmojiRegex).map((i) => { return f.split(EmojiRegex).map(i => {
const t = tags.find((a) => a[0] === "emoji" && a[1] === i); const t = tags.find(a => a[0] === "emoji" && a[1] === i);
if (t) { if (t) {
return <Emoji name={t[1]} url={t[2]} />; return <Emoji name={t[1]} url={t[2]} />;
} else { } else {
@ -70,9 +60,9 @@ function extractEmoji(fragments: Fragment[], tags: string[][]) {
function extractNprofiles(fragments: Fragment[]) { function extractNprofiles(fragments: Fragment[]) {
return fragments return fragments
.map((f) => { .map(f => {
if (typeof f === "string") { 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")) { if (i.startsWith("nostr:nprofile1")) {
try { try {
const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
@ -92,9 +82,9 @@ function extractNprofiles(fragments: Fragment[]) {
function extractNpubs(fragments: Fragment[]) { function extractNpubs(fragments: Fragment[]) {
return fragments return fragments
.map((f) => { .map(f => {
if (typeof f === "string") { 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")) { if (i.startsWith("nostr:npub1")) {
try { try {
const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
@ -114,9 +104,9 @@ function extractNpubs(fragments: Fragment[]) {
function extractNevents(fragments: Fragment[], Event: NostrComponent) { function extractNevents(fragments: Fragment[], Event: NostrComponent) {
return fragments return fragments
.map((f) => { .map(f => {
if (typeof f === "string") { 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")) { if (i.startsWith("nostr:nevent1")) {
try { try {
const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
@ -136,9 +126,9 @@ function extractNevents(fragments: Fragment[], Event: NostrComponent) {
function extractNaddrs(fragments: Fragment[], Address: NostrComponent) { function extractNaddrs(fragments: Fragment[], Address: NostrComponent) {
return fragments return fragments
.map((f) => { .map(f => {
if (typeof f === "string") { 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")) { if (i.startsWith("nostr:naddr1")) {
try { try {
const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
@ -159,9 +149,9 @@ function extractNaddrs(fragments: Fragment[], Address: NostrComponent) {
function extractNoteIds(fragments: Fragment[], Event: NostrComponent) { function extractNoteIds(fragments: Fragment[], Event: NostrComponent) {
return fragments return fragments
.map((f) => { .map(f => {
if (typeof f === "string") { 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")) { if (i.startsWith("nostr:note1")) {
try { try {
const link = parseNostrLink(i.replace(NostrPrefixRegex, "")); const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
@ -189,11 +179,7 @@ const components: NostrComponents = {
Event, Event,
}; };
export function transformText( export function transformText(ps: Fragment[], tags: Array<string[]>, customComponents = components) {
ps: Fragment[],
tags: Array<string[]>,
customComponents = components
) {
let fragments = extractEmoji(ps, tags); let fragments = extractEmoji(ps, tags);
fragments = extractNprofiles(fragments); fragments = extractNprofiles(fragments);
fragments = extractNevents(fragments, customComponents.Event); fragments = extractNevents(fragments, customComponents.Event);
@ -214,11 +200,7 @@ interface TextProps {
export function Text({ content, tags, customComponents }: TextProps) { export function Text({ content, tags, customComponents }: TextProps) {
// todo: RTL langugage support // todo: RTL langugage support
const element = useMemo(() => { const element = useMemo(() => {
return ( return <span className="text">{transformText([content], tags, customComponents)}</span>;
<span className="text">
{transformText([content], tags, customComponents)}
</span>
);
}, [content, tags]); }, [content, tags]);
return <>{element}</>; return <>{element}</>;

View File

@ -1,8 +1,6 @@
import "./textarea.css"; import "./textarea.css";
import type { KeyboardEvent, ChangeEvent } from "react"; import type { KeyboardEvent, ChangeEvent } from "react";
import ReactTextareaAutocomplete, { import ReactTextareaAutocomplete, { TriggerType } from "@webscopeio/react-textarea-autocomplete";
TriggerType,
} from "@webscopeio/react-textarea-autocomplete";
import "@webscopeio/react-textarea-autocomplete/style.css"; import "@webscopeio/react-textarea-autocomplete/style.css";
import uniqWith from "lodash/uniqWith"; import uniqWith from "lodash/uniqWith";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
@ -59,7 +57,7 @@ export function Textarea({ emojis, ...props }: TextareaProps) {
const emojiDataProvider = (token: string) => { const emojiDataProvider = (token: string) => {
const results = emojis const results = emojis
.map((t) => { .map(t => {
return { return {
name: t.at(1) || "", name: t.at(1) || "",
url: t.at(2) || "", url: t.at(2) || "",
@ -78,11 +76,8 @@ export function Textarea({ emojis, ...props }: TextareaProps) {
"@": { "@": {
afterWhitespace: true, afterWhitespace: true,
dataProvider: userDataProvider, dataProvider: userDataProvider,
component: (props: { entity: MetadataCache }) => ( component: (props: { entity: MetadataCache }) => <UserItem {...props.entity} />,
<UserItem {...props.entity} /> output: (item: { pubkey: string }) => `@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`,
),
output: (item: { pubkey: string }) =>
`@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`,
}, },
} as TriggerType<string | object>; } as TriggerType<string | object>;

View File

@ -10,6 +10,7 @@ import { formatSats } from "number";
import ZapStream from "../../public/zap-stream.svg"; import ZapStream from "../../public/zap-stream.svg";
import { isContentWarningAccepted } from "./content-warning"; import { isContentWarningAccepted } from "./content-warning";
import { Tags } from "element/tags"; import { Tags } from "element/tags";
import { FormattedMessage } from "react-intl";
export function VideoTile({ export function VideoTile({
ev, ev,
@ -26,37 +27,22 @@ export function VideoTile({
const image = findTag(ev, "image"); const image = findTag(ev, "image");
const status = findTag(ev, "status"); const status = findTag(ev, "status");
const viewers = findTag(ev, "current_participants"); const viewers = findTag(ev, "current_participants");
const contentWarning = const contentWarning = findTag(ev, "content-warning") && !isContentWarningAccepted();
findTag(ev, "content-warning") && !isContentWarningAccepted();
const host = getHost(ev); const host = getHost(ev);
const link = encodeTLV( const link = encodeTLV(NostrPrefix.Address, id, undefined, ev.kind, ev.pubkey);
NostrPrefix.Address,
id,
undefined,
ev.kind,
ev.pubkey
);
return ( return (
<div className="video-tile-container"> <div className="video-tile-container">
<Link <Link to={`/${link}`} className={`video-tile${contentWarning ? " nsfw" : ""}`} ref={ref} state={ev}>
to={`/${link}`}
className={`video-tile${contentWarning ? " nsfw" : ""}`}
ref={ref}
state={ev}
>
<div <div
style={{ style={{
backgroundImage: `url(${ backgroundImage: `url(${inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : ""})`,
inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : "" }}></div>
})`,
}}
></div>
<span className="pill-box"> <span className="pill-box">
{showStatus && <StatePill state={status as StreamState} />} {showStatus && <StatePill state={status as StreamState} />}
{viewers && ( {viewers && (
<span className="pill viewers"> <span className="pill viewers">
{formatSats(Number(viewers))} viewers <FormattedMessage defaultMessage="{n} viewers" values={{ n: formatSats(Number(viewers)) }} />
</span> </span>
)} )}
</span> </span>

View File

@ -1,5 +1,6 @@
import { NostrLink, EventKind } from "@snort/system"; import { NostrLink, EventKind } from "@snort/system";
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import AsyncButton from "element/async-button"; import AsyncButton from "element/async-button";
@ -10,20 +11,14 @@ import type { EmojiPack, Emoji } from "types";
import { System } from "index"; import { System } from "index";
import { LIVE_STREAM_CHAT } from "const"; import { LIVE_STREAM_CHAT } from "const";
export function WriteMessage({ export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks: EmojiPack[] }) {
link,
emojiPacks,
}: {
link: NostrLink;
emojiPacks: EmojiPack[];
}) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const emojiRef = useRef(null); const emojiRef = useRef(null);
const [chat, setChat] = useState(""); const [chat, setChat] = useState("");
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const login = useLogin(); const login = useLogin();
const emojis = emojiPacks.map((pack) => pack.emojis).flat(); const emojis = emojiPacks.map(pack => pack.emojis).flat();
const names = emojis.map((t) => t.at(1)); const names = emojis.map(t => t.at(1));
const topOffset = ref.current?.getBoundingClientRect().top; const topOffset = ref.current?.getBoundingClientRect().top;
const leftOffset = ref.current?.getBoundingClientRect().left; const leftOffset = ref.current?.getBoundingClientRect().left;
@ -39,10 +34,8 @@ export function WriteMessage({
} }
} }
const reply = await pub?.generic((eb) => { const reply = await pub?.generic(eb => {
const emoji = [...emojiNames].map((name) => const emoji = [...emojiNames].map(name => emojis.find(e => e.at(1) === name));
emojis.find((e) => e.at(1) === name)
);
eb.kind(LIVE_STREAM_CHAT as EventKind) eb.kind(LIVE_STREAM_CHAT as EventKind)
.content(chat) .content(chat)
.tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"]) .tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"])
@ -86,12 +79,7 @@ export function WriteMessage({
return ( return (
<> <>
<div className="paper" ref={ref}> <div className="paper" ref={ref}>
<Textarea <Textarea emojis={emojis} value={chat} onKeyDown={onKeyDown} onChange={e => setChat(e.target.value)} />
emojis={emojis}
value={chat}
onKeyDown={onKeyDown}
onChange={(e) => setChat(e.target.value)}
/>
<div onClick={pickEmoji}> <div onClick={pickEmoji}>
<Icon name="face" className="write-emoji-button" /> <Icon name="face" className="write-emoji-button" />
</div> </div>
@ -107,7 +95,7 @@ export function WriteMessage({
)} )}
</div> </div>
<AsyncButton onClick={sendChatMessage} className="btn btn-border"> <AsyncButton onClick={sendChatMessage} className="btn btn-border">
Send <FormattedMessage defaultMessage="Send" />
</AsyncButton> </AsyncButton>
</> </>
); );

View File

@ -5,8 +5,8 @@
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(outfit_400_latin-ext.woff2) format("woff2"); 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, 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+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
@ -15,9 +15,8 @@
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(outfit_400_latin.woff2) format("woff2"); 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, 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+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
@ -26,8 +25,8 @@
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
src: url(outfit_500_latin-ext.woff2) format("woff2"); 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, 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+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
@ -36,9 +35,8 @@
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
src: url(outfit_500_latin.woff2) format("woff2"); 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, 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+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
@ -47,8 +45,8 @@
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(outfit_600_latin-ext.woff2) format("woff2"); 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, 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+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
@ -57,9 +55,8 @@
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(outfit_600_latin.woff2) format("woff2"); 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, 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+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
@ -68,8 +65,8 @@
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url(outfit_700_latin-ext.woff2) format("woff2"); 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, 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+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
@ -78,7 +75,6 @@
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url(outfit_700_latin.woff2) format("woff2"); 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, 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+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }

View File

@ -1,50 +1,35 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { import { TaggedNostrEvent, EventKind, NoteCollection, RequestBuilder } from "@snort/system";
TaggedRawEvent,
EventKind,
NoteCollection,
RequestBuilder,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { findTag, toAddress, getTagValues } from "utils"; import { findTag, toAddress, getTagValues } from "utils";
import { System } from "index";
import type { Badge } from "types"; import type { Badge } from "types";
export function useBadges( export function useBadges(
pubkey: string, pubkey: string,
since: number, since: number,
leaveOpen = true leaveOpen = true
): { badges: Badge[]; awards: TaggedRawEvent[] } { ): { badges: Badge[]; awards: TaggedNostrEvent[] } {
const rb = useMemo(() => { const rb = useMemo(() => {
const rb = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`); const rb = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`);
rb.withOptions({ leaveOpen }); rb.withOptions({ leaveOpen });
rb.withFilter().authors([pubkey]).kinds([EventKind.Badge]); rb.withFilter().authors([pubkey]).kinds([EventKind.Badge]);
rb.withFilter() rb.withFilter().authors([pubkey]).kinds([EventKind.BadgeAward]).since(since);
.authors([pubkey])
.kinds([EventKind.BadgeAward])
.since(since);
return rb; return rb;
}, [pubkey, since]); }, [pubkey, since]);
const { data: badgeEvents } = useRequestBuilder<NoteCollection>( const { data: badgeEvents } = useRequestBuilder(NoteCollection, rb);
System,
NoteCollection,
rb
);
const rawBadges = useMemo(() => { const rawBadges = useMemo(() => {
if (badgeEvents) { if (badgeEvents) {
return badgeEvents return badgeEvents.filter(e => e.kind === EventKind.Badge).sort((a, b) => b.created_at - a.created_at);
.filter((e) => e.kind === EventKind.Badge)
.sort((a, b) => b.created_at - a.created_at);
} }
return []; return [];
}, [badgeEvents]); }, [badgeEvents]);
const badgeAwards = useMemo(() => { const badgeAwards = useMemo(() => {
if (badgeEvents) { if (badgeEvents) {
return badgeEvents.filter((e) => e.kind === EventKind.BadgeAward); return badgeEvents.filter(e => e.kind === EventKind.BadgeAward);
} }
return []; return [];
}, [badgeEvents]); }, [badgeEvents]);
@ -52,37 +37,24 @@ export function useBadges(
const acceptedSub = useMemo(() => { const acceptedSub = useMemo(() => {
if (rawBadges.length === 0) return null; if (rawBadges.length === 0) return null;
const rb = new RequestBuilder(`accepted-badges:${pubkey.slice(0, 12)}`); const rb = new RequestBuilder(`accepted-badges:${pubkey.slice(0, 12)}`);
rb.withFilter() rb.withFilter().kinds([EventKind.ProfileBadges]).tag("d", ["profile_badges"]).tag("a", rawBadges.map(toAddress));
.kinds([EventKind.ProfileBadges])
.tag("d", ["profile_badges"])
.tag("a", rawBadges.map(toAddress));
return rb; return rb;
}, [rawBadges]); }, [rawBadges]);
const acceptedStream = useRequestBuilder<NoteCollection>( const acceptedStream = useRequestBuilder(NoteCollection, acceptedSub);
System,
NoteCollection,
acceptedSub
);
const acceptedEvents = acceptedStream.data ?? []; const acceptedEvents = acceptedStream.data ?? [];
const badges = useMemo(() => { const badges = useMemo(() => {
return rawBadges.map((e) => { return rawBadges.map(e => {
const name = findTag(e, "d") ?? ""; const name = findTag(e, "d") ?? "";
const address = toAddress(e); const address = toAddress(e);
const awardEvents = badgeAwards.filter( const awardEvents = badgeAwards.filter(b => findTag(b, "a") === address);
(b) => findTag(b, "a") === address const awardees = new Set(awardEvents.map(e => getTagValues(e.tags, "p")).flat());
);
const awardees = new Set(
awardEvents.map((e) => getTagValues(e.tags, "p")).flat()
);
const accepted = new Set( const accepted = new Set(
acceptedEvents acceptedEvents
.filter((pb) => awardees.has(pb.pubkey)) .filter(pb => awardees.has(pb.pubkey))
.filter((pb) => .filter(pb => pb.tags.find(t => t.at(0) === "a" && t.at(1) === address))
pb.tags.find((t) => t.at(0) === "a" && t.at(1) === address) .map(pb => pb.pubkey)
)
.map((pb) => pb.pubkey)
); );
const thumb = findTag(e, "thumb"); const thumb = findTag(e, "thumb");
const image = findTag(e, "image"); const image = findTag(e, "image");

View File

@ -1,80 +1,55 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { import { TaggedNostrEvent, ReplaceableNoteStore, NoteCollection, RequestBuilder } from "@snort/system";
TaggedRawEvent,
ReplaceableNoteStore,
NoteCollection,
RequestBuilder,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { USER_CARDS, CARD } from "const"; import { USER_CARDS, CARD } from "const";
import { findTag } from "utils"; import { findTag } from "utils";
import { System } from "index";
export function useUserCards( export function useUserCards(pubkey: string, userCards: Array<string[]>, leaveOpen = false): TaggedNostrEvent[] {
pubkey: string,
userCards: Array<string[]>,
leaveOpen = false
): TaggedRawEvent[] {
const related = useMemo(() => { const related = useMemo(() => {
// filtering to only show CARD kinds for now, but in the future we could link and render anything // filtering to only show CARD kinds for now, but in the future we could link and render anything
if (userCards?.length > 0) { if (userCards?.length > 0) {
return userCards.filter( return userCards.filter(t => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`));
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`)
);
} }
return []; return [];
}, [userCards]); }, [userCards]);
const subRelated = useMemo(() => { const subRelated = useMemo(() => {
if (!pubkey) return null; if (!pubkey) return null;
const splitted = related.map((t) => t[1].split(":")); const splitted = related.map(t => t[1].split(":"));
const authors = splitted const authors = splitted
.map((s) => s.at(1)) .map(s => s.at(1))
.filter((s) => s) .filter(s => s)
.map((s) => s as string); .map(s => s as string);
const identifiers = splitted const identifiers = splitted
.map((s) => s.at(2)) .map(s => s.at(2))
.filter((s) => s) .filter(s => s)
.map((s) => s as string); .map(s => s as string);
const rb = new RequestBuilder(`cards:${pubkey}`); const rb = new RequestBuilder(`cards:${pubkey}`);
rb.withOptions({ leaveOpen }) rb.withOptions({ leaveOpen }).withFilter().kinds([CARD]).authors(authors).tag("d", identifiers);
.withFilter()
.kinds([CARD])
.authors(authors)
.tag("d", identifiers);
return rb; return rb;
}, [pubkey, related]); }, [pubkey, related]);
const { data } = useRequestBuilder<NoteCollection>( const { data } = useRequestBuilder(NoteCollection, subRelated);
System,
NoteCollection,
subRelated
);
const cards = useMemo(() => { const cards = useMemo(() => {
return related return related
.map((t) => { .map(t => {
const [k, pubkey, identifier] = t[1].split(":"); const [k, pubkey, identifier] = t[1].split(":");
const kind = Number(k); const kind = Number(k);
return (data ?? []).find( return (data ?? []).find(e => e.kind === kind && e.pubkey === pubkey && findTag(e, "d") === identifier);
(e) =>
e.kind === kind &&
e.pubkey === pubkey &&
findTag(e, "d") === identifier
);
}) })
.filter((e) => e) .filter(e => e)
.map((e) => e as TaggedRawEvent); .map(e => e as TaggedNostrEvent);
}, [related, data]); }, [related, data]);
return cards; return cards;
} }
export function useCards(pubkey: string, leaveOpen = false): TaggedRawEvent[] { export function useCards(pubkey: string, leaveOpen = false): TaggedNostrEvent[] {
const sub = useMemo(() => { const sub = useMemo(() => {
const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`); const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`);
b.withOptions({ b.withOptions({
@ -86,65 +61,46 @@ export function useCards(pubkey: string, leaveOpen = false): TaggedRawEvent[] {
return b; return b;
}, [pubkey, leaveOpen]); }, [pubkey, leaveOpen]);
const { data: userCards } = useRequestBuilder<ReplaceableNoteStore>( const { data: userCards } = useRequestBuilder(ReplaceableNoteStore, sub);
System,
ReplaceableNoteStore,
sub
);
const related = useMemo(() => { const related = useMemo(() => {
// filtering to only show CARD kinds for now, but in the future we could link and render anything // filtering to only show CARD kinds for now, but in the future we could link and render anything
if (userCards) { if (userCards) {
return userCards.tags.filter( return userCards.tags.filter(t => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`));
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`)
);
} }
return []; return [];
}, [userCards]); }, [userCards]);
const subRelated = useMemo(() => { const subRelated = useMemo(() => {
if (!pubkey) return null; if (!pubkey) return null;
const splitted = related.map((t) => t[1].split(":")); const splitted = related.map(t => t[1].split(":"));
const authors = splitted const authors = splitted
.map((s) => s.at(1)) .map(s => s.at(1))
.filter((s) => s) .filter(s => s)
.map((s) => s as string); .map(s => s as string);
const identifiers = splitted const identifiers = splitted
.map((s) => s.at(2)) .map(s => s.at(2))
.filter((s) => s) .filter(s => s)
.map((s) => s as string); .map(s => s as string);
const rb = new RequestBuilder(`cards:${pubkey}`); const rb = new RequestBuilder(`cards:${pubkey}`);
rb.withOptions({ leaveOpen }) rb.withOptions({ leaveOpen }).withFilter().kinds([CARD]).authors(authors).tag("d", identifiers);
.withFilter()
.kinds([CARD])
.authors(authors)
.tag("d", identifiers);
return rb; return rb;
}, [pubkey, related]); }, [pubkey, related]);
const { data } = useRequestBuilder<NoteCollection>( const { data } = useRequestBuilder(NoteCollection, subRelated);
System,
NoteCollection,
subRelated
);
const cardEvents = data ?? []; const cardEvents = data ?? [];
const cards = useMemo(() => { const cards = useMemo(() => {
return related return related
.map((t) => { .map(t => {
const [k, pubkey, identifier] = t[1].split(":"); const [k, pubkey, identifier] = t[1].split(":");
const kind = Number(k); const kind = Number(k);
return cardEvents.find( return cardEvents.find(e => e.kind === kind && e.pubkey === pubkey && findTag(e, "d") === identifier);
(e) =>
e.kind === kind &&
e.pubkey === pubkey &&
findTag(e, "d") === identifier
);
}) })
.filter((e) => e) .filter(e => e)
.map((e) => e as TaggedRawEvent); .map(e => e as TaggedNostrEvent);
}, [related, cardEvents]); }, [related, cardEvents]);
return cards; return cards;

View File

@ -1,33 +1,17 @@
import { unwrap } from "@snort/shared"; import { unwrap } from "@snort/shared";
import { import { NostrEvent, NostrLink, NostrPrefix, NoteCollection, RequestBuilder, TaggedNostrEvent } from "@snort/system";
NostrEvent,
NostrLink,
NostrPrefix,
NoteCollection,
RequestBuilder,
TaggedRawEvent,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { LIVE_STREAM } from "const"; import { LIVE_STREAM } from "const";
import { System } from "index";
import { useMemo } from "react"; import { useMemo } from "react";
export function useCurrentStreamFeed( export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: NostrEvent) {
link: NostrLink, const author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
leaveOpen = false,
evPreload?: NostrEvent
) {
const author =
link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
const sub = useMemo(() => { const sub = useMemo(() => {
const b = new RequestBuilder(`current-event:${link.id}`); const b = new RequestBuilder(`current-event:${link.id}`);
b.withOptions({ b.withOptions({
leaveOpen, leaveOpen,
}); });
if ( if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) {
link.type === NostrPrefix.PublicKey ||
link.type === NostrPrefix.Profile
) {
b.withFilter().authors([link.id]).kinds([LIVE_STREAM]).limit(1); b.withFilter().authors([link.id]).kinds([LIVE_STREAM]).limit(1);
b.withFilter().tag("p", [link.id]).kinds([LIVE_STREAM]).limit(1); b.withFilter().tag("p", [link.id]).kinds([LIVE_STREAM]).limit(1);
} else if (link.type === NostrPrefix.Address) { } else if (link.type === NostrPrefix.Address) {
@ -42,20 +26,16 @@ export function useCurrentStreamFeed(
return b; return b;
}, [link.id, leaveOpen]); }, [link.id, leaveOpen]);
const q = useRequestBuilder(System, NoteCollection, sub); const q = useRequestBuilder(NoteCollection, sub);
if (evPreload) { if (evPreload) {
q.add(evPreload as TaggedRawEvent); q.add(evPreload as TaggedNostrEvent);
} }
return useMemo(() => { return useMemo(() => {
const hosting = q.data?.filter( const hosting = q.data?.filter(
(a) => a => a.pubkey === author || a.tags.some(b => b[0] === "p" && b[1] === author && b[3] === "host")
a.pubkey === author ||
a.tags.some((b) => b[0] === "p" && b[1] === author && b[3] === "host")
); );
return [...(hosting ?? [])] return [...(hosting ?? [])].sort((a, b) => (b.created_at > a.created_at ? 1 : -1)).at(0);
.sort((a, b) => (b.created_at > a.created_at ? 1 : -1))
.at(0);
}, [q.data]); }, [q.data]);
} }

View File

@ -1,14 +1,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import uniqBy from "lodash.uniqby"; import uniqBy from "lodash.uniqby";
import { import { RequestBuilder, ReplaceableNoteStore, NoteCollection, NostrEvent } from "@snort/system";
RequestBuilder,
ReplaceableNoteStore,
NoteCollection,
NostrEvent,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
import { findTag } from "utils"; import { findTag } from "utils";
import { EMOJI_PACK, USER_EMOJIS } from "const"; import { EMOJI_PACK, USER_EMOJIS } from "const";
import type { EmojiPack, Tags, EmojiTag } from "types"; import type { EmojiPack, Tags, EmojiTag } from "types";
@ -24,8 +18,8 @@ export function toEmojiPack(ev: NostrEvent): EmojiPack {
name: d, name: d,
author: ev.pubkey, author: ev.pubkey,
emojis: ev.tags emojis: ev.tags
.filter((t) => t.at(0) === "emoji") .filter(t => t.at(0) === "emoji")
.map((t) => ["emoji", cleanShortcode(t.at(1)), t.at(2)]) as EmojiTag[], .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) { export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) {
const related = useMemo(() => { const related = useMemo(() => {
if (userEmoji) { if (userEmoji) {
return userEmoji?.filter( return userEmoji?.filter(t => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`));
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`)
);
} }
return []; return [];
}, [userEmoji]); }, [userEmoji]);
const subRelated = useMemo(() => { const subRelated = useMemo(() => {
if (!pubkey) return null; if (!pubkey) return null;
const splitted = related.map((t) => t[1].split(":")); const splitted = related.map(t => t[1].split(":"));
const authors = splitted const authors = splitted
.map((s) => s.at(1)) .map(s => s.at(1))
.filter((s) => s) .filter(s => s)
.map((s) => s as string); .map(s => s as string);
const identifiers = splitted const identifiers = splitted
.map((s) => s.at(2)) .map(s => s.at(2))
.filter((s) => s) .filter(s => s)
.map((s) => s as string); .map(s => s as string);
const rb = new RequestBuilder(`emoji-related:${pubkey}`); const rb = new RequestBuilder(`emoji-related:${pubkey}`);
@ -64,11 +56,7 @@ export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) {
return rb; return rb;
}, [pubkey, related]); }, [pubkey, related]);
const { data: relatedData } = useRequestBuilder<NoteCollection>( const { data: relatedData } = useRequestBuilder(NoteCollection, subRelated);
System,
NoteCollection,
subRelated
);
const emojiPacks = useMemo(() => { const emojiPacks = useMemo(() => {
return relatedData ?? []; return relatedData ?? [];
@ -92,11 +80,7 @@ export default function useEmoji(pubkey?: string) {
return rb; return rb;
}, [pubkey]); }, [pubkey]);
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>( const { data: userEmoji } = useRequestBuilder(ReplaceableNoteStore, sub);
System,
ReplaceableNoteStore,
sub
);
const emojis = useUserEmojiPacks(pubkey, userEmoji?.tags ?? []); const emojis = useUserEmojiPacks(pubkey, userEmoji?.tags ?? []);
return emojis; return emojis;

View File

@ -1,14 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { import { NostrPrefix, RequestBuilder, ReplaceableNoteStore, NostrLink } from "@snort/system";
NostrPrefix,
RequestBuilder,
ReplaceableNoteStore,
NostrLink,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
export default function useEventFeed(link: NostrLink, leaveOpen = false) { export default function useEventFeed(link: NostrLink, leaveOpen = false) {
const sub = useMemo(() => { const sub = useMemo(() => {
const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`); const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`);
@ -26,7 +19,7 @@ export default function useEventFeed(link: NostrLink, leaveOpen = false) {
} else { } else {
const f = b.withFilter().ids([link.id]); const f = b.withFilter().ids([link.id]);
if (link.relays) { 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) { if (link.author) {
f.authors([link.author]); f.authors([link.author]);
@ -35,9 +28,5 @@ export default function useEventFeed(link: NostrLink, leaveOpen = false) {
return b; return b;
}, [link, leaveOpen]); }, [link, leaveOpen]);
return useRequestBuilder<ReplaceableNoteStore>( return useRequestBuilder(ReplaceableNoteStore, sub);
System,
ReplaceableNoteStore,
sub
);
} }

View File

@ -1,15 +1,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { import { NostrPrefix, ReplaceableNoteStore, RequestBuilder, type NostrLink } from "@snort/system";
NostrPrefix,
ReplaceableNoteStore,
RequestBuilder,
type NostrLink,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
export function useAddress(kind: number, pubkey: string, identifier: string) { export function useAddress(kind: number, pubkey: string, identifier: string) {
const sub = useMemo(() => { const sub = useMemo(() => {
const b = new RequestBuilder(`event:${kind}:${identifier}`); const b = new RequestBuilder(`event:${kind}:${identifier}`);
@ -17,11 +10,7 @@ export function useAddress(kind: number, pubkey: string, identifier: string) {
return b; return b;
}, [kind, pubkey, identifier]); }, [kind, pubkey, identifier]);
const { data } = useRequestBuilder<ReplaceableNoteStore>( const { data } = useRequestBuilder(ReplaceableNoteStore, sub);
System,
ReplaceableNoteStore,
sub
);
return data; return data;
} }
@ -40,7 +29,7 @@ export function useEvent(link: NostrLink) {
} else { } else {
const f = b.withFilter().ids([link.id]); const f = b.withFilter().ids([link.id]);
if (link.relays) { 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) { if (link.author) {
f.authors([link.author]); f.authors([link.author]);
@ -49,11 +38,7 @@ export function useEvent(link: NostrLink) {
return b; return b;
}, [link]); }, [link]);
const { data } = useRequestBuilder<ReplaceableNoteStore>( const { data } = useRequestBuilder(ReplaceableNoteStore, sub);
System,
ReplaceableNoteStore,
sub
);
return data; return data;
} }

View File

@ -17,24 +17,13 @@ export function useZaps(goal: NostrEvent, leaveOpen = false) {
const sub = useMemo(() => { const sub = useMemo(() => {
const b = new RequestBuilder(`goal-zaps:${goal.id.slice(0, 12)}`); const b = new RequestBuilder(`goal-zaps:${goal.id.slice(0, 12)}`);
b.withOptions({ leaveOpen }); b.withOptions({ leaveOpen });
b.withFilter() b.withFilter().kinds([EventKind.ZapReceipt]).tag("e", [goal.id]).since(goal.created_at);
.kinds([EventKind.ZapReceipt])
.tag("e", [goal.id])
.since(goal.created_at);
return b; return b;
}, [goal, leaveOpen]); }, [goal, leaveOpen]);
const { data } = useRequestBuilder<NoteCollection>( const { data } = useRequestBuilder(NoteCollection, sub);
System,
NoteCollection,
sub
);
return ( return data?.map(ev => parseZap(ev, System.ProfileLoader.Cache)).filter(z => z && z.valid) ?? [];
data
?.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid) ?? []
);
} }
export function useZapGoal(host: string, link?: NostrLink, leaveOpen = false) { export function useZapGoal(host: string, link?: NostrLink, leaveOpen = false) {
@ -49,11 +38,7 @@ export function useZapGoal(host: string, link?: NostrLink, leaveOpen = false) {
return b; return b;
}, [link, leaveOpen]); }, [link, leaveOpen]);
const { data } = useRequestBuilder<ReplaceableNoteStore>( const { data } = useRequestBuilder(ReplaceableNoteStore, sub);
System,
ReplaceableNoteStore,
sub
);
return data; return data;
} }

View File

@ -5,7 +5,6 @@ import { useRequestBuilder } from "@snort/system-react";
import { MUTED } from "const"; import { MUTED } from "const";
import { getTagValues } from "utils"; import { getTagValues } from "utils";
import { System } from "index";
export function useMutedPubkeys(host?: string, leaveOpen = false) { export function useMutedPubkeys(host?: string, leaveOpen = false) {
const mutedSub = useMemo(() => { const mutedSub = useMemo(() => {
@ -16,11 +15,7 @@ export function useMutedPubkeys(host?: string, leaveOpen = false) {
return rb; return rb;
}, [host]); }, [host]);
const { data: muted } = useRequestBuilder<ReplaceableNoteStore>( const { data: muted } = useRequestBuilder(ReplaceableNoteStore, mutedSub);
System,
ReplaceableNoteStore,
mutedSub
);
const mutedPubkeys = useMemo(() => { const mutedPubkeys = useMemo(() => {
return new Set(getTagValues(muted?.tags ?? [], "p")); return new Set(getTagValues(muted?.tags ?? [], "p"));
}, [muted]); }, [muted]);

View File

@ -1,12 +1,6 @@
import { import { NostrLink, RequestBuilder, EventKind, NoteCollection } from "@snort/system";
NostrLink,
RequestBuilder,
EventKind,
FlatNoteStore,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import { System } from "index";
import { useMemo } from "react"; import { useMemo } from "react";
import { LIVE_STREAM_CHAT, WEEK } from "const"; import { LIVE_STREAM_CHAT, WEEK } from "const";
@ -27,17 +21,17 @@ export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
return rb; return rb;
}, [link.id, since, eZaps]); }, [link.id, since, eZaps]);
const feed = useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub); const feed = useRequestBuilder(NoteCollection, sub);
const messages = useMemo(() => { 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]); }, [feed.data]);
const zaps = useMemo(() => { const zaps = useMemo(() => {
return (feed.data ?? []).filter((ev) => ev.kind === EventKind.ZapReceipt); return (feed.data ?? []).filter(ev => ev.kind === EventKind.ZapReceipt);
}, [feed.data]); }, [feed.data]);
const etags = useMemo(() => { const etags = useMemo(() => {
return messages.map((e) => e.id); return messages.map(e => e.id);
}, [messages]); }, [messages]);
const esub = useMemo(() => { const esub = useMemo(() => {
@ -46,17 +40,11 @@ export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
rb.withOptions({ rb.withOptions({
leaveOpen: true, leaveOpen: true,
}); });
rb.withFilter() rb.withFilter().kinds([EventKind.Reaction, EventKind.ZapReceipt]).tag("e", etags);
.kinds([EventKind.Reaction, EventKind.ZapReceipt])
.tag("e", etags);
return rb; return rb;
}, [etags]); }, [etags]);
const reactionsSub = useRequestBuilder<FlatNoteStore>( const reactionsSub = useRequestBuilder(NoteCollection, esub);
System,
FlatNoteStore,
esub
);
const reactions = reactionsSub.data ?? []; const reactions = reactionsSub.data ?? [];

View File

@ -5,7 +5,7 @@ import { useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import { LIVE_STREAM } from "const"; import { LIVE_STREAM } from "const";
import { System, StreamState } from "index"; import { StreamState } from "index";
import { findTag } from "utils"; import { findTag } from "utils";
import { WEEK } from "const"; import { WEEK } from "const";
@ -34,30 +34,22 @@ export function useStreamsFeed(tag?: string) {
return bStart > aStart ? 1 : -1; return bStart > aStart ? 1 : -1;
} }
const feed = useRequestBuilder<NoteCollection>(System, NoteCollection, rb); const feed = useRequestBuilder(NoteCollection, rb);
const feedSorted = useMemo(() => { const feedSorted = useMemo(() => {
if (feed.data) { if (feed.data) {
if (__XXX) { if (__XXX) {
return [...feed.data].filter( return [...feed.data].filter(a => findTag(a, "content-warning") !== undefined);
(a) => findTag(a, "content-warning") !== undefined
);
} else { } else {
return [...feed.data].filter( return [...feed.data].filter(a => findTag(a, "content-warning") === undefined);
(a) => findTag(a, "content-warning") === undefined
);
} }
} }
return []; return [];
}, [feed.data]); }, [feed.data]);
const live = feedSorted const live = feedSorted.filter(a => findTag(a, "status") === StreamState.Live).sort(sortStarts);
.filter((a) => findTag(a, "status") === StreamState.Live) const planned = feedSorted.filter(a => findTag(a, "status") === StreamState.Planned).sort(sortStarts);
.sort(sortStarts);
const planned = feedSorted
.filter((a) => findTag(a, "status") === StreamState.Planned)
.sort(sortStarts);
const ended = feedSorted const ended = feedSorted
.filter((a) => { .filter(a => {
const hasEnded = findTag(a, "status") === StreamState.Ended; const hasEnded = findTag(a, "status") === StreamState.Ended;
const recording = findTag(a, "recording") ?? ""; const recording = findTag(a, "recording") ?? "";
return hasEnded && recording?.length > 0; return hasEnded && recording?.length > 0;

View File

@ -6,12 +6,12 @@ import { useRequestBuilder } from "@snort/system-react";
import { useUserEmojiPacks } from "hooks/emoji"; import { useUserEmojiPacks } from "hooks/emoji";
import { MUTED, USER_CARDS, USER_EMOJIS } from "const"; import { MUTED, USER_CARDS, USER_EMOJIS } from "const";
import type { Tags } from "types"; import type { Tags } from "types";
import { System, Login } from "index";
import { getPublisher } from "login"; import { getPublisher } from "login";
import { Login } from "index";
export function useLogin() { export function useLogin() {
const session = useSyncExternalStore( const session = useSyncExternalStore(
(c) => Login.hook(c), c => Login.hook(c),
() => Login.snapshot() () => Login.snapshot()
); );
if (!session) return; if (!session) return;
@ -26,7 +26,7 @@ export function useLogin() {
export function useLoginEvents(pubkey?: string, leaveOpen = false) { export function useLoginEvents(pubkey?: string, leaveOpen = false) {
const [userEmojis, setUserEmojis] = useState<Tags>([]); const [userEmojis, setUserEmojis] = useState<Tags>([]);
const session = useSyncExternalStore( const session = useSyncExternalStore(
(c) => Login.hook(c), c => Login.hook(c),
() => Login.snapshot() () => Login.snapshot()
); );
@ -42,11 +42,7 @@ export function useLoginEvents(pubkey?: string, leaveOpen = false) {
return b; return b;
}, [pubkey, leaveOpen]); }, [pubkey, leaveOpen]);
const { data } = useRequestBuilder<NoteCollection>( const { data } = useRequestBuilder(NoteCollection, sub);
System,
NoteCollection,
sub
);
useEffect(() => { useEffect(() => {
if (!data) { if (!data) {

View File

@ -1,9 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
export default function usePlaceholder(pubkey: string) { export default function usePlaceholder(pubkey: string) {
const url = useMemo( const url = useMemo(() => `https://robohash.v0l.io/${pubkey}.png?set=2`, [pubkey]);
() => `https://robohash.v0l.io/${pubkey}.png?set=2`,
[pubkey]
);
return url; return url;
} }

View File

@ -1,12 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { import { RequestBuilder, NoteCollection, NostrLink, EventKind, parseZap } from "@snort/system";
RequestBuilder,
FlatNoteStore,
NoteCollection,
NostrLink,
EventKind,
parseZap,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { LIVE_STREAM } from "const"; import { LIVE_STREAM } from "const";
import { findTag } from "utils"; import { findTag } from "utils";
@ -27,16 +20,12 @@ export function useProfile(link: NostrLink, leaveOpen = false) {
return b; return b;
}, [link, leaveOpen]); }, [link, leaveOpen]);
const { data: streamsData } = useRequestBuilder<NoteCollection>( const { data: streamsData } = useRequestBuilder(NoteCollection, sub);
System,
NoteCollection,
sub
);
const streams = streamsData ?? []; const streams = streamsData ?? [];
const addresses = useMemo(() => { const addresses = useMemo(() => {
if (streamsData) { 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 []; return [];
}, [streamsData]); }, [streamsData]);
@ -52,14 +41,10 @@ export function useProfile(link: NostrLink, leaveOpen = false) {
return b; return b;
}, [link, addresses, leaveOpen]); }, [link, addresses, leaveOpen]);
const { data: zapsData } = useRequestBuilder<FlatNoteStore>( const { data: zapsData } = useRequestBuilder(NoteCollection, zapsSub);
System,
FlatNoteStore,
zapsSub
);
const zaps = (zapsData ?? []) const zaps = (zapsData ?? [])
.map((ev) => parseZap(ev, System.ProfileLoader.Cache)) .map(ev => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid && z.receiver === link.id); .filter(z => z && z.valid && z.receiver === link.id);
const sortedStreams = useMemo(() => { const sortedStreams = useMemo(() => {
const sorted = [...streams]; const sorted = [...streams];

View File

@ -3,7 +3,7 @@ import { useSyncExternalStore } from "react";
export function useStreamProvider() { export function useStreamProvider() {
return useSyncExternalStore( return useSyncExternalStore(
(c) => StreamProviderStore.hook(c), c => StreamProviderStore.hook(c),
() => StreamProviderStore.snapshot() () => StreamProviderStore.snapshot()
); );
} }

View File

@ -2,19 +2,15 @@ import { useMemo } from "react";
import { ParsedZap } from "@snort/system"; import { ParsedZap } from "@snort/system";
function totalZapped(pubkey: string, zaps: ParsedZap[]) { function totalZapped(pubkey: string, zaps: ParsedZap[]) {
return zaps return zaps.filter(z => (z.anonZap ? pubkey === "anon" : z.sender === pubkey)).reduce((acc, z) => acc + z.amount, 0);
.filter((z) => (z.anonZap ? pubkey === "anon" : z.sender === pubkey))
.reduce((acc, z) => acc + z.amount, 0);
} }
export default function useTopZappers(zaps: ParsedZap[]) { export default function useTopZappers(zaps: ParsedZap[]) {
const zappers = zaps const zappers = zaps.map(z => (z.anonZap ? "anon" : z.sender)).map(p => p as string);
.map((z) => (z.anonZap ? "anon" : z.sender))
.map((p) => p as string);
const sorted = useMemo(() => { const sorted = useMemo(() => {
const pubkeys = [...new Set([...zappers])]; const pubkeys = [...new Set([...zappers])];
const result = pubkeys.map((pubkey) => { const result = pubkeys.map(pubkey => {
return { pubkey, total: totalZapped(pubkey, zaps) }; return { pubkey, total: totalZapped(pubkey, zaps) };
}); });
result.sort((a, b) => b.total - a.total); result.sort((a, b) => b.total - a.total);

View File

@ -19,11 +19,7 @@ body {
--border: #171717; --border: #171717;
--gradient-purple: linear-gradient(135deg, #882bff 0%, #f83838 100%); --gradient-purple: linear-gradient(135deg, #882bff 0%, #f83838 100%);
--gradient-yellow: linear-gradient(270deg, #adff27 0%, #ffd027 100%); --gradient-yellow: linear-gradient(270deg, #adff27 0%, #ffd027 100%);
--gradient-orange: linear-gradient( --gradient-orange: linear-gradient(270deg, #ff5b27 0%, rgba(255, 182, 39, 0.99) 100%);
270deg,
#ff5b27 0%,
rgba(255, 182, 39, 0.99) 100%
);
} }
@media (max-width: 1020px) { @media (max-width: 1020px) {
@ -35,8 +31,7 @@ body {
} }
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
monospace;
} }
a { a {
@ -119,14 +114,12 @@ a {
.btn-border { .btn-border {
border: 1px solid transparent; border: 1px solid transparent;
color: inherit; color: inherit;
background: linear-gradient(black, black) padding-box, background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
transition: 0.3s; transition: 0.3s;
} }
.btn-border:hover { .btn-border:hover {
background: linear-gradient(black, black) padding-box, background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
} }
.btn-primary { .btn-primary {

View File

@ -5,6 +5,7 @@ import "./fonts/outfit/outfit.css";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { NostrSystem } from "@snort/system"; import { NostrSystem } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { RootPage } from "pages/root"; import { RootPage } from "pages/root";
@ -18,6 +19,8 @@ import { StreamProvidersPage } from "pages/providers";
import { defaultRelays } from "const"; import { defaultRelays } from "const";
import { CatchAllRoutePage } from "pages/catch-all"; import { CatchAllRoutePage } from "pages/catch-all";
import { SettingsPage } from "pages/settings-page"; import { SettingsPage } from "pages/settings-page";
import { register } from "serviceWorker";
import { IntlProvider } from "intl";
export enum StreamState { export enum StreamState {
Live = "live", Live = "live",
@ -28,7 +31,9 @@ export enum StreamState {
export const System = new NostrSystem({}); export const System = new NostrSystem({});
export const Login = new LoginStore(); export const Login = new LoginStore();
Object.entries(defaultRelays).forEach((params) => { register();
Object.entries(defaultRelays).forEach(params => {
const [relay, settings] = params; const [relay, settings] = params;
System.ConnectToRelay(relay, settings); System.ConnectToRelay(relay, settings);
}); });
@ -76,11 +81,13 @@ const router = createBrowserRouter([
element: <ChatPopout />, element: <ChatPopout />,
}, },
]); ]);
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(document.getElementById("root") as HTMLDivElement);
document.getElementById("root") as HTMLDivElement
);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<RouterProvider router={router} /> <SnortContext.Provider value={System}>
<IntlProvider>
<RouterProvider router={router} />
</IntlProvider>
</SnortContext.Provider>
</React.StrictMode> </React.StrictMode>
); );

40
src/intl.tsx Normal file
View 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
View 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"
}
}

View File

@ -130,10 +130,7 @@ export function getPublisher(session: LoginSession) {
return new EventPublisher(new Nip7Signer(), session.pubkey); return new EventPublisher(new Nip7Signer(), session.pubkey);
} }
case LoginType.PrivateKey: { case LoginType.PrivateKey: {
return new EventPublisher( return new EventPublisher(new PrivateKeySigner(unwrap(session.privateKey)), session.pubkey);
new PrivateKeySigner(unwrap(session.privateKey)),
session.pubkey
);
} }
} }
} }

View File

@ -11,15 +11,7 @@ export function ChatPopout() {
const link = parseNostrLink(unwrap(params.id)); const link = parseNostrLink(unwrap(params.id));
const ev = useCurrentStreamFeed(link, true); const ev = useCurrentStreamFeed(link, true);
const lnk = parseNostrLink( const lnk = parseNostrLink(encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev?.kind, ev?.pubkey));
encodeTLV(
NostrPrefix.Address,
findTag(ev, "d") ?? "",
undefined,
ev?.kind,
ev?.pubkey
)
);
const chat = Boolean(new URL(window.location.href).searchParams.get("chat")); const chat = Boolean(new URL(window.location.href).searchParams.get("chat"));
return ( return (
<div className={`popout-chat${chat ? "" : " embed"}`}> <div className={`popout-chat${chat ? "" : " embed"}`}>

View File

@ -12,6 +12,7 @@ import { LoginSignup } from "element/login-signup";
import { Menu, MenuItem } from "@szhsin/react-menu"; import { Menu, MenuItem } from "@szhsin/react-menu";
import { hexToBech32 } from "@snort/shared"; import { hexToBech32 } from "@snort/shared";
import { Login } from "index"; import { Login } from "index";
import { FormattedMessage } from "react-intl";
export function LayoutPage() { export function LayoutPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -40,13 +41,10 @@ export function LayoutPage() {
</div> </div>
} }
align="end" align="end"
gap={5} gap={5}>
> <MenuItem onClick={() => navigate(`/p/${hexToBech32("npub", login.pubkey)}`)}>
<MenuItem
onClick={() => navigate(`/p/${hexToBech32("npub", login.pubkey)}`)}
>
<Icon name="user" size={24} /> <Icon name="user" size={24} />
Profile <FormattedMessage defaultMessage="Profile" />
</MenuItem> </MenuItem>
<MenuItem onClick={() => navigate("/settings")}> <MenuItem onClick={() => navigate("/settings")}>
<Icon name="settings" size={24} /> <Icon name="settings" size={24} />
@ -54,7 +52,7 @@ export function LayoutPage() {
</MenuItem> </MenuItem>
<MenuItem onClick={() => Login.logout()}> <MenuItem onClick={() => Login.logout()}>
<Icon name="logout" size={24} /> <Icon name="logout" size={24} />
Logout <FormattedMessage defaultMessage="Logout" />
</MenuItem> </MenuItem>
</Menu> </Menu>
</> </>
@ -67,12 +65,8 @@ export function LayoutPage() {
return ( return (
<Dialog.Root open={showLogin} onOpenChange={setShowLogin}> <Dialog.Root open={showLogin} onOpenChange={setShowLogin}>
<Dialog.Trigger asChild> <Dialog.Trigger asChild>
<button <button type="button" className="btn btn-border" onClick={() => setShowLogin(true)}>
type="button" <FormattedMessage defaultMessage="Login" />
className="btn btn-border"
onClick={() => setShowLogin(true)}
>
Login
<Icon name="login" /> <Icon name="login" />
</button> </button>
</Dialog.Trigger> </Dialog.Trigger>
@ -87,11 +81,7 @@ export function LayoutPage() {
} }
return ( return (
<div <div className={`page${location.pathname.startsWith("/naddr1") ? " stream" : ""}`}>
className={`page${
location.pathname.startsWith("/naddr1") ? " stream" : ""
}`}
>
<Helmet> <Helmet>
<title>Home - zap.stream</title> <title>Home - zap.stream</title>
</Helmet> </Helmet>

View File

@ -162,12 +162,7 @@
.tabs-tab[data-state="active"] .tab-border { .tabs-tab[data-state="active"] .tab-border {
height: 1px; height: 1px;
background: linear-gradient( background: linear-gradient(94.73deg, #2bd9ff 0%, #8c8ded 47.4%, #f838d9 100%);
94.73deg,
#2bd9ff 0%,
#8c8ded 47.4%,
#f838d9 100%
);
} }
.tabs-content { .tabs-content {

View File

@ -3,12 +3,7 @@ import { useMemo } from "react";
import moment from "moment"; import moment from "moment";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import * as Tabs from "@radix-ui/react-tabs"; import * as Tabs from "@radix-ui/react-tabs";
import { import { parseNostrLink, NostrPrefix, ParsedZap, encodeTLV } from "@snort/system";
parseNostrLink,
NostrPrefix,
ParsedZap,
encodeTLV,
} from "@snort/system";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { unwrap } from "@snort/shared"; import { unwrap } from "@snort/shared";
import { Profile } from "element/profile"; import { Profile } from "element/profile";
@ -21,9 +16,10 @@ import { useProfile } from "hooks/profile";
import useTopZappers from "hooks/top-zappers"; import useTopZappers from "hooks/top-zappers";
import usePlaceholder from "hooks/placeholders"; import usePlaceholder from "hooks/placeholders";
import { Text } from "element/text"; import { Text } from "element/text";
import { StreamState, System } from "index"; import { StreamState } from "index";
import { findTag } from "utils"; import { findTag } from "utils";
import { formatSats } from "number"; import { formatSats } from "number";
import { FormattedMessage } from "react-intl";
function Zapper({ pubkey, total }: { pubkey: string; total: number }) { function Zapper({ pubkey, total }: { pubkey: string; total: number }) {
return ( return (
@ -41,7 +37,7 @@ function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
const zappers = useTopZappers(zaps); const zappers = useTopZappers(zaps);
return ( return (
<section className="profile-top-zappers"> <section className="profile-top-zappers">
{zappers.map((z) => ( {zappers.map(z => (
<Zapper key={z.pubkey} pubkey={z.pubkey} total={z.total} /> <Zapper key={z.pubkey} pubkey={z.pubkey} total={z.total} />
))} ))}
</section> </section>
@ -55,32 +51,24 @@ export function ProfilePage() {
const params = useParams(); const params = useParams();
const link = parseNostrLink(unwrap(params.npub)); const link = parseNostrLink(unwrap(params.npub));
const placeholder = usePlaceholder(link.id); const placeholder = usePlaceholder(link.id);
const profile = useUserProfile(System, link.id); const profile = useUserProfile(link.id);
const zapTarget = profile?.lud16 ?? profile?.lud06; const zapTarget = profile?.lud16 ?? profile?.lud06;
const { streams, zaps } = useProfile(link, true); const { streams, zaps } = useProfile(link, true);
const liveEvent = useMemo(() => { const liveEvent = useMemo(() => {
return streams.find((ev) => findTag(ev, "status") === StreamState.Live); return streams.find(ev => findTag(ev, "status") === StreamState.Live);
}, [streams]); }, [streams]);
const pastStreams = useMemo(() => { const pastStreams = useMemo(() => {
return streams.filter((ev) => findTag(ev, "status") === StreamState.Ended); return streams.filter(ev => findTag(ev, "status") === StreamState.Ended);
}, [streams]); }, [streams]);
const futureStreams = useMemo(() => { const futureStreams = useMemo(() => {
return streams.filter( return streams.filter(ev => findTag(ev, "status") === StreamState.Planned);
(ev) => findTag(ev, "status") === StreamState.Planned
);
}, [streams]); }, [streams]);
const isLive = Boolean(liveEvent); const isLive = Boolean(liveEvent);
function goToLive() { function goToLive() {
if (liveEvent) { if (liveEvent) {
const d = findTag(liveEvent, "d") || ""; const d = findTag(liveEvent, "d") || "";
const naddr = encodeTLV( const naddr = encodeTLV(NostrPrefix.Address, d, undefined, liveEvent.kind, liveEvent.pubkey);
NostrPrefix.Address,
d,
undefined,
liveEvent.kind,
liveEvent.pubkey
);
navigate(`/${naddr}`); navigate(`/${naddr}`);
} }
} }
@ -88,52 +76,39 @@ export function ProfilePage() {
return ( return (
<div className="profile-page"> <div className="profile-page">
<div className="profile-container"> <div className="profile-container">
<img <img className="banner" alt={profile?.name || link.id} src={profile?.banner || defaultBanner} />
className="banner"
alt={profile?.name || link.id}
src={profile?.banner || defaultBanner}
/>
<div className="profile-content"> <div className="profile-content">
{profile?.picture ? ( {profile?.picture ? (
<img <img className="avatar" alt={profile.name || link.id} src={profile.picture} />
className="avatar"
alt={profile.name || link.id}
src={profile.picture}
/>
) : ( ) : (
<img <img className="avatar" alt={profile?.name || link.id} src={placeholder} />
className="avatar"
alt={profile?.name || link.id}
src={placeholder}
/>
)} )}
<div className="status-indicator"> <div className="status-indicator">
{isLive ? ( {isLive ? (
<div className="live-button pill live" onClick={goToLive}> <div className="live-button pill live" onClick={goToLive}>
<Icon name="signal" /> <Icon name="signal" />
<span>live</span> <span>
<FormattedMessage defaultMessage="live" />
</span>
</div> </div>
) : ( ) : (
<span className="pill offline">offline</span> <span className="pill offline">
<FormattedMessage defaultMessage="offline" />
</span>
)} )}
</div> </div>
<div className="profile-actions"> <div className="profile-actions">
{zapTarget && ( {zapTarget && (
<SendZapsDialog <SendZapsDialog
aTag={ aTag={liveEvent ? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(liveEvent, "d")}` : undefined}
liveEvent
? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(
liveEvent,
"d"
)}`
: undefined
}
lnurl={zapTarget} lnurl={zapTarget}
button={ button={
<button className="btn"> <button className="btn">
<div className="zap-button"> <div className="zap-button">
<Icon name="zap-filled" className="zap-button-icon" /> <Icon name="zap-filled" className="zap-button-icon" />
<span>Zap</span> <span>
<FormattedMessage defaultMessage="Zap" />
</span>
</div> </div>
</button> </button>
} }
@ -152,22 +127,17 @@ export function ProfilePage() {
)} )}
</div> </div>
<Tabs.Root className="tabs-root" defaultValue="top-zappers"> <Tabs.Root className="tabs-root" defaultValue="top-zappers">
<Tabs.List <Tabs.List className="tabs-list" aria-label={`Information about ${profile ? profile.name : link.id}`}>
className="tabs-list"
aria-label={`Information about ${
profile ? profile.name : link.id
}`}
>
<Tabs.Trigger className="tabs-tab" value="top-zappers"> <Tabs.Trigger className="tabs-tab" value="top-zappers">
Top Zappers <FormattedMessage defaultMessage="Top Zappers" />
<div className="tab-border"></div> <div className="tab-border"></div>
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger className="tabs-tab" value="past-streams"> <Tabs.Trigger className="tabs-tab" value="past-streams">
Past Streams <FormattedMessage defaultMessage="Past Streams" />
<div className="tab-border"></div> <div className="tab-border"></div>
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger className="tabs-tab" value="schedule"> <Tabs.Trigger className="tabs-tab" value="schedule">
Schedule <FormattedMessage defaultMessage="Schedule" />
<div className="tab-border"></div> <div className="tab-border"></div>
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
@ -176,14 +146,16 @@ export function ProfilePage() {
</Tabs.Content> </Tabs.Content>
<Tabs.Content className="tabs-content" value="past-streams"> <Tabs.Content className="tabs-content" value="past-streams">
<div className="stream-list"> <div className="stream-list">
{pastStreams.map((ev) => ( {pastStreams.map(ev => (
<div key={ev.id} className="stream-item"> <div key={ev.id} className="stream-item">
<VideoTile ev={ev} showAuthor={false} showStatus={false} /> <VideoTile ev={ev} showAuthor={false} showStatus={false} />
<span className="timestamp"> <span className="timestamp">
Streamed on{" "} <FormattedMessage
{moment(Number(ev.created_at) * 1000).format( defaultMessage="Streamed on {date}"
"MMM DD, YYYY" values={{
)} date: moment(Number(ev.created_at) * 1000).format("MMM DD, YYYY"),
}}
/>
</span> </span>
</div> </div>
))} ))}
@ -191,14 +163,16 @@ export function ProfilePage() {
</Tabs.Content> </Tabs.Content>
<Tabs.Content className="tabs-content" value="schedule"> <Tabs.Content className="tabs-content" value="schedule">
<div className="stream-list"> <div className="stream-list">
{futureStreams.map((ev) => ( {futureStreams.map(ev => (
<div key={ev.id} className="stream-item"> <div key={ev.id} className="stream-item">
<VideoTile ev={ev} showAuthor={false} showStatus={false} /> <VideoTile ev={ev} showAuthor={false} showStatus={false} />
<span className="timestamp"> <span className="timestamp">
Scheduled for{" "} <FormattedMessage
{moment(Number(ev.created_at) * 1000).format( defaultMessage="Scheduled for {date}"
"MMM DD, YYYY h:mm:ss a" values={{
)} date: moment(Number(ev.created_at) * 1000).format("MMM DD, YYYY h:mm:ss a"),
}}
/>
</span> </span>
</div> </div>
))} ))}

View File

@ -48,16 +48,9 @@ export function StreamProvidersPage() {
return ( return (
<div className="stream-providers-page"> <div className="stream-providers-page">
<h1>Providers</h1> <h1>Providers</h1>
<p> <p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p>
Stream providers streamline the process of streaming on Nostr, some
event accept lightning payments!
</p>
<div className="stream-providers-grid"> <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>
</div> </div>
); );

View File

@ -6,6 +6,7 @@ import { StatePill } from "element/state-pill";
import { StreamState } from "index"; import { StreamState } from "index";
import { StreamProviderInfo, StreamProviderStore } from "providers"; import { StreamProviderInfo, StreamProviderStore } from "providers";
import { Nip103StreamProvider } from "providers/zsz"; import { Nip103StreamProvider } from "providers/zsz";
import { FormattedMessage } from "react-intl";
export function ConfigureNostrType() { export function ConfigureNostrType() {
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
@ -59,9 +60,8 @@ export function ConfigureNostrType() {
onClick={() => { onClick={() => {
StreamProviderStore.add(new Nip103StreamProvider(url)); StreamProviderStore.add(new Nip103StreamProvider(url));
navigate("/"); navigate("/");
}} }}>
> <FormattedMessage defaultMessage="Save" />
Save
</button> </button>
</div> </div>
</> </>
@ -74,16 +74,11 @@ export function ConfigureNostrType() {
<div> <div>
<p>Nostr streaming provider URL</p> <p>Nostr streaming provider URL</p>
<div className="paper"> <div className="paper">
<input <input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
type="text"
placeholder="https://"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div> </div>
</div> </div>
<AsyncButton className="btn btn-primary" onClick={tryConnect}> <AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect <FormattedMessage defaultMessage="Connect" />
</AsyncButton> </AsyncButton>
</div> </div>
<div>{status()}</div> <div>{status()}</div>

View File

@ -59,8 +59,7 @@ export function ConfigureOwncast() {
onClick={() => { onClick={() => {
StreamProviderStore.add(new OwncastProvider(url, token)); StreamProviderStore.add(new OwncastProvider(url, token));
navigate("/"); navigate("/");
}} }}>
>
Save Save
</button> </button>
</div> </div>
@ -74,22 +73,13 @@ export function ConfigureOwncast() {
<div> <div>
<p>Owncast instance url</p> <p>Owncast instance url</p>
<div className="paper"> <div className="paper">
<input <input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
type="text"
placeholder="https://"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div> </div>
</div> </div>
<div> <div>
<p>API token</p> <p>API token</p>
<div className="paper"> <div className="paper">
<input <input type="password" value={token} onChange={e => setToken(e.target.value)} />
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
/>
</div> </div>
</div> </div>
<AsyncButton className="btn btn-primary" onClick={tryConnect}> <AsyncButton className="btn btn-primary" onClick={tryConnect}>

View File

@ -15,19 +15,17 @@ export function RootPage() {
const tags = login?.follows.tags ?? []; const tags = login?.follows.tags ?? [];
const followsHost = useCallback( const followsHost = useCallback(
(ev: NostrEvent) => { (ev: NostrEvent) => {
return tags.find((t) => t.at(1) === getHost(ev)); return tags.find(t => t.at(1) === getHost(ev));
}, },
[tags] [tags]
); );
const hashtags = getTagValues(tags, "t"); const hashtags = getTagValues(tags, "t");
const following = live.filter(followsHost); 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 hasFollowingLive = following.length > 0;
const plannedEvents = planned const plannedEvents = planned.filter(e => !mutedHosts.has(getHost(e))).filter(followsHost);
.filter((e) => !mutedHosts.has(getHost(e))) const endedEvents = ended.filter(e => !mutedHosts.has(getHost(e)));
.filter(followsHost);
const endedEvents = ended.filter((e) => !mutedHosts.has(getHost(e)));
return ( return (
<div className="homepage"> <div className="homepage">
@ -35,7 +33,7 @@ export function RootPage() {
<> <>
<h2 className="divider line one-line">Following</h2> <h2 className="divider line one-line">Following</h2>
<div className="video-grid"> <div className="video-grid">
{following.map((e) => ( {following.map(e => (
<VideoTile ev={e} key={e.id} /> <VideoTile ev={e} key={e.id} />
))} ))}
</div> </div>
@ -44,23 +42,23 @@ export function RootPage() {
{!hasFollowingLive && ( {!hasFollowingLive && (
<div className="video-grid"> <div className="video-grid">
{live {live
.filter((e) => !mutedHosts.has(getHost(e))) .filter(e => !mutedHosts.has(getHost(e)))
.map((e) => ( .map(e => (
<VideoTile ev={e} key={e.id} /> <VideoTile ev={e} key={e.id} />
))} ))}
</div> </div>
)} )}
{hashtags.map((t) => ( {hashtags.map(t => (
<> <>
<h2 className="divider line one-line">#{t}</h2> <h2 className="divider line one-line">#{t}</h2>
<div className="video-grid"> <div className="video-grid">
{live {live
.filter((e) => !mutedHosts.has(getHost(e))) .filter(e => !mutedHosts.has(getHost(e)))
.filter((e) => { .filter(e => {
const evTags = getTagValues(e.tags, "t"); const evTags = getTagValues(e.tags, "t");
return evTags.includes(t); return evTags.includes(t);
}) })
.map((e) => ( .map(e => (
<VideoTile ev={e} key={e.id} /> <VideoTile ev={e} key={e.id} />
))} ))}
</div> </div>
@ -71,8 +69,8 @@ export function RootPage() {
<h2 className="divider line one-line">Live</h2> <h2 className="divider line one-line">Live</h2>
<div className="video-grid"> <div className="video-grid">
{liveNow {liveNow
.filter((e) => !mutedHosts.has(getHost(e))) .filter(e => !mutedHosts.has(getHost(e)))
.map((e) => ( .map(e => (
<VideoTile ev={e} key={e.id} /> <VideoTile ev={e} key={e.id} />
))} ))}
</div> </div>
@ -82,7 +80,7 @@ export function RootPage() {
<> <>
<h2 className="divider line one-line">Planned</h2> <h2 className="divider line one-line">Planned</h2>
<div className="video-grid"> <div className="video-grid">
{plannedEvents.map((e) => ( {plannedEvents.map(e => (
<VideoTile ev={e} key={e.id} /> <VideoTile ev={e} key={e.id} />
))} ))}
</div> </div>
@ -92,7 +90,7 @@ export function RootPage() {
<> <>
<h2 className="divider line one-line">Ended</h2> <h2 className="divider line one-line">Ended</h2>
<div className="video-grid"> <div className="video-grid">
{endedEvents.map((e) => ( {endedEvents.map(e => (
<VideoTile ev={e} key={e.id} /> <VideoTile ev={e} key={e.id} />
))} ))}
</div> </div>

View File

@ -2,9 +2,7 @@
display: grid; display: grid;
grid-template-columns: auto 450px; grid-template-columns: auto 450px;
gap: var(--gap-m); gap: var(--gap-m);
height: calc( height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s));
100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s)
);
} }
.stream-page .video-content { .stream-page .video-content {
@ -33,19 +31,14 @@
padding: 24px 16px 8px 24px; padding: 24px 16px 8px 24px;
border: 1px solid #171717; border: 1px solid #171717;
border-radius: 24px; border-radius: 24px;
height: calc( height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s) - 24px - 8px);
100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s) -
24px - 8px
);
} }
@media (max-width: 1020px) { @media (max-width: 1020px) {
.stream-page { .stream-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc( height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s));
100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s)
);
} }
.stream-page .video-content { .stream-page .video-content {

View File

@ -1,17 +1,11 @@
import "./stream-page.css"; 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 { fetchNip05Pubkey } from "@snort/shared";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { LiveVideoPlayer } from "element/live-video-player"; import { LiveVideoPlayer } from "element/live-video-player";
import { import { createNostrLink, findTag, getEventFromLocationState, getHost, hexToBech32 } from "utils";
createNostrLink,
findTag,
getEventFromLocationState,
getHost,
hexToBech32,
} from "utils";
import { Profile, getName } from "element/profile"; import { Profile, getName } from "element/profile";
import { LiveChat } from "element/live-chat"; import { LiveChat } from "element/live-chat";
import AsyncButton from "element/async-button"; import AsyncButton from "element/async-button";
@ -28,18 +22,15 @@ import { StreamCards } from "element/stream-cards";
import { formatSats } from "number"; import { formatSats } from "number";
import { StreamTimer } from "element/stream-time"; import { StreamTimer } from "element/stream-time";
import { ShareMenu } from "element/share-menu"; import { ShareMenu } from "element/share-menu";
import { import { ContentWarningOverlay, isContentWarningAccepted } from "element/content-warning";
ContentWarningOverlay,
isContentWarningAccepted,
} from "element/content-warning";
import { useCurrentStreamFeed } from "hooks/current-stream-feed"; import { useCurrentStreamFeed } from "hooks/current-stream-feed";
import { useEffect, useState } from "react"; 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 login = useLogin();
const navigate = useNavigate(); const navigate = useNavigate();
const host = getHost(ev); const host = getHost(ev);
const profile = useUserProfile(System, host); const profile = useUserProfile(host);
const zapTarget = profile?.lud16 ?? profile?.lud06; const zapTarget = profile?.lud16 ?? profile?.lud06;
const status = findTag(ev, "status") ?? ""; const status = findTag(ev, "status") ?? "";
@ -64,11 +55,7 @@ function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) {
<p>{findTag(ev, "summary")}</p> <p>{findTag(ev, "summary")}</p>
<div className="tags"> <div className="tags">
<StatePill state={status as StreamState} /> <StatePill state={status as StreamState} />
{viewers > 0 && ( {viewers > 0 && <span className="pill viewers">{formatSats(viewers)} viewers</span>}
<span className="pill viewers">
{formatSats(viewers)} viewers
</span>
)}
{status === StreamState.Live && ( {status === StreamState.Live && (
<span className="pill"> <span className="pill">
<StreamTimer ev={ev} /> <StreamTimer ev={ev} />
@ -79,11 +66,7 @@ function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) {
{isMine && ( {isMine && (
<div className="actions"> <div className="actions">
{ev && <NewStreamDialog text="Edit" ev={ev} btnClassName="btn" />} {ev && <NewStreamDialog text="Edit" ev={ev} btnClassName="btn" />}
<AsyncButton <AsyncButton type="button" className="btn btn-warning" onClick={deleteStream}>
type="button"
className="btn btn-warning"
onClick={deleteStream}
>
Delete Delete
</AsyncButton> </AsyncButton>
</div> </div>
@ -131,20 +114,20 @@ export function StreamPageHandler() {
setLink({ setLink({
id: d, id: d,
type: NostrPrefix.PublicKey, type: NostrPrefix.PublicKey,
encode: () => hexToBech32(NostrPrefix.PublicKey, d) encode: () => hexToBech32(NostrPrefix.PublicKey, d),
} as NostrLink); } as NostrLink);
} }
}) });
} }
} }
}, [params.id]); }, [params.id]);
if (link) { 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 ev = useCurrentStreamFeed(link, true, evPreload);
const host = getHost(ev); const host = getHost(ev);
const goal = useZapGoal(host, createNostrLink(ev), true); 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 summary = findTag(ev, "summary");
const image = findTag(ev, "image"); const image = findTag(ev, "image");
const status = findTag(ev, "status"); const status = findTag(ev, "status");
const stream = const stream = status === StreamState.Live ? findTag(ev, "streaming") : findTag(ev, "recording");
status === StreamState.Live
? findTag(ev, "streaming")
: findTag(ev, "recording");
const contentWarning = findTag(ev, "content-warning"); 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()) { if (contentWarning && !isContentWarningAccepted()) {
return <ContentWarningOverlay />; return <ContentWarningOverlay />;
} }
const descriptionContent = [ const descriptionContent = [title, (summary?.length ?? 0) > 0 ? summary : "Nostr live streaming", ...tags].join(", ");
title,
(summary?.length ?? 0) > 0 ? summary : "Nostr live streaming",
...tags,
].join(", ");
return ( return (
<div className="stream-page"> <div className="stream-page">
<Helmet> <Helmet>
<title>{`${title} - zap.stream`}</title> <title>{`${title} - zap.stream`}</title>
<meta name="description" content={descriptionContent} /> <meta name="description" content={descriptionContent} />
<meta <meta property="og:url" content={`https://${window.location.host}/${link.encode()}`} />
property="og:url"
content={`https://${window.location.host}/${link.encode()}`}
/>
<meta property="og:type" content="video" /> <meta property="og:type" content="video" />
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
<meta property="og:description" content={descriptionContent} /> <meta property="og:description" content={descriptionContent} />

View File

@ -16,7 +16,7 @@ export function TagPage() {
<FollowTagButton tag={unwrap(tag)} /> <FollowTagButton tag={unwrap(tag)} />
</div> </div>
<div className="video-grid"> <div className="video-grid">
{live.map((e) => ( {live.map(e => (
<VideoTile ev={e} key={e.id} /> <VideoTile ev={e} key={e.id} />
))} ))}
</div> </div>

View File

@ -80,8 +80,7 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
super(); super();
const cache = window.localStorage.getItem("providers"); const cache = window.localStorage.getItem("providers");
if (cache) { if (cache) {
const cached: Array<{ type: StreamProviders } & Record<string, unknown>> = const cached: Array<{ type: StreamProviders } & Record<string, unknown>> = JSON.parse(cache);
JSON.parse(cache);
for (const c of cached) { for (const c of cached) {
switch (c.type) { switch (c.type) {
case StreamProviders.Manual: { case StreamProviders.Manual: {
@ -93,9 +92,7 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
break; break;
} }
case StreamProviders.Owncast: { case StreamProviders.Owncast: {
this.#providers.push( this.#providers.push(new OwncastProvider(c.url as string, c.token as string));
new OwncastProvider(c.url as string, c.token as string)
);
break; break;
} }
} }
@ -110,14 +107,12 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
} }
takeSnapshot() { takeSnapshot() {
const defaultProvider = new Nip103StreamProvider( const defaultProvider = new Nip103StreamProvider("https://api.zap.stream/api/nostr/");
"https://api.zap.stream/api/nostr/"
);
return [defaultProvider, new ManualProvider(), ...this.#providers]; return [defaultProvider, new ManualProvider(), ...this.#providers];
} }
#save() { #save() {
const cfg = this.#providers.map((a) => a.createConfig()); const cfg = this.#providers.map(a => a.createConfig());
window.localStorage.setItem("providers", JSON.stringify(cfg)); window.localStorage.setItem("providers", JSON.stringify(cfg));
} }
} }

View File

@ -52,11 +52,7 @@ export class OwncastProvider implements StreamProvider {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
async #getJson<T>( async #getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
method: "GET" | "POST",
path: string,
body?: unknown
): Promise<T> {
const rsp = await fetch(`${this.#url}${path}`, { const rsp = await fetch(`${this.#url}${path}`, {
method, method,
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,

View File

@ -36,7 +36,7 @@ export class Nip103StreamProvider implements StreamProvider {
balance: rsp.balance, balance: rsp.balance,
tosAccepted: rsp.tos?.accepted, tosAccepted: rsp.tos?.accepted,
tosLink: rsp.tos?.link, tosLink: rsp.tos?.link,
endpoints: rsp.endpoints.map((a) => { endpoints: rsp.endpoints.map(a => {
return { return {
name: a.name, name: a.name,
url: a.url, url: a.url,
@ -60,7 +60,7 @@ export class Nip103StreamProvider implements StreamProvider {
const title = findTag(ev, "title"); const title = findTag(ev, "title");
const summary = findTag(ev, "summary"); const summary = findTag(ev, "summary");
const image = findTag(ev, "image"); 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"); const contentWarning = findTag(ev, "content-warning");
await this.#getJson("PATCH", "event", { await this.#getJson("PATCH", "event", {
title, title,
@ -72,10 +72,7 @@ export class Nip103StreamProvider implements StreamProvider {
} }
async topup(amount: number): Promise<string> { async topup(amount: number): Promise<string> {
const rsp = await this.#getJson<TopUpResponse>( const rsp = await this.#getJson<TopUpResponse>("GET", `topup?amount=${amount}`);
"GET",
`topup?amount=${amount}`
);
return rsp.pr; return rsp.pr;
} }
@ -85,22 +82,14 @@ export class Nip103StreamProvider implements StreamProvider {
}); });
} }
async #getJson<T>( async #getJson<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T> {
method: "GET" | "POST" | "PATCH",
path: string,
body?: unknown
): Promise<T> {
const login = Login.snapshot(); const login = Login.snapshot();
const pub = login && getPublisher(login); const pub = login && getPublisher(login);
if (!pub) throw new Error("No signer"); if (!pub) throw new Error("No signer");
const u = `${this.#url}${path}`; const u = `${this.#url}${path}`;
const token = await pub.generic((eb) => { const token = await pub.generic(eb => {
return eb return eb.kind(EventKind.HttpAuthentication).content("").tag(["u", u]).tag(["method", method]);
.kind(EventKind.HttpAuthentication)
.content("")
.tag(["u", u])
.tag(["method", method]);
}); });
const rsp = await fetch(u, { const rsp = await fetch(u, {
method, method,

View File

@ -1,40 +1,15 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import {} from "."; declare const self: ServiceWorkerGlobalScope & {
declare const self: ServiceWorkerGlobalScope; __WB_MANIFEST: (string | PrecacheEntry)[];
};
import { clientsClaim } from "workbox-core"; import { clientsClaim } from "workbox-core";
import { registerRoute } from "workbox-routing"; import { PrecacheEntry, precacheAndRoute } from "workbox-precaching";
import { CacheFirst } from "workbox-strategies";
precacheAndRoute(self.__WB_MANIFEST);
clientsClaim(); clientsClaim();
const staticTypes = ["image", "video", "audio", "script", "style", "font"]; self.addEventListener("message", event => {
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) => {
if (event.data && event.data.type === "SKIP_WAITING") { if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting(); self.skipWaiting();
} }

30
src/serviceWorker.ts Normal file
View 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
View 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"
}

View File

@ -1,10 +1,4 @@
import { import { NostrEvent, NostrPrefix, TaggedNostrEvent, encodeTLV, parseNostrLink } from "@snort/system";
NostrEvent,
NostrPrefix,
TaggedRawEvent,
encodeTLV,
parseNostrLink,
} from "@snort/system";
import * as utils from "@noble/curves/abstract/utils"; import * as utils from "@noble/curves/abstract/utils";
import { bech32 } from "@scure/base"; import { bech32 } from "@scure/base";
import type { Tag, Tags } from "types"; import type { Tag, Tags } from "types";
@ -39,7 +33,7 @@ export function toTag(e: NostrEvent): Tag {
} }
export function findTag(e: NostrEvent | undefined, tag: string) { 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 evTag[0] === tag;
}); });
return maybeTag && maybeTag[1]; return maybeTag && maybeTag[1];
@ -54,11 +48,7 @@ export function hexToBech32(hrp: string, hex?: string) {
} }
try { try {
if ( if (hrp === NostrPrefix.Note || hrp === NostrPrefix.PrivateKey || hrp === NostrPrefix.PublicKey) {
hrp === NostrPrefix.Note ||
hrp === NostrPrefix.PrivateKey ||
hrp === NostrPrefix.PublicKey
) {
const buf = utils.hexToBytes(hex); const buf = utils.hexToBytes(hex);
return bech32.encode(hrp, bech32.toWords(buf)); return bech32.encode(hrp, bech32.toWords(buf));
} else { } else {
@ -77,22 +67,12 @@ export function splitByUrl(str: string) {
return str.split(urlRegex); 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) { if (ev.kind && ev.kind >= 30000 && ev.kind <= 40000) {
const d = findTag(ev, "d") ?? ""; const d = findTag(ev, "d") ?? "";
return encodeTLV( return encodeTLV(NostrPrefix.Address, d, "relays" in ev ? ev.relays : undefined, ev.kind, ev.pubkey);
NostrPrefix.Address,
d,
"relays" in ev ? ev.relays : undefined,
ev.kind,
ev.pubkey
);
} else { } else {
return encodeTLV( return encodeTLV(NostrPrefix.Event, ev.id, "relays" in ev ? ev.relays : undefined);
NostrPrefix.Event,
ev.id,
"relays" in ev ? ev.relays : undefined
);
} }
} }
@ -102,15 +82,11 @@ export function createNostrLink(ev?: NostrEvent) {
} }
export function getHost(ev?: NostrEvent) { export function getHost(ev?: NostrEvent) {
return ( return ev?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev?.pubkey ?? "";
ev?.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ??
ev?.pubkey ??
""
);
} }
export function openFile(): Promise<File | undefined> { export function openFile(): Promise<File | undefined> {
return new Promise((resolve) => { return new Promise(resolve => {
const elm = document.createElement("input"); const elm = document.createElement("input");
elm.type = "file"; elm.type = "file";
elm.onchange = (e: Event) => { elm.onchange = (e: Event) => {
@ -127,17 +103,14 @@ export function openFile(): Promise<File | undefined> {
export function getTagValues(tags: Tags, tag: string): Array<string> { export function getTagValues(tags: Tags, tag: string): Array<string> {
return tags return tags
.filter((t) => t.at(0) === tag) .filter(t => t.at(0) === tag)
.map((t) => t.at(1)) .map(t => t.at(1))
.filter((t) => t) .filter(t => t)
.map((t) => t as string); .map(t => t as string);
} }
export function getEventFromLocationState(state: unknown | undefined | null) { export function getEventFromLocationState(state: unknown | undefined | null) {
return state && return state && typeof state === "object" && "kind" in state && state.kind === LIVE_STREAM
typeof state === "object" &&
"kind" in state &&
state.kind === LIVE_STREAM
? (state as NostrEvent) ? (state as NostrEvent)
: undefined; : undefined;
} }

View File

@ -3,10 +3,7 @@ import { CandidateInfo, SDPInfo } from "semantic-sdp";
import { TypedEventTarget, type StatusEvent, type LogEvent } from "./events"; import { TypedEventTarget, type StatusEvent, type LogEvent } from "./events";
import { parserLinkHeader } from "./parser"; import { parserLinkHeader } from "./parser";
export const DEFAULT_ICE_SERVERS = [ export const DEFAULT_ICE_SERVERS = ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"];
"stun:stun.cloudflare.com:3478",
"stun:stun.l.google.com:19302",
];
export const TRICKLE_BATCH_INTERVAL = 50; export const TRICKLE_BATCH_INTERVAL = 50;
@ -49,9 +46,7 @@ export class WISH extends TypedEventTarget {
if (iceServers) { if (iceServers) {
this.iceServers = iceServers ? iceServers : DEFAULT_ICE_SERVERS; this.iceServers = iceServers ? iceServers : DEFAULT_ICE_SERVERS;
} }
this.logMessage( this.logMessage(`Enabling webrtc-adapter for ${adapter.browserDetails.browser}@${adapter.browserDetails.version}`);
`Enabling webrtc-adapter for ${adapter.browserDetails.browser}@${adapter.browserDetails.version}`
);
this.newResolvers(); this.newResolvers();
} }
@ -99,7 +94,7 @@ export class WISH extends TypedEventTarget {
this.connectedResolver = resolve; this.connectedResolver = resolve;
this.connectedRejector = reject; this.connectedRejector = reject;
}); });
this.gatherPromise = new Promise((resolve) => { this.gatherPromise = new Promise(resolve => {
this.gatherResolver = resolve; this.gatherResolver = resolve;
}); });
} }
@ -108,36 +103,19 @@ export class WISH extends TypedEventTarget {
if (!this.peerConnection) { if (!this.peerConnection) {
return; return;
} }
this.peerConnection.addEventListener( this.peerConnection.addEventListener("connectionstatechange", this.onConnectionStateChange.bind(this));
"connectionstatechange", this.peerConnection.addEventListener("iceconnectionstatechange", this.onICEConnectionStateChange.bind(this));
this.onConnectionStateChange.bind(this) this.peerConnection.addEventListener("icegatheringstatechange", this.onGatheringStateChange.bind(this));
); this.peerConnection.addEventListener("icecandidate", this.onICECandidate.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("track", this.onTrack.bind(this));
this.peerConnection.addEventListener( this.peerConnection.addEventListener("signalingstatechange", this.onSignalingStateChange.bind(this));
"signalingstatechange",
this.onSignalingStateChange.bind(this)
);
} }
private onGatheringStateChange() { private onGatheringStateChange() {
if (!this.peerConnection) { if (!this.peerConnection) {
return; return;
} }
this.logMessage( this.logMessage(`ICE Gathering State changed: ${this.peerConnection.iceGatheringState}`);
`ICE Gathering State changed: ${this.peerConnection.iceGatheringState}`
);
switch (this.peerConnection.iceGatheringState) { switch (this.peerConnection.iceGatheringState) {
case "complete": case "complete":
this.gatherResolver(); this.gatherResolver();
@ -149,13 +127,8 @@ export class WISH extends TypedEventTarget {
if (!this.peerConnection) { if (!this.peerConnection) {
return; return;
} }
this.logMessage( this.logMessage(`Peer Connection State changed: ${this.peerConnection.connectionState}`);
`Peer Connection State changed: ${this.peerConnection.connectionState}` const transportHandler = (track: MediaStreamTrack, transport: RTCDtlsTransport) => {
);
const transportHandler = (
track: MediaStreamTrack,
transport: RTCDtlsTransport
) => {
const ice = transport.iceTransport; const ice = transport.iceTransport;
if (!ice) { if (!ice) {
return; return;
@ -217,9 +190,7 @@ export class WISH extends TypedEventTarget {
if (!candidate.candidate) { if (!candidate.candidate) {
return; return;
} }
this.logMessage( this.logMessage(`Got ICE candidate: ${candidate.candidate.replace("candidate:", "")}`);
`Got ICE candidate: ${candidate.candidate.replace("candidate:", "")}`
);
if (!this.parsedOffer) { if (!this.parsedOffer) {
return; return;
} }
@ -240,13 +211,8 @@ export class WISH extends TypedEventTarget {
if (this.trickleBatchingJob) { if (this.trickleBatchingJob) {
clearInterval(this.trickleBatchingJob); clearInterval(this.trickleBatchingJob);
} }
this.logMessage( this.logMessage(`Starting batching job to trickle candidates every ${TRICKLE_BATCH_INTERVAL}ms`);
`Starting batching job to trickle candidates every ${TRICKLE_BATCH_INTERVAL}ms` this.trickleBatchingJob = setInterval(this.trickleBatch.bind(this), TRICKLE_BATCH_INTERVAL);
);
this.trickleBatchingJob = setInterval(
this.trickleBatch.bind(this),
TRICKLE_BATCH_INTERVAL
);
} }
private stopTrickleBatching() { private stopTrickleBatching() {
@ -281,8 +247,7 @@ export class WISH extends TypedEventTarget {
type: candidate.type || "host", type: candidate.type || "host",
relAddr: candidate.relatedAddress || undefined, relAddr: candidate.relatedAddress || undefined,
relPort: relPort:
typeof candidate.relatedPort !== "undefined" && typeof candidate.relatedPort !== "undefined" && candidate.relatedPort !== null
candidate.relatedPort !== null
? candidate.relatedPort.toString() ? candidate.relatedPort.toString()
: undefined, : undefined,
}); });
@ -307,18 +272,14 @@ export class WISH extends TypedEventTarget {
if (!this.peerConnection) { if (!this.peerConnection) {
return; return;
} }
this.logMessage( this.logMessage(`Signaling State changed: ${this.peerConnection.signalingState}`);
`Signaling State changed: ${this.peerConnection.signalingState}`
);
} }
private onICEConnectionStateChange() { private onICEConnectionStateChange() {
if (!this.peerConnection) { if (!this.peerConnection) {
return; return;
} }
this.logMessage( this.logMessage(`ICE Connection State changed: ${this.peerConnection.iceConnectionState}`);
`ICE Connection State changed: ${this.peerConnection.iceConnectionState}`
);
switch (this.peerConnection.iceConnectionState) { switch (this.peerConnection.iceConnectionState) {
case "checking": case "checking":
this.iceStartTime = performance.now(); this.iceStartTime = performance.now();
@ -327,19 +288,11 @@ export class WISH extends TypedEventTarget {
const connected = performance.now(); const connected = performance.now();
if (this.connectStartTime) { if (this.connectStartTime) {
const delta = connected - this.connectStartTime; const delta = connected - this.connectStartTime;
this.logMessage( this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to establish PeerConnection (end-to-end)`);
`Took ${(delta / 1000).toFixed(
2
)} seconds to establish PeerConnection (end-to-end)`
);
} }
if (this.iceStartTime) { if (this.iceStartTime) {
const delta = connected - this.iceStartTime; const delta = connected - this.iceStartTime;
this.logMessage( this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to establish PeerConnection (ICE)`);
`Took ${(delta / 1000).toFixed(
2
)} seconds to establish PeerConnection (ICE)`
);
} }
this.dispatchEvent( this.dispatchEvent(
new CustomEvent<StatusEvent>("status", { new CustomEvent<StatusEvent>("status", {
@ -421,19 +374,12 @@ export class WISH extends TypedEventTarget {
} }
private setVideoCodecPreference(transceiver: RTCRtpTransceiver) { private setVideoCodecPreference(transceiver: RTCRtpTransceiver) {
if ( if (typeof RTCRtpSender.getCapabilities === "undefined" || typeof transceiver.setCodecPreferences === "undefined") {
typeof RTCRtpSender.getCapabilities === "undefined" ||
typeof transceiver.setCodecPreferences === "undefined"
) {
return; return;
} }
const capability = RTCRtpSender.getCapabilities("video"); const capability = RTCRtpSender.getCapabilities("video");
const codecs = capability ? capability.codecs : []; const codecs = capability ? capability.codecs : [];
this.logMessage( this.logMessage(`Available codecs for outbound video: ${codecs.map(c => c.mimeType).join(", ")}`);
`Available codecs for outbound video: ${codecs
.map((c) => c.mimeType)
.join(", ")}`
);
for (let i = 0; i < codecs.length; i++) { for (let i = 0; i < codecs.length; i++) {
const codec = codecs[i]; const codec = codecs[i];
if (codec.mimeType === "video/VP9") { if (codec.mimeType === "video/VP9") {
@ -486,10 +432,7 @@ export class WISH extends TypedEventTarget {
} }
} }
private async doSignalingPOST( private async doSignalingPOST(sdp: string, useLink?: boolean): Promise<string> {
sdp: string,
useLink?: boolean
): Promise<string> {
if (!this.endpoint) { if (!this.endpoint) {
throw new Error("No WHIP/WHEP endpoint has been set"); 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")) { if (resp.headers.get("accept-post") || resp.headers.get("accept-patch")) {
switch (this.mode) { switch (this.mode) {
case Mode.Publisher: case Mode.Publisher:
this.logMessage( this.logMessage(`WHIP version draft-ietf-wish-whip-05 (Accept-Post/Accept-Patch)`);
`WHIP version draft-ietf-wish-whip-05 (Accept-Post/Accept-Patch)`
);
break; break;
case Mode.Player: case Mode.Player:
this.logMessage( this.logMessage(`WHEP version draft-murillo-whep-01 (Accept-Post/Accept-Patch)`);
`WHEP version draft-murillo-whep-01 (Accept-Post/Accept-Patch)`
);
break; break;
} }
} }
@ -560,9 +499,7 @@ export class WISH extends TypedEventTarget {
const signaled = performance.now(); const signaled = performance.now();
const delta = signaled - signalStartTime; const delta = signaled - signalStartTime;
this.logMessage( this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to exchange SDP`);
`Took ${(delta / 1000).toFixed(2)} seconds to exchange SDP`
);
return body; return body;
} }

View File

@ -8,16 +8,14 @@ const ESLintPlugin = require("eslint-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const CopyPlugin = require("copy-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 isProduction = process.env.NODE_ENV == "production";
const config = { const config = {
entry: { entry: {
main: "./src/index.tsx", main: "./src/index.tsx",
sw: {
import: "./src/service-worker.ts",
filename: "service-worker.js",
},
}, },
target: "browserslist", target: "browserslist",
mode: isProduction ? "production" : "development", mode: isProduction ? "production" : "development",
@ -25,12 +23,7 @@ const config = {
output: { output: {
publicPath: "/", publicPath: "/",
path: path.resolve(__dirname, "build"), path: path.resolve(__dirname, "build"),
filename: ({ runtime }) => { filename: isProduction ? "[name].[chunkhash].js" : "[name].js",
if (runtime === "sw") {
return "[name].js";
}
return isProduction ? "[name].[chunkhash].js" : "[name].js";
},
clean: isProduction, clean: isProduction,
}, },
devServer: { devServer: {
@ -51,12 +44,11 @@ const config = {
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
template: "public/index.html", template: "public/index.html",
favicon: "public/favicon.ico", favicon: "public/favicon.ico",
excludeChunks: ["sw"],
}), }),
new ESLintPlugin({ new ESLintPlugin({
extensions: ["js", "mjs", "jsx", "ts", "tsx"], extensions: ["js", "mjs", "jsx", "ts", "tsx"],
eslintPath: require.resolve("eslint"), eslintPath: require.resolve("eslint"),
failOnError: !isProduction, failOnError: true,
cache: true, cache: true,
}), }),
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
@ -70,6 +62,9 @@ const config = {
__XXX: process.env["__XXX"] || JSON.stringify(false), __XXX: process.env["__XXX"] || JSON.stringify(false),
__XXX_HOST: JSON.stringify("https://xxzap.com"), __XXX_HOST: JSON.stringify("https://xxzap.com"),
}), }),
new WorkboxPlugin.InjectManifest({
swSrc: "./src/service-worker.ts",
}),
], ],
module: { module: {
rules: [ rules: [
@ -97,28 +92,24 @@ const config = {
options: { options: {
babelrc: false, babelrc: false,
configFile: false, configFile: false,
presets: [ presets: [["@babel/preset-env"], ["@babel/preset-react", { runtime: "automatic" }]],
[ },
"@babel/preset-env", },
{ {
targets: "defaults", loader: require.resolve("ts-loader"),
}, options: {
], getCustomTransformers() {
["@babel/preset-react", { runtime: "automatic" }], return {
"@babel/preset-typescript", before: [
], IntlTsTransformer.transform({
plugins: [ overrideIdFn: "[sha512:contenthash:base64:6]",
[ ast: true,
"formatjs", }),
{ ],
idInterpolationPattern: "[sha512:contenthash:base64:6]", };
ast: true, },
},
],
],
}, },
}, },
require.resolve("ts-loader"),
], ],
}, },
{ {

2318
yarn.lock

File diff suppressed because it is too large Load Diff