Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

99 changed files with 9 additions and 13582 deletions

23
.gitignore vendored
View File

@ -1,23 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -1 +0,0 @@
{}

View File

@ -1,70 +1,9 @@
# 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)
# Mappr
# Mappr
# Mappr
# Mappr
# Mappr
# blog
# blog
# blog
# blog

View File

@ -1,2 +0,0 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src *; img-src * data: blob:; font-src https://fonts.gstatic.com; media-src * blob:; script-src 'self';

View File

@ -1,88 +0,0 @@
{
"name": "stream_ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@noble/curves": "^1.1.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@react-hook/resize-observer": "^1.2.6",
"@snort/system-react": "^1.0.11",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^13.2.1",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"buffer": "^6.0.3",
"emoji-mart": "^5.5.2",
"hls.js": "^1.4.6",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"qr-code-styling": "^1.6.0-rc.1",
"react": "^18.2.0",
"react-confetti": "^6.1.0",
"react-dom": "^18.2.0",
"react-intersection-observer": "^9.5.1",
"react-router-dom": "^6.13.0",
"react-tag-input-component": "^2.0.2",
"semantic-sdp": "^3.26.2",
"usehooks-ts": "^2.9.1",
"web-vitals": "^2.1.0",
"webrtc-adapter": "^8.2.3"
},
"scripts": {
"start": "webpack serve",
"build": "webpack --node-env=production",
"deploy": "npx wrangler pages publish build"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
"chrome >= 67",
"edge >= 79",
"firefox >= 68",
"opera >= 54",
"safari >= 14"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/plugin-syntax-import-assertions": "^7.20.0",
"@babel/preset-env": "^7.21.5",
"@babel/preset-react": "^7.18.6",
"@formatjs/cli": "^6.0.1",
"@formatjs/ts-transformer": "^3.13.1",
"@types/lodash": "^4.14.195",
"@webbtc/webln-types": "^1.0.12",
"babel-loader": "^9.1.2",
"babel-plugin-formatjs": "^10.5.1",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^5.0.0",
"eslint": "^8.43.0",
"eslint-plugin-formatjs": "^4.10.1",
"eslint-webpack-plugin": "^4.0.1",
"html-webpack-plugin": "^5.5.1",
"mini-css-extract-plugin": "^2.7.5",
"prettier": "^2.8.8",
"ts-loader": "^9.4.2",
"typescript": "^5.1.3",
"webpack": "^5.82.1",
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^5.1.1",
"webpack-dev-server": "^4.15.0",
"workbox-webpack-plugin": "^6.5.4"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,34 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<symbol id="zap" viewBox="0 0 16 20" fill="none">
<path d="M8.8333 1.70166L1.41118 10.6082C1.12051 10.957 0.975169 11.1314 0.972948 11.2787C0.971017 11.4068 1.02808 11.5286 1.12768 11.6091C1.24226 11.7017 1.46928 11.7017 1.92333 11.7017H7.99997L7.16663 18.3683L14.5888 9.46178C14.8794 9.11297 15.0248 8.93857 15.027 8.79128C15.0289 8.66323 14.9719 8.54141 14.8723 8.46092C14.7577 8.36833 14.5307 8.36833 14.0766 8.36833H7.99997L8.8333 1.70166Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="zap-filled" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.3983 1.08269C13.8055 1.25946 14.0474 1.68353 13.9924 2.12403L13.1329 9L19.3279 8.99999C19.5689 8.99995 19.813 8.9999 20.0124 9.01796C20.201 9.03503 20.5622 9.08021 20.8754 9.33332C21.234 9.62308 21.4394 10.0616 21.4324 10.5226C21.4263 10.9253 21.2298 11.2316 21.1222 11.3875C21.0084 11.5522 20.8521 11.7397 20.6978 11.9248L11.7683 22.6402C11.4841 22.9812 11.0091 23.0941 10.6019 22.9173C10.1947 22.7405 9.95277 22.3165 10.0078 21.876L10.8673 15L4.67233 15C4.43134 15 4.18725 15.0001 3.98782 14.982C3.79921 14.965 3.43805 14.9198 3.12483 14.6667C2.76626 14.3769 2.56085 13.9383 2.5678 13.4774C2.57387 13.0747 2.77038 12.7684 2.878 12.6125C2.9918 12.4478 3.14811 12.2603 3.30242 12.0752C3.31007 12.066 3.31771 12.0568 3.32534 12.0477L12.2319 1.35981C12.5161 1.01878 12.9911 0.905925 13.3983 1.08269Z" fill="currentColor"/>
</symbol>
<symbol id="search" viewBox="0 0 20 21" fill="none">
<path d="M19 19.5L14.65 15.15M17 9.5C17 13.9183 13.4183 17.5 9 17.5C4.58172 17.5 1 13.9183 1 9.5C1 5.08172 4.58172 1.5 9 1.5C13.4183 1.5 17 5.08172 17 9.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="logout" viewBox="0 0 22 20" fill="none">
<path d="M17 6L21 10M21 10L17 14M21 10H8M14 2.20404C12.7252 1.43827 11.2452 1 9.66667 1C4.8802 1 1 5.02944 1 10C1 14.9706 4.8802 19 9.66667 19C11.2452 19 12.7252 18.5617 14 17.796" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="message" viewBox="0 0 18 16" fill="none">
<path d="M7.75036 8.00004H3.16702M3.09648 8.24296L1.15071 14.0552C0.997847 14.5118 0.921417 14.7401 0.976267 14.8807C1.0239 15.0028 1.1262 15.0954 1.25244 15.1306C1.3978 15.1712 1.61736 15.0724 2.05647 14.8748L15.9827 8.60799C16.4113 8.41512 16.6256 8.31868 16.6918 8.18471C16.7494 8.06832 16.7494 7.93176 16.6918 7.81537C16.6256 7.6814 16.4113 7.58497 15.9827 7.39209L2.05161 1.12314C1.61383 0.926139 1.39493 0.827637 1.24971 0.868044C1.1236 0.903136 1.0213 0.995457 0.973507 1.11733C0.91847 1.25766 0.994084 1.48547 1.14531 1.9411L3.09702 7.82131C3.12299 7.89957 3.13598 7.9387 3.14111 7.97871C3.14565 8.01422 3.14561 8.05017 3.14097 8.08567C3.13574 8.12567 3.12265 8.16477 3.09648 8.24296Z" stroke="currentColor" stroke-opacity="0.5" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="login" viewBox="0 0 18 18" fill="none">
<path d="M4 13.1667C4 13.4594 4 13.6058 4.01306 13.7331C4.12146 14.7895 4.8855 15.6622 5.91838 15.9093C6.04279 15.939 6.18792 15.9584 6.47807 15.9971L11.9713 16.7295C13.535 16.938 14.3169 17.0423 14.9237 16.801C15.4565 16.5891 15.9002 16.2006 16.1806 15.7005C16.5 15.1309 16.5 14.3421 16.5 12.7646V5.23541C16.5 3.65787 16.5 2.8691 16.1806 2.2995C15.9002 1.7994 15.4565 1.41088 14.9237 1.19904C14.3169 0.957756 13.535 1.062 11.9713 1.2705L6.47807 2.00293C6.18788 2.04162 6.04279 2.06097 5.91838 2.09073C4.8855 2.33781 4.12145 3.21049 4.01306 4.26696C4 4.39421 4 4.54059 4 4.83334M9 5.66668L12.3333 9.00001M12.3333 9.00001L9 12.3333M12.3333 9.00001H1.5" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="signal" viewBox="0 0 22 18" fill="none">
<path d="M15.2426 4.75735C17.5858 7.1005 17.5858 10.8995 15.2426 13.2426M6.75736 13.2426C4.41421 10.8995 4.41421 7.10046 6.75736 4.75732M3.92893 16.0711C0.0236893 12.1658 0.0236893 5.83417 3.92893 1.92892M18.0711 1.92897C21.9763 5.83421 21.9763 12.1659 18.0711 16.0711M13 8.99999C13 10.1046 12.1046 11 11 11C9.89543 11 9 10.1046 9 8.99999C9 7.89542 9.89543 6.99999 11 6.99999C12.1046 6.99999 13 7.89542 13 8.99999Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="face" viewBox="0 0 24 24" fill="none">
<path d="M15 9H15.01M9 9H9.01M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM15.5 9C15.5 9.27614 15.2761 9.5 15 9.5C14.7239 9.5 14.5 9.27614 14.5 9C14.5 8.72386 14.7239 8.5 15 8.5C15.2761 8.5 15.5 8.72386 15.5 9ZM9.5 9C9.5 9.27614 9.27614 9.5 9 9.5C8.72386 9.5 8.5 9.27614 8.5 9C8.5 8.72386 8.72386 8.5 9 8.5C9.27614 8.5 9.5 8.72386 9.5 9ZM12 17.5C14.5005 17.5 16.5 15.667 16.5 14H7.5C7.5 15.667 9.4995 17.5 12 17.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="link" viewBox="0 0 32 32" fill="none">
<path d="M22 14L22 10M22 10H18M22 10L16 16M14.6667 10H13.2C12.0799 10 11.5198 10 11.092 10.218C10.7157 10.4097 10.4097 10.7157 10.218 11.092C10 11.5198 10 12.0799 10 13.2V18.8C10 19.9201 10 20.4802 10.218 20.908C10.4097 21.2843 10.7157 21.5903 11.092 21.782C11.5198 22 12.0799 22 13.2 22H18.8C19.9201 22 20.4802 22 20.908 21.782C21.2843 21.5903 21.5903 21.2843 21.782 20.908C22 20.4802 22 19.9201 22 18.8V17.3333" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="zap-stream" viewBox="0 0 160 160" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M82.4852 54.5094L87.7882 48.2773C87.8525 48.2098 87.9174 48.1429 87.9826 48.0768C94.4927 41.1346 103.63 36.8165 113.748 36.8165C133.516 36.8165 149.541 53.2997 149.541 73.6327C149.541 82.0501 146.795 89.8077 142.174 96.0093L142.197 96.029L141.843 96.4456C141.126 97.3799 140.364 98.2774 139.563 99.1352L87.9613 160L43.5147 158.617L58.9832 140.033L112.875 76.6987C114.038 75.3317 113.873 73.2807 112.506 72.1175C111.139 70.9544 109.088 71.1196 107.925 72.4865L71.2247 115.617C64.7813 121.963 55.8992 125.885 46.0917 125.885C26.4118 125.885 10.458 110.093 10.458 90.6136C10.458 81.6851 13.8096 73.5314 19.3355 67.318L76.4941 3.75969e-05L120.334 8.27526e-08L51.0699 81.3993C49.9068 82.7663 50.072 84.8173 51.4389 85.9805C52.8059 87.1437 54.857 86.9784 56.0201 85.6115L72.1945 66.6032C72.207 66.6164 72.2194 66.6297 72.2319 66.643L82.4852 54.5094Z" fill="white"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Nostr live streaming" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<title>zap.stream</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -1,15 +0,0 @@
{
"short_name": "zap_stream",
"name": "zap.stream",
"icons": [
{
"src": "logo.png",
"type": "image/png",
"sizes": "256x256"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -1,5 +0,0 @@
{
"names":{
"_": "cf45a6ba1363ad7ed213a078e710d24115ae721c9b47bd1ebf4458eaefb4c2a5"
}
}

View File

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -1,3 +0,0 @@
[
{ "id": "nsfw", "text": "NSFW" }
]

View File

@ -1,4 +0,0 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M82.4852 54.5094L87.7882 48.2773C87.8525 48.2098 87.9174 48.1429 87.9826 48.0768C94.4927 41.1346 103.63 36.8165 113.748 36.8165C133.516 36.8165 149.541 53.2997 149.541 73.6327C149.541 82.0501 146.795 89.8077 142.174 96.0093L142.197 96.029L141.843 96.4456C141.126 97.3799 140.364 98.2774 139.563 99.1352L87.9613 160L43.5147 158.617L58.9832 140.033L112.875 76.6987C114.038 75.3317 113.873 73.2807 112.506 72.1175C111.139 70.9544 109.088 71.1196 107.925 72.4865L71.2247 115.617C64.7813 121.963 55.8992 125.885 46.0917 125.885C26.4118 125.885 10.458 110.093 10.458 90.6136C10.458 81.6851 13.8096 73.5314 19.3355 67.318L76.4941 3.75969e-05L120.334 8.27526e-08L51.0699 81.3993C49.9068 82.7663 50.072 84.8173 51.4389 85.9805C52.8059 87.1437 54.857 86.9784 56.0201 85.6115L72.1945 66.6032C72.207 66.6164 72.2194 66.6297 72.2319 66.643L82.4852 54.5094Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

@ -1,5 +0,0 @@
import { EventKind } from "@snort/system";
export const LIVE_STREAM = 30_311 as EventKind;
export const LIVE_STREAM_CHAT = 1_311 as EventKind;
export const GOAL = 9041 as EventKind;

View File

@ -1,46 +0,0 @@
/// <reference types="@webbtc/webln-types" />
declare module "*.jpg" {
const value: unknown;
export default value;
}
declare module "*.svg" {
const value: unknown;
export default value;
}
declare module "*.webp" {
const value: string;
export default value;
}
declare module "*.png" {
const value: string;
export default value;
}
declare module "*.css" {
const stylesheet: CSSStyleSheet;
export default stylesheet;
}
declare module "translations/*.json" {
const value: Record<string, string>;
export default value;
}
declare module "light-bolt11-decoder" {
export function decode(pr?: string): ParsedInvoice;
export interface ParsedInvoice {
paymentRequest: string;
sections: Section[];
}
export interface Section {
name: string;
value: string | Uint8Array | number | undefined;
}
}

View File

@ -1,14 +0,0 @@
button {
position: relative;
}
.spinner-wrapper {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}

View File

@ -1,48 +0,0 @@
import "./async-button.css";
import { useState } from "react";
import Spinner from "element/spinner";
interface AsyncButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
disabled?: boolean;
onClick(e: React.MouseEvent): Promise<void> | void;
children?: React.ReactNode;
}
export default function AsyncButton(props: AsyncButtonProps) {
const [loading, setLoading] = useState<boolean>(false);
async function handle(e: React.MouseEvent) {
e.stopPropagation();
if (loading || props.disabled) return;
setLoading(true);
try {
if (typeof props.onClick === "function") {
const f = props.onClick(e);
if (f instanceof Promise) {
await f;
}
}
} finally {
setLoading(false);
}
}
return (
<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 />
</span>
)}
</button>
);
}

View File

@ -1,17 +0,0 @@
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 ?? ""}
/>
);
}

View File

@ -1,180 +0,0 @@
import { useUserProfile } from "@snort/system-react";
import { NostrEvent, parseZap, EventPublisher, EventKind } from "@snort/system";
import { useRef, useState, useMemo } from "react";
import { useMediaQuery, useHover, useOnClickOutside, useIntersectionObserver } from "usehooks-ts";
import { System } from "../index";
import { formatSats } from "../number";
import { EmojiPicker } from "./emoji-picker";
import { Icon } from "./icon";
import { Profile } from "./profile";
import { Text } from "./text";
import { SendZapsDialog } from "./send-zap";
import { findTag } from "../utils";
interface Emoji {
id: string;
native?: string;
}
function emojifyReaction(reaction: string) {
if (reaction === "+") {
return "💜";
}
if (reaction === "-") {
return "👎";
}
return reaction;
}
export function ChatMessage({
streamer,
ev,
reactions,
}: {
ev: NostrEvent;
streamer: string;
reactions: readonly NostrEvent[];
}) {
const ref = useRef(null);
const inView = useIntersectionObserver(ref, {
freezeOnceVisible: true
})
const emojiRef = useRef(null);
const isTablet = useMediaQuery("(max-width: 1020px)");
const isHovering = useHover(ref);
const [showZapDialog, setShowZapDialog] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const profile = useUserProfile(System, inView?.isIntersecting ? ev.pubkey : undefined);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const zaps = useMemo(() => {
return reactions.filter(a => a.kind === EventKind.ZapReceipt)
.map(a => parseZap(a, System.ProfileLoader.Cache))
.filter(a => a && a.valid);
}, [reactions])
const emojis = useMemo(() => {
const emojified = reactions
.filter((e) => e.kind === EventKind.Reaction && findTag(e, "e") === ev.id)
.map((ev) => emojifyReaction(ev.content));
return [...new Set(emojified)];
}, [ev, reactions]);
const hasReactions = emojis.length > 0;
const totalZaps = useMemo(() => {
const messageZaps = zaps.filter((z) => z.event === ev.id);
return messageZaps.reduce((acc, z) => acc + z.amount, 0);
}, [zaps, ev]);
const hasZaps = totalZaps > 0;
useOnClickOutside(ref, () => {
setShowZapDialog(false);
});
useOnClickOutside(emojiRef, () => {
setShowEmojiPicker(false);
});
async function onEmojiSelect(emoji: Emoji) {
setShowEmojiPicker(false);
setShowZapDialog(false);
try {
const pub = await EventPublisher.nip7();
const reply = await pub?.react(ev, emoji.native || "+1");
if (reply) {
console.debug(reply);
System.BroadcastEvent(reply);
}
} catch (error) { }
}
// @ts-expect-error
const topOffset = ref.current?.getBoundingClientRect().top;
// @ts-expect-error
const leftOffset = ref.current?.getBoundingClientRect().left;
function pickEmoji(ev: any) {
ev.stopPropagation();
setShowEmojiPicker(!showEmojiPicker);
}
return (
<>
<div
className={`message${streamer === ev.pubkey ? " streamer" : ""}`}
ref={ref}
onClick={() => setShowZapDialog(true)}
>
<Profile
icon={
ev.pubkey === streamer && (
<Icon name="signal" size={16} />
)
}
pubkey={ev.pubkey}
profile={profile}
/>
<Text content={ev.content} tags={ev.tags} />
{(hasReactions || hasZaps) && (
<div className="message-reactions">
{hasZaps && (
<div className="zap-pill">
<Icon name="zap-filled" className="zap-pill-icon" />
<span className="zap-pill-amount">{formatSats(totalZaps)}</span>
</div>
)}
{emojis.map((e) => (
<div className="message-reaction-container">
<span className="message-reaction">{e}</span>
</div>
))}
</div>
)}
{ref.current && (
<div
className="message-zap-container"
style={
isTablet
? {
display: showZapDialog || isHovering ? "flex" : "none",
}
: {
position: "fixed",
top: topOffset - 12,
left: leftOffset - 32,
opacity: showZapDialog || isHovering ? 1 : 0,
pointerEvents:
showZapDialog || isHovering ? "auto" : "none",
}
}
>
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
eTag={ev.id}
pubkey={ev.pubkey}
button={
<button className="message-zap-button">
<Icon name="zap" className="message-zap-button-icon" />
</button>
}
targetName={profile?.name || ev.pubkey}
/>
)}
<button className="message-zap-button" onClick={pickEmoji}>
<Icon name="face" className="message-zap-button-icon" />
</button>
</div>
)}
</div>
{showEmojiPicker && (
<EmojiPicker
topOffset={topOffset}
leftOffset={leftOffset}
onEmojiSelect={onEmojiSelect}
onClickOutside={() => setShowEmojiPicker(false)}
ref={emojiRef}
/>
)}
</>
);
}

View File

@ -1,71 +0,0 @@
import data, { Emoji } from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { RefObject } from "react";
import { EmojiPack } from "../hooks/emoji";
interface EmojiPickerProps {
topOffset: number;
leftOffset: number;
emojiPacks?: EmojiPack[];
onEmojiSelect: (e: Emoji) => void;
onClickOutside: () => void;
height?: number;
ref: RefObject<HTMLDivElement>;
}
export function EmojiPicker({
topOffset,
leftOffset,
onEmojiSelect,
onClickOutside,
emojiPacks = [],
height = 300,
ref,
}: EmojiPickerProps) {
const customEmojiList = emojiPacks.map((pack) => {
return {
id: pack.address,
name: pack.name,
emojis: pack.emojis.map((e) => {
const [, name, url] = e;
return {
id: name,
name,
skins: [{ src: url }],
};
}),
};
});
return (
<>
<div
style={{
position: "fixed",
top: topOffset - height - 10,
left: leftOffset,
zIndex: 1,
}}
ref={ref}
>
<style>
{`
em-emoji-picker { max-height: ${height}px; }
`}
</style>
<Picker
autoFocus
data={data}
custom={customEmojiList}
perLine={7}
previewPosition="none"
skinTonePosition="search"
theme="dark"
onEmojiSelect={onEmojiSelect}
onClickOutside={onClickOutside}
maxFrequentRows={0}
/>
</div>
</>
);
}

View File

@ -1,6 +0,0 @@
.emoji {
width: 21px;
height: 21px;
display: inline-block;
margin-bottom: -5px;
}

View File

@ -1,33 +0,0 @@
import "./emoji.css";
import { useMemo } from "react";
export type EmojiProps = {
name: string;
url: string;
};
export function Emoji({ name, url }: EmojiProps) {
return <img alt={name} src={url} className="emoji" />;
}
export type EmojiTag = ["emoji", string, string];
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);
if (t) {
return <Emoji name={t[1]} url={t[2]} />;
} else {
return i;
}
});
}, [content, emoji]);
return <>{emojified}</>;
}

View File

@ -1,66 +0,0 @@
import { EventKind, EventPublisher } from "@snort/system";
import { useLogin } from "hooks/login";
import useFollows from "hooks/follows";
import AsyncButton from "element/async-button";
import { System } from "index";
export function LoggedInFollowButton({
loggedIn,
pubkey,
}: {
loggedIn: string;
pubkey: string;
}) {
const { contacts, relays } = useFollows(loggedIn, true);
const isFollowing = contacts.find((t) => t.at(1) === pubkey);
async function unfollow() {
const pub = await EventPublisher.nip7();
if (pub) {
const ev = await pub.generic((eb) => {
eb.kind(EventKind.ContactList).content(JSON.stringify(relays));
for (const c of contacts) {
if (c.at(1) !== pubkey) {
eb.tag(c);
}
}
return eb;
});
console.debug(ev);
System.BroadcastEvent(ev);
}
}
async function follow() {
const pub = await EventPublisher.nip7();
if (pub) {
const ev = await pub.generic((eb) => {
eb.kind(EventKind.ContactList).content(JSON.stringify(relays));
for (const tag of contacts) {
eb.tag(tag);
}
eb.tag(["p", pubkey]);
return eb;
});
console.debug(ev);
System.BroadcastEvent(ev);
}
}
return (
<AsyncButton
type="button"
className="btn btn-primary"
onClick={isFollowing ? unfollow : follow}
>
{isFollowing ? "Unfollow" : "Follow"}
</AsyncButton>
);
}
export function FollowButton({ pubkey }: { pubkey: string }) {
const login = useLogin();
return login?.pubkey ? (
<LoggedInFollowButton loggedIn={login.pubkey} pubkey={pubkey} />
) : null;
}

View File

@ -1,74 +0,0 @@
.goal {
font-size: 16px;
font-weight: 600;
}
.goal p {
margin: 0 0 12px 0;
}
.goal .amount {
font-size: 10px;
}
.goal .progress-container {
position: relative;
}
.progress-root {
position: relative;
overflow: hidden;
background: #222;
border-radius: 1000px;
height: 12px;
/* Fix overflow clipping in Safari */
/* https://gist.github.com/domske/b66047671c780a238b51c51ffde8d3a0 */
transform: translateZ(0);
}
.goal .progress-indicator {
background-color: #FF8D2B;
width: 100%;
height: 100%;
transition: transform 660ms cubic-bezier(0.65, 0, 0.35, 1);
}
.goal .progress-indicator .so-far {
position: absolute;
right: 0;
top: 0;
z-index: 10;
}
.goal .progress-root .target {
position: absolute;
right: 40px;
top: 0;
z-index: 10;
}
.goal .progress-container .zap-circle {
width: 40px;
height: 40px;
border-radius: 100%;
background: #222;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 0;
top: -15px;
}
.goal .progress-container.finished .zap-circle {
background: #FF8D2B;
}
.goal .goal-finished {
color: #FFFFFF;
}
.goal .goal-unfinished {
color: #FFFFFF33;
}

View File

@ -1,62 +0,0 @@
import "./goal.css";
import { useMemo } from "react";
import * as Progress from "@radix-ui/react-progress";
import Confetti from "react-confetti";
import { NostrLink, ParsedZap, NostrEvent } from "@snort/system";
import { Icon } from "./icon";
import { findTag } from "utils";
import { formatSats } from "number";
export function Goal({
link,
ev,
zaps,
}: {
link: NostrLink;
ev: NostrEvent;
zaps: ParsedZap[];
}) {
const goalAmount = useMemo(() => {
const amount = findTag(ev, "amount");
return amount ? Number(amount) / 1000 : null;
}, [ev]);
if (!goalAmount) {
return null;
}
const soFar = useMemo(() => {
return zaps
.filter((z) => z.receiver === ev.pubkey && z.event === ev.id)
.reduce((acc, z) => acc + z.amount, 0);
}, [zaps]);
const progress = (soFar / goalAmount) * 100;
const isFinished = progress >= 100;
return (
<div className="goal">
{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>
<span className="amount target">Goal: {formatSats(goalAmount)}</span>
</Progress.Root>
<div className="zap-circle">
<Icon
name="zap-filled"
className={isFinished ? "goal-finished" : "goal-unfinished"}
/>
</div>
</div>
{isFinished && <Confetti numberOfPieces={2100} recycle={false} />}
</div>
);
}

View File

@ -1,25 +0,0 @@
import { NostrLink } from "./nostr-link";
interface HyperTextProps {
link: string;
}
export function HyperText({ link }: HyperTextProps) {
try {
const url = new URL(link);
if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
return <NostrLink link={link} />;
} else {
<a href={link} target="_blank" rel="noreferrer">
{link}
</a>;
}
} catch {
// Ignore the error.
}
return (
<a href={link} target="_blank" rel="noreferrer">
{link}
</a>
);
}

View File

@ -1,24 +0,0 @@
import { MouseEventHandler } from "react";
type Props = {
name: string;
size?: number;
className?: string;
onClick?: MouseEventHandler<SVGSVGElement>;
};
export function Icon(props: Props) {
const size = props.size || 20;
const href = `/icons.svg#` + props.name;
return (
<svg
width={size}
height={size}
className={props.className}
onClick={props.onClick}
>
<use href={href} />
</svg>
);
}

View File

@ -1,386 +0,0 @@
.live-chat {
grid-area: chat;
display: flex;
flex-direction: column;
padding: 8px 16px;
border: none;
height: calc(100vh - 56px - 64px - 16px - 230px);
}
.live-chat ::-webkit-scrollbar {
width: 8px;
}
.live-chat ::-webkit-scrollbar-thumb {
background: #333;
border-radius: 100px;
min-height: 24px;
}
@media (min-width: 768px) {
.profile-info {
width: calc(100vw - 600px - 16px);
}
.live-chat {
width: calc(100vw - 600px - 16px);
height: calc(100vh - 56px - 64px - 16px);
}
.video-content video {
width: 100%;
}
}
@media (min-width: 1020px) {
.profile-info {
width: unset;
padding: 0;
}
.live-chat {
height: calc(100vh - 72px - 96px);
padding: 24px 16px 8px 24px;
border: 1px solid #171717;
border-radius: 24px;
}
.live-chat {
width: auto;
}
}
@media (min-width: 2000px) {
.live-chat {
height: calc(100vh - 72px - 96px - 120px - 56px);
}
}
.live-chat>.header {
display: flex;
justify-content: space-between
}
.live-chat .header .title {
font-size: 24px;
font-weight: 600;
line-height: normal;
margin: 0;
}
.live-chat .header .popout-link {
color: #FFFFFF80;
}
.live-chat>.messages {
display: flex;
gap: 12px;
flex-direction: column-reverse;
overflow-y: auto;
overflow-x: hidden;
padding-right: 8px;
}
@media (min-width: 1020px) {
.live-chat>.messages {
flex-grow: 1;
}
}
.live-chat>.write-message {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 8px;
border-top: 1px solid var(--border, #171717);
}
.live-chat>.write-message>div:nth-child(1) {
height: 32px;
flex-grow: 1;
}
.live-chat .write-message input {
background: unset;
border: unset;
color: inherit;
font: inherit;
flex-grow: 1;
}
.live-chat .message {
word-wrap: break-word;
position: relative;
}
.live-chat .message .profile {
gap: 8px;
font-weight: 600;
font-size: 15px;
float: left;
}
.live-chat .message .profile {
color: #34D2FE;
}
.live-chat .message.streamer .profile {
color: #F838D9;
}
.live-chat .message a {
color: #F838D9;
}
.live-chat .profile img {
width: 24px;
height: 24px;
}
.live-chat .message>span {
font-weight: 400;
font-size: 15px;
line-height: 24px;
margin-left: 8px;
}
.live-chat .messages {
color: white;
}
.live-chat .zap {
display: flex;
align-items: center;
gap: 8px;
}
.top-zappers {
display: flex;
flex-direction: column;
gap: 16px;
border-bottom: 1px solid var(--border, #171717);
padding-bottom: 18px;
}
.top-zappers h3 {
margin: 0;
font-size: 16px;
font-family: Outfit;
font-weight: 600;
}
.top-zappers-container {
display: flex;
overflow-y: scroll;
-ms-overflow-style: none;
scrollbar-width: none;
}
.top-zappers-container::-webkit-scrollbar {
display: none;
}
@media (min-width: 1020px) {
.top-zappers-container {
display: flex;
gap: 8px;
}
}
.top-zapper {
display: flex;
padding: 4px 8px 4px 4px;
align-items: center;
gap: 8px;
border-radius: 49px;
border: 1px solid var(--border, #171717);
}
.top-zapper .top-zapper-amount {
font-size: 15px;
font-family: Outfit;
font-weight: 700;
line-height: 22px;
margin: 0;
}
.top-zapper .top-zapper-name {
font-size: 14px;
margin: 0;
}
.zap-container {
position: relative;
border-radius: 12px;
border: 1px solid transparent;
background: #0A0A0A;
background-clip: padding-box;
padding: 8px 12px;
}
.zap-container:before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: -1;
margin: -1px;
background: linear-gradient(to bottom right, #FF902B, #F83838);
border-radius: inherit;
}
.zap-container .profile {
color: #FF8D2B;
}
.zap-container .zap-amount {
color: #FF8D2B;
}
.zap-container.big-zap:before {
background: linear-gradient(60deg, #2BD9FF, #8C8DED, #F838D9, #F83838, #FF902B, #DDF838);
animation: animatedgradient 3s ease alternate infinite;
background-size: 300% 300%;
}
@keyframes animatedgradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.zap-content {
margin-top: 8px;
}
.zap-pill {
border-radius: 100px;
background: rgba(255, 255, 255, 0.10);
width: fit-content;
display: flex;
height: 24px;
padding: 0px 4px;
align-items: center;
gap: 2px;
}
.zap-pill-icon {
width: 12px;
height: 12px;
color: #FF8D2B;
}
.message-zap-container {
display: flex;
padding: 8px;
justify-content: center;
align-items: flex-start;
gap: 12px;
border-radius: 12px;
border: 1px solid #303030;
background: #111;
box-shadow: 0px 7px 4px 0px rgba(0, 0, 0, 0.25);
margin-top: 4px;
width: fit-content;
z-index: 1;
transition: opacity .3s ease-out;
}
@media (min-width: 1020px) {
.message-zap-container {
flex-direction: column;
}
}
.message-zap-button {
border: none;
cursor: pointer;
height: 24px;
padding: 4px;
justify-content: center;
align-items: center;
gap: 2px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.05);
color: #FFFFFF66;
}
.message-zap-button:hover {
color: white;
}
.message-zap-button-icon {
width: 16px;
height: 16px;
}
.message-reactions {
display: flex;
align-items: flex-end;
gap: 4px;
margin-top: 4px;
}
.message-reaction-container {
display: flex;
width: 24px;
height: 24px;
padding: 0px 4px;
justify-content: center;
align-items: center;
gap: 2px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.10);
}
.message-reaction {
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
.zap-pill-amount {
text-transform: lowercase;
color: #FFF;
font-size: 12px;
font-family: Outfit;
font-style: normal;
font-weight: 500;
line-height: 18px;
}
.message-composer {
display: flex;
flex-direction: column;
}
.write-message-container {
display: flex;
align-items: center;
gap: 8px;
}
.write-message-container .paper {
flex: 1;
}
.write-emoji-button {
color: #FFFFFF80;
cursor: pointer;
}
.write-emoji-button:hover {
color: white;
}

View File

@ -1,198 +0,0 @@
import "./live-chat.css";
import {
EventKind,
NostrPrefix,
NostrLink,
ParsedZap,
NostrEvent,
parseZap,
encodeTLV,
} from "@snort/system";
import { useEffect, useMemo } from "react";
import { System } from "../index";
import { useLiveChatFeed } from "../hooks/live-chat";
import { Profile } from "./profile";
import { Icon } from "./icon";
import Spinner from "./spinner";
import { useLogin } from "../hooks/login";
import { formatSats } from "../number";
import useTopZappers from "../hooks/top-zappers";
import { LIVE_STREAM_CHAT } from "../const";
import { ChatMessage } from "./chat-message";
import { Goal } from "./goal";
import { NewGoalDialog } from "./new-goal";
import { WriteMessage } from "./write-message";
import { findTag, getHost } from "utils";
export interface LiveChatOptions {
canWrite?: boolean;
showHeader?: boolean;
}
function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
const zappers = useTopZappers(zaps).slice(0, 3);
return (
<>
{zappers.map(({ pubkey, total }, idx) => {
return (
<div className="top-zapper" key={pubkey}>
{pubkey === "anon" ? (
<p className="top-zapper-name">Anon</p>
) : (
<Profile pubkey={pubkey} options={{ showName: false }} />
)}
<Icon name="zap-filled" className="zap-icon" />
<p className="top-zapper-amount">{formatSats(total)}</p>
</div>
);
})}
</>
);
}
export function LiveChat({
link,
ev,
goal,
options,
height,
}: {
link: NostrLink;
ev?: NostrEvent;
goal?: NostrEvent;
options?: LiveChatOptions;
height?: number;
}) {
const feed = useLiveChatFeed(link);
const login = useLogin();
useEffect(() => {
const pubkeys = [
...new Set(feed.zaps.flatMap((a) => [a.pubkey, findTag(a, "p")!])),
];
System.ProfileLoader.TrackMetadata(pubkeys);
return () => System.ProfileLoader.UntrackMetadata(pubkeys);
}, [feed.zaps]);
const zaps = feed.zaps
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid);
const goalZaps = feed.zaps
.filter((ev) => (goal ? ev.created_at > goal.created_at : false))
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid);
const events = useMemo(() => {
return [...feed.messages, ...feed.zaps].sort(
(a, b) => b.created_at - a.created_at
);
}, [feed.messages, feed.zaps]);
const streamer = getHost(ev);
const naddr = useMemo(() => {
if (ev) {
return encodeTLV(
NostrPrefix.Address,
findTag(ev, "d") ?? "",
undefined,
ev.kind,
ev.pubkey
);
}
}, [ev]);
return (
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
{(options?.showHeader ?? true) && (
<div className="header">
<h2 className="title">Stream Chat</h2>
<a
href={`/chat/${naddr}`}
className="popout-link"
target="_blank"
rel="noopener noreferrer"
>
<Icon name="link" size={32} />
</a>
</div>
)}
{zaps.length > 0 && (
<div className="top-zappers">
<h3>Top zappers</h3>
<div className="top-zappers-container">
<TopZappers zaps={zaps} />
</div>
{goal ? (
<Goal link={link} ev={goal} zaps={goalZaps} />
) : (
login?.pubkey === streamer && <NewGoalDialog link={link} />
)}
</div>
)}
<div className="messages">
{events.map((a) => {
switch (a.kind) {
case LIVE_STREAM_CHAT: {
return (
<ChatMessage
streamer={streamer}
ev={a}
key={a.id}
reactions={feed.reactions}
/>
);
}
case EventKind.ZapReceipt: {
const zap = zaps.find(
(b) => b.id === a.id && b.receiver === streamer
);
if (zap) {
return <ChatZap zap={zap} key={a.id} />;
}
}
}
return null;
})}
{feed.messages.length === 0 && <Spinner />}
</div>
{(options?.canWrite ?? true) && (
<div className="write-message">
{login ? (
<WriteMessage link={link} />
) : (
<p>Please login to write messages!</p>
)}
</div>
)}
</div>
);
}
const BIG_ZAP_THRESHOLD = 100_000;
function ChatZap({ zap }: { zap: ParsedZap }) {
if (!zap.valid) {
return null;
}
const isBig = zap.amount >= BIG_ZAP_THRESHOLD;
return (
<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,
}}
/>
zapped
<span className="zap-amount">{formatSats(zap.amount)}</span>
sats
</div>
{zap.content && <div className="zap-content">{zap.content}</div>}
</div>
);
}

View File

@ -1,97 +0,0 @@
import Hls from "hls.js";
import { HTMLProps, useEffect, useMemo, useRef, useState } from "react";
import { WISH } from "wish";
export enum VideoStatus {
Online = "online",
Offline = "offline",
}
export interface VideoPlayerProps {
stream?: string, status?: string, poster?: string
}
export function LiveVideoPlayer(
props: VideoPlayerProps
) {
const video = useRef<HTMLVideoElement>(null);
const streamCached = useMemo(() => props.stream, [props.stream]);
const [status, setStatus] = useState<VideoStatus>();
const [src, setSrc] = useState<string>();
useEffect(() => {
if (
streamCached &&
video.current
) {
if (Hls.isSupported()) {
try {
const hls = new Hls();
hls.loadSource(streamCached);
hls.attachMedia(video.current);
hls.on(Hls.Events.ERROR, (event, data) => {
console.debug(event, data);
const errorType = data.type;
if (errorType === Hls.ErrorTypes.NETWORK_ERROR && data.fatal) {
hls.stopLoad();
hls.detachMedia();
setStatus(VideoStatus.Offline);
}
});
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setStatus(VideoStatus.Online);
});
return () => hls.destroy();
} catch (e) {
console.error(e);
setStatus(VideoStatus.Offline);
}
} else {
setSrc(streamCached);
setStatus(VideoStatus.Online);
video.current.muted = true;
video.current.load();
}
}
}, [video, streamCached, props.status]);
return (
<>
<div className={status}>
<div>{status}</div>
</div>
<video ref={video} autoPlay={true} poster={props.poster} src={src} playsInline={true} controls={status === VideoStatus.Online} />
</>
);
}
export function WebRTCPlayer(props: VideoPlayerProps) {
const video = useRef<HTMLVideoElement>(null);
const streamCached = useMemo(() => "https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play", [props.stream]);
const [status, setStatus] = useState<VideoStatus>();
//https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play
useEffect(() => {
if (video.current && streamCached) {
const client = new WISH();
client.addEventListener("log", console.debug);
client.WithEndpoint(streamCached, true)
client.Play().then(s => {
if (video.current) {
video.current.srcObject = s;
}
}).catch(console.error);
return () => { client.Disconnect().catch(console.error); }
}
}, [video, streamCached]);
return (
<>
<div className={status}>
<div>{status}</div>
</div>
<video ref={video} autoPlay={true} poster={props.poster} controls={status === VideoStatus.Online} />
</>
);
}

View File

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

View File

@ -1,23 +0,0 @@
.new-goal .h3 {
font-size: 24px;
margin: 0;
}
.new-goal .zap-goals {
display: flex;
align-items: center;
gap: 8px;
}
.new-goal .paper {
background: #262626;
height: 32px;
}
.new-goal .btn:disabled {
opacity: 0.3;
}
.new-goal .create-goal {
margin-top: 24px;
}

View File

@ -1,107 +0,0 @@
import "./new-goal.css";
import * as Dialog from "@radix-ui/react-dialog";
import AsyncButton from "./async-button";
import { NostrLink, EventPublisher } from "@snort/system";
import { unixNow } from "@snort/shared";
import { Icon } from "element/icon";
import { useEffect, useState } from "react";
import { eventLink } from "utils";
import { NostrProviderDialog } from "./nostr-provider-dialog";
import { System } from "index";
import { GOAL } from "const";
interface NewGoalDialogProps {
link: NostrLink;
}
export function NewGoalDialog({ link }: NewGoalDialogProps) {
const [open, setOpen] = useState(false);
const [goalAmount, setGoalAmount] = useState("");
const [goalName, setGoalName] = useState("");
async function publishGoal() {
const pub = await EventPublisher.nip7();
if (pub) {
const evNew = await pub.generic((eb) => {
eb.kind(GOAL)
.tag(["a", `${link.kind}:${link.author}:${link.id}`])
.tag(["amount", String(Number(goalAmount) * 1000)])
.content(goalName);
if (link.relays?.length) {
eb.tag(["relays", ...link.relays]);
}
return eb;
});
console.debug(evNew);
System.BroadcastEvent(evNew);
setOpen(false);
setGoalName("");
setGoalAmount("");
}
}
const isValid = goalName.length && Number(goalAmount) > 0;
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button type="button" className="btn btn-primary">
<span>
<Icon name="zap-filled" size={12} />
<span>Add stream goal</span>
</span>
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<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>
</div>
<div>
<p>Name</p>
<div className="paper">
<input
type="text"
value={goalName}
placeholder="e.g. New Laptop"
onChange={(e) => setGoalName(e.target.value)}
/>
</div>
</div>
<div>
<p>Amount</p>
<div className="paper">
<input
type="number"
placeholder="21"
min="1"
max="2100000000000000"
value={goalAmount}
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>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -1,48 +0,0 @@
.new-stream {
display: flex;
flex-direction: column;
gap: 24px;
}
.new-stream h3 {
font-size: 24px;
margin: 0;
}
.new-stream p {
margin: 0 0 8px 0;
}
.new-stream small {
display: block;
margin: 8px 0 0 0;
}
.new-stream .btn.wide {
padding: 12px 16px;
border-radius: 16px;
width: 100%;
}
.new-stream div.paper {
background: #262626;
padding: 12px 16px;
}
.new-stream .btn:disabled {
opacity: 0.3;
}
.new-stream .pill {
border-radius: 16px;
background: #262626;
padding: 8px 12px;
text-align: center;
text-transform: uppercase;
}
.new-stream .pill.active {
color: inherit;
background: #353535;
}

View File

@ -1,88 +0,0 @@
import "./new-stream.css";
import * as Dialog from "@radix-ui/react-dialog";
import { Icon } from "element/icon";
import { useStreamProvider } from "hooks/stream-provider";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
import { useEffect, useState } from "react";
import { StreamEditor, StreamEditorProps } from "./stream-editor";
import { useNavigate } from "react-router-dom";
import { eventLink } from "utils";
import { NostrProviderDialog } from "./nostr-provider-dialog";
function NewStream({ ev, onFinish }: StreamEditorProps) {
const providers = useStreamProvider();
const [currentProvider, setCurrentProvider] = useState<StreamProvider>();
const navigate = useNavigate();
useEffect(() => {
if (!currentProvider) {
setCurrentProvider(providers.at(0));
}
}, [providers, currentProvider]);
function providerDialog() {
if (!currentProvider) return;
switch (currentProvider.type) {
case StreamProviders.Manual: {
return <StreamEditor onFinish={ex => {
currentProvider.updateStreamInfo(ex);
if (!ev) {
navigate(eventLink(ex));
} else {
onFinish?.(ev);
}
}} ev={ev} />
}
case StreamProviders.NostrType: {
return <NostrProviderDialog provider={currentProvider} onFinish={onFinish} ev={ev} />
}
case StreamProviders.Owncast: {
return
}
}
}
return <>
<p>Stream Providers</p>
<div className="flex g12">
{providers.map(v => <span className={`pill${v === currentProvider ? " active" : ""}`} onClick={() => setCurrentProvider(v)}>{v.name}</span>)}
</div>
{providerDialog()}
</>
}
interface NewStreamDialogProps {
text?: string;
btnClassName?: string;
}
export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps) {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button type="button" className={props.btnClassName}>
{props.text && props.text}
{!props.text && (
<>
<span className="hide-on-mobile">Stream</span>
<Icon name="signal" />
</>
)}
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<div className="new-stream">
<NewStream {...props} onFinish={() => setOpen(false)} />
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -1,16 +0,0 @@
import { NostrPrefix, tryParseNostrLink } from "@snort/system";
import { Mention } from "./mention";
export function NostrLink({ link }: { link: string }) {
const nav = tryParseNostrLink(link);
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">
{link}
</a>;
}
}

View File

@ -1,89 +0,0 @@
import { NostrEvent } from "@snort/system";
import { StreamProvider, StreamProviderInfo } from "providers";
import { useEffect, useState } from "react";
import { SendZaps } from "./send-zap";
import { StreamEditor, StreamEditorProps } from "./stream-editor";
import Spinner from "./spinner";
import { LIVE_STREAM } from "const";
const DummyEvent = { content: "", id: "", pubkey: "", sig: "", kind: LIVE_STREAM, created_at: 0, tags: [] } as NostrEvent;
export function NostrProviderDialog({ provider, ...others }: { provider: StreamProvider } & StreamEditorProps) {
const [topup, setTopup] = useState(false);
const [info, setInfo] = useState<StreamProviderInfo>();
useEffect(() => {
provider.info().then(v => setInfo(v));
}, [provider]);
if (!info) {
return <Spinner />
}
if (topup) {
return <SendZaps lnurl={{
name: provider.name,
canZap: false,
maxCommentLength: 0,
getInvoice: async (amount) => {
const pr = await provider.topup(amount);
return { pr };
}
}} onFinish={() => {
provider.info().then(v => {
setInfo(v);
setTopup(false);
});
}} />
}
function calcEstimate() {
if (!info?.rate || !info?.unit || !info?.balance || !info.balance) return;
const raw = Math.max(0, info.balance / info.rate);
if (info.unit === "min" && raw > 60) {
return `${(raw / 60).toFixed(0)} hour`
}
return `${raw.toFixed(0)} ${info.unit}`
}
const streamEvent = others.ev ?? info.publishedEvent ?? DummyEvent;
return <>
<div>
<p>Stream Url</p>
<div className="paper">
<input type="text" value={info.ingressUrl} disabled />
</div>
</div>
<div>
<p>Stream Key</p>
<div className="flex g12">
<div className="paper f-grow">
<input type="password" value={info.ingressKey} disabled />
</div>
<button className="btn btn-primary" onClick={() => window.navigator.clipboard.writeText(info.ingressKey ?? "")}>
Copy
</button>
</div>
</div>
<div>
<p>Balance</p>
<div className="flex g12">
<div className="paper f-grow">
{info.balance?.toLocaleString()} sats
</div>
<button className="btn btn-primary" onClick={() => setTopup(true)}>
Topup
</button>
</div>
<small>About {calcEstimate()} @ {info.rate} sats/{info.unit}</small>
</div>
{streamEvent && <StreamEditor onFinish={(ex) => {
provider.updateStreamInfo(ex);
others.onFinish?.(ex);
}} ev={streamEvent} options={{
canSetStream: false,
canSetStatus: false
}} />}
</>
}

View File

@ -1,19 +0,0 @@
.profile {
display: flex;
align-items: center;
gap: 12px;
font-weight: 500;
font-size: 16px;
line-height: 20px;
}
.profile img {
width: 40px;
height: 40px;
border-radius: 100%;
background: #A7A7A7;
border: unset;
outline: unset;
object-fit: cover;
overflow: hidden;
}

View File

@ -1,85 +0,0 @@
import "./profile.css";
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { useUserProfile } from "@snort/system-react";
import { UserMetadata } from "@snort/system";
import { hexToBech32 } from "@snort/shared";
import { Icon } from "element/icon";
import { System } from "index";
import { useInView } from "react-intersection-observer";
export interface ProfileOptions {
showName?: boolean;
showAvatar?: boolean;
suffix?: string;
overrideName?: string;
}
export function getName(pk: string, user?: UserMetadata) {
const npub = hexToBech32("npub", pk);
const shortPubkey = npub.slice(0, 12);
if ((user?.name?.length ?? 0) > 0) {
return user?.name;
}
if ((user?.display_name?.length ?? 0) > 0) {
return user?.display_name;
}
return shortPubkey;
}
export function Profile({
pubkey,
icon,
avatarClassname,
options,
profile,
}: {
pubkey: string;
icon?: ReactNode;
avatarClassname?: string;
options?: ProfileOptions;
profile?: UserMetadata;
}) {
const { inView, ref } = useInView();
const pLoaded =
useUserProfile(System, inView && !profile ? pubkey : undefined) || profile;
const showAvatar = options?.showAvatar ?? true;
const showName = options?.showName ?? true;
const content = (
<>
{showAvatar &&
(pubkey === "anon" ? (
<Icon size={40} name="zap-filled" />
) : (
<img
alt={pLoaded?.name || pubkey}
className={avatarClassname ? avatarClassname : ""}
src={pLoaded?.picture ?? ""}
/>
))}
{icon}
{showName && (
<span>
{options?.overrideName ?? pubkey === "anon"
? "Anon"
: getName(pubkey, pLoaded)}
</span>
)}
</>
);
return pubkey === "anon" ? (
<div className="profile" ref={ref}>
{content}
</div>
) : (
<Link
to={`/p/${hexToBech32("npub", pubkey)}`}
className="profile"
ref={ref}
>
{content}
</Link>
);
}

View File

@ -1,55 +0,0 @@
import QRCodeStyling from "qr-code-styling";
import { useEffect, useRef } from "react";
export interface QrCodeProps {
data?: string;
link?: string;
avatar?: string;
height?: number;
width?: number;
className?: string;
}
export default function QrCode(props: QrCodeProps) {
const qrRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
const qr = new QRCodeStyling({
width: props.width || 256,
height: props.height || 256,
data: props.data,
margin: 5,
type: "canvas",
image: props.avatar,
dotsOptions: {
type: "rounded",
},
cornersSquareOptions: {
type: "extra-rounded",
},
imageOptions: {
crossOrigin: "anonymous",
},
});
qrRef.current.innerHTML = "";
qr.append(qrRef.current);
if (props.link) {
qrRef.current.onclick = function () {
const elm = document.createElement("a");
elm.href = props.link ?? "";
elm.click();
};
}
} else if (qrRef.current) {
qrRef.current.innerHTML = "";
}
}, [props.data, props.link, props.width, props.height, props.avatar]);
return (
<div
className={`qr${props.className ? ` ${props.className}` : ""}`}
ref={qrRef}
></div>
);
}

View File

@ -1,60 +0,0 @@
.send-zap {
display: flex;
gap: 24px;
flex-direction: column;
}
.send-zap h3 {
font-size: 24px;
font-weight: 500;
margin: 0;
}
.send-zap small {
display: block;
text-transform: uppercase;
color: #868686;
margin-bottom: 12px;
}
.send-zap .amounts {
display: grid;
grid-template-columns: repeat(4, 1fr);
justify-content: space-evenly;
gap: 8px;
}
.send-zap .pill {
border-radius: 16px;
background: #262626;
padding: 8px 12px;
text-align: center;
}
.send-zap .pill.active {
color: inherit;
background: #353535;
}
.send-zap div.paper {
background: #262626;
}
.send-zap p {
margin: 0 0 8px 0;
font-weight: 500;
}
.send-zap .btn {
width: 100%;
padding: 12px 16px;
}
.send-zap .btn>span {
justify-content: center;
}
.send-zap .qr {
align-self: center;
}

View File

@ -1,228 +0,0 @@
import "./send-zap.css";
import * as Dialog from "@radix-ui/react-dialog";
import { useEffect, useState, type ReactNode } from "react";
import { LNURL } from "@snort/shared";
import { NostrEvent, EventPublisher } from "@snort/system";
import { secp256k1 } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/curves/abstract/utils";
import { formatSats } from "../number";
import { Icon } from "./icon";
import AsyncButton from "./async-button";
import { Relays } from "index";
import QrCode from "./qr-code";
export interface LNURLLike {
get name(): string;
get maxCommentLength(): number;
get canZap(): boolean;
getInvoice(
amountInSats: number,
comment?: string,
zap?: NostrEvent
): Promise<{ pr?: string }>;
}
export interface SendZapsProps {
lnurl: string | LNURLLike;
pubkey?: string;
aTag?: string;
eTag?: string;
targetName?: string;
onFinish: () => void;
button?: ReactNode;
}
export function SendZaps({
lnurl,
pubkey,
aTag,
eTag,
targetName,
onFinish,
}: SendZapsProps) {
const UsdRate = 30_000;
const satsAmounts = [
100, 1_000, 5_000, 10_000, 50_000, 100_000, 500_000, 1_000_000,
];
const usdAmounts = [0.05, 0.5, 2, 5, 10, 50, 100, 200];
const [isFiat, setIsFiat] = useState(false);
const [svc, setSvc] = useState<LNURLLike>();
const [amount, setAmount] = useState(satsAmounts[0]);
const [comment, setComment] = useState("");
const [invoice, setInvoice] = useState("");
const name = targetName ?? svc?.name;
async function loadService(lnurl: string) {
const s = new LNURL(lnurl);
await s.load();
setSvc(s);
}
useEffect(() => {
if (!svc) {
if (typeof lnurl === "string") {
loadService(lnurl).catch(console.warn);
} else {
setSvc(lnurl);
}
}
}, [lnurl]);
async function send() {
if (!svc) return;
let pub = await EventPublisher.nip7();
let isAnon = false;
if (!pub) {
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;
}
);
}
const invoice = await svc.getInvoice(amountInSats, comment, zap);
if (!invoice.pr) return;
if (window.webln) {
await window.webln.enable();
try {
await window.webln.sendPayment(invoice.pr);
onFinish();
} catch (error) {
setInvoice(invoice.pr);
}
} else {
setInvoice(invoice.pr);
}
}
function input() {
if (invoice) return;
return (
<>
<div className="flex g12">
<span
className={`pill${isFiat ? "" : " active"}`}
onClick={() => {
setIsFiat(false);
setAmount(satsAmounts[0]);
}}
>
SATS
</span>
<span
className={`pill${isFiat ? " active" : ""}`}
onClick={() => {
setIsFiat(true);
setAmount(usdAmounts[0]);
}}
>
USD
</span>
</div>
<div>
<small>Zap amount in {isFiat ? "USD" : "sats"}</small>
<div className="amounts">
{(isFiat ? usdAmounts : satsAmounts).map((a) => (
<span
key={a}
className={`pill${a === amount ? " active" : ""}`}
onClick={() => setAmount(a)}
>
{isFiat ? `$${a.toLocaleString()}` : formatSats(a)}
</span>
))}
</div>
</div>
{svc && (svc.maxCommentLength > 0 || svc.canZap) && (
<div>
<small>Your comment for {name}</small>
<div className="paper">
<textarea
placeholder="Nice!"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
</div>
)}
<div>
<AsyncButton onClick={send} className="btn btn-primary">
Zap!
</AsyncButton>
</div>
</>
);
}
function payInvoice() {
if (!invoice) return;
const link = `lightning:${invoice}`;
return (
<>
<QrCode data={link} link={link} />
<button className="btn btn-primary wide" onClick={() => onFinish()}>
Back
</button>
</>
);
}
return (
<div className="send-zap">
<h3>
Zap {name}
<Icon name="zap" />
</h3>
{input()}
{payInvoice()}
</div>
);
}
export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
{props.button ? (
props.button
) : (
<button className="btn btn-primary zap">
<span className="hide-on-mobile">Zap</span>
<Icon name="zap" size={16} />
</button>
)}
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<SendZaps {...props} onFinish={() => setIsOpen(false)} />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -1,33 +0,0 @@
.spinner_V8m1 {
transform-origin: center;
animation: spinner_zKoa 2s linear infinite;
}
.spinner_V8m1 circle {
stroke-linecap: round;
animation: spinner_YpZS 1.5s ease-in-out infinite;
}
@keyframes spinner_zKoa {
100% {
transform: rotate(360deg);
}
}
@keyframes spinner_YpZS {
0% {
stroke-dasharray: 0 150;
stroke-dashoffset: 0;
}
47.5% {
stroke-dasharray: 42 150;
stroke-dashoffset: -16;
}
95%,
100% {
stroke-dasharray: 42 150;
stroke-dashoffset: -59;
}
}

View File

@ -1,17 +0,0 @@
import "./spinner.css";
export interface IconProps {
className?: string;
width?: number;
height?: number;
}
const Spinner = (props: IconProps) => (
<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>
</svg>
);
export default Spinner;

View File

@ -1,3 +0,0 @@
.pill.state {
text-transform: uppercase;
}

View File

@ -1,6 +0,0 @@
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>
}

View File

@ -1,20 +0,0 @@
.rti--container {
background-color: unset !important;
border: 0 !important;
border-radius: 0 !important;
padding: 0 !important;
box-shadow: unset !important;
}
.rti--tag {
color: black !important;
padding: 4px 10px !important;
border-radius: 12px !important;
display: unset !important;
}
.content-warning {
padding: 16px;
border-radius: 16px;
border: 1px solid #FF563F;
}

View File

@ -1,207 +0,0 @@
import "./stream-editor.css";
import { useEffect, useState, useCallback } from "react";
import { EventPublisher, NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import { TagsInput } from "react-tag-input-component";
import AsyncButton from "./async-button";
import { StreamState } from "../index";
import { findTag } from "../utils";
export interface StreamEditorProps {
ev?: NostrEvent;
onFinish?: (ev: NostrEvent) => void;
options?: {
canSetTitle?: boolean
canSetSummary?: boolean
canSetImage?: boolean
canSetStatus?: boolean
canSetStream?: boolean
canSetTags?: boolean
canSetContentWarning?: boolean
}
}
export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
const [image, setImage] = useState("");
const [stream, setStream] = useState("");
const [status, setStatus] = useState("");
const [start, setStart] = useState<string>();
const [tags, setTags] = useState<string[]>([]);
const [contentWarning, setContentWarning] = useState(false);
const [isValid, setIsValid] = useState(false);
useEffect(() => {
setTitle(findTag(ev, "title") ?? "");
setSummary(findTag(ev, "summary") ?? "");
setImage(findTag(ev, "image") ?? "");
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]) ?? []);
setContentWarning(findTag(ev, "content-warning") !== undefined);
}, [ev?.id]);
const validate = useCallback(() => {
if (title.length < 2) {
return false;
}
if (stream.length < 5 || !stream.match(/^https?:\/\/.*\.m3u8?$/i)) {
return false;
}
if (image.length > 0 && !image.match(/^https?:\/\//i)) {
return false;
}
return true;
}, [title, image, stream]);
useEffect(() => {
setIsValid(ev !== undefined || validate());
}, [validate, title, summary, image, stream]);
async function publishStream() {
const pub = await EventPublisher.nip7();
if (pub) {
const evNew = await pub.generic((eb) => {
const now = unixNow();
const dTag = findTag(ev, "d") ?? now.toString();
const starts = start ?? now.toString();
const ends = findTag(ev, "ends") ?? now.toString();
eb.kind(30311)
.tag(["d", dTag])
.tag(["title", title])
.tag(["summary", summary])
.tag(["image", image])
.tag(["streaming", stream])
.tag(["status", status])
.tag(["starts", starts]);
if (status === StreamState.Ended) {
eb.tag(["ends", ends]);
}
for (const tx of tags) {
eb.tag(["t", tx.trim()]);
}
if(contentWarning) {
eb.tag(["content-warning", "nsfw"])
}
return eb;
});
console.debug(evNew);
onFinish && onFinish(evNew);
}
}
function toDateTimeString(n: number) {
return new Date(n * 1000).toISOString().substring(0, -1);
}
function fromDateTimeString(s: string) {
return Math.floor(new Date(s).getTime() / 1000);
}
return (
<>
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
{(options?.canSetTitle ?? true) && <div>
<p>Title</p>
<div className="paper">
<input
type="text"
placeholder="What are we steaming today?"
value={title}
onChange={(e) => setTitle(e.target.value)} />
</div>
</div>}
{(options?.canSetSummary ?? true) && <div>
<p>Summary</p>
<div className="paper">
<input
type="text"
placeholder="A short description of the content"
value={summary}
onChange={(e) => setSummary(e.target.value)} />
</div>
</div>}
{(options?.canSetImage ?? true) && <div>
<p>Cover image</p>
<div className="paper">
<input
type="text"
placeholder="https://"
value={image}
onChange={(e) => setImage(e.target.value)} />
</div>
</div>}
{(options?.canSetStream ?? true) && <div>
<p>Stream Url</p>
<div className="paper">
<input
type="text"
placeholder="https://"
value={stream}
onChange={(e) => setStream(e.target.value)} />
</div>
<small>Stream type should be HLS</small>
</div>}
{(options?.canSetStatus ?? true) && <><div>
<p>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>
)
)}
</div>
</div>
{status === StreamState.Planned && (
<div>
<p>Start Time</p>
<div className="paper">
<input
type="datetime-local"
value={toDateTimeString(Number(start ?? "0"))}
onChange={(e) => setStart(fromDateTimeString(e.target.value).toString())} />
</div>
</div>
)}</>}
{(options?.canSetTags ?? true) && <div>
<p>Tags</p>
<div className="paper">
<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)} />
</div>
<div>
<div className="warning">NSFW Content</div>
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>
</div>
</>
);
}

View File

@ -1,26 +0,0 @@
import { useEffect, useState } from "react";
import { NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import { findTag } from "../utils";
export function StreamTimer({ ev }: { ev?: NostrEvent }) {
const [time, setTime] = useState("");
function updateTime() {
const starts = Number(findTag(ev, "starts") ?? unixNow());
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")}`);
}
useEffect(() => {
updateTime();
const t = setInterval(() => {
updateTime();
}, 1000);
return () => clearInterval(t);
}, []);
return time
}

View File

@ -1,35 +0,0 @@
import type { ReactNode } from "react";
import moment from "moment";
import { NostrEvent } from "@snort/system";
import { StreamState } from "index";
import { findTag } from "utils";
export function Tags({
children,
ev,
}: {
children?: ReactNode;
ev: NostrEvent;
}) {
const status = findTag(ev, "status");
const start = findTag(ev, "starts");
return (
<div className="tags">
{children}
{status === StreamState.Planned && (
<span className="pill">
{status === StreamState.Planned ? "Starts " : ""}
{moment(Number(start) * 1000).fromNow()}
</span>
)}
{ev.tags
.filter((a) => a[0] === "t")
.map((a) => a[1])
.map((a) => (
<span className="pill" key={a}>
{a}
</span>
))}
</div>
);
}

View File

@ -1,5 +0,0 @@
.custom-emoji {
width: 21px;
height: 21px;
display: inline-block;
}

View File

@ -1,84 +0,0 @@
import { useMemo, type ReactNode } from "react";
import { validateNostrLink } from "@snort/system";
import { splitByUrl } from "utils";
import { Emoji } from "./emoji";
import { HyperText } from "./hypertext";
type Fragment = string | ReactNode;
function transformText(fragments: Fragment[], tags: string[][]) {
return extractLinks(extractEmoji(fragments, tags));
}
function extractEmoji(fragments: Fragment[], tags: string[][]) {
return fragments
.map((f) => {
if (typeof f === "string") {
return f.split(/:([\w-]+):/g).map((i) => {
const t = tags.find((a) => a[0] === "emoji" && a[1] === i);
if (t) {
return <Emoji name={t[1]} url={t[2]} />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractLinks(fragments: Fragment[]) {
return fragments
.map((f) => {
if (typeof f === "string") {
return splitByUrl(f).map((a) => {
const validateLink = () => {
const normalizedStr = a.toLowerCase();
if (
normalizedStr.startsWith("web+nostr:") ||
normalizedStr.startsWith("nostr:")
) {
return validateNostrLink(normalizedStr);
}
return (
normalizedStr.startsWith("http:") ||
normalizedStr.startsWith("https:") ||
normalizedStr.startsWith("magnet:")
);
};
if (validateLink()) {
if (!a.startsWith("nostr:")) {
return (
<a
href={a}
onClick={(e) => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext"
>
{a}
</a>
);
}
return <HyperText link={a} />;
}
return a;
});
}
return f;
})
.flat();
}
export function Text({ content, tags }: { content: string; tags: string[][] }) {
// todo: RTL langugage support
const element = useMemo(() => {
return <span>{transformText([content], tags)}</span>;
}, [content, tags]);
return <>{element}</>;
}

View File

@ -1,36 +0,0 @@
.rta__textarea {
resize: none;
}
.rta__list {
border: none;
}
.rta__item:not(:last-child) {
border: none;
}
.rta__entity--selected .emoji-item {
text-decoration: none;
background: #F838D9;
}
.emoji-item, .user-item {
color: white;
background: #171717;
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
font-size: 16px;
padding: 10px;
}
.emoji-item:hover, .user-item:hover {
color: #171717;
background: white;
}
.user-image {
width: 21px;
height: 21px;
border-radius: 100%;
}

View File

@ -1,92 +0,0 @@
import "./textarea.css";
import type { KeyboardEvent, ChangeEvent } from "react";
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
import "@webscopeio/react-textarea-autocomplete/style.css";
import uniqWith from "lodash/uniqWith";
import isEqual from "lodash/isEqual";
import { MetadataCache, NostrPrefix } from "@snort/system";
import { System } from "index";
import { Emoji, type EmojiTag } from "./emoji";
import { Avatar } from "element/avatar";
import { hexToBech32 } from "utils";
interface EmojiItemProps {
name: string;
url: string;
}
const EmojiItem = ({ entity: { name, url } }: { entity: EmojiItemProps }) => {
return (
<div className="emoji-item">
<div className="emoji-image">
<Emoji name={name} url={url} />
</div>
<div className="emoji-name">{name}</div>
</div>
);
};
const UserItem = (metadata: MetadataCache) => {
const { pubkey, display_name, nip05, ...rest } = metadata;
return (
<div key={pubkey} className="user-item">
<Avatar avatarClassname="user-image" user={metadata} />
<div className="user-details">{display_name || rest.name}</div>
</div>
);
};
interface TextareaProps {
emojis: EmojiTag[];
value: string;
onChange: (e: ChangeEvent<Element>) => void;
onKeyDown: (e: KeyboardEvent<Element>) => void;
}
export function Textarea({ emojis, ...props }: TextareaProps) {
const userDataProvider = async (token: string) => {
// @ts-expect-error: Property 'search'
return System.ProfileLoader.Cache.search(token);
};
const emojiDataProvider = async (token: string) => {
const results = emojis
.map((t) => {
return {
name: t.at(1) || "",
url: t.at(2) || "",
};
})
.filter(({ name }) => name.toLowerCase().includes(token.toLowerCase()));
return uniqWith(results, isEqual).slice(0, 5);
};
const trigger = {
":": {
dataProvider: emojiDataProvider,
component: EmojiItem,
output: (item: EmojiItemProps) => `:${item.name}:`,
},
"@": {
afterWhitespace: true,
dataProvider: userDataProvider,
component: (props: { entity: MetadataCache }) => (
<UserItem {...props.entity} />
),
output: (item: { pubkey: string }) =>
`@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`,
},
};
return (
<ReactTextareaAutocomplete
dir="auto"
loadingComponent={() => <span>Loading...</span>}
placeholder="Message"
autoFocus={false}
// @ts-expect-error
trigger={trigger}
{...props}
/>
);
}

View File

@ -1,35 +0,0 @@
.video-tile {}
.video-tile>div:nth-child(1) {
border-radius: 16px;
width: 100%;
aspect-ratio: 16 / 10;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.video-tile h3 {
font-size: 20px;
line-height: 25px;
margin: 16px 0;
}
.video-tile .pill-box {
float: right;
margin: 16px 20px;
text-transform: uppercase;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
height: calc(100% - 32px);
}
.video-tile .pill-box .pill {
width: fit-content;
}
.video-tile .pill-box .pill.viewers {
text-transform: lowercase;
}

View File

@ -1,52 +0,0 @@
import { Link } from "react-router-dom";
import { Profile } from "./profile";
import "./video-tile.css";
import { NostrEvent, encodeTLV, NostrPrefix } from "@snort/system";
import { useInView } from "react-intersection-observer";
import { StatePill } from "./state-pill";
import { StreamState } from "index";
import { findTag, getHost } from "utils";
import { formatSats } from "number";
import ZapStream from "../../public/zap-stream.svg";
export function VideoTile({
ev,
showAuthor = true,
showStatus = true,
}: {
ev: NostrEvent;
showAuthor?: boolean;
showStatus?: boolean;
}) {
const { inView, ref } = useInView({ triggerOnce: true });
const id = findTag(ev, "d") ?? "";
const title = findTag(ev, "title");
const image = findTag(ev, "image");
const status = findTag(ev, "status");
const viewers = findTag(ev, "current_participants");
const host = getHost(ev);
const link = encodeTLV(
NostrPrefix.Address,
id,
undefined,
ev.kind,
ev.pubkey
);
return (
<Link to={`/${link}`} className="video-tile" ref={ref}>
<div
style={{
backgroundImage: `url(${inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : ""})`,
}}
>
<span className="pill-box">
{showStatus && <StatePill state={status as StreamState} />}
{viewers && <span className="pill viewers">{formatSats(Number(viewers))} viewers</span>}
</span>
</div>
<h3>{title}</h3>
{showAuthor && <div>{inView && <Profile pubkey={host} />}</div>}
</Link>
);
}

View File

@ -1,125 +0,0 @@
import {NostrLink, EventPublisher, EventKind} from "@snort/system";
import { useRef, useState, ChangeEvent } from "react";
import { LIVE_STREAM_CHAT } from "../const";
import useEmoji from "../hooks/emoji";
import { useLogin } from "../hooks/login";
import { System } from "../index";
import AsyncButton from "./async-button";
import { Icon } from "./icon";
import { Textarea } from "./textarea";
import { EmojiPicker } from "./emoji-picker";
interface Emoji {
id: string;
native?: string;
}
export function WriteMessage({ link }: { link: NostrLink }) {
const ref = useRef(null);
const emojiRef = useRef(null);
const [chat, setChat] = useState("");
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const login = useLogin();
const userEmojiPacks = useEmoji(login!.pubkey);
const userEmojis = userEmojiPacks.map((pack) => pack.emojis).flat();
const channelEmojiPacks = useEmoji(link.author!);
const channelEmojis = channelEmojiPacks.map((pack) => pack.emojis).flat();
const emojis = userEmojis.concat(channelEmojis);
const names = emojis.map((t) => t.at(1));
const allEmojiPacks = userEmojiPacks.concat(channelEmojiPacks);
// @ts-expect-error
const topOffset = ref.current?.getBoundingClientRect().top;
// @ts-expect-error
const leftOffset = ref.current?.getBoundingClientRect().left;
async function sendChatMessage() {
const pub = await EventPublisher.nip7();
if (chat.length > 1) {
let emojiNames = new Set();
for (const name of names) {
if (chat.includes(`:${name}:`)) {
emojiNames.add(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"])
.processContent();
for (const e of emoji) {
if (e) {
eb.tag(e);
}
}
return eb;
});
if (reply) {
console.debug(reply);
System.BroadcastEvent(reply);
}
setChat("");
}
}
function onEmojiSelect(emoji: Emoji) {
if (emoji.native) {
setChat(`${chat}${emoji.native}`);
} else {
setChat(`${chat}:${emoji.id}:`);
}
setShowEmojiPicker(false);
}
async function onKeyDown(e: React.KeyboardEvent) {
if (e.code === "Enter") {
e.preventDefault();
await sendChatMessage();
}
}
async function onChange(e: ChangeEvent) {
// @ts-expect-error
setChat(e.target.value);
}
function pickEmoji(ev: any) {
ev.stopPropagation();
setShowEmojiPicker(!showEmojiPicker);
}
return (
<>
<div className="paper" ref={ref}>
<Textarea
emojis={emojis}
value={chat}
onKeyDown={onKeyDown}
onChange={onChange}
/>
<div onClick={pickEmoji}>
<Icon name="face" className="write-emoji-button" />
</div>
{showEmojiPicker && (
<EmojiPicker
topOffset={topOffset}
leftOffset={leftOffset}
emojiPacks={allEmojiPacks}
onEmojiSelect={onEmojiSelect}
onClickOutside={() => setShowEmojiPicker(false)}
ref={emojiRef}
/>
)}
</div>
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
Send
</AsyncButton>
</>
);
}

View File

@ -1,91 +0,0 @@
import {
RequestBuilder,
EventKind,
ReplaceableNoteStore,
NoteCollection,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
import { useMemo } from "react";
import { findTag } from "utils";
import type { EmojiTag } from "../element/emoji";
export interface EmojiPack {
address: string;
name: string;
author: string;
emojis: EmojiTag[];
}
export default function useEmoji(pubkey: string) {
const sub = useMemo(() => {
const rb = new RequestBuilder(`emoji:${pubkey}`);
rb.withFilter()
.authors([pubkey])
.kinds([10030 as EventKind]);
return rb;
}, [pubkey]);
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub
);
const related = useMemo(() => {
if (userEmoji) {
return userEmoji.tags.filter(
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`30030:`)
);
}
return [];
}, [userEmoji]);
const subRelated = useMemo(() => {
const splitted = related.map((t) => t.at(1)!.split(":"));
const authors = splitted
.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);
const rb = new RequestBuilder(`emoji-related:${pubkey}`);
rb.withFilter()
.kinds([30030 as EventKind])
.authors(authors)
.tag("d", identifiers);
return rb;
}, [pubkey, related]);
const { data: relatedData } =
useRequestBuilder<NoteCollection>(
System,
NoteCollection,
subRelated
);
const emojiPacks = useMemo(() => {
return relatedData ?? [];
}, [relatedData]);
const emojis = useMemo(() => {
return emojiPacks.map((ev) => {
const d = findTag(ev, "d");
return {
address: `${ev.kind}:${ev.pubkey}:${d}`,
name: d,
author: ev.pubkey,
emojis: ev.tags.filter((t) => t.at(0) === "emoji") as EmojiTag[],
} as EmojiPack;
});
}, [userEmoji, emojiPacks]);
return emojis;
}

View File

@ -1,43 +0,0 @@
import { useMemo } from "react";
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)}`);
b.withOptions({
leaveOpen,
});
if (link.type === NostrPrefix.Address) {
const f = b.withFilter().tag("d", [link.id]);
if (link.author) {
f.authors([link.author]);
}
if (link.kind) {
f.kinds([link.kind]);
}
} else {
const f = b.withFilter().ids([link.id]);
if (link.relays) {
link.relays.slice(0, 2).forEach((r) => f.relay(r));
}
if (link.author) {
f.authors([link.author]);
}
}
return b;
}, [link, leaveOpen]);
return useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub
);
}

View File

@ -1,28 +0,0 @@
import { useMemo } from "react";
import { EventKind, ReplaceableNoteStore, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
export default function useFollows(pubkey: string, leaveOpen = false) {
const sub = useMemo(() => {
const b = new RequestBuilder(`follows:${pubkey.slice(0, 12)}`);
b.withOptions({
leaveOpen,
})
.withFilter()
.authors([pubkey])
.kinds([EventKind.ContactList]);
return b;
}, [pubkey, leaveOpen]);
const { data } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub
);
const contacts = (data?.tags ?? []).filter((t) => t.at(0) === "p");
const relays = JSON.parse(data?.content ?? "{}");
return { contacts, relays };
}

View File

@ -1,32 +0,0 @@
import { useMemo } from "react";
import {
RequestBuilder,
ReplaceableNoteStore,
NostrEvent,
EventKind,
NostrLink,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { GOAL } from "const";
import { System } from "index";
import { findTag } from "utils";
export function useZapGoal(host: string, link: NostrLink, leaveOpen = false) {
const sub = useMemo(() => {
const b = new RequestBuilder(`goals:${host.slice(0, 12)}`);
b.withOptions({ leaveOpen });
b.withFilter()
.kinds([GOAL])
.authors([host])
.tag("a", [`${link.kind}:${link.author!}:${link.id}`]);
return b;
}, [link, leaveOpen]);
const { data } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub
);
return data;
}

View File

@ -1,63 +0,0 @@
import {
NostrLink,
RequestBuilder,
EventKind,
FlatNoteStore,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
import { useMemo } from "react";
import { LIVE_STREAM_CHAT } from "const";
export function useLiveChatFeed(link: NostrLink) {
const sub = useMemo(() => {
const rb = new RequestBuilder(`live:${link.id}:${link.author}`);
rb.withOptions({
leaveOpen: true,
});
const aTag = `${link.kind}:${link.author}:${link.id}`;
rb.withFilter()
.kinds([LIVE_STREAM_CHAT])
.tag("a", [aTag])
.limit(100);
rb.withFilter()
.kinds([EventKind.ZapReceipt])
.tag("a", [aTag]);
return rb;
}, [link]);
const feed = useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
const messages = useMemo(() => {
return (feed.data ?? []).filter((ev) => ev.kind === LIVE_STREAM_CHAT);
}, [feed.data]);
const zaps = useMemo(() => {
return (feed.data ?? []).filter((ev) => ev.kind === EventKind.ZapReceipt);
}, [feed.data]);
const etags = useMemo(() => {
return messages.map((e) => e.id);
}, [messages]);
const esub = useMemo(() => {
if (etags.length === 0) return null;
const rb = new RequestBuilder(`reactions:${link.id}:${link.author}`);
rb.withOptions({
leaveOpen: true,
});
rb.withFilter()
.kinds([EventKind.Reaction, EventKind.ZapReceipt])
.tag("e", etags);
return rb;
}, [etags]);
const reactionsSub = useRequestBuilder<FlatNoteStore>(
System,
FlatNoteStore,
esub
);
const reactions = reactionsSub.data ?? [];
return { messages, zaps, reactions };
}

View File

@ -1,9 +0,0 @@
import { Login } from "index";
import { useSyncExternalStore } from "react";
export function useLogin() {
return useSyncExternalStore(
(c) => Login.hook(c),
() => Login.snapshot()
);
}

View File

@ -1,75 +0,0 @@
import { useMemo } from "react";
import {
RequestBuilder,
FlatNoteStore,
NoteCollection,
NostrLink,
EventKind,
parseZap,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { LIVE_STREAM } from "const";
import { findTag } from "utils";
import { System } from "index";
export function useProfile(link: NostrLink, leaveOpen = false) {
const sub = useMemo(() => {
const b = new RequestBuilder(`profile:${link.id.slice(0, 12)}`);
b.withOptions({
leaveOpen,
})
.withFilter()
.kinds([LIVE_STREAM])
.authors([link.id]);
b.withFilter().kinds([LIVE_STREAM]).tag("p", [link.id]);
return b;
}, [link, leaveOpen]);
const { data: streamsData } =
useRequestBuilder<NoteCollection>(
System,
NoteCollection,
sub
);
const streams = streamsData ?? [];
const addresses = useMemo(() => {
if (streamsData) {
return streamsData.map((e) => `${e.kind}:${e.pubkey}:${findTag(e, "d")}`);
}
return [];
}, [streamsData]);
const zapsSub = useMemo(() => {
const b = new RequestBuilder(`profile-zaps:${link.id.slice(0, 12)}`);
b.withOptions({
leaveOpen,
})
.withFilter()
.kinds([EventKind.ZapReceipt])
.tag("a", addresses);
return b;
}, [link, addresses, leaveOpen]);
const { data: zapsData } = useRequestBuilder<FlatNoteStore>(
System,
FlatNoteStore,
zapsSub
);
const zaps = (zapsData ?? [])
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid && z.receiver === link.id);
const sortedStreams = useMemo(() => {
const sorted = [...streams];
sorted.sort((a, b) => b.created_at - a.created_at);
return sorted;
}, [streams]);
return {
streams: sortedStreams,
zaps,
};
}

View File

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

View File

@ -1,25 +0,0 @@
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);
}
export default function useTopZappers(zaps: ParsedZap[]) {
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) => {
return { pubkey, total: totalZapped(pubkey, zaps) };
});
result.sort((a, b) => b.total - a.total);
return result;
}, [zaps, zappers]);
return sorted;
}

View File

@ -1,167 +0,0 @@
body {
margin: 0;
font-family: 'Outfit', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #0A0A0A;
color: white;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
a {
color: unset;
text-decoration: unset;
}
:focus-visible {
outline: none;
}
.flex {
display: flex;
}
.f-grow {
flex-grow: 1;
}
.f-col {
flex-direction: column;
}
.pill {
background: #171717;
padding: 4px 8px;
border-radius: 9px;
font-weight: 700;
font-size: 14px;
line-height: 18px;
cursor: pointer;
user-select: none;
}
.pill.live {
background: #F838D9;
color: white;
}
.g24 {
gap: 24px;
}
.g12 {
gap: 12px;
}
.btn {
border: none;
outline: none;
cursor: pointer;
font-weight: 700;
font-size: 16px;
line-height: 20px;
padding: 8px 16px;
border-radius: 16px;
}
.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;
transition: 0.3s;
}
.btn-border:hover {
background: linear-gradient(black, black) padding-box,
linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
}
.btn-primary {
background: #FFF;
color: #0a0a0a;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-warning {
background: #FF563F;
color: white;
}
.btn>span {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
input[type="text"],
textarea,
input[type="datetime-local"],
input[type="password"],
input[type="number"] {
font-family: inherit;
border: unset;
background-color: unset;
color: inherit;
width: 100%;
font-size: 16px;
font-weight: 500;
}
input[type="checkbox"] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 4px;
border: 2px solid #333;
background-color: transparent;
}
input[type="checkbox"]:after {
content: ' ';
position: relative;
left: 40%;
top: 20%;
width: 15%;
height: 40%;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(50deg);
display: none;
}
input[type="checkbox"]:checked:after {
display: block;
}
div.paper {
background: #171717;
border-radius: 16px;
padding: 8px 16px;
display: flex;
gap: 10px;
align-items: center;
}
.scroll-lock {
overflow: hidden;
height: 100vh;
}
.warning {
color: #FF563F;
}
.border-warning {
border: 1px solid #FF563F;
}

View File

@ -1,76 +0,0 @@
import "./index.css";
import React from "react";
import ReactDOM from "react-dom/client";
import { NostrSystem } from "@snort/system";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { RootPage } from "./pages/root";
import { LayoutPage } from "pages/layout";
import { ProfilePage } from "pages/profile-page";
import { StreamPage } from "pages/stream-page";
import { ChatPopout } from "pages/chat-popout";
import { LoginStore } from "login";
import { StreamProvidersPage } from "pages/providers";
export enum StreamState {
Live = "live",
Ended = "ended",
Planned = "planned",
}
export const System = new NostrSystem({});
export const Login = new LoginStore();
export const Relays = [
"wss://relay.snort.social",
"wss://nos.lol",
"wss://relay.damus.io",
"wss://nostr.wine",
];
Relays.forEach((r) => System.ConnectToRelay(r, { read: true, write: true }));
const router = createBrowserRouter([
{
element: <LayoutPage />,
loader: async () => {
await System.Init();
return null;
},
children: [
{
path: "/",
element: <RootPage />,
},
{
path: "/p/:npub",
element: <ProfilePage />,
},
{
path: "/nsfw",
element: <RootPage nsfw={true} />
},
{
path: "/:id",
element: <StreamPage />,
},
{
path: "/providers/:id?",
element: <StreamProvidersPage />,
},
],
},
{
path: "/chat/:id",
element: <ChatPopout />,
},
]);
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLDivElement
);
root.render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);

View File

@ -1,31 +0,0 @@
import { ExternalStore } from "@snort/shared";
export interface LoginSession {
pubkey: string;
follows: string[];
}
export class LoginStore extends ExternalStore<LoginSession | undefined> {
#session?: LoginSession;
constructor() {
super();
const json = window.localStorage.getItem("session");
if (json) {
this.#session = JSON.parse(json);
}
}
loginWithPubkey(pk: string) {
this.#session = {
pubkey: pk,
follows: [],
};
window.localStorage.setItem("session", JSON.stringify(this.#session));
this.notifyChange();
}
takeSnapshot() {
return this.#session ? { ...this.#session } : undefined;
}
}

View File

@ -1,20 +0,0 @@
const intlSats = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
export function formatShort(fmt: Intl.NumberFormat, n: number) {
if (n < 2e3) {
return n;
} else if (n < 1e6) {
return `${fmt.format(n / 1e3)}K`;
} else if (n < 1e9) {
return `${fmt.format(n / 1e6)}M`;
} else {
return `${fmt.format(n / 1e9)}G`;
}
}
export function formatSats(n: number) {
return formatShort(intlSats, n);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

View File

@ -1,22 +0,0 @@
.popout-chat {
display: grid;
grid-template-areas:
"main-content";
grid-template-rows: 1fr;
grid-template-columns: 1fr;
height: 100vh;
gap: 0;
}
.popout-chat .live-chat {
padding: 8px 16px;
width: 100vw;
height: calc(100vh - 32px);
margin-left: 0;
border: unset;
border-radius: unset;
}
.popout-chat .live-chat .messages {
overflow: hidden;
}

View File

@ -1,24 +0,0 @@
import "./chat-popout.css";
import { LiveChat } from "element/live-chat";
import { useParams } from "react-router-dom";
import { parseNostrLink } from "@snort/system";
import useEventFeed from "../hooks/event-feed";
export function ChatPopout() {
const params = useParams();
const link = parseNostrLink(params.id!);
const { data: ev } = useEventFeed(link, true);
return (
<div className="popout-chat">
<LiveChat
ev={ev}
link={link}
options={{
canWrite: false,
showHeader: false,
}}
/>
</div>
);
}

View File

@ -1,245 +0,0 @@
.page {
display: grid;
gap: 0;
grid-template-areas:
"header"
"main-content"
"profile"
"chat";
grid-template-rows: 64px 230px 56px 1fr;
grid-template-columns: 1fr;
height: 100vh;
}
.page.only-content {
display: grid;
height: 100vh;
grid-template-areas:
"header"
"main-content";
grid-template-rows: 64px 1fr;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.page {
display: grid;
height: 100vh;
grid-template-areas:
"header header"
"main-content profile"
"main-content chat";
grid-template-rows: 64px min-content;
grid-template-columns: 600px 1fr;
gap: 0;
}
.video-content video {
height: 100%;
}
}
@media (min-width: 1020px) {
.page {
display: grid;
height: calc(100vh - 72px);
padding: 0 40px;
grid-template-columns: auto 376px;
grid-template-rows: unset;
grid-template-areas:
"header header"
"main-content chat"
"profile chat";
gap: 0;
}
}
@media (min-width: 2000px) {
.page {
padding: 0 40px;
grid-template-columns: auto 450px;
}
.video-content {
max-height: calc(100vh - 320px);
}
.video-content video {
height: 100%;
}
}
header {
grid-area: header;
align-items: center;
display: grid;
grid-template-columns: min-content min-content min-content auto;
padding: 8px 16px;
gap: 8px;
white-space: nowrap;
}
@media (min-width: 1020px) {
header {
gap: 24px;
padding: 24px 0 32px 0;
}
.page.only-content {
grid-template-rows: 88px 1fr;
}
}
header .logo {
background: url("public/logo.png") no-repeat #171717;
background-size: cover;
border-radius: 16px;
width: 48px;
height: 48px;
cursor: pointer;
}
header .btn-header {
height: 32px;
border-bottom: 2px solid transparent;
user-select: none;
cursor: pointer;
font-weight: 700;
font-size: 16px;
line-height: 20px;
padding: 8px 16px;
display: flex;
align-items: center;
}
header .btn-header.active {
border-bottom: 2px solid;
}
header .btn-header:hover {
border-bottom: 2px solid;
}
header .paper {
min-width: 300px;
height: 32px;
}
header .header-right {
justify-self: end;
display: flex;
gap: 24px;
}
header input[type="text"]:active {
border: unset;
}
header button {
height: 48px;
display: flex;
align-items: center;
gap: 8px;
}
header .profile img {
width: 48px;
height: 48px;
}
@media (max-width: 1020px) {
header .header-right {
gap: 8px;
}
header .paper {
min-width: unset;
}
header .paper .search-input {
display: none;
}
header .new-stream-button-text {
display: none;
}
}
button span.hide-on-mobile {
display: none;
}
@media (min-width: 1020px) {
button span.hide-on-mobile {
display: block;
}
}
.dialog-overlay {
background-color: rgba(0, 0, 0, 0.8);
position: fixed;
inset: 0;
z-index: 1;
}
.dialog-content {
z-index: 2;
background-color: #171717;
border-radius: 6px;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-width: 450px;
max-height: 85vh;
padding: 25px;
overflow-y: auto;
}
.zap-icon {
color: #FF8D2B;
}
.tags {
display: flex;
gap: 8px;
}
.fullscreen-exclusive {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
z-index: 999;
background: #0A0A0A;
}
.age-check {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
}
.age-check::after {
content: " ";
background: url("public/zap-stream.svg") no-repeat;
background-position: center;
background-size: contain;
position: absolute;
top: 20px;
left: 20px;
width: calc(100vw - 40px);
height: calc(100vh - 40px);
z-index: -1;
opacity: 0.02;
}
.age-check .btn {
padding: 12px 16px;
}

View File

@ -1,112 +0,0 @@
import { Icon } from "element/icon";
import "./layout.css";
import {
EventPublisher,
} from "@snort/system";
import { Outlet, useNavigate, useLocation, Link } from "react-router-dom";
import AsyncButton from "element/async-button";
import { Login } from "index";
import { useLogin } from "hooks/login";
import { Profile } from "element/profile";
import { NewStreamDialog } from "element/new-stream";
import { useState } from "react";
export function LayoutPage() {
const navigate = useNavigate();
const login = useLogin();
const location = useLocation();
async function doLogin() {
const pub = await EventPublisher.nip7();
if (pub) {
Login.loginWithPubkey(pub.pubKey);
}
}
function loggedIn() {
if (!login) return;
return (
<>
<NewStreamDialog btnClassName="btn btn-primary" />
<Profile
avatarClassname="mb-squared"
pubkey={login.pubkey}
options={{
showName: false,
}}
/>
</>
);
}
function loggedOut() {
if (login) return;
return (
<>
<AsyncButton type="button" className="btn btn-border" onClick={doLogin}>
Login
<Icon name="login" />
</AsyncButton>
</>
);
}
const isNsfw = window.location.pathname === "/nsfw";
return (
<div
className={
location.pathname === "/" || location.pathname.startsWith("/p/") || location.pathname.startsWith("/providers") || location.pathname === "/nsfw"
? "page only-content"
: location.pathname.startsWith("/chat/")
? "page chat"
: "page"
}
>
<header>
<div className="logo" onClick={() => navigate("/")}></div>
<div className="paper">
<input className="search-input" type="text" placeholder="Search" />
<Icon name="search" size={15} />
</div>
<Link to={"/nsfw"}>
<div className={`btn-header${isNsfw ? " active" : ""}`}>
Adult (18+)
</div>
</Link>
<div className="header-right">
{loggedIn()}
{loggedOut()}
</div>
</header>
<Outlet />
{isNsfw && <ContentWarningOverlay />}
</div>
);
}
function ContentWarningOverlay() {
const navigate = useNavigate();
const [is18Plus, setIs18Plus] = useState(Boolean(window.localStorage.getItem("accepted-content-warning")));
if (is18Plus) return null;
function grownUp() {
window.localStorage.setItem("accepted-content-warning", "true");
setIs18Plus(true);
}
return <div className="fullscreen-exclusive age-check">
<h1>Sexually explicit material ahead!</h1>
<h2>Confirm your age</h2>
<div className="flex g24">
<button className="btn btn-warning" onClick={grownUp}>
Yes, I am over 18
</button>
<button className="btn" onClick={() => navigate("/")}>
No, I am under 18
</button>
</div>
</div>
}

View File

@ -1,223 +0,0 @@
.profile-page {
display: flex;
justify-content: center;
}
@media (min-width: 768px) {
.profile-page .profile-container {
width: 620px;
}
}
.profile-page .profile-content {
position: relative;
}
.profile-page .banner {
width: 100%;
border-radius: 16px;
}
@media (min-width: 768px){
.profile-page .banner {
height: 348.75px;
object-fit: cover;
}
}
.profile-page .avatar {
width: 88px;
height: 88px;
border-radius: 88px;
border: 3px solid #FFF;
object-fit: cover;
margin-left: 16px;
margin-top: -40px;
}
.profile-page .status-indicator {
position: absolute;
top: 16px;
left: 120px;
}
.profile-page .profile-actions {
position: absolute;
display: flex;
gap: 12px;
top: 12px;
right: 12px;
}
.profile-page .profile-information {
margin: 12px;
margin-left: 16px;
display: flex;
flex-direction: column;
}
.profile-page .name {
margin: 0;
color: #FFF;
font-size: 21px;
font-style: normal;
font-weight: 600;
line-height: normal;
}
.profile-page .bio {
margin: 0;
color: #ADADAD;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.profile-page .icon-button {
display: flex;
align-items: center;
gap: 8px;
}
.profile-page .icon-button span {
display: none;
}
@media (min-width: 420px) {
.profile-page .icon-button span {
display: block;
}
}
.profile-page .zap-button-icon {
color: #171717;
}
.profile-page .pill.live {
display: flex;
align-items: center;
gap: 8px;
}
.profile-page .pill.offline {
cursor: default;
}
.tabs-root {
display: flex;
flex-direction: column;
margin-top: 20px;
padding: 0 16px;
}
.tabs-list {
flex-shrink: 0;
display: flex;
}
.tabs-tab {
background: #0A0A0A;
background-clip: padding-box;
color: white;
border: 1px solid #0A0A0A;
border-bottom: 1px solid transparent;
position: relative;
cursor: pointer;
height: 52px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
font-size: 16px;
font-family: Outfit;
font-style: normal;
font-weight: 500;
line-height: 24px;
display: flex;
flex-direction: column;
}
@media (max-width: 400px){
.tabs-tab {
font-size: 14px;
}
}
.tab-border {
height: 1px;
margin-top: 12px;
background: transparent;
width: 100%;
}
.tabs-tab[data-state='active'] .tab-border {
height: 1px;
background: linear-gradient(94.73deg, #2BD9FF 0%, #8C8DED 47.4%, #F838D9 100%);
}
.tabs-content {
flex-grow: 1;
padding: 6px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
.tabs-content:focus {
box-shadow: 0 0 0 2px black;
}
.profile-page .profile-top-zappers {
display: flex;
flex-direction: column;
gap: 4px;
}
.profile-page .zapper {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: normal;
}
.profile-page .zapper .zapper-amount {
display: flex;
align-items: center;
gap: 4px;
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: 22px;
}
.profile-page .stream-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.profile-page .stream-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stream-item .video-tile h3 {
font-size: 20px;
font-style: normal;
font-weight: 600;
line-height: normal;
margin: 6px 0 0 0;
}
.stream-item .timestamp {
color: #ADADAD;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@ -1,201 +0,0 @@
import "./profile-page.css";
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 { useUserProfile } from "@snort/system-react";
import { Profile } from "element/profile";
import { Icon } from "element/icon";
import { SendZapsDialog } from "element/send-zap";
import { VideoTile } from "element/video-tile";
import { FollowButton } from "element/follow-button";
import { useProfile } from "hooks/profile";
import useTopZappers from "hooks/top-zappers";
import { Text } from "element/text";
import { StreamState, System } from "index";
import { findTag } from "utils";
import { formatSats } from "number";
function Zapper({ pubkey, total }: { pubkey: string; total: number }) {
return (
<div className="zapper">
<Profile pubkey={pubkey} />
<div className="zapper-amount">
<Icon name="zap-filled" className="zap-icon" />
<p className="top-zapper-amount">{formatSats(total)}</p>
</div>
</div>
);
}
function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
const zappers = useTopZappers(zaps);
return (
<section className="profile-top-zappers">
{zappers.map((z) => (
<Zapper key={z.pubkey} pubkey={z.pubkey} total={z.total} />
))}
</section>
);
}
const defaultBanner = "https://void.cat/d/Hn1AdN5UKmceuDkgDW847q.webp";
export function ProfilePage() {
const navigate = useNavigate();
const params = useParams();
const link = parseNostrLink(params.npub!);
const profile = useUserProfile(System, 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);
}, [streams]);
const pastStreams = useMemo(() => {
return streams.filter((ev) => findTag(ev, "status") === StreamState.Ended);
}, [streams]);
const futureStreams = useMemo(() => {
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
);
navigate(`/${naddr}`);
}
}
return (
<div className="profile-page">
<div className="profile-container">
<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}
/>
)}
<div className="status-indicator">
{isLive ? (
<div className="icon-button pill live" onClick={goToLive}>
<Icon name="signal" />
<span>live</span>
</div>
) : (
<span className="pill offline">offline</span>
)}
</div>
<div className="profile-actions">
{zapTarget && (
<SendZapsDialog
aTag={
liveEvent
? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(
liveEvent,
"d"
)}`
: undefined
}
lnurl={zapTarget}
button={
<button className="btn">
<div className="icon-button">
<span>Zap</span>
<Icon name="zap-filled" className="zap-button-icon" />
</div>
</button>
}
targetName={profile?.name || link.id}
/>
)}
<FollowButton pubkey={link.id} />
</div>
<div className="profile-information">
{profile?.name && <h1 className="name">{profile.name}</h1>}
{profile?.about && (
<p className="bio">
<Text content={profile.about} tags={[]} />
</p>
)}
</div>
<Tabs.Root className="tabs-root" defaultValue="top-zappers">
<Tabs.List
className="tabs-list"
aria-label={`Information about ${
profile ? profile.name : link.id
}`}
>
<Tabs.Trigger className="tabs-tab" value="top-zappers">
Top Zappers
<div className="tab-border"></div>
</Tabs.Trigger>
<Tabs.Trigger className="tabs-tab" value="past-streams">
Past Streams
<div className="tab-border"></div>
</Tabs.Trigger>
<Tabs.Trigger className="tabs-tab" value="schedule">
Schedule
<div className="tab-border"></div>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content className="tabs-content" value="top-zappers">
<TopZappers zaps={zaps} />
</Tabs.Content>
<Tabs.Content className="tabs-content" value="past-streams">
<div className="stream-list">
{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"
)}
</span>
</div>
))}
</div>
</Tabs.Content>
<Tabs.Content className="tabs-content" value="schedule">
<div className="stream-list">
{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"
)}
</span>
</div>
))}
</div>
</Tabs.Content>
</Tabs.Root>
</div>
</div>
</div>
);
}

View File

@ -1,31 +0,0 @@
.stream-providers-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stream-providers-grid>div {
display: flex;
flex-direction: column;
gap: 16px;
}
.stream-providers-grid>div img {
height: 64px;
}
.owncast-config {
display: flex;
gap: 16px;
padding: 40px;
}
.owncast-config>div {
flex: 1;
}
.owncast-config>div:nth-child(2) {
display: flex;
flex-direction: column;
gap: 16px;
}

View File

@ -1,62 +0,0 @@
import "./index.css";
import { StreamProviders } from "providers";
import Owncast from "owncast.png";
import Cloudflare from "cloudflare.png";
import { useNavigate, useParams } from "react-router-dom";
import { ConfigureOwncast } from "./owncast";
import { ConfigureNostrType } from "./nostr";
export function StreamProvidersPage() {
const navigate = useNavigate();
const { id } = useParams();
function mapName(p: StreamProviders) {
switch (p) {
case StreamProviders.Owncast: return "Owncast"
case StreamProviders.Cloudflare: return "Cloudflare"
case StreamProviders.NostrType: return "Nostr Native"
}
return "Unknown"
}
function mapLogo(p: StreamProviders) {
switch (p) {
case StreamProviders.Owncast: return <img src={Owncast} />
case StreamProviders.Cloudflare: return <img src={Cloudflare} />
}
}
function providerLink(p: StreamProviders) {
return <div className="paper">
<h3>{mapName(p)}</h3>
{mapLogo(p)}
<button className="btn btn-border" onClick={() => navigate(p)}>
+ Configure
</button>
</div>
}
function index() {
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>
<div className="stream-providers-grid">
{[StreamProviders.NostrType, StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
</div>
</div >
}
if (!id) {
return index();
} else {
switch (id) {
case StreamProviders.Owncast: {
return <ConfigureOwncast />
}
case StreamProviders.NostrType: {
return <ConfigureNostrType />
}
}
}
}

View File

@ -1,83 +0,0 @@
import AsyncButton from "element/async-button";
import { StatePill } from "element/state-pill";
import { StreamState } from "index";
import { StreamProviderInfo, StreamProviderStore } from "providers";
import { Nip103StreamProvider } from "providers/nip103";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
export function ConfigureNostrType() {
const [url, setUrl] = useState("");
const [info, setInfo] = useState<StreamProviderInfo>();
const navigate = useNavigate();
async function tryConnect() {
try {
const api = new Nip103StreamProvider(url);
const inf = await api.info();
setInfo(inf);
} catch (e) {
console.error(e);
}
}
function status() {
if (!info) return;
return <>
<h3>Status</h3>
<div>
<StatePill state={info?.state ?? StreamState.Ended} />
</div>
<div>
<p>Name</p>
<div className="paper">
{info?.name}
</div>
</div>
{info?.summary && <div>
<p>Summary</p>
<div className="paper">
{info?.summary}
</div>
</div>}
{info?.viewers && <div>
<p>Viewers</p>
<div className="paper">
{info?.viewers}
</div>
</div>}
{info?.version && <div>
<p>Version</p>
<div className="paper">
{info?.version}
</div>
</div>}
<div>
<button className="btn btn-border" onClick={() => {
StreamProviderStore.add(new Nip103StreamProvider(url));
navigate("/");
}}>
Save
</button>
</div>
</>
}
return <div className="owncast-config">
<div className="flex f-col g24">
<div>
<p>Nostr streaming provider URL</p>
<div className="paper">
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
</div>
</div>
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect
</AsyncButton>
</div>
<div>
{status()}
</div>
</div>
}

View File

@ -1,91 +0,0 @@
import AsyncButton from "element/async-button";
import { StatePill } from "element/state-pill";
import { StreamState } from "index";
import { StreamProviderInfo, StreamProviderStore } from "providers";
import { OwncastProvider } from "providers/owncast";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
export function ConfigureOwncast() {
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [info, setInfo] = useState<StreamProviderInfo>();
const navigate = useNavigate();
async function tryConnect() {
try {
const api = new OwncastProvider(url, token);
const i = await api.info();
setInfo(i);
}
catch (e) {
console.debug(e);
}
}
function status() {
if (!info) return;
return <>
<h3>Status</h3>
<div>
<StatePill state={info?.state ?? StreamState.Ended} />
</div>
<div>
<p>Name</p>
<div className="paper">
{info?.name}
</div>
</div>
{info?.summary && <div>
<p>Summary</p>
<div className="paper">
{info?.summary}
</div>
</div>}
{info?.viewers && <div>
<p>Viewers</p>
<div className="paper">
{info?.viewers}
</div>
</div>}
{info?.version && <div>
<p>Version</p>
<div className="paper">
{info?.version}
</div>
</div>}
<div>
<button className="btn btn-border" onClick={() => {
StreamProviderStore.add(new OwncastProvider(url, token));
navigate("/");
}}>
Save
</button>
</div>
</>
}
return <div className="owncast-config">
<div className="flex f-col g24">
<div>
<p>Owncast instance url</p>
<div className="paper">
<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)} />
</div>
</div>
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect
</AsyncButton>
</div>
<div>
{status()}
</div>
</div>
}

View File

@ -1,76 +0,0 @@
.video-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 32px;
padding: 40px 0;
}
@media (max-width: 1020px) {
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px;
}
}
@media (max-width: 720px) {
.video-grid {
display: grid;
grid-template-columns: 1fr;
gap: 32px;
padding: 16px;
}
}
@media(min-width: 1600px) {
.video-grid {
grid-template-columns: repeat(6, 1fr);
}
}
@media(min-width: 2000px) {
.video-grid {
grid-template-columns: repeat(8, 1fr);
}
}
.homepage {
width: 100%;
grid-area: main-content;
}
.divider {
display: flex;
}
.divider:after {
content: "";
flex: 1;
}
.line {
align-items: center;
margin: 1em 0;
}
.line:after {
height: 1px;
margin: 0 1em;
}
.one-line:before,
.one-line:after {
background-color: #171717;
}
::-webkit-scrollbar {
width: 10px;
background: #111;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 100px;
min-height: 24px;
}

View File

@ -1,87 +0,0 @@
import "./root.css";
import { useMemo } from "react";
import { unixNow } from "@snort/shared";
import {
NoteCollection,
RequestBuilder,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { StreamState, System } from "..";
import { VideoTile } from "../element/video-tile";
import { findTag } from "../utils";
import { LIVE_STREAM } from "../const";
export function RootPage({ nsfw }: { nsfw?: boolean }) {
const rb = useMemo(() => {
const rb = new RequestBuilder("root");
rb.withOptions({
leaveOpen: true,
})
.withFilter()
.kinds([LIVE_STREAM])
.since(unixNow() - 86400);
return rb;
}, []);
const feed = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
rb
);
const feedSorted = useMemo(() => {
if (feed.data) {
return [...feed.data].filter(a => nsfw ? findTag(a, "content-warning") !== undefined : findTag(a, "content-warning") === undefined).sort((a, b) => {
const aStatus = findTag(a, "status")!;
const bStatus = findTag(b, "status")!;
if (aStatus === bStatus) {
const aStart = Number(findTag(a, "starts") ?? "0");
const bStart = Number(findTag(b, "starts") ?? "0");
return bStart > aStart ? 1 : -1;
} else {
return aStatus === "live" ? -1 : 1;
}
});
}
return [];
}, [feed.data, nsfw]);
const live = feedSorted.filter(
(a) => findTag(a, "status") === StreamState.Live
);
const planned = feedSorted.filter(
(a) => findTag(a, "status") === StreamState.Planned
);
const ended = feedSorted.filter(
(a) => findTag(a, "status") === StreamState.Ended
);
return (
<div className="homepage">
<div className="video-grid">
{live.map((e) => (
<VideoTile ev={e} key={e.id} />
))}
</div>
{planned.length > 0 && (
<>
<h2 className="divider line one-line">Planned</h2>
<div className="video-grid">
{planned.map((e) => (
<VideoTile ev={e} key={e.id} />
))}
</div>
</>
)}
{ended.length > 0 && (
<>
<h2 className="divider line one-line">Ended</h2>
<div className="video-grid">
{ended.map((e) => (
<VideoTile ev={e} key={e.id} />
))}
</div>
</>
)}
</div>
);
}

View File

@ -1,154 +0,0 @@
.video-content {
grid-area: main-content;
}
.video-content video {
max-height: 230px;
width: 100vw;
max-width: 100vw;
background: #000;
}
.live-chat {
max-width: calc(100vw - 32px);
}
.profile-info {
display: flex;
justify-content: space-between;
padding: 0 16px;
width: 100%;
}
@media (min-width: 768px) {
.video-content {
height: calc(100vh - 64px);
}
.video-content video {
max-height: unset;
}
.profile-info {
max-height: 42px;
}
}
.pill {
font-weight: 700;
font-size: 14px;
line-height: 18px;
color: #A7A7A7;
}
.pill.live {
color: inherit;
text-transform: uppercase;
}
.pill.viewers {
color: white;
background: rgba(23, 23, 23, 0.70);
}
@media (min-width: 1020px) {
.info {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.video-content video {
width: unset;
}
.profile-info {
width: unset;
}
}
.live-chat .header {
display: none;
}
.stream-info {
display: none;
}
@media (min-width: 1020px) {
.live-chat .header {
display: block;
}
.stream-info {
display: block;
}
.video-content {
height: 100%;
}
.live-chat {
margin-left: 32px;
}
}
.info {
grid-area: profile;
margin-top: 8px
}
.info h1 {
margin: 0 0 8px 0;
font-weight: 600;
font-size: 28px;
line-height: 35px;
}
.info p {
margin: 0 0 12px 0;
}
.actions {
margin: 8px 0 0 0;
display: flex;
gap: 12px;
}
.info .btn.zap {
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.offline {
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.online>div {
display: none;
}
.offline>div {
position: fixed;
top: 5em;
text-transform: uppercase;
font-size: 30px;
font-weight: 700;
}
@media (min-width: 768px) {
.offline>div {
top: 10em;
}
}
.offline>video {
z-index: -1;
position: relative;
}

View File

@ -1,121 +0,0 @@
import "./stream-page.css";
import { parseNostrLink, TaggedRawEvent, EventPublisher } from "@snort/system";
import { useNavigate, useParams } from "react-router-dom";
import useEventFeed from "hooks/event-feed";
import { LiveVideoPlayer } from "element/live-video-player";
import { findTag, getHost } from "utils";
import { Profile, getName } from "element/profile";
import { LiveChat } from "element/live-chat";
import AsyncButton from "element/async-button";
import { useLogin } from "hooks/login";
import { useZapGoal } from "hooks/goals";
import { StreamState, System } from "index";
import { SendZapsDialog } from "element/send-zap";
import { NostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { NewStreamDialog } from "element/new-stream";
import { Tags } from "element/tags";
import { StatePill } from "element/state-pill";
import { formatSats } from "number";
import { StreamTimer } from "element/stream-time";
function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) {
const login = useLogin();
const navigate = useNavigate();
const host = getHost(ev);
const profile = useUserProfile(System, host);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const status = findTag(ev, "status") ?? "";
const isMine = ev?.pubkey === login?.pubkey;
async function deleteStream() {
const pub = await EventPublisher.nip7();
if (pub && ev) {
const evDelete = await pub.delete(ev.id);
console.debug(evDelete);
System.BroadcastEvent(evDelete);
navigate("/");
}
}
const viewers = Number(findTag(ev, "current_participants") ?? "0");
return (
<>
<div className="flex info">
<div className="f-grow stream-info">
<h1>{findTag(ev, "title")}</h1>
<p>{findTag(ev, "summary")}</p>
{ev && (
<Tags ev={ev}>
<StatePill state={status as StreamState} />
{viewers > 0 && (
<span className="pill viewers">
{formatSats(viewers)} viewers
</span>
)}
{status === StreamState.Live && (
<span className="pill">
<StreamTimer ev={ev} />
</span>
)}
</Tags>
)}
{isMine && (
<div className="actions">
{ev && <NewStreamDialog text="Edit" ev={ev} btnClassName="btn" />}
<AsyncButton
type="button"
className="btn btn-warning"
onClick={deleteStream}
>
Delete
</AsyncButton>
</div>
)}
</div>
<div className="profile-info flex g24">
<Profile pubkey={host ?? ""} />
{zapTarget && ev && (
<SendZapsDialog
lnurl={zapTarget}
pubkey={host}
aTag={`${ev.kind}:${ev.pubkey}:${findTag(ev, "d")}`}
eTag={goal?.id}
targetName={getName(ev.pubkey, profile)}
/>
)}
</div>
</div>
</>
);
}
function VideoPlayer({ ev }: { ev?: NostrEvent }) {
const stream = findTag(ev, "streaming");
const image = findTag(ev, "image");
const status = findTag(ev, "status");
return (
<div className="video-content">
<LiveVideoPlayer stream={stream} poster={image} status={status} />
</div>
);
}
export function StreamPage() {
const params = useParams();
const link = parseNostrLink(params.id!);
const { data: ev } = useEventFeed(link, true);
const host = getHost(ev);
const goal = useZapGoal(host, link, true);
return (
<>
<VideoPlayer ev={ev} />
<ProfileInfo ev={ev} goal={goal} />
<LiveChat link={link} ev={ev} goal={goal} />
</>
);
}

View File

@ -1,98 +0,0 @@
import { StreamState } from "index"
import { NostrEvent } from "@snort/system";
import { ExternalStore } from "@snort/shared";
import { Nip103StreamProvider } from "./nip103";
import { ManualProvider } from "./manual";
import { OwncastProvider } from "./owncast";
export interface StreamProvider {
get name(): string
get type(): StreamProviders
/**
* Get general info about connected provider to test everything is working
*/
info(): Promise<StreamProviderInfo>
/**
* Create a config object to save in localStorage
*/
createConfig(): any & { type: StreamProviders }
/**
* Update stream info event
*/
updateStreamInfo(ev: NostrEvent): Promise<void>
/**
* Top-up balance with provider
*/
topup(amount: number): Promise<string>
}
export enum StreamProviders {
Manual = "manual",
Owncast = "owncast",
Cloudflare = "cloudflare",
NostrType = "nostr"
}
export interface StreamProviderInfo {
name: string
summary?: string
version?: string
state: StreamState
viewers?: number
ingressUrl?: string
ingressKey?: string
balance?: number
publishedEvent?: NostrEvent
rate?: number
unit?: string
}
export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
#providers: Array<StreamProvider> = []
constructor() {
super();
const cache = window.localStorage.getItem("providers");
if (cache) {
const cached: Array<{ type: StreamProviders } & any> = JSON.parse(cache);
for (const c of cached) {
switch (c.type) {
case StreamProviders.Manual: {
this.#providers.push(new ManualProvider());
break;
}
case StreamProviders.NostrType: {
this.#providers.push(new Nip103StreamProvider(c.url));
break;
}
case StreamProviders.Owncast: {
this.#providers.push(new OwncastProvider(c.url, c.token));
break;
}
}
}
}
}
add(p: StreamProvider) {
this.#providers.push(p);
this.#save();
this.notifyChange();
}
takeSnapshot() {
return [new Nip103StreamProvider("https://api.zap.stream/api/nostr/"), new ManualProvider(), ...this.#providers];
}
#save() {
const cfg = this.#providers.map(a => a.createConfig());
window.localStorage.setItem("providers", JSON.stringify(cfg));
}
}
export const StreamProviderStore = new ProviderStore();

View File

@ -1,34 +0,0 @@
import { NostrEvent } from "@snort/system";
import { System } from "index";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
export class ManualProvider implements StreamProvider {
get name(): string {
return "Manual"
}
get type() {
return StreamProviders.Manual
}
info(): Promise<StreamProviderInfo> {
return Promise.resolve({
name: this.name
} as StreamProviderInfo)
}
createConfig() {
return {
type: StreamProviders.Manual
}
}
updateStreamInfo(ev: NostrEvent): Promise<void> {
System.BroadcastEvent(ev);
return Promise.resolve();
}
topup(amount: number): Promise<string> {
throw new Error("Method not implemented.");
}
}

View File

@ -1,101 +0,0 @@
import { StreamProvider, StreamProviderInfo, StreamProviders } from ".";
import { EventPublisher, EventKind, NostrEvent } from "@snort/system";
import { findTag } from "utils";
export class Nip103StreamProvider implements StreamProvider {
#url: string
constructor(url: string) {
this.#url = url;
}
get name() {
return new URL(this.#url).host;
}
get type() {
return StreamProviders.NostrType
}
async info() {
const rsp = await this.#getJson<AccountResponse>("GET", "account");
const title = findTag(rsp.event, "title");
const state = findTag(rsp.event, "status");
return {
type: StreamProviders.NostrType,
name: title ?? "",
state: state,
viewers: 0,
ingressUrl: rsp.url,
ingressKey: rsp.key,
balance: rsp.quota.remaining,
publishedEvent: rsp.event,
rate: rsp.quota.rate,
unit: rsp.quota.unit
} as StreamProviderInfo
}
createConfig() {
return {
type: StreamProviders.NostrType,
url: this.#url
}
}
async updateStreamInfo(ev: NostrEvent): Promise<void> {
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 contentWarning = findTag(ev, "content-warning");
await this.#getJson("PATCH", "event", {
title, summary, image, tags, content_warning: contentWarning
});
}
async topup(amount: number): Promise<string> {
const rsp = await this.#getJson<TopUpResponse>("GET", `topup?amount=${amount}`);
return rsp.pr;
}
async #getJson<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T> {
const pub = await EventPublisher.nip7();
if (!pub) throw new Error("No event publisher");
const u = `${this.#url}${path}`;
const token = await pub.generic(eb => {
return eb.kind(EventKind.HttpAuthentication)
.content("")
.tag(["u", u])
.tag(["method", method])
});
const rsp = await fetch(u, {
method: method,
body: body ? JSON.stringify(body) : undefined,
headers: {
"content-type": "application/json",
"authorization": `Nostr ${btoa(JSON.stringify(token))}`
},
});
const json = await rsp.text();
if (!rsp.ok) {
throw new Error(json);
}
return json.length > 0 ? JSON.parse(json) as T : {} as T;
}
}
interface AccountResponse {
url: string
key: string
event?: NostrEvent
quota: {
unit: string
rate: number
remaining: number
}
}
interface TopUpResponse {
pr: string
}

View File

@ -1,84 +0,0 @@
import { NostrEvent } from "@snort/system";
import { StreamState } from "index";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
export class OwncastProvider implements StreamProvider {
#url: string
#token: string
constructor(url: string, token: string) {
this.#url = url;
this.#token = token;
}
get name() {
return new URL(this.#url).host
}
get type() {
return StreamProviders.Owncast
}
createConfig(): any & { type: StreamProviders; } {
return {
type: StreamProviders.Owncast,
url: this.#url,
token: this.#token
}
}
updateStreamInfo(ev: NostrEvent): Promise<void> {
return Promise.resolve();
}
async info() {
const info = await this.#getJson<ConfigResponse>("GET", "/api/config");
const status = await this.#getJson<StatusResponse>("GET", "/api/status");
return {
type: StreamProviders.Owncast,
name: info.name,
summary: info.summary,
version: info.version,
state: status.online ? StreamState.Live : StreamState.Ended,
viewers: status.viewerCount
} as StreamProviderInfo
}
topup(amount: number): Promise<string> {
throw new Error("Method not implemented.");
}
async #getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
const rsp = await fetch(`${this.#url}${path}`, {
method: method,
body: body ? JSON.stringify(body) : undefined,
headers: {
"content-type": "application/json",
"authorization": `Bearer ${this.#token}`
},
});
const json = await rsp.text();
if (!rsp.ok) {
throw new Error(json);
}
return JSON.parse(json) as T;
}
}
interface ConfigResponse {
name?: string,
summary?: string,
logo?: string,
tags?: Array<string>,
version?: string
}
interface StatusResponse {
lastConnectTime?: string
lastDisconnectTime?: string
online: boolean
overallMaxViewerCount: number
sessionMaxViewerCount: number
viewerCount: number
}

View File

@ -1,32 +0,0 @@
/// <reference lib="webworker" />
import {} from ".";
declare const self: ServiceWorkerGlobalScope;
import { clientsClaim } from "workbox-core";
import { registerRoute } from "workbox-routing";
import { CacheFirst } from "workbox-strategies";
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 => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});

View File

@ -1,58 +0,0 @@
import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system";
import * as utils from "@noble/curves/abstract/utils";
import { bech32 } from "@scure/base";
export function findTag(e: NostrEvent | undefined, tag: string) {
const maybeTag = e?.tags.find((evTag) => {
return evTag[0] === tag;
});
return maybeTag && maybeTag[1];
}
/**
* Convert hex to bech32
*/
export function hexToBech32(hrp: string, hex?: string) {
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) {
return "";
}
try {
if (
hrp === NostrPrefix.Note ||
hrp === NostrPrefix.PrivateKey ||
hrp === NostrPrefix.PublicKey
) {
const buf = utils.hexToBytes(hex);
return bech32.encode(hrp, bech32.toWords(buf));
} else {
return encodeTLV(hrp as NostrPrefix, hex);
}
} catch (e) {
console.warn("Invalid hex", hex, e);
return "";
}
}
export function splitByUrl(str: string) {
const urlRegex =
/((?:http|ftp|https|nostr|web\+nostr|magnet):\/?\/?(?:[\w+?.\w+])+(?:[a-zA-Z0-9~!@#$%^&*()_\-=+\\/?.:;',]*)?(?:[-A-Za-z0-9+&@#/%=~()_|]))/i;
return str.split(urlRegex);
}
export function eventLink(ev: NostrEvent) {
const d = findTag(ev, "d") ?? "";
const naddr = encodeTLV(
NostrPrefix.Address,
d,
undefined,
ev.kind,
ev.pubkey
);
return `/${naddr}`;
}
export function getHost(ev?: NostrEvent) {
return ev?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev?.pubkey ?? "";
}

View File

@ -1,32 +0,0 @@
interface StateEventMap {
log: CustomEvent<LogEvent>;
status: CustomEvent<StatusEvent>;
}
interface StateEventTarget extends EventTarget {
addEventListener<K extends keyof StateEventMap>(
type: K,
listener: (ev: StateEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean
): void;
}
export const TypedEventTarget = EventTarget as {
new (): StateEventTarget;
prototype: StateEventTarget;
};
export interface LogEvent {
message: string;
}
export interface StatusEvent {
status: Status;
}
export type Status = "connected" | "disconnected";

View File

@ -1,675 +0,0 @@
import adapter from "webrtc-adapter";
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 TRICKLE_BATCH_INTERVAL = 50;
enum Mode {
Player = "player",
Publisher = "publisher",
}
export class WISH extends TypedEventTarget {
private peerConnection?: RTCPeerConnection;
private iceServers: string[] = DEFAULT_ICE_SERVERS;
private videoSender?: RTCRtpSender;
private remoteTracks: MediaStreamTrack[] = [];
private playerMedia?: MediaStream;
private connecting: boolean = false;
private connectedPromise!: Promise<void>;
private connectedResolver!: (any: void) => void;
private connectedRejector!: (reason?: any) => void;
private gatherPromise!: Promise<void>;
private gatherResolver!: (any: void) => void;
private endpoint?: string;
private resourceURL?: string;
private mode: Mode = Mode.Player;
private parsedOffer?: SDPInfo;
private useTrickle: boolean = false;
private etag?: string;
private trickleBatchingJob?: ReturnType<typeof setInterval>;
private batchedCandidates: RTCIceCandidate[] = [];
private connectStartTime?: number;
private iceStartTime?: number;
constructor(iceServers?: string[]) {
super();
if (iceServers) {
this.iceServers = iceServers ? iceServers : DEFAULT_ICE_SERVERS;
}
this.logMessage(
`Enabling webrtc-adapter for ${adapter.browserDetails.browser}@${adapter.browserDetails.version}`
);
this.newResolvers();
}
private logMessage(str: string) {
const now = new Date().toLocaleString();
console.log(`${now}: ${str}`);
this.dispatchEvent(
new CustomEvent<LogEvent>("log", {
detail: {
message: str,
},
})
);
}
private killConnection() {
if (this.peerConnection) {
this.logMessage("Closing RTCPeerConnection");
this.peerConnection.close();
this.peerConnection = undefined;
this.parsedOffer = undefined;
this.playerMedia = undefined;
this.videoSender = undefined;
this.connecting = false;
this.remoteTracks = [];
this.batchedCandidates = [];
this.stopTrickleBatching();
}
}
private createConnection() {
this.logMessage("Creating a new RTCPeerConnection");
this.peerConnection = new RTCPeerConnection({
iceServers: [{ urls: this.iceServers }],
});
if (!this.peerConnection) {
throw new Error("Failed to create a new RTCPeerConnection");
}
this.addEventListeners();
this.newResolvers();
}
private newResolvers() {
this.connectedPromise = new Promise((resolve, reject) => {
this.connectedResolver = resolve;
this.connectedRejector = reject;
});
this.gatherPromise = new Promise((resolve) => {
this.gatherResolver = resolve;
});
}
private addEventListeners() {
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("track", this.onTrack.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}`
);
switch (this.peerConnection.iceGatheringState) {
case "complete":
this.gatherResolver();
break;
}
}
private onConnectionStateChange() {
if (!this.peerConnection) {
return;
}
this.logMessage(
`Peer Connection State changed: ${this.peerConnection.connectionState}`
);
const transportHandler = (
track: MediaStreamTrack,
transport: RTCDtlsTransport
) => {
const ice = transport.iceTransport;
if (!ice) {
return;
}
const pair = ice.getSelectedCandidatePair();
if (!pair) {
return;
}
if (pair.local && pair.remote) {
this.logMessage(
`[${track.kind}] Selected Candidate: (local ${pair.local.address})-(remote ${pair.remote.candidate})`
);
}
};
switch (this.peerConnection.connectionState) {
case "connected":
switch (this.mode) {
case Mode.Player:
for (const receiver of this.peerConnection.getReceivers()) {
const transport = receiver.transport;
if (!transport) {
continue;
}
transportHandler(receiver.track, transport);
}
break;
case Mode.Publisher:
for (const sender of this.peerConnection.getSenders()) {
const transport = sender.transport;
if (!transport) {
continue;
}
if (!sender.track) {
continue;
}
if (sender.track.kind === "video") {
this.videoSender = sender;
}
transportHandler(sender.track, transport);
}
break;
}
break;
case "failed":
this.dispatchEvent(
new CustomEvent<StatusEvent>("status", {
detail: {
status: "disconnected",
},
})
);
break;
}
}
private onICECandidate(ev: RTCPeerConnectionIceEvent) {
if (ev.candidate) {
const candidate = ev.candidate;
if (!candidate.candidate) {
return;
}
this.logMessage(
`Got ICE candidate: ${candidate.candidate.replace("candidate:", "")}`
);
if (!this.parsedOffer) {
return;
}
if (!this.useTrickle) {
return;
}
if (candidate.candidate.includes(".local")) {
this.logMessage("Skipping mDNS candidate for trickle ICE");
return;
}
this.batchedCandidates.push(candidate);
} else {
this.logMessage(`End of ICE candidates`);
}
}
private startTrickleBatching() {
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
);
}
private stopTrickleBatching() {
if (!this.trickleBatchingJob) {
return;
}
this.logMessage("Stopping trickle batching job");
clearInterval(this.trickleBatchingJob);
this.trickleBatchingJob = undefined;
}
private async trickleBatch() {
if (!this.parsedOffer) {
return;
}
if (!this.batchedCandidates.length) {
return;
}
const fragSDP = new SDPInfo();
const candidates = this.batchedCandidates.splice(0);
this.logMessage(`Tricking with ${candidates.length} candidates`);
for (const candidate of candidates) {
const candidateObject = CandidateInfo.expand({
foundation: candidate.foundation || "",
componentId: candidate.component === "rtp" ? 1 : 2,
transport: candidate.protocol || "udp",
priority: candidate.priority || 0,
address: candidate.address || "",
port: candidate.port || 0,
type: candidate.type || "host",
relAddr: candidate.relatedAddress || undefined,
relPort:
typeof candidate.relatedPort !== "undefined" &&
candidate.relatedPort !== null
? candidate.relatedPort.toString()
: undefined,
});
fragSDP.addCandidate(candidateObject);
}
fragSDP.setICE(this.parsedOffer.getICE());
const generated = fragSDP.toIceFragmentString();
// for trickle-ice-sdpfrag, we need a psuedo m= line
const lines = generated.split(/\r?\n/);
lines.splice(2, 0, "m=audio 9 RTP/AVP 0");
lines.splice(3, 0, "a=mid:0");
const frag = lines.join("\r\n");
try {
await this.doSignalingPATCH(frag, false);
} catch (e) {
this.logMessage(`Failed to trickle: ${(e as Error).message}`);
}
}
private onSignalingStateChange() {
if (!this.peerConnection) {
return;
}
this.logMessage(
`Signaling State changed: ${this.peerConnection.signalingState}`
);
}
private onICEConnectionStateChange() {
if (!this.peerConnection) {
return;
}
this.logMessage(
`ICE Connection State changed: ${this.peerConnection.iceConnectionState}`
);
switch (this.peerConnection.iceConnectionState) {
case "checking":
this.iceStartTime = performance.now();
break;
case "connected":
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)`
);
}
if (this.iceStartTime) {
const delta = connected - this.iceStartTime;
this.logMessage(
`Took ${(delta / 1000).toFixed(
2
)} seconds to establish PeerConnection (ICE)`
);
}
this.dispatchEvent(
new CustomEvent<StatusEvent>("status", {
detail: {
status: "connected",
},
})
);
this.connecting = false;
this.connectedResolver();
this.stopTrickleBatching();
break;
case "failed":
if (this.connecting) {
this.connectedRejector("ICE failed while trying to connect");
this.stopTrickleBatching();
this.connecting = false;
}
break;
}
}
private onTrack(ev: RTCTrackEvent) {
if (this.mode !== Mode.Player) {
return;
}
this.remoteTracks.push(ev.track);
if (this.remoteTracks.length === 2) {
for (const track of this.remoteTracks) {
this.logMessage(`Got remote ${track.kind} track`);
if (this.playerMedia) {
this.playerMedia.addTrack(track);
}
}
}
}
private async waitForICEGather() {
setTimeout(() => {
this.gatherResolver();
}, 1000);
await this.gatherPromise;
}
private async doSignaling() {
if (!this.peerConnection) {
return;
}
this.connectStartTime = performance.now();
const localOffer = await this.peerConnection.createOffer();
if (!localOffer.sdp) {
throw new Error("Fail to create offer");
}
this.parsedOffer = SDPInfo.parse(localOffer.sdp);
let remoteOffer: string = "";
if (!this.useTrickle) {
await this.peerConnection.setLocalDescription(localOffer);
await this.waitForICEGather();
const offer = this.peerConnection.localDescription;
if (!offer) {
throw new Error("no LocalDescription");
}
remoteOffer = await this.doSignalingPOST(offer.sdp);
} else {
// ensure that resourceURL is set before trickle happens
remoteOffer = await this.doSignalingPOST(localOffer.sdp, true);
this.startTrickleBatching();
await this.peerConnection.setLocalDescription(localOffer);
}
await this.peerConnection.setRemoteDescription({
sdp: remoteOffer,
type: "answer",
});
this.connecting = true;
}
private setVideoCodecPreference(transceiver: RTCRtpTransceiver) {
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(", ")}`
);
for (let i = 0; i < codecs.length; i++) {
const codec = codecs[i];
if (codec.mimeType === "video/VP9") {
codecs.unshift(codecs.splice(i, 1)[0]);
}
}
transceiver.setCodecPreferences(codecs);
}
private async whipOffer(src: MediaStream) {
if (!this.peerConnection) {
return;
}
for (const track of src.getTracks()) {
this.logMessage(`Adding local ${track.kind} track`);
const transceiver = this.peerConnection.addTransceiver(track, {
direction: "sendonly",
});
if (track.kind === "video") {
this.setVideoCodecPreference(transceiver);
}
}
await this.doSignaling();
}
private async whepClientOffer() {
if (!this.peerConnection) {
return;
}
this.peerConnection.addTransceiver("video", {
direction: "recvonly",
});
this.peerConnection.addTransceiver("audio", {
direction: "recvonly",
});
await this.doSignaling();
}
private updateETag(resp: Response) {
const etag = resp.headers.get("etag");
if (etag) {
try {
this.etag = JSON.parse(etag);
} catch (e) {
this.logMessage("Failed to parse ETag header for PATCH");
}
}
if (this.etag) {
this.logMessage(`Got ${this.etag} as ETag`);
}
}
private async doSignalingPOST(
sdp: string,
useLink?: boolean
): Promise<string> {
if (!this.endpoint) {
throw new Error("No WHIP/WHEP endpoint has been set");
}
const signalStartTime = performance.now();
const resp = await fetch(this.endpoint, {
method: "POST",
mode: "cors",
body: sdp,
headers: {
"content-type": "application/sdp",
},
});
const body = await resp.text();
if (resp.status != 201) {
throw new Error(`Unexpected status code ${resp.status}: ${body}`);
}
const resource = resp.headers.get("location");
if (resource) {
if (resource.startsWith("http")) {
// absolute path
this.resourceURL = resource;
} else {
// relative path
const parsed = new URL(this.endpoint);
parsed.pathname = resource;
this.resourceURL = parsed.toString();
}
this.logMessage(`Using ${this.resourceURL} as WHIP/WHEP Resource URL`);
} else {
this.logMessage("No Location header in response");
}
this.updateETag(resp);
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)`
);
break;
case Mode.Player:
this.logMessage(
`WHEP version draft-murillo-whep-01 (Accept-Post/Accept-Patch)`
);
break;
}
}
if (this.peerConnection && useLink) {
const link = resp.headers.get("link");
if (link) {
const links = parserLinkHeader(link);
if (links["ice-server"]) {
const url = links["ice-server"].url;
this.logMessage(`Endpoint provided ice-server ${url}`);
this.peerConnection.setConfiguration({
iceServers: [
{
urls: [url],
},
],
});
}
}
}
const signaled = performance.now();
const delta = signaled - signalStartTime;
this.logMessage(
`Took ${(delta / 1000).toFixed(2)} seconds to exchange SDP`
);
return body;
}
private async doSignalingPATCH(frag: string, iceRestart: boolean) {
if (!this.resourceURL) {
throw new Error("No resource URL");
}
const headers: HeadersInit = {
"content-type": "application/trickle-ice-sdpfrag",
};
if (this.etag) {
headers["if-match"] = this.etag;
}
const resp = await fetch(this.resourceURL, {
method: "PATCH",
mode: "cors",
body: frag,
headers,
});
switch (resp.status) {
case 200:
if (iceRestart) {
this.updateETag(resp);
return;
}
// if we are doing an ice restart, we expect 200 OK
break;
case 204:
if (!iceRestart) {
return;
}
// if we are doing trickle ice, we expect 204 No Content
break;
case 405:
case 501:
this.logMessage("Trickle ICE not supported, disabling");
this.useTrickle = false;
break;
case 412:
this.logMessage("Resource returns 412, session is outdated");
this.useTrickle = false;
break;
}
const body = await resp.text();
throw new Error(`Unexpected status code ${resp.status}: ${body}`);
}
async WithEndpoint(endpoint: string, trickle: boolean) {
if (endpoint === "") {
throw new Error("Endpoint cannot be empty");
}
try {
const parsed = new URL(endpoint);
this.logMessage(`Using ${parsed.toString()} as the WHIP/WHEP Endpoint`);
this.useTrickle = trickle;
this.logMessage(`${trickle ? "Enabling" : "Disabling"} trickle ICE`);
} catch (e) {
throw new Error("Invalid Endpoint URL");
}
this.endpoint = endpoint;
this.resourceURL = "";
}
async Disconnect() {
this.endpoint = "";
this.killConnection();
if (!this.resourceURL) {
throw new Error("No resource URL");
}
const resp = await fetch(this.resourceURL, {
method: "DELETE",
mode: "cors",
});
if (resp.status != 200) {
const body = await resp.text();
throw new Error(`Unexpected status code ${resp.status}: ${body}`);
}
this.logMessage(`----- Disconnected via DELETE -----`);
this.resourceURL = "";
}
async Play(): Promise<MediaStream> {
this.mode = Mode.Player;
this.killConnection();
this.playerMedia = new MediaStream();
this.createConnection();
await this.whepClientOffer();
await this.connectedPromise;
return this.playerMedia;
}
async Publish(src: MediaStream) {
this.mode = Mode.Publisher;
this.killConnection();
this.createConnection();
await this.whipOffer(src);
await this.connectedPromise;
}
async ReplaceVideoTrack(src: MediaStream) {
if (!this.videoSender) {
throw new Error("Publisher is not active");
}
const tracks = src.getTracks();
if (tracks.length < 1) {
throw new Error("No tracks in MediaStream");
}
return await this.videoSender.replaceTrack(tracks[0]);
}
}

View File

@ -1,65 +0,0 @@
// adopted from https://github.com/thlorenz/parse-link-header
function parseLink(link: string): Link | null {
const matches = link.match(/<?([^>]*)>(.*)/);
if (!matches) {
return null;
}
try {
const linkUrl = matches[1];
const parts = matches[2].split(";");
const parsedUrl = new URL(linkUrl);
const qs = parsedUrl.searchParams;
parts.shift();
const initial: Link = { rel: "", url: linkUrl };
const reduced = parts.reduce((acc: Link, p) => {
const m = p.match(/\s*(.+)\s*=\s*"?([^"]+)"?/);
if (m) {
acc[m[1]] = m[2];
}
return acc;
}, initial);
if (!reduced.rel) {
return null;
}
qs.forEach((v, k) => {
reduced[k] = v;
});
return reduced;
} catch (e) {
return null;
}
}
// https://stackoverflow.com/a/46700791
function notEmpty<T>(value: T | null | undefined): value is T {
if (value === null || value === undefined) return false;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const testDummy: T = value;
return true;
}
export interface Link {
rel: string;
url: string;
[key: string]: string;
}
export interface Links {
[key: string]: Link;
}
export function parserLinkHeader(links: string): Links {
return links
.split(/,\s*</)
.map(parseLink)
.filter(notEmpty)
.reduce((links, l) => {
links[l.rel] = l;
return links;
}, {} as Links);
}

View File

@ -1,15 +0,0 @@
{
"compilerOptions": {
"baseUrl": "src",
"target": "es2020",
"module": "es2020",
"jsx": "react-jsx",
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"allowJs": true
}
}

View File

@ -1,140 +0,0 @@
// Generated using webpack-cli https://github.com/webpack/webpack-cli
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const TerserPlugin = require("terser-webpack-plugin");
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 TsTransformer = require("@formatjs/ts-transformer");
const isProduction = process.env.NODE_ENV == "production";
const config = {
entry: {
main: "./src/index.tsx",
},
target: "browserslist",
devtool: isProduction ? "source-map" : "eval",
output: {
publicPath: "/",
path: path.resolve(__dirname, "build"),
filename: ({ runtime }) => {
if (runtime === "sw") {
return "[name].js";
}
return isProduction ? "[name].[chunkhash].js" : "[name].js";
},
clean: isProduction,
},
devServer: {
open: true,
host: "localhost",
historyApiFallback: true,
},
plugins: [
new CopyPlugin({
patterns: [
{ from: "public/manifest.json" },
{ from: "public/robots.txt" },
{ from: "public/icons.svg" },
{ from: "public/logo.png" },
{ from: "public/nostr.json", to: ".well-known/nostr.json" },
{ from: "_headers" },
],
}),
new HtmlWebpackPlugin({
template: "public/index.html",
favicon: "public/favicon.ico",
excludeChunks: ["sw"],
}),
new ESLintPlugin(),
new MiniCssExtractPlugin({
filename: isProduction ? "[name].[chunkhash].css" : "[name].css",
}),
],
module: {
rules: [
{
test: /\.tsx?$/i,
use: [
"babel-loader",
{
loader: "ts-loader",
options: {
getCustomTransformers() {
return {
before: [
TsTransformer.transform({
overrideIdFn: "[sha512:contenthash:base64:6]",
}),
],
};
},
},
},
],
exclude: ["/node_modules/"],
},
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
{
test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif|webp)$/i,
type: "asset",
},
],
},
optimization: {
usedExports: true,
chunkIds: "deterministic",
minimize: isProduction,
minimizer: [
"...",
// same as https://github.com/facebook/create-react-app/blob/main/packages/react-scripts/config/webpack.config.js
new TerserPlugin({
terserOptions: {
parse: {
ecma: 8,
},
compress: {
ecma: 5,
warnings: false,
comparisons: false,
inline: 2,
},
mangle: {
safari10: true,
},
keep_classnames: isProduction,
keep_fnames: isProduction,
output: {
ecma: 5,
comments: false,
ascii_only: true,
},
},
}),
new CssMinimizerPlugin(),
],
},
resolve: {
extensions: [".tsx", ".ts", ".jsx", ".js", "..."],
modules: ["node_modules", __dirname, path.resolve(__dirname, "src")],
},
};
module.exports = () => {
if (isProduction) {
config.mode = "production";
config.entry.sw = {
import: "./src/service-worker.ts",
filename: "service-worker.js",
};
} else {
config.mode = "development";
}
return config;
};

6820
yarn.lock

File diff suppressed because it is too large Load Diff