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
claim:
name: docker-cache
---
kind: pipeline
type: kubernetes
name: crowdin
concurrency:
limit: 1
trigger:
branch:
- main
metadata:
namespace: git
steps:
- name: Push/Pull translations
image: node:current-bullseye
volumes:
- name: cache
path: /cache
environment:
YARN_CACHE_FOLDER: /cache/.yarn-translations
TOKEN:
from_secret: gitea
CTOKEN:
from_secret: crowdin
commands:
- git config --global user.email drone@v0l.io
- git config --global user.name "Drone CI"
- git remote set-url origin https://drone:$TOKEN@git.v0l.io/Kieran/stream.git
- yarn install
- npx @crowdin/cli upload sources -b main -T $CTOKEN
- npx @crowdin/cli pull -b main -T $CTOKEN
- git add .
- 'git commit -a -m "chore: Update translations"'
- git push -u origin main
volumes:
- name: cache
claim:
name: docker-cache

View File

@ -1 +0,0 @@
{}

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,8 +5,8 @@
font-weight: 400;
font-display: swap;
src: url(outfit_400_latin-ext.woff2) format("woff2");
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
@ -15,9 +15,8 @@
font-weight: 400;
font-display: swap;
src: url(outfit_400_latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
@ -26,8 +25,8 @@
font-weight: 500;
font-display: swap;
src: url(outfit_500_latin-ext.woff2) format("woff2");
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
@ -36,9 +35,8 @@
font-weight: 500;
font-display: swap;
src: url(outfit_500_latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
@ -47,8 +45,8 @@
font-weight: 600;
font-display: swap;
src: url(outfit_600_latin-ext.woff2) format("woff2");
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
@ -57,9 +55,8 @@
font-weight: 600;
font-display: swap;
src: url(outfit_600_latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
@ -68,8 +65,8 @@
font-weight: 700;
font-display: swap;
src: url(outfit_700_latin-ext.woff2) format("woff2");
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
@ -78,7 +75,6 @@
font-weight: 700;
font-display: swap;
src: url(outfit_700_latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

40
src/intl.tsx Normal file
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);
}
case LoginType.PrivateKey: {
return new EventPublisher(
new PrivateKeySigner(unwrap(session.privateKey)),
session.pubkey
);
return new EventPublisher(new PrivateKeySigner(unwrap(session.privateKey)), session.pubkey);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -48,16 +48,9 @@ export function StreamProvidersPage() {
return (
<div className="stream-providers-page">
<h1>Providers</h1>
<p>
Stream providers streamline the process of streaming on Nostr, some
event accept lightning payments!
</p>
<p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p>
<div className="stream-providers-grid">
{[
StreamProviders.NostrType,
StreamProviders.Owncast,
StreamProviders.Cloudflare,
].map((v) => providerLink(v))}
{[StreamProviders.NostrType, StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
</div>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,40 +1,15 @@
/// <reference lib="webworker" />
import {} from ".";
declare const self: ServiceWorkerGlobalScope;
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: (string | PrecacheEntry)[];
};
import { clientsClaim } from "workbox-core";
import { registerRoute } from "workbox-routing";
import { CacheFirst } from "workbox-strategies";
import { PrecacheEntry, precacheAndRoute } from "workbox-precaching";
precacheAndRoute(self.__WB_MANIFEST);
clientsClaim();
const staticTypes = ["image", "video", "audio", "script", "style", "font"];
registerRoute(
({ request, url }) =>
url.origin === self.location.origin &&
staticTypes.includes(request.destination),
new CacheFirst({
cacheName: "static-content",
})
);
// External media domains which have unique urls (never changing content) and can be cached forever
const externalMediaHosts = [
"void.cat",
"nostr.build",
"imgur.com",
"i.imgur.com",
"pbs.twimg.com",
"i.ibb.co",
];
registerRoute(
({ url }) => externalMediaHosts.includes(url.host),
new CacheFirst({
cacheName: "ext-content-hosts",
})
);
self.addEventListener("message", (event) => {
self.addEventListener("message", event => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}

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

View File

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

View File

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

2318
yarn.lock

File diff suppressed because it is too large Load Diff