forked from Kieran/zap.stream
Compare commits
No commits in common. "main" and "master" have entirely different histories.
23
.gitignore
vendored
23
.gitignore
vendored
@ -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*
|
@ -1 +0,0 @@
|
||||
{}
|
79
README.md
79
README.md
@ -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
|
||||
|
2
_headers
2
_headers
@ -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';
|
88
package.json
88
package.json
@ -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 |
@ -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 |
@ -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>
|
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
Before Width: | Height: | Size: 4.8 KiB |
@ -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"
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
"names":{
|
||||
"_": "cf45a6ba1363ad7ed213a078e710d24115ae721c9b47bd1ebf4458eaefb4c2a5"
|
||||
}
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
@ -1,3 +0,0 @@
|
||||
[
|
||||
{ "id": "nsfw", "text": "NSFW" }
|
||||
]
|
@ -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 |
@ -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;
|
46
src/d.ts
46
src/d.ts
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
.emoji {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
display: inline-block;
|
||||
margin-bottom: -5px;
|
||||
}
|
@ -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}</>;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>;
|
||||
}
|
||||
}
|
@ -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
|
||||
}} />}
|
||||
</>
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
@ -1,3 +0,0 @@
|
||||
.pill.state {
|
||||
text-transform: uppercase;
|
||||
}
|
@ -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>
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
.custom-emoji {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
display: inline-block;
|
||||
}
|
@ -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}</>;
|
||||
}
|
@ -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%;
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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
|
||||
);
|
||||
}
|
@ -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 };
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 };
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { Login } from "index";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export function useLogin() {
|
||||
return useSyncExternalStore(
|
||||
(c) => Login.hook(c),
|
||||
() => Login.snapshot()
|
||||
);
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import { StreamProviderStore } from "providers";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export function useStreamProvider() {
|
||||
return useSyncExternalStore(c => StreamProviderStore.hook(c), () => StreamProviderStore.snapshot());
|
||||
}
|
@ -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;
|
||||
}
|
167
src/index.css
167
src/index.css
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
31
src/login.ts
31
src/login.ts
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
BIN
src/owncast.png
BIN
src/owncast.png
Binary file not shown.
Before Width: | Height: | Size: 121 KiB |
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 />
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
}
|
@ -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>
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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();
|
@ -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.");
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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();
|
||||
}
|
||||
});
|
58
src/utils.ts
58
src/utils.ts
@ -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 ?? "";
|
||||
}
|
@ -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";
|
@ -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]);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user