Compare commits

...

160 Commits

Author SHA1 Message Date
039d2d1551 chore: Update translations 2023-12-23 19:47:35 +00:00
e21d8d3ae4
feat: add recording to manual editor 2023-12-23 19:45:33 +00:00
2bc5fe5eb1 chore: Update translations 2023-12-18 12:24:37 +00:00
4e947dbaa6
chore: formatting 2023-12-18 12:21:12 +00:00
fec6f48bce
feat: push notifications 2023-12-18 12:21:12 +00:00
a3d4b81e23
feat: clip title 2023-12-18 12:21:11 +00:00
f777e06990 chore: Update translations 2023-12-13 14:42:29 +00:00
565de1a19e
feat: better clips 2023-12-13 14:40:52 +00:00
3d21e1ca19
feat: instant redirect on raid 2023-12-11 17:42:02 +00:00
aafb2225e4 chore: Update translations 2023-12-11 12:22:09 +00:00
cd51e508b7
chore: formatting 2023-12-11 12:20:53 +00:00
d5944d5239
fix: text wrap (again)
closes Kieran/stream#102
2023-12-11 12:20:04 +00:00
37a043b2ea
fix: play state autoplay-stuck
related #111
2023-12-11 12:15:23 +00:00
2c168e0142
fix: badge awards in chat
closes Kieran/stream#112
2023-12-11 11:50:46 +00:00
dcd9b5e8c8
fix: dialog z-index
closes Kieran/stream#113
2023-12-11 11:47:21 +00:00
a23177393d
fix: #117 2023-12-11 11:38:44 +00:00
9ac66131a1 chore: Update translations 2023-12-09 14:31:38 +00:00
e1d8d1c022 chore: Update translations 2023-12-09 14:30:53 +00:00
ad13c41eb9
fix: hide discord on small screens 2023-12-09 14:30:04 +00:00
b085b77020
feat: add discord link 2023-12-09 14:30:04 +00:00
4f69b7245b chore: Update translations 2023-12-08 22:37:14 +00:00
d9e7fcbbbd
chore: remove clip button 2023-12-08 22:36:08 +00:00
136212829b chore: Update translations 2023-12-08 19:15:11 +00:00
b8ad7de209
fix: add keys to new chat elements 2023-12-08 19:13:36 +00:00
710c7b1062
fix: hide clip button 2023-12-08 19:13:36 +00:00
1f191c341f chore: Update translations 2023-12-08 15:47:05 +00:00
6d3724fd87
feat: clips 2023-12-08 15:45:52 +00:00
74c087525c
fix: note embeds 2023-12-08 15:45:52 +00:00
6cf144127a chore: Update translations 2023-12-08 11:47:23 +00:00
3dabb0d929
feat: forward ui improvements 2023-12-08 11:46:21 +00:00
f61d5b3119 chore: Update translations 2023-12-07 22:51:56 +00:00
dd4c2bae2b
chore: formatting 2023-12-07 22:50:55 +00:00
31b8549632
feat: stream forwards 2023-12-07 22:50:25 +00:00
7600d93983 chore: Update translations 2023-12-07 17:59:28 +00:00
da75e6e2ff
fix: recording playback 2023-12-07 17:54:43 +00:00
3f8742238b
feat: lazy load dashboard 2023-12-07 16:45:01 +00:00
0897f406ca
fix: badges 2023-12-07 16:42:45 +00:00
43bf7f2d00
feat: raids 2023-12-07 16:41:29 +00:00
4336513184
fix: leave open dashboard 2023-12-07 15:39:03 +00:00
fedf674819
feat: dashboard 2023-12-07 15:35:13 +00:00
30907927d1
refactor: profile & other styles 2023-12-07 12:35:46 +00:00
51905c4b7f chore: Update translations 2023-12-06 16:06:09 +00:00
a36ef90f88 Merge pull request 'Hide followed hashtags when no streams are live' (#108) from florian/stream:fix/hide-followed-hashtags-with-no-streams into main
Reviewed-on: Kieran/stream#108
Reviewed-by: Kieran <kieran@noreply.localhost>
2023-12-06 16:05:06 +00:00
9cbf88555c chore: Update translations 2023-12-05 22:47:42 +00:00
9dc1e41566
feat: theme color 2023-12-05 22:46:47 +00:00
d3c2d508e5 Merge branch 'main' into fix/hide-followed-hashtags-with-no-streams 2023-12-05 22:39:53 +00:00
c7fdf78db8 chore: Update translations 2023-12-05 16:34:17 +00:00
130c6048a2
feat: player overlay styles 2023-12-05 16:33:21 +00:00
florian
20f5554a48 fix: Hide followed hashtags when no streams are live 2023-12-05 17:12:30 +01:00
296789978c chore: Update translations 2023-12-05 13:00:26 +00:00
1c6ff7f729
chore: formatting 2023-12-05 12:58:50 +00:00
13edd58987
refactor: optimize bundle size 2023-12-05 12:58:17 +00:00
6905fb63fd
feat: seek bar on recordings 2023-12-05 11:41:23 +00:00
aaf832a9af chore: Update translations 2023-12-05 11:14:47 +00:00
8cbc7f0633
fix: overlay status 2023-12-05 11:13:59 +00:00
7c321c70a0
feat: custom player controls
closes #34
2023-12-05 11:13:59 +00:00
348759f652 chore: Update translations 2023-12-05 10:00:26 +00:00
fe6ea586fc
chore: use heavy logo 2023-12-05 09:59:20 +00:00
eea617686f
fix: IME input composing 2023-12-04 15:23:26 +00:00
572ed3d783 chore: Update translations 2023-12-04 13:19:26 +00:00
5780bc38c0
chore: formatting 2023-12-04 13:18:35 +00:00
22297aecaa
feat: add link to settings 2023-12-04 13:17:55 +00:00
28981264bf
chore: add logo favicon 2023-12-04 13:00:36 +00:00
00d0309ddd chore: Update translations 2023-12-04 12:56:20 +00:00
9585f2b3df
chore: adjust heading font weights 2023-12-04 12:55:34 +00:00
563b171ade
refactor: style settings page with tailwind 2023-12-04 12:55:34 +00:00
01cb378ff0
chore: update logo 2023-12-04 12:55:34 +00:00
c79c3dae0b chore: Update translations 2023-12-04 12:29:55 +00:00
59ee653348
chore: add ids 2023-12-04 12:28:45 +00:00
a560cdba76
fix: bork merge 2023-12-04 12:26:48 +00:00
11adfe875e
chore: Update translations 2023-12-04 12:26:48 +00:00
04cdc1e08f
chore: formatting 2023-12-04 12:26:48 +00:00
eb58fa4d2e
feat: tailwind 2023-12-04 12:26:47 +00:00
5197f433b2
chore: cleanup 2023-12-04 12:26:47 +00:00
711d9d12b2
fix: tsconfig paths 2023-12-04 12:26:47 +00:00
e2714c4274
refactor: upgrade 2023-12-04 12:26:47 +00:00
01eaf9996c chore: Update translations 2023-11-15 10:59:50 +00:00
a6777d0441
fix: short links 2023-11-15 10:58:31 +00:00
ee2cd4bb4d
feat: stream summary init 2023-11-15 10:35:27 +00:00
080955532c
feat: time sync
closes #75
2023-11-14 15:27:48 +00:00
27cf614048 chore: Update translations 2023-11-14 15:14:33 +00:00
2f4d6d20fd
chore: update build 2023-11-14 15:13:51 +00:00
3df69c1e39
fix: chat style fixes 2023-11-14 15:04:58 +00:00
f2c2e3a39b
fix: profile banner
closes #30
2023-11-14 14:53:38 +00:00
0882425eea
fix: badges
closes #103
2023-11-14 14:53:38 +00:00
c0ef9a5fc1 chore: Update translations 2023-11-14 14:15:11 +00:00
148e399775
fix: stream zaps
fixes #93
2023-11-14 14:04:07 +00:00
9e3b2c1ee7
fix: timer font style
closes #94
2023-11-14 12:42:40 +00:00
dff9aaddaf
feat: follow button
closes #100
2023-11-14 12:41:20 +00:00
647dad14ac
fix: text-wrap
closes #102
2023-11-14 12:36:15 +00:00
cdf2f12e80
feat: render zap dialog for lnurlp link 2023-11-14 12:31:17 +00:00
a34ad5ff71 chore: Update translations 2023-11-08 17:40:13 +00:00
7d7ab005af
fix: filter by host 2023-11-08 17:39:22 +00:00
224bf5c85c
feat: single publisher 2023-11-08 17:39:21 +00:00
7ca6a3bc6e chore: Update translations 2023-11-01 19:46:47 +00:00
f42992ff77
chore: add player config 2023-11-02 04:45:42 +09:00
a8f9b9a152
fix: link render 2023-10-20 17:35:58 +01:00
533d314e00 chore: Update translations 2023-10-16 22:06:20 +00:00
b36c305a53
refactor: upgrade snort pkgs 2023-10-16 23:05:37 +01:00
d9bcea518b
Fix selected goal 2023-09-29 11:03:33 +01:00
11ead3e8bd
Close stream dialog starting new manual stream 2023-09-29 10:50:25 +01:00
1a07577ecd chore: Update translations 2023-09-25 08:54:11 +00:00
3faec9e1d8
Add license 2023-09-25 09:51:52 +01:00
ed50749368
Fix expiration check 2023-09-19 13:00:02 +01:00
6c127d4e9b Merge pull request 'fix: add background' (#99) from music into main
Reviewed-on: Kieran/stream#99
2023-09-19 09:16:44 +00:00
verbiricha
6e1979a3b4 fix: add background 2023-09-19 10:47:21 +02:00
df4587b679 chore: Update translations 2023-09-19 08:43:06 +00:00
68812cc3bf Merge pull request 'feat: add music widget' (#98) from music into main
Reviewed-on: Kieran/stream#98
Reviewed-by: Kieran <kieran@noreply.localhost>
2023-09-19 08:42:27 +00:00
verbiricha
fb4201efe0 fix: run prettier 2023-09-19 08:38:50 +02:00
verbiricha
f5b67a2293 feat: add music widget 2023-09-19 08:37:10 +02:00
a309737d8c
Add limit 1 to rates 2023-09-16 23:08:47 +01:00
193f78a967 chore: Update translations 2023-09-16 22:04:26 +00:00
8c0afbe63d
Use rates 2023-09-16 23:03:11 +01:00
c926586647
Merge branch 'rolznz-fix/settings-icon' 2023-09-15 10:24:46 +01:00
6de9f3dfe3
chore: Update translations 2023-09-15 10:22:34 +01:00
db00b6db1a
Add new langs 2023-09-15 10:22:33 +01:00
Roland Bewick
7d52570c17
chore: optimize jpg images with optimizilla 2023-09-15 10:22:33 +01:00
Roland Bewick
4d677f4568
chore: update all login images, use srcset 2023-09-15 10:22:33 +01:00
Roland Bewick
7e4c176423
chore: use jpg for login-start images 2023-09-15 10:22:33 +01:00
Roland Bewick
b7338cb2fe
chore: progressive image POC 2023-09-15 10:22:33 +01:00
2fc28eb3fc
chore: Update translations 2023-09-15 10:22:32 +01:00
c07ebc869e
Add new langs 2023-09-15 10:22:32 +01:00
af79f8f201 chore: Update translations 2023-09-15 09:22:12 +00:00
c5ce120b25
Merge branch 'rolznz-experimental/progressive-image' 2023-09-15 10:19:46 +01:00
0aa31db4b4
Merge branch 'rolznz-experimental/progressive-image' 2023-09-15 10:18:37 +01:00
3b3e2ba50d
chore: Update translations 2023-09-15 10:18:21 +01:00
f8f793e7f1
Add new langs 2023-09-15 10:18:20 +01:00
Roland Bewick
8380279963 chore: optimize jpg images with optimizilla 2023-09-15 10:30:00 +07:00
Roland Bewick
8e238deff4 chore: update all login images, use srcset 2023-09-15 10:22:56 +07:00
f3e4c13625 chore: Update translations 2023-09-12 15:20:04 +00:00
dd5d6aa65e
Add new langs 2023-09-12 16:19:13 +01:00
Roland Bewick
2bc773241f chore: use jpg for login-start images 2023-09-12 12:36:28 +07:00
Roland Bewick
507b0b94bf chore: progressive image POC 2023-09-12 12:22:25 +07:00
Roland Bewick
24e1deb35c fix: settings icon 2023-09-12 11:54:32 +07:00
3dd105c13b chore: Update translations 2023-09-11 09:11:28 +00:00
b64c51e02d Merge pull request 'WIP - make zaps prettier. fix goal selection.' (#95) from TheGrinder/stream:main into main
Reviewed-on: Kieran/stream#95
Reviewed-by: verbiricha <bandarra@protonmail.com>
Reviewed-by: Kieran <kieran@noreply.localhost>
2023-09-11 09:11:00 +00:00
dedf9fbf1a Merge pull request 'feat: text to speech and animation for zap alerts' (#96) from zap-alerts into main
Reviewed-on: Kieran/stream#96
Reviewed-by: Kieran <kieran@noreply.localhost>
2023-09-11 09:10:13 +00:00
verbiricha
fc37cfe92d feat: add volume control 2023-09-10 14:03:13 +02:00
verbiricha
216311951b fix: run prettier 2023-09-10 11:49:27 +02:00
verbiricha
29b208b136 feat: text to speech and animation for zap alerts 2023-09-10 11:27:47 +02:00
TheGrinder
476e07ee21 make zaps prettier. fix goal selection. 2023-09-09 20:06:07 +02:00
15be10aa02 chore: Update translations 2023-09-07 15:00:02 +00:00
000a6a9ea2 Merge pull request 'chore: update bitcoin connect package' (#92) from roland/stream:chore/update-bitcoin-connect into main
Reviewed-on: Kieran/stream#92
Reviewed-by: Kieran <kieran@noreply.localhost>
2023-09-07 14:59:15 +00:00
Roland Bewick
4ec8241cc0 chore: update bitcoin connect package
- fix breaking change with NIP-46 get_balance method
- add help screen and some minor UX improvements
2023-09-07 21:29:58 +07:00
4b7e54a16d
Use flags directly 2023-09-06 16:50:07 +01:00
660fbe746b
Add flags 2023-09-06 16:50:07 +01:00
64012932f6 chore: Update translations 2023-09-06 14:49:47 +00:00
a278c530e6 Merge pull request 'feat: allow to configure a stream goal' (#90) from stream-goal into main
Reviewed-on: Kieran/stream#90
Reviewed-by: Kieran <kieran@noreply.localhost>
2023-09-06 14:48:39 +00:00
verbiricha
19fcb58104 fix: extract translations 2023-09-06 14:48:39 +00:00
verbiricha
fd86069420 chore: run prettier 2023-09-06 14:48:39 +00:00
verbiricha
9fd59817d9 feat: allow to configure a stream goal 2023-09-06 14:48:39 +00:00
3da6f5439b chore: Update translations 2023-09-06 14:28:24 +00:00
d20919e439
Enable other langs 2023-09-06 15:27:07 +01:00
600672cceb
Upgrade pkgs 2023-09-06 15:27:07 +01:00
605aa17829
Fix modal UI paddings 2023-09-06 15:27:07 +01:00
348f98931b chore: Update translations 2023-09-01 19:10:56 +00:00
567258ce73
Prettier 2023-09-01 20:09:32 +01:00
0c06f88a2d
Lang selector 2023-09-01 20:09:32 +01:00
5a90164922
extract lang 2023-09-01 20:09:31 +01:00
eddf9232d6
New settings page 2023-09-01 20:09:30 +01:00
205 changed files with 12954 additions and 8113 deletions

View File

@ -53,9 +53,13 @@ steps:
- yarn install
- npx @crowdin/cli upload sources -b main -T $CTOKEN
- npx @crowdin/cli pull -b main -T $CTOKEN
- yarn prettier --write .
- git add .
- 'git commit -a -m "chore: Update translations"'
- git push -u origin main
- >
if output=$(git status --porcelain) && [ -n "$output" ]; then
git commit -a -m "chore: Update translations"
git push -u origin main
fi
volumes:
- name: cache
claim:

View File

@ -1,7 +1,15 @@
module.exports = {
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
plugins: ["@typescript-eslint", "formatjs"],
rules: {
"formatjs/enforce-id": [
"error",
{
idInterpolationPattern: "[sha512:contenthash:base64:6]",
},
],
},
root: true,
ignorePatterns: ["build/", "*.test.ts", "*.js"],
env: {
@ -10,10 +18,4 @@ module.exports = {
commonjs: true,
node: false,
},
rules: {
"@typescript-eslint/no-non-null-assertion": "error",
"require-await": "error",
eqeqeq: "error",
"object-shorthand": "warn",
},
};

4
.gitignore vendored
View File

@ -29,4 +29,6 @@ yarn-error.log*
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
!.yarn/versions
dev-dist/

View File

@ -1,4 +1,6 @@
.yarn/
.vscode/
node_modules/
build/
build/
dist/
dev-dist/

View File

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

View File

@ -1,8 +1,8 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";

View File

@ -1,8 +1,8 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";

View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/use-at-your-own-risk
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/use-at-your-own-risk your application uses
module.exports = absRequire(`eslint/use-at-your-own-risk`);

View File

@ -2,5 +2,13 @@
"name": "eslint",
"version": "8.48.0-sdk",
"main": "./lib/api.js",
"type": "commonjs"
"type": "commonjs",
"bin": {
"eslint": "./bin/eslint.js"
},
"exports": {
"./package.json": "./package.json",
".": "./lib/api.js",
"./use-at-your-own-risk": "./lib/unsupported-api.js"
}
}

20
.yarn/sdks/prettier/bin-prettier.js vendored Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier/bin-prettier.js
require(absPnpApiPath).setup();
}
}
// Defer to the real prettier/bin-prettier.js your application uses
module.exports = absRequire(`prettier/bin-prettier.js`);

6
.yarn/sdks/prettier/index.js vendored Executable file → Normal file
View File

@ -1,8 +1,8 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../.pnp.cjs";

View File

@ -2,5 +2,6 @@
"name": "prettier",
"version": "2.8.8-sdk",
"main": "./index.js",
"type": "commonjs"
"type": "commonjs",
"bin": "./bin-prettier.js"
}

View File

@ -1,8 +1,8 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";

View File

@ -1,8 +1,8 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
@ -14,18 +14,16 @@ const moduleWrapper = tsserver => {
return tsserver;
}
const { isAbsolute } = require(`path`);
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(
pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
})
);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
@ -47,10 +45,7 @@ const moduleWrapper = tsserver => {
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (
locator &&
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))
) {
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
@ -78,55 +73,41 @@ const moduleWrapper = tsserver => {
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`:
{
str = `^zip:${str}`;
}
break;
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`:
{
str = `^/zip/${str}`;
}
break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`:
{
str = `^/zip${str}`;
}
break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`:
{
str = `^/zip/${str}`;
}
break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
}
break;
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
}
break;
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default:
{
str = `zip:${str}`;
}
break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
@ -138,30 +119,26 @@ const moduleWrapper = tsserver => {
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32` ? str.replace(/^.*zipfile:\//, ``) : str.replace(/^.*zipfile:/, ``);
}
break;
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
}
break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default:
{
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`);
}
break;
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
@ -173,8 +150,8 @@ const moduleWrapper = tsserver => {
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function () {
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
@ -184,12 +161,12 @@ const moduleWrapper = tsserver => {
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const { onMessage: originalOnMessage, send: originalSend } = Session.prototype;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === "string";
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
@ -200,12 +177,10 @@ const moduleWrapper = tsserver => {
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (
process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []
).map(Number);
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
@ -220,22 +195,20 @@ const moduleWrapper = tsserver => {
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === "string" ? fromEditorPath(value) : value;
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(this, isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON));
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(
this,
JSON.parse(
JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})
)
);
},
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;

View File

@ -1,8 +1,8 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
@ -14,18 +14,16 @@ const moduleWrapper = tsserver => {
return tsserver;
}
const { isAbsolute } = require(`path`);
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(
pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
})
);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
@ -47,10 +45,7 @@ const moduleWrapper = tsserver => {
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (
locator &&
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))
) {
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
@ -78,55 +73,41 @@ const moduleWrapper = tsserver => {
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`:
{
str = `^zip:${str}`;
}
break;
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`:
{
str = `^/zip/${str}`;
}
break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`:
{
str = `^/zip${str}`;
}
break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`:
{
str = `^/zip/${str}`;
}
break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
}
break;
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
}
break;
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default:
{
str = `zip:${str}`;
}
break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
@ -138,30 +119,26 @@ const moduleWrapper = tsserver => {
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32` ? str.replace(/^.*zipfile:\//, ``) : str.replace(/^.*zipfile:/, ``);
}
break;
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
}
break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default:
{
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`);
}
break;
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
@ -173,8 +150,8 @@ const moduleWrapper = tsserver => {
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function () {
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
@ -184,12 +161,12 @@ const moduleWrapper = tsserver => {
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const { onMessage: originalOnMessage, send: originalSend } = Session.prototype;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === "string";
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
@ -200,12 +177,10 @@ const moduleWrapper = tsserver => {
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (
process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []
).map(Number);
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
@ -220,22 +195,20 @@ const moduleWrapper = tsserver => {
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === "string" ? fromEditorPath(value) : value;
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(this, isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON));
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(
this,
JSON.parse(
JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})
)
);
},
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;

View File

@ -1,8 +1,8 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
@ -11,10 +11,10 @@ const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/typescript.js
// Setup the environment to be able to require typescript
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/typescript.js your application uses
module.exports = absRequire(`typescript/lib/typescript.js`);
// Defer to the real typescript your application uses
module.exports = absRequire(`typescript`);

View File

@ -2,5 +2,9 @@
"name": "typescript",
"version": "5.2.2-sdk",
"main": "./lib/typescript.js",
"type": "commonjs"
"type": "commonjs",
"bin": {
"tsc": "./bin/tsc",
"tsserver": "./bin/tsserver"
}
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Kieran (v0l)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -6,12 +6,13 @@
<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="icon" href="/logo_32.png" />
<link rel="manifest" href="/manifest.json" />
<title>zap.stream</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@ -4,32 +4,31 @@
"dependencies": {
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@getalby/bitcoin-connect-react": "1.0.0",
"@noble/curves": "^1.1.0",
"@noble/hashes": "^1.3.1",
"@getalby/bitcoin-connect-react": "^1.1.0",
"@noble/curves": "^1.2.0",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3",
"@react-hook/resize-observer": "^1.2.6",
"@scure/base": "^1.1.1",
"@snort/shared": "^1.0.4",
"@snort/system": "^1.0.17",
"@snort/system-react": "^1.0.12",
"@scure/base": "^1.1.3",
"@snort/shared": "^1.0.10",
"@snort/system": "^1.1.8",
"@snort/system-react": "^1.1.8",
"@snort/system-wasm": "^1.0.1",
"@snort/system-web": "^1.0.2",
"@szhsin/react-menu": "^4.0.2",
"@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",
"@void-cat/api": "^1.0.7",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"buffer": "^6.0.3",
"classnames": "^2.3.2",
"emoji-mart": "^5.5.2",
"flag-icons": "^6.11.0",
"hls.js": "^1.4.6",
"lodash": "^4.17.21",
"lodash.uniqby": "^4.7.0",
"moment": "^2.29.4",
"marked": "^9.1.2",
"qr-code-styling": "^1.6.0-rc.1",
"react": "^18.2.0",
"react-confetti": "^6.1.0",
@ -39,9 +38,9 @@
"react-helmet": "^6.1.0",
"react-intersection-observer": "^9.5.1",
"react-intl": "^6.4.4",
"react-markdown": "^8.0.7",
"react-router-dom": "^6.13.0",
"react-tag-input-component": "^2.0.2",
"recharts": "^2.9.3",
"semantic-sdp": "^3.26.3",
"usehooks-ts": "^2.9.1",
"web-vitals": "^2.1.0",
@ -52,8 +51,8 @@
"workbox-strategies": "^7.0.0"
},
"scripts": {
"start": "webpack serve --node-env=development --mode=development",
"build": "webpack --node-env=production --mode=production",
"start": "vite",
"build": "vite build",
"deploy": "__XXX='false' && yarn build && npx wrangler pages publish --project-name nostr-live build",
"deploy:xxzap": "__XXX='true' && yarn build && npx wrangler pages publish --project-name xxzap build",
"intl-extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file src/lang.json --flatten true",
@ -80,40 +79,31 @@
]
},
"devDependencies": {
"@babel/core": "^7.22.11",
"@babel/plugin-syntax-import-assertions": "^7.20.0",
"@babel/preset-env": "^7.21.5",
"@babel/preset-react": "^7.18.6",
"@formatjs/cli": "^6.1.3",
"@formatjs/ts-transformer": "^3.13.3",
"@testing-library/dom": "^9.3.1",
"@types/lodash": "^4.14.195",
"@types/lodash.uniqby": "^4.7.7",
"@types/node": "^20.10.3",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"@types/react-helmet": "^6.1.6",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"@vitejs/plugin-react": "^4.2.0",
"@webbtc/webln-types": "^1.0.12",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1",
"css-minimizer-webpack-plugin": "^5.0.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.48.0",
"eslint-webpack-plugin": "^4.0.1",
"html-webpack-plugin": "^5.5.1",
"mini-css-extract-plugin": "^2.7.5",
"eslint-plugin-formatjs": "^4.11.3",
"postcss": "^8.4.32",
"prettier": "^2.8.8",
"prop-types": "^15.8.1",
"source-map-loader": "^4.0.1",
"terser-webpack-plugin": "^5.3.9",
"ts-loader": "^9.4.4",
"rollup-plugin-visualizer": "^5.10.0",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"webpack": "^5.88.2",
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
"workbox-webpack-plugin": "^7.0.0"
"vite": "^5.0.5",
"vite-plugin-pwa": "^0.17.2",
"vite-plugin-version-mark": "^0.0.10"
},
"packageManager": "yarn@3.6.3",
"prettier": {

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -30,9 +30,6 @@
<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>
<symbol id="camera-plus" viewBox="0 0 24 24" fill="none">
<g>
<path d="M22 11.5V14.6C22 16.8402 22 17.9603 21.564 18.816C21.1805 19.5686 20.5686 20.1805 19.816 20.564C18.9603 21 17.8402 21 15.6 21H8.4C6.15979 21 5.03969 21 4.18404 20.564C3.43139 20.1805 2.81947 19.5686 2.43597 18.816C2 17.9603 2 16.8402 2 14.6V9.4C2 7.15979 2 6.03969 2.43597 5.18404C2.81947 4.43139 3.43139 3.81947 4.18404 3.43597C5.03969 3 6.15979 3 8.4 3H12.5M19 8V2M16 5H22M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@ -58,20 +55,10 @@
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="toggle-off" viewBox="0 0 24 24" fill="none">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 5C4.13401 5 1 8.13401 1 12C1 15.866 4.13401 19 8 19H16C19.866 19 23 15.866 23 12C23 8.13401 19.866 5 16 5H8ZM12 12C12 14.2091 10.2091 16 8 16C5.79086 16 4 14.2091 4 12C4 9.79086 5.79086 8 8 8C10.2091 8 12 9.79086 12 12Z"
fill="currentColor"
/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 5C4.13401 5 1 8.13401 1 12C1 15.866 4.13401 19 8 19H16C19.866 19 23 15.866 23 12C23 8.13401 19.866 5 16 5H8ZM12 12C12 14.2091 10.2091 16 8 16C5.79086 16 4 14.2091 4 12C4 9.79086 5.79086 8 8 8C10.2091 8 12 9.79086 12 12Z" fill="currentColor" />
</symbol>
<symbol id="toggle-on" viewBox="0 0 24 24" fill="none">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16 5C19.866 5 23 8.13401 23 12C23 15.866 19.866 19 16 19H8C4.13401 19 1 15.866 1 12C1 8.13401 4.13401 5 8 5H16ZM12 12C12 14.2091 13.7909 16 16 16C18.2091 16 20 14.2091 20 12C20 9.79086 18.2091 8 16 8C13.7909 8 12 9.79086 12 12Z"
fill="currentColor"
/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 5C19.866 5 23 8.13401 23 12C23 15.866 19.866 19 16 19H8C4.13401 19 1 15.866 1 12C1 8.13401 4.13401 5 8 5H16ZM12 12C12 14.2091 13.7909 16 16 16C18.2091 16 20 14.2091 20 12C20 9.79086 18.2091 8 16 8C13.7909 8 12 9.79086 12 12Z" fill="currentColor" />
</symbol>
<symbol id="badge" viewBox="0 0 24 24" fill="none">
<path d="M8.87625 13.0953L4.70122 7.87653C4.44132 7.55166 4.31138 7.38922 4.21897 7.20834C4.13698 7.04787 4.07706 6.87705 4.04084 6.70052C4 6.50155 4 6.29354 4 5.8775V5.2C4 4.0799 4 3.51984 4.21799 3.09202C4.40973 2.71569 4.71569 2.40973 5.09202 2.21799C5.51984 2 6.0799 2 7.2 2H16.8C17.9201 2 18.4802 2 18.908 2.21799C19.2843 2.40973 19.5903 2.71569 19.782 3.09202C20 3.51984 20 4.0799 20 5.2V5.8775C20 6.29354 20 6.50155 19.9592 6.70052C19.9229 6.87705 19.863 7.04787 19.781 7.20834C19.6886 7.38922 19.5587 7.55166 19.2988 7.87652L15.1238 13.0953M5.00005 3L12.0001 12L19 3M15.5355 13.4645C17.4882 15.4171 17.4882 18.5829 15.5355 20.5355C13.5829 22.4882 10.4171 22.4882 8.46446 20.5355C6.51185 18.5829 6.51185 15.4171 8.46446 13.4645C10.4171 11.5118 13.5829 11.5118 15.5355 13.4645Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@ -79,7 +66,7 @@
<symbol id="piggybank" viewBox="0 0 24 24" fill="none">
<path d="M4.99993 13C4.99993 9.68629 7.68622 7 10.9999 7M4.99993 13C4.99993 14.6484 5.66466 16.1415 6.74067 17.226C6.84445 17.3305 6.89633 17.3828 6.92696 17.4331C6.95619 17.4811 6.9732 17.5224 6.98625 17.5771C6.99993 17.6343 6.99993 17.6995 6.99993 17.8298V20.2C6.99993 20.48 6.99993 20.62 7.05443 20.727C7.10236 20.8211 7.17885 20.8976 7.27293 20.9455C7.37989 21 7.5199 21 7.79993 21H9.69993C9.97996 21 10.12 21 10.2269 20.9455C10.321 20.8976 10.3975 20.8211 10.4454 20.727C10.4999 20.62 10.4999 20.48 10.4999 20.2V19.8C10.4999 19.52 10.4999 19.38 10.5544 19.273C10.6024 19.1789 10.6789 19.1024 10.7729 19.0545C10.8799 19 11.0199 19 11.2999 19H12.6999C12.98 19 13.12 19 13.2269 19.0545C13.321 19.1024 13.3975 19.1789 13.4454 19.273C13.4999 19.38 13.4999 19.52 13.4999 19.8V20.2C13.4999 20.48 13.4999 20.62 13.5544 20.727C13.6024 20.8211 13.6789 20.8976 13.7729 20.9455C13.8799 21 14.0199 21 14.2999 21H16.2C16.48 21 16.62 21 16.727 20.9455C16.8211 20.8976 16.8976 20.8211 16.9455 20.727C17 20.62 17 20.48 17 20.2V19.2243C17 19.0223 17 18.9212 17.0288 18.8401C17.0563 18.7624 17.0911 18.708 17.15 18.6502C17.2114 18.59 17.3155 18.5417 17.5237 18.445C18.5059 17.989 19.344 17.2751 19.9511 16.3902C20.0579 16.2346 20.1112 16.1568 20.1683 16.1108C20.2228 16.0668 20.2717 16.0411 20.3387 16.021C20.4089 16 20.4922 16 20.6587 16H21.2C21.48 16 21.62 16 21.727 15.9455C21.8211 15.8976 21.8976 15.8211 21.9455 15.727C22 15.62 22 15.48 22 15.2V11.7857C22 11.5192 22 11.3859 21.9505 11.283C21.9013 11.181 21.819 11.0987 21.717 11.0495C21.6141 11 21.4808 11 21.2143 11C21.0213 11 20.9248 11 20.8471 10.9738C20.7633 10.9456 20.7045 10.908 20.6437 10.8438C20.5874 10.7842 20.5413 10.6846 20.4493 10.4855C20.1538 9.84622 19.7492 9.26777 19.2593 8.77404C19.1555 8.66945 19.1036 8.61716 19.073 8.56687C19.0437 8.51889 19.0267 8.47759 19.0137 8.42294C19 8.36567 19 8.30051 19 8.17018V7.06058C19 6.70053 19 6.52051 18.925 6.39951C18.8593 6.29351 18.7564 6.21588 18.6365 6.18184C18.4995 6.14299 18.3264 6.19245 17.9802 6.29136L15.6077 6.96922C15.5673 6.98074 15.5472 6.9865 15.5267 6.99054C15.5085 6.99414 15.4901 6.99671 15.4716 6.99826C15.4508 7 15.4297 7 15.3874 7H10.9999M4.99993 13H4C2.89543 13 2 12.1046 2 11C2 10.2597 2.4022 9.61337 3 9.26756M10.9999 7H14.9646C14.9879 6.8367 15 6.66976 15 6.5C15 4.567 13.433 3 11.5 3C9.567 3 8 4.567 8 6.5C8 6.9172 8.073 7.31736 8.20692 7.68839C9.04114 7.24881 9.99144 7 10.9999 7Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="note" viewBox="0 0 24 24" fill="none" >
<symbol id="note" viewBox="0 0 24 24" fill="none">
<path d="M7 8.5H12M7 12H15M9.68375 18H16.2C17.8802 18 18.7202 18 19.362 17.673C19.9265 17.3854 20.3854 16.9265 20.673 16.362C21 15.7202 21 14.8802 21 13.2V7.8C21 6.11984 21 5.27976 20.673 4.63803C20.3854 4.07354 19.9265 3.6146 19.362 3.32698C18.7202 3 17.8802 3 16.2 3H7.8C6.11984 3 5.27976 3 4.63803 3.32698C4.07354 3.6146 3.6146 4.07354 3.32698 4.63803C3 5.27976 3 6.11984 3 7.8V20.3355C3 20.8684 3 21.1348 3.10923 21.2716C3.20422 21.3906 3.34827 21.4599 3.50054 21.4597C3.67563 21.4595 3.88367 21.2931 4.29976 20.9602L6.68521 19.0518C7.17252 18.662 7.41617 18.4671 7.68749 18.3285C7.9282 18.2055 8.18443 18.1156 8.44921 18.0613C8.74767 18 9.0597 18 9.68375 18Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="face-content" viewBox="0 0 24 24" fill="none">
@ -89,7 +76,54 @@
<path d="M16.5 16L21.5 21M21.5 16L16.5 21M15.5 3.29076C16.9659 3.88415 18 5.32131 18 7C18 8.67869 16.9659 10.1159 15.5 10.7092M12 15H8C6.13623 15 5.20435 15 4.46927 15.3045C3.48915 15.7105 2.71046 16.4892 2.30448 17.4693C2 18.2044 2 19.1362 2 21M13.5 7C13.5 9.20914 11.7091 11 9.5 11C7.29086 11 5.5 9.20914 5.5 7C5.5 4.79086 7.29086 3 9.5 3C11.7091 3 13.5 4.79086 13.5 7Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="settings" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" d="M11.828 2.25c-.916 0-1.699.663-1.85 1.567l-.091.549a.798.798 0 01-.517.608 7.45 7.45 0 00-.478.198.798.798 0 01-.796-.064l-.453-.324a1.875 1.875 0 00-2.416.2l-.243.243a1.875 1.875 0 00-.2 2.416l.324.453a.798.798 0 01.064.796 7.448 7.448 0 00-.198.478.798.798 0 01-.608.517l-.55.092a1.875 1.875 0 00-1.566 1.849v.344c0 .916.663 1.699 1.567 1.85l.549.091c.281.047.508.25.608.517.06.162.127.321.198.478a.798.798 0 01-.064.796l-.324.453a1.875 1.875 0 00.2 2.416l.243.243c.648.648 1.67.733 2.416.2l.453-.324a.798.798 0 01.796-.064c.157.071.316.137.478.198.267.1.47.327.517.608l.092.55c.15.903.932 1.566 1.849 1.566h.344c.916 0 1.699-.663 1.85-1.567l.091-.549a.798.798 0 01.517-.608 7.52 7.52 0 00.478-.198.798.798 0 01.796.064l.453.324a1.875 1.875 0 002.416-.2l.243-.243c.648-.648.733-1.67.2-2.416l-.324-.453a.798.798 0 01-.064-.796c.071-.157.137-.316.198-.478.1-.267.327-.47.608-.517l.55-.091a1.875 1.875 0 001.566-1.85v-.344c0-.916-.663-1.699-1.567-1.85l-.549-.091a.798.798 0 01-.608-.517 7.507 7.507 0 00-.198-.478.798.798 0 01.064-.796l.324-.453a1.875 1.875 0 00-.2-2.416l-.243-.243a1.875 1.875 0 00-2.416-.2l-.453.324a.798.798 0 01-.796.064 7.462 7.462 0 00-.478-.198.798.798 0 01-.517-.608l-.091-.55a1.875 1.875 0 00-1.85-1.566h-.344zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" clip-rule="evenodd" stroke="currentColor" />
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.7273 14.7273C18.6063 15.0015 18.5702 15.3056 18.6236 15.6005C18.6771 15.8954 18.8177 16.1676 19.0273 16.3818L19.0818 16.4364C19.2509 16.6052 19.385 16.8057 19.4765 17.0265C19.568 17.2472 19.6151 17.4838 19.6151 17.7227C19.6151 17.9617 19.568 18.1983 19.4765 18.419C19.385 18.6397 19.2509 18.8402 19.0818 19.0091C18.913 19.1781 18.7124 19.3122 18.4917 19.4037C18.271 19.4952 18.0344 19.5423 17.7955 19.5423C17.5565 19.5423 17.3199 19.4952 17.0992 19.4037C16.8785 19.3122 16.678 19.1781 16.5091 19.0091L16.4545 18.9545C16.2403 18.745 15.9682 18.6044 15.6733 18.5509C15.3784 18.4974 15.0742 18.5335 14.8 18.6545C14.5311 18.7698 14.3018 18.9611 14.1403 19.205C13.9788 19.4489 13.8921 19.7347 13.8909 20.0273V20.1818C13.8909 20.664 13.6994 21.1265 13.3584 21.4675C13.0174 21.8084 12.5549 22 12.0727 22C11.5905 22 11.1281 21.8084 10.7871 21.4675C10.4461 21.1265 10.2545 20.664 10.2545 20.1818V20.1C10.2475 19.7991 10.1501 19.5073 9.97501 19.2625C9.79991 19.0176 9.55521 18.8312 9.27273 18.7273C8.99853 18.6063 8.69437 18.5702 8.39947 18.6236C8.10456 18.6771 7.83244 18.8177 7.61818 19.0273L7.56364 19.0818C7.39478 19.2509 7.19425 19.385 6.97353 19.4765C6.7528 19.568 6.51621 19.6151 6.27727 19.6151C6.03834 19.6151 5.80174 19.568 5.58102 19.4765C5.36029 19.385 5.15977 19.2509 4.99091 19.0818C4.82186 18.913 4.68775 18.7124 4.59626 18.4917C4.50476 18.271 4.45766 18.0344 4.45766 17.7955C4.45766 17.5565 4.50476 17.3199 4.59626 17.0992C4.68775 16.8785 4.82186 16.678 4.99091 16.5091L5.04545 16.4545C5.25503 16.2403 5.39562 15.9682 5.4491 15.6733C5.50257 15.3784 5.46647 15.0742 5.34545 14.8C5.23022 14.5311 5.03887 14.3018 4.79497 14.1403C4.55107 13.9788 4.26526 13.8921 3.97273 13.8909H3.81818C3.33597 13.8909 2.87351 13.6994 2.53253 13.3584C2.19156 13.0174 2 12.5549 2 12.0727C2 11.5905 2.19156 11.1281 2.53253 10.7871C2.87351 10.4461 3.33597 10.2545 3.81818 10.2545H3.9C4.2009 10.2475 4.49273 10.1501 4.73754 9.97501C4.98236 9.79991 5.16883 9.55521 5.27273 9.27273C5.39374 8.99853 5.42984 8.69437 5.37637 8.39947C5.3229 8.10456 5.18231 7.83244 4.97273 7.61818L4.91818 7.56364C4.74913 7.39478 4.61503 7.19425 4.52353 6.97353C4.43203 6.7528 4.38493 6.51621 4.38493 6.27727C4.38493 6.03834 4.43203 5.80174 4.52353 5.58102C4.61503 5.36029 4.74913 5.15977 4.91818 4.99091C5.08704 4.82186 5.28757 4.68775 5.50829 4.59626C5.72901 4.50476 5.96561 4.45766 6.20455 4.45766C6.44348 4.45766 6.68008 4.50476 6.9008 4.59626C7.12152 4.68775 7.32205 4.82186 7.49091 4.99091L7.54545 5.04545C7.75971 5.25503 8.03183 5.39562 8.32674 5.4491C8.62164 5.50257 8.9258 5.46647 9.2 5.34545H9.27273C9.54161 5.23022 9.77093 5.03887 9.93245 4.79497C10.094 4.55107 10.1807 4.26526 10.1818 3.97273V3.81818C10.1818 3.33597 10.3734 2.87351 10.7144 2.53253C11.0553 2.19156 11.5178 2 12 2C12.4822 2 12.9447 2.19156 13.2856 2.53253C13.6266 2.87351 13.8182 3.33597 13.8182 3.81818V3.9C13.8193 4.19253 13.906 4.47834 14.0676 4.72224C14.2291 4.96614 14.4584 5.15749 14.7273 5.27273C15.0015 5.39374 15.3056 5.42984 15.6005 5.37637C15.8954 5.3229 16.1676 5.18231 16.3818 4.97273L16.4364 4.91818C16.6052 4.74913 16.8057 4.61503 17.0265 4.52353C17.2472 4.43203 17.4838 4.38493 17.7227 4.38493C17.9617 4.38493 18.1983 4.43203 18.419 4.52353C18.6397 4.61503 18.8402 4.74913 19.0091 4.91818C19.1781 5.08704 19.3122 5.28757 19.4037 5.50829C19.4952 5.72901 19.5423 5.96561 19.5423 6.20455C19.5423 6.44348 19.4952 6.68008 19.4037 6.9008C19.3122 7.12152 19.1781 7.32205 19.0091 7.49091L18.9545 7.54545C18.745 7.75971 18.6044 8.03183 18.5509 8.32674C18.4974 8.62164 18.5335 8.9258 18.6545 9.2V9.27273C18.7698 9.54161 18.9611 9.77093 19.205 9.93245C19.4489 10.094 19.7347 10.1807 20.0273 10.1818H20.1818C20.664 10.1818 21.1265 10.3734 21.4675 10.7144C21.8084 11.0553 22 11.5178 22 12C22 12.4822 21.8084 12.9447 21.4675 13.2856C21.1265 13.6266 20.664 13.8182 20.1818 13.8182H20.1C19.8075 13.8193 19.5217 13.906 19.2778 14.0676C19.0339 14.2291 18.8425 14.4584 18.7273 14.7273Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="widget" viewBox="0 0 24 24" fill="none">
<path d="M11 4H7.8C6.11984 4 5.27976 4 4.63803 4.32698C4.07354 4.6146 3.6146 5.07354 3.32698 5.63803C3 6.27976 3 7.11984 3 8.8V16.2C3 17.8802 3 18.7202 3.32698 19.362C3.6146 19.9265 4.07354 20.3854 4.63803 20.673C5.27976 21 6.11984 21 7.8 21H15.2C16.8802 21 17.7202 21 18.362 20.673C18.9265 20.3854 19.3854 19.9265 19.673 19.362C20 18.7202 20 17.8802 20 16.2V13M13 17H7M15 13H7M20.1213 3.87868C21.2929 5.05025 21.2929 6.94975 20.1213 8.12132C18.9497 9.29289 17.0503 9.29289 15.8787 8.12132C14.7071 6.94975 14.7071 5.05025 15.8787 3.87868C17.0503 2.70711 18.9497 2.70711 20.1213 3.87868Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="play" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.00625 2.8023C8.0182 2.81028 8.03019 2.81827 8.04222 2.82629L18.591 9.85878C18.8962 10.0622 19.1792 10.2509 19.3965 10.4261C19.6234 10.6091 19.8908 10.8628 20.0447 11.2339C20.2481 11.7244 20.2481 12.2756 20.0447 12.7661C19.8908 13.1372 19.6234 13.3909 19.3965 13.5738C19.1792 13.7491 18.8962 13.9377 18.591 14.1412L8.00628 21.1977C7.63319 21.4464 7.29772 21.6701 7.01305 21.8244C6.72818 21.9788 6.33717 22.1552 5.8808 22.1279C5.29705 22.0931 4.75779 21.8045 4.40498 21.3381C4.12916 20.9735 4.05905 20.5503 4.02949 20.2276C3.99994 19.9052 3.99997 19.502 4 19.0536L4 4.98962C4 4.97516 4 4.96075 4 4.94638C3.99997 4.49798 3.99994 4.09479 4.02949 3.77236C4.05905 3.44971 4.12916 3.02651 4.40498 2.6619C4.75779 2.19552 5.29705 1.90692 5.8808 1.87207C6.33717 1.84482 6.72818 2.02123 7.01305 2.17561C7.29771 2.32988 7.63317 2.55355 8.00625 2.8023Z" fill="currentColor"/>
</symbol>
<symbol id="pause" viewBox="0 0 24 24" fill="none">
<path d="M20.25 4.5V19.5C20.25 19.8978 20.092 20.2794 19.8107 20.5607C19.5294 20.842 19.1478 21 18.75 21H15C14.6022 21 14.2206 20.842 13.9393 20.5607C13.658 20.2794 13.5 19.8978 13.5 19.5V4.5C13.5 4.10218 13.658 3.72064 13.9393 3.43934C14.2206 3.15804 14.6022 3 15 3H18.75C19.1478 3 19.5294 3.15804 19.8107 3.43934C20.092 3.72064 20.25 4.10218 20.25 4.5ZM9 3H5.25C4.85218 3 4.47064 3.15804 4.18934 3.43934C3.90804 3.72064 3.75 4.10218 3.75 4.5V19.5C3.75 19.8978 3.90804 20.2794 4.18934 20.5607C4.47064 20.842 4.85218 21 5.25 21H9C9.39782 21 9.77936 20.842 10.0607 20.5607C10.342 20.2794 10.5 19.8978 10.5 19.5V4.5C10.5 4.10218 10.342 3.72064 10.0607 3.43934C9.77936 3.15804 9.39782 3 9 3Z" fill="currentColor"/>
</symbol>
<symbol id="volume" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.1639 4.18822C19.6123 3.8657 20.2372 3.96769 20.5597 4.41602C22.0953 6.55072 23 9.17119 23 12C23 14.8288 22.0953 17.4493 20.5597 19.584C20.2372 20.0323 19.6123 20.1343 19.1639 19.8118C18.7156 19.4893 18.6136 18.8644 18.9361 18.416C20.2352 16.6102 21 14.3959 21 12C21 9.60407 20.2352 7.38974 18.9361 5.58396C18.6136 5.13563 18.7156 4.51073 19.1639 4.18822Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.1732 7.17981C15.6262 6.86385 16.2495 6.97492 16.5655 7.4279C17.4696 8.7241 18 10.3016 18 12C18 13.6984 17.4696 15.2759 16.5655 16.5721C16.2495 17.0251 15.6262 17.1361 15.1732 16.8202C14.7202 16.5042 14.6092 15.8809 14.9251 15.4279C15.6027 14.4564 16 13.2761 16 12C16 10.7239 15.6027 9.54355 14.9251 8.57209C14.6092 8.11912 14.7202 7.49577 15.1732 7.17981Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.3823 2.71172C10.861 2.67405 11.3288 2.86781 11.6406 3.23293C11.9199 3.55988 11.9642 3.95313 11.9811 4.14402C12.0001 4.35799 12 4.62375 12 4.89413C12 4.90653 12 4.91895 12 4.93136L12 19.1059C12 19.3762 12.0001 19.642 11.9811 19.856C11.9642 20.0469 11.9199 20.4401 11.6406 20.7671C11.3288 21.1322 10.861 21.3259 10.3823 21.2883C9.95368 21.2545 9.64424 21.0078 9.4973 20.8848C9.33259 20.7469 9.14469 20.559 8.95353 20.3677L5.76153 17.1757C5.6689 17.0831 5.6225 17.037 5.58738 17.005L5.58472 17.0026L5.58114 17.0024C5.53365 17.0002 5.46826 17 5.33726 17L3.56812 17C3.31574 17 3.06994 17.0001 2.86178 16.983C2.63318 16.9644 2.36345 16.9203 2.09202 16.782C1.7157 16.5903 1.40974 16.2843 1.21799 15.908C1.07969 15.6366 1.03563 15.3668 1.01695 15.1382C0.999943 14.9301 0.999973 14.6843 1 14.4319L1.00001 9.59999C1.00001 9.58935 1 9.57872 1 9.5681C0.999973 9.31571 0.999943 9.06992 1.01695 8.86176C1.03563 8.63317 1.07969 8.36344 1.21799 8.09201C1.40974 7.71569 1.7157 7.40973 2.09202 7.21798C2.36345 7.07968 2.63318 7.03562 2.86178 7.01694C3.06993 6.99993 3.31572 6.99996 3.56811 6.99999C3.57873 6.99999 3.58936 6.99999 3.60001 6.99999H5.33726C5.46826 6.99999 5.53365 6.99976 5.58114 6.99758L5.58472 6.99741L5.58738 6.995C5.6225 6.96295 5.6689 6.91689 5.76153 6.82426L8.92721 3.65857C8.936 3.64979 8.94477 3.64101 8.95354 3.63224C9.1447 3.44102 9.33259 3.25308 9.4973 3.11518C9.64424 2.99217 9.95368 2.74546 10.3823 2.71172Z" fill="currentColor"/>
</symbol>
<symbol id="loading" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.25C12.5523 1.25 13 1.69772 13 2.25V4.75C13 5.30228 12.5523 5.75 12 5.75C11.4477 5.75 11 5.30228 11 4.75V2.25C11 1.69772 11.4477 1.25 12 1.25Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 17C12.5523 17 13 17.4477 13 18V22C13 22.5523 12.5523 23 12 23C11.4477 23 11 22.5523 11 22V18C11 17.4477 11.4477 17 12 17Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.25 12C1.25 11.4477 1.69772 11 2.25 11H5.75C6.30228 11 6.75 11.4477 6.75 12C6.75 12.5523 6.30228 13 5.75 13H2.25C1.69772 13 1.25 12.5523 1.25 12Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.75 12C18.75 11.4477 19.1977 11 19.75 11H21.25C21.8023 11 22.25 11.4477 22.25 12C22.25 12.5523 21.8023 13 21.25 13H19.75C19.1977 13 18.75 12.5523 18.75 12Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.0429 17.0429C17.4334 16.6524 18.0666 16.6524 18.4571 17.0429L19.1642 17.75C19.5547 18.1405 19.5547 18.7737 19.1642 19.1642C18.7737 19.5547 18.1405 19.5547 17.75 19.1642L17.0429 18.4571C16.6524 18.0666 16.6524 17.4334 17.0429 17.0429Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.3713 4.70868C19.7618 5.0992 19.7618 5.73237 19.3713 6.12289L17.9571 7.53711C17.5666 7.92763 16.9334 7.92763 16.5429 7.53711C16.1524 7.14658 16.1524 6.51342 16.5429 6.12289L17.9571 4.70868C18.3476 4.31816 18.9808 4.31816 19.3713 4.70868Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.45711 15.5429C8.84763 15.9334 8.84763 16.5666 8.45711 16.9571L5.62868 19.7855C5.23815 20.1761 4.60499 20.1761 4.21447 19.7855C3.82394 19.395 3.82394 18.7618 4.21447 18.3713L7.04289 15.5429C7.43342 15.1524 8.06658 15.1524 8.45711 15.5429Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.42157 4.50157C4.8121 4.11105 5.44526 4.11105 5.83579 4.50157L7.95711 6.62289C8.34763 7.01342 8.34763 7.64658 7.95711 8.03711C7.56658 8.42763 6.93342 8.42763 6.54289 8.03711L4.42157 5.91579C4.03105 5.52526 4.03105 4.8921 4.42157 4.50157Z" fill="currentColor"/>
</symbol>
<symbol id="fullscreen" viewBox="0 0 24 24" fill="none">
<path d="M20.25 3.75H3.75C3.35218 3.75 2.97064 3.90804 2.68934 4.18934C2.40804 4.47064 2.25 4.85218 2.25 5.25V18.75C2.25 19.1478 2.40804 19.5294 2.68934 19.8107C2.97064 20.092 3.35218 20.25 3.75 20.25H20.25C20.6478 20.25 21.0294 20.092 21.3107 19.8107C21.592 19.5294 21.75 19.1478 21.75 18.75V5.25C21.75 4.85218 21.592 4.47064 21.3107 4.18934C21.0294 3.90804 20.6478 3.75 20.25 3.75ZM8.25 18H5.25C5.05109 18 4.86032 17.921 4.71967 17.7803C4.57902 17.6397 4.5 17.4489 4.5 17.25V14.25C4.5 14.0511 4.57902 13.8603 4.71967 13.7197C4.86032 13.579 5.05109 13.5 5.25 13.5C5.44891 13.5 5.63968 13.579 5.78033 13.7197C5.92098 13.8603 6 14.0511 6 14.25V16.5H8.25C8.44891 16.5 8.63968 16.579 8.78033 16.7197C8.92098 16.8603 9 17.0511 9 17.25C9 17.4489 8.92098 17.6397 8.78033 17.7803C8.63968 17.921 8.44891 18 8.25 18ZM19.5 9.75C19.5 9.94891 19.421 10.1397 19.2803 10.2803C19.1397 10.421 18.9489 10.5 18.75 10.5C18.5511 10.5 18.3603 10.421 18.2197 10.2803C18.079 10.1397 18 9.94891 18 9.75V7.5H15.75C15.5511 7.5 15.3603 7.42098 15.2197 7.28033C15.079 7.13968 15 6.94891 15 6.75C15 6.55109 15.079 6.36032 15.2197 6.21967C15.3603 6.07902 15.5511 6 15.75 6H18.75C18.9489 6 19.1397 6.07902 19.2803 6.21967C19.421 6.36032 19.5 6.55109 19.5 6.75V9.75Z" fill="currentColor"/>
</symbol>
<symbol id="volume-muted" viewBox="0 0 24 24" fill="none">
<path d="M14.58 2.32607C14.4538 2.26442 14.3127 2.23947 14.173 2.25405C14.0333 2.26864 13.9005 2.32218 13.7897 2.40857L7.24219 7.50013H3C2.60218 7.50013 2.22064 7.65817 1.93934 7.93947C1.65804 8.22077 1.5 8.6023 1.5 9.00013V15.0001C1.5 15.398 1.65804 15.7795 1.93934 16.0608C2.22064 16.3421 2.60218 16.5001 3 16.5001H7.24219L13.7897 21.5917C13.921 21.6946 14.0831 21.7504 14.25 21.7501C14.4489 21.7501 14.6397 21.6711 14.7803 21.5305C14.921 21.3898 15 21.199 15 21.0001V3.00013C15.0001 2.85972 14.9608 2.72211 14.8865 2.60294C14.8123 2.48378 14.7061 2.38785 14.58 2.32607Z" fill="currentColor"/>
<path d="M21.3107 12.0004L23.031 10.281C23.1718 10.1403 23.2508 9.94944 23.2508 9.75042C23.2508 9.55139 23.1718 9.36052 23.031 9.21979C22.8903 9.07906 22.6994 9 22.5004 9C22.3014 9 22.1105 9.07906 21.9698 9.21979L20.2504 10.9401L18.531 9.21979C18.3903 9.07906 18.1994 9 18.0004 9C17.8014 9 17.6105 9.07906 17.4698 9.21979C17.3291 9.36052 17.25 9.55139 17.25 9.75042C17.25 9.94944 17.3291 10.1403 17.4698 10.281L19.1901 12.0004L17.4698 13.7198C17.3291 13.8605 17.25 14.0514 17.25 14.2504C17.25 14.4494 17.3291 14.6403 17.4698 14.781C17.6105 14.9218 17.8014 15.0008 18.0004 15.0008C18.1994 15.0008 18.3903 14.9218 18.531 14.781L20.2504 13.0607L21.9698 14.781C22.1105 14.9218 22.3014 15.0008 22.5004 15.0008C22.6994 15.0008 22.8903 14.9218 23.031 14.781C23.1718 14.6403 23.2508 14.4494 23.2508 14.2504C23.2508 14.0514 23.1718 13.8605 23.031 13.7198L21.3107 12.0004Z" fill="currentColor"/>
</symbol>
<symbol id="line-chart-up" viewBox="0 0 20 20" fill="none">
<path d="M19 19H2.6C2.03995 19 1.75992 19 1.54601 18.891C1.35785 18.7951 1.20487 18.6422 1.10899 18.454C1 18.2401 1 17.9601 1 17.4V1M19 5L13.5657 10.4343C13.3677 10.6323 13.2687 10.7313 13.1545 10.7684C13.0541 10.8011 12.9459 10.8011 12.8455 10.7684C12.7313 10.7313 12.6323 10.6323 12.4343 10.4343L10.5657 8.56569C10.3677 8.36768 10.2687 8.26867 10.1545 8.23158C10.0541 8.19895 9.94591 8.19895 9.84549 8.23158C9.73133 8.26867 9.63232 8.36768 9.43431 8.56569L5 13M19 5H15M19 5V9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="clapperboard" viewBox="0 0 22 20" fill="none">
<path d="M6.78874 0.956901C6.85435 0.628893 6.88715 0.464889 6.84274 0.336538C6.8038 0.223961 6.72594 0.128987 6.62319 0.0687195C6.50604 7.31647e-06 6.33878 7.31647e-06 6.00428 7.31647e-06H5.7587C4.95375 -4.70504e-06 4.28936 -1.46255e-05 3.74818 0.0442022C3.18608 0.0901274 2.66938 0.188692 2.18404 0.435982C1.43139 0.819476 0.819469 1.4314 0.435976 2.18405C0.188685 2.66938 0.0901205 3.18609 0.0441953 3.74818C0.0294183 3.92905 0.0220297 4.01948 0.0341758 4.11998C0.0833235 4.52667 0.425196 4.89762 0.826532 4.97972C0.925715 5.00001 1.0353 5.00001 1.25446 5.00001H5.32428C5.5579 5.00001 5.67471 5.00001 5.77045 4.95817C5.85488 4.92127 5.92747 4.86176 5.98021 4.7862C6.04002 4.70052 6.06293 4.58598 6.10874 4.3569L6.78874 0.956901Z" fill="currentColor"/>
<path d="M8.21111 4.04311C8.14551 4.37112 8.11271 4.53513 8.15711 4.66348C8.19606 4.77605 8.27392 4.87103 8.37667 4.9313C8.49382 5.00001 8.66107 5.00001 8.99557 5.00001H12.3243C12.5579 5.00001 12.6747 5.00001 12.7705 4.95817C12.8549 4.92127 12.9275 4.86176 12.9802 4.7862C13.04 4.70052 13.0629 4.58598 13.1087 4.3569L13.7887 0.956901C13.8543 0.628893 13.8871 0.464889 13.8427 0.336538C13.8038 0.223961 13.7259 0.128987 13.6232 0.0687195C13.506 7.31647e-06 13.3388 7.31647e-06 13.0043 7.31647e-06H9.67557C9.44195 7.31647e-06 9.32514 7.31647e-06 9.2294 0.0418505C9.14497 0.078749 9.07238 0.13826 9.01964 0.213813C8.95983 0.299491 8.93693 0.414032 8.89111 0.643115L8.21111 4.04311Z" fill="currentColor"/>
<path d="M16.6756 -0.00186977C16.4415 -0.00137503 16.3245 -0.00112766 16.229 0.0407906C16.1446 0.0778141 16.0724 0.13713 16.0197 0.212716C15.9601 0.298296 15.9371 0.413236 15.8911 0.643114L15.2111 4.04311C15.1455 4.37112 15.1127 4.53513 15.1571 4.66348C15.1961 4.77605 15.2739 4.87103 15.3767 4.9313C15.4938 5.00001 15.6611 5.00001 15.9956 5.00001H20.7455C20.9647 5.00001 21.0743 5.00001 21.1735 4.97972C21.5748 4.89762 21.9167 4.52668 21.9658 4.11999C21.978 4.01949 21.9706 3.92906 21.9558 3.74822C21.9099 3.18613 21.8113 2.66938 21.564 2.18405C21.1805 1.4314 20.5686 0.819476 19.816 0.435982C19.3306 0.188692 18.8139 0.0901274 18.2518 0.0442022C17.7281 0.00141033 17.2016 -0.00298175 16.6756 -0.00186977Z" fill="currentColor"/>
<path d="M21.891 7.54602C22 7.75993 22 8.03995 22 8.60001V14.2413C22 15.0463 22 15.7106 21.9558 16.2518C21.9099 16.8139 21.8113 17.3306 21.564 17.816C21.1805 18.5686 20.5686 19.1805 19.816 19.564C19.3306 19.8113 18.8139 19.9099 18.2518 19.9558C17.7106 20 17.0463 20 16.2413 20H5.75868C4.95372 20 4.28937 20 3.74818 19.9558C3.18608 19.9099 2.66938 19.8113 2.18404 19.564C1.43139 19.1805 0.819469 18.5686 0.435976 17.816C0.188685 17.3306 0.0901205 16.8139 0.0441953 16.2518C-2.13385e-05 15.7106 -1.15136e-05 15.0463 3.88855e-07 14.2413V8.60001C3.88855e-07 8.03995 3.8743e-07 7.75993 0.108994 7.54602C0.204868 7.35786 0.357848 7.20487 0.54601 7.109C0.759922 7.00001 1.03995 7.00001 1.6 7.00001H20.4C20.9601 7.00001 21.2401 7.00001 21.454 7.109C21.6422 7.20487 21.7951 7.35786 21.891 7.54602Z" fill="currentColor"/>
</symbol>
<symbol id="bell-ringing" viewBox="0 0 22 22" fill="none">
<path d="M8.35442 20C9.05956 20.6224 9.9858 21 11.0002 21C12.0147 21 12.9409 20.6224 13.6461 20M1.29414 4.81989C1.27979 3.36854 2.06227 2.01325 3.32635 1.3M20.7024 4.8199C20.7167 3.36855 19.9342 2.01325 18.6702 1.3M17.0002 7C17.0002 5.4087 16.3681 3.88258 15.2429 2.75736C14.1177 1.63214 12.5915 1 11.0002 1C9.40895 1 7.88283 1.63214 6.75761 2.75736C5.63239 3.88258 5.00025 5.4087 5.00025 7C5.00025 10.0902 4.22072 12.206 3.34991 13.6054C2.61538 14.7859 2.24811 15.3761 2.26157 15.5408C2.27649 15.7231 2.31511 15.7926 2.46203 15.9016C2.59471 16 3.19284 16 4.3891 16H17.6114C18.8077 16 19.4058 16 19.5385 15.9016C19.6854 15.7926 19.724 15.7231 19.7389 15.5408C19.7524 15.3761 19.3851 14.7859 18.6506 13.6054C17.7798 12.206 17.0002 10.0902 17.0002 7Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="bell-off" viewBox="0 0 24 24" fill="none">
<path d="M8.63306 3.03371C9.61959 2.3649 10.791 2 12 2C13.5913 2 15.1174 2.63214 16.2426 3.75736C17.3679 4.88258 18 6.4087 18 8C18 10.1008 18.2702 11.7512 18.6484 13.0324M6.25867 6.25724C6.08866 6.81726 6 7.40406 6 8C6 11.0902 5.22047 13.206 4.34966 14.6054C3.61513 15.7859 3.24786 16.3761 3.26132 16.5408C3.27624 16.7231 3.31486 16.7926 3.46178 16.9016C3.59446 17 4.19259 17 5.38885 17H17M9.35418 21C10.0593 21.6224 10.9856 22 12 22C13.0144 22 13.9407 21.6224 14.6458 21M21 21L3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
public/logo_256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
public/logo_32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

View File

@ -1,10 +1,15 @@
{
"short_name": "zap_stream",
"short_name": "zap.stream",
"name": "zap.stream",
"icons": [
{
"src": "logo.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "logo_256.png",
"type": "image/png",
"sizes": "256x256"
}
],

View File

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

View File

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

View File

@ -1,4 +1,3 @@
<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>
<svg xmlns="http://www.w3.org/2000/svg" width="33" height="23" viewBox="0 0 33 23" fill="none">
<path d="M32.7877 1.72093C32.3558 0.67677 31.3439 0 30.216 0H10.6802C10.6738 0 10.6673 0 10.6609 0C10.6545 0 10.648 0 10.6416 0C6.54235 0 3.21012 3.33229 3.21012 7.42513C3.21012 9.5779 4.13825 11.5824 5.70446 12.9746L0.812466 17.8667C0.0132476 18.666 -0.225229 19.8584 0.206607 20.8961C0.638443 21.9402 1.65036 22.617 2.77829 22.617H22.314C22.3205 22.617 22.3269 22.617 22.3398 22.617C22.3463 22.617 22.3527 22.617 22.3656 22.617C26.4584 22.617 29.7906 19.2847 29.7906 15.1919C29.7906 13.0391 28.8625 11.0346 27.2963 9.64236L32.1882 4.75028C32.981 3.95105 33.2195 2.75864 32.7877 1.72093ZM2.71383 19.8584C2.6945 19.8132 2.70739 19.8004 2.73317 19.7746L8.10856 14.3991L22.3914 19.4523C22.4043 19.4587 22.4236 19.4652 22.4365 19.4652C22.4558 19.4716 22.4752 19.4781 22.4945 19.491C22.5267 19.5103 22.5525 19.5425 22.5718 19.5812C22.5783 19.5877 22.5783 19.5941 22.5847 19.6005C22.5912 19.6134 22.5912 19.6263 22.5912 19.6392C22.5912 19.6521 22.5976 19.665 22.5976 19.6779C22.5976 19.7939 22.4687 19.897 22.3269 19.897H2.78473C2.75251 19.9099 2.73317 19.9099 2.71383 19.8584ZM25.208 18.956C25.208 18.9496 25.2015 18.9367 25.2015 18.9302C25.1757 18.8271 25.1435 18.7304 25.1048 18.6337C25.0984 18.6208 25.0984 18.6079 25.092 18.5951C25.0533 18.4919 25.0017 18.3952 24.9502 18.2986C24.9437 18.2857 24.9308 18.2663 24.9244 18.2535C24.8148 18.0601 24.6859 17.8796 24.5377 17.712C24.5248 17.6991 24.5119 17.6863 24.499 17.6734C24.4216 17.596 24.3443 17.5187 24.2605 17.4478C24.2476 17.4413 24.2347 17.4284 24.2283 17.422C24.1509 17.3575 24.0672 17.2995 23.9769 17.2415C23.964 17.2351 23.9511 17.2222 23.9382 17.2157C23.848 17.1577 23.7513 17.1062 23.6547 17.0546C23.6353 17.0417 23.6095 17.0353 23.5902 17.0224C23.4935 16.9773 23.3904 16.9321 23.2808 16.8999L16.6744 14.5602L9.03668 11.8596L9.0109 11.8531C9.00446 11.8531 9.00446 11.8531 8.99801 11.8467C8.8111 11.7758 8.62418 11.692 8.43727 11.5953C6.88395 10.7768 5.9236 9.17829 5.9236 7.42513C5.9236 5.89112 6.65836 4.52469 7.79918 3.661C7.79918 3.66745 7.80563 3.68034 7.80563 3.68678C7.83141 3.78347 7.86364 3.88015 7.90231 3.97683C7.9152 4.00906 7.92809 4.04128 7.94098 4.07351C7.97321 4.14441 8.00543 4.20886 8.03766 4.27976C8.05055 4.31199 8.06989 4.34422 8.08278 4.37C8.1279 4.45379 8.17946 4.53114 8.23747 4.60848C8.26325 4.64071 8.28258 4.67293 8.30836 4.70516C8.35348 4.76317 8.3986 4.82118 8.45016 4.87274C8.4695 4.89852 8.48883 4.92431 8.51462 4.94364C8.57907 5.01454 8.65641 5.079 8.72731 5.14345C8.75309 5.16923 8.77887 5.18857 8.8111 5.20791C8.882 5.26591 8.9529 5.31748 9.03024 5.36904C9.04313 5.38193 9.05602 5.38838 9.07536 5.40127C9.16559 5.45928 9.26227 5.51084 9.35895 5.55596C9.38473 5.56885 9.41051 5.58174 9.43629 5.59463C9.53942 5.63975 9.64254 5.68487 9.74567 5.71709L24.0091 10.7639C24.0156 10.7639 24.022 10.7703 24.022 10.7703C24.209 10.8412 24.3894 10.9186 24.5634 11.0152C26.1168 11.8338 27.0771 13.4323 27.0771 15.1854C27.0836 16.7259 26.3423 18.0923 25.208 18.956ZM30.2675 2.83599L24.8922 8.21147C24.8793 8.20503 24.8599 8.19858 24.847 8.19214L10.6222 3.1647C10.6029 3.15826 10.5836 3.15181 10.5707 3.14537H10.5642C10.5449 3.13892 10.532 3.13248 10.5127 3.11959C10.5062 3.11314 10.4998 3.1067 10.4933 3.10025C10.4804 3.09381 10.474 3.08091 10.4675 3.07447C10.4611 3.06802 10.4547 3.05513 10.4547 3.04869C10.4482 3.0358 10.4418 3.02291 10.4353 3.01001C10.4353 3.00357 10.4289 2.99068 10.4289 2.97779C10.4289 2.9649 10.4224 2.94556 10.4224 2.92622C10.4224 2.91333 10.4289 2.90689 10.4289 2.894C10.4482 2.79732 10.5642 2.71352 10.6867 2.71352H30.2289C30.2611 2.71352 30.2804 2.71352 30.2998 2.75864C30.3062 2.79732 30.2933 2.81665 30.2675 2.83599Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -2,6 +2,8 @@ import { EventKind } from "@snort/system";
export const LIVE_STREAM = 30_311 as EventKind;
export const LIVE_STREAM_CHAT = 1_311 as EventKind;
export const LIVE_STREAM_RAID = 1_312 as EventKind;
export const LIVE_STREAM_CLIP = 1_313 as EventKind;
export const EMOJI_PACK = 30_030 as EventKind;
export const USER_EMOJIS = 10_030 as EventKind;
export const GOAL = 9041 as EventKind;
@ -12,6 +14,12 @@ export const MUTED = 10_000 as EventKind;
export const DAY = 60 * 60 * 24;
export const WEEK = 7 * DAY;
export enum StreamState {
Live = "live",
Ended = "ended",
Planned = "planned",
}
export const defaultRelays = {
"wss://relay.snort.social": { read: true, write: true },
"wss://nos.lol": { read: true, write: true },

View File

@ -3,6 +3,8 @@
declare const __XXX: boolean;
declare const __XXX_HOST: string;
declare const __ZAP_STREAM_VERSION__: string;
declare const __SINGLE_PUBLISHER: string;
declare module "*.jpg" {
const value: unknown;

View File

@ -1,14 +1,14 @@
import "./event.css";
import { type NostrLink, type NostrEvent as NostrEventType, EventKind } from "@snort/system";
import { EventKind, type NostrEvent as NostrEventType, type NostrLink } from "@snort/system";
import { Icon } from "element/icon";
import { Goal } from "element/goal";
import { Note } from "element/note";
import { EmojiPack } from "element/emoji-pack";
import { Badge } from "element/badge";
import { useEvent } from "hooks/event";
import { GOAL, EMOJI_PACK } from "const";
import { Icon } from "./icon";
import { Goal } from "./goal";
import { Note } from "./note";
import { EmojiPack } from "./emoji-pack";
import { Badge } from "./badge";
import { useEvent } from "@/hooks/event";
import { EMOJI_PACK, GOAL } from "@/const";
interface EventProps {
link: NostrLink;

View File

@ -0,0 +1,5 @@
import { Button as AlbyZapsButton } from "@getalby/bitcoin-connect-react";
export default function AlbyButton() {
return <AlbyZapsButton />;
}

View File

@ -1,14 +1,15 @@
import "./async-button.css";
import { useState } from "react";
import Spinner from "element/spinner";
import { forwardRef, useState } from "react";
import Spinner from "./spinner";
import classNames from "classnames";
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
disabled?: boolean;
onClick(e: React.MouseEvent): Promise<void> | void;
onClick?: (e: React.MouseEvent) => Promise<void> | void;
children?: React.ReactNode;
}
export default function AsyncButton(props: AsyncButtonProps) {
const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((props: AsyncButtonProps, ref) => {
const [loading, setLoading] = useState<boolean>(false);
async function handle(e: React.MouseEvent) {
@ -16,11 +17,8 @@ export default function AsyncButton(props: AsyncButtonProps) {
if (loading || props.disabled) return;
setLoading(true);
try {
if (typeof props.onClick === "function") {
const f = props.onClick(e);
if (f instanceof Promise) {
await f;
}
if (props.onClick) {
await props.onClick(e);
}
} finally {
setLoading(false);
@ -28,8 +26,17 @@ export default function AsyncButton(props: AsyncButtonProps) {
}
return (
<button type="button" disabled={loading || props.disabled} {...props} onClick={handle}>
<span style={{ visibility: loading ? "hidden" : "visible" }}>{props.children}</span>
<button
ref={ref}
disabled={loading || props.disabled}
{...props}
onClick={handle}
className={classNames("px-3 py-2 bg-gray-2 rounded-full", props.className)}>
<span
style={{ visibility: loading ? "hidden" : "visible" }}
className="whitespace-nowrap flex gap-2 items-center justify-center">
{props.children}
</span>
{loading && (
<span className="spinner-wrapper">
<Spinner />
@ -37,4 +44,5 @@ export default function AsyncButton(props: AsyncButtonProps) {
)}
</button>
);
}
});
export default AsyncButton;

View File

@ -1,5 +1,23 @@
import { MetadataCache } from "@snort/system";
import { HTMLProps, useState } from "react";
import classNames from "classnames";
import { getPlaceholder } from "@/utils";
export function Avatar({ user, avatarClassname }: { user: MetadataCache; avatarClassname: string }) {
return <img className={avatarClassname} alt={user?.name || user?.pubkey} src={user?.picture ?? ""} />;
type AvatarProps = HTMLProps<HTMLImageElement> & { size?: number; pubkey: string; user?: MetadataCache };
export function Avatar({ pubkey, size, user, ...props }: AvatarProps) {
const [failed, setFailed] = useState(false);
const src = user?.picture && !failed ? user.picture : getPlaceholder(pubkey);
return (
<img
{...props}
className={classNames("aspect-square rounded-full bg-gray-1", props.className)}
alt={user?.name || user?.pubkey}
src={src}
onError={() => setFailed(true)}
style={{
width: `${size ?? 40}px`,
height: `${size ?? 40}px`,
}}
/>
);
}

View File

@ -1,6 +1,6 @@
import "./badge.css";
import type { NostrEvent } from "@snort/system";
import { findTag } from "utils";
import { findTag } from "@/utils";
export function Badge({ ev }: { ev: NostrEvent }) {
const name = findTag(ev, "name") || findTag(ev, "d");

View File

@ -1,20 +1,22 @@
import { useUserProfile, SnortContext } from "@snort/system-react";
import { NostrEvent, parseZap, EventKind } from "@snort/system";
import React, { useRef, useState, useMemo, useContext } from "react";
import { useMediaQuery, useHover, useOnClickOutside, useIntersectionObserver } from "usehooks-ts";
import { SnortContext, useEventReactions, useUserProfile } from "@snort/system-react";
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import React, { Suspense, lazy, useContext, useMemo, useRef, useState } from "react";
import { useHover, useIntersectionObserver, useMediaQuery, useOnClickOutside } from "usehooks-ts";
import { dedupe } from "@snort/shared";
import { EmojiPicker } from "element/emoji-picker";
import { Icon } from "element/icon";
import { Emoji as EmojiComponent } from "element/emoji";
const EmojiPicker = lazy(() => import("./emoji-picker"));
import { Icon } from "./icon";
import { Emoji as EmojiComponent } from "./emoji";
import { Profile } from "./profile";
import { Text } from "element/text";
import { useMute } from "element/mute-button";
import { SendZapsDialog } from "element/send-zap";
import { CollapsibleEvent } from "element/collapsible";
import { useLogin } from "hooks/login";
import { formatSats } from "number";
import { findTag } from "utils";
import type { Badge, Emoji, EmojiPack } from "types";
import { Text } from "./text";
import { useMute } from "./mute-button";
import { SendZapsDialog } from "./send-zap";
import { CollapsibleEvent } from "./collapsible";
import { useLogin } from "@/hooks/login";
import { formatSats } from "@/number";
import type { Badge, Emoji, EmojiPack } from "@/types";
import AsyncButton from "./async-button";
function emojifyReaction(reaction: string) {
if (reaction === "+") {
@ -26,28 +28,26 @@ function emojifyReaction(reaction: string) {
return reaction;
}
const customComponents = {
Event: CollapsibleEvent,
};
export function ChatMessage({
streamer,
ev,
reactions,
related,
emojiPacks,
badges,
}: {
ev: NostrEvent;
ev: TaggedNostrEvent;
streamer: string;
reactions: readonly NostrEvent[];
related: ReadonlyArray<TaggedNostrEvent>;
emojiPacks: EmojiPack[];
badges: Badge[];
}) {
const system = useContext(SnortContext);
const ref = useRef<HTMLDivElement | null>(null);
const inView = useIntersectionObserver(ref, {
freezeOnceVisible: true,
});
const emojiRef = useRef(null);
const link = NostrLink.fromEvent(ev);
const isTablet = useMediaQuery("(max-width: 1020px)");
const isHovering = useHover(ref);
const { mute } = useMute(ev.pubkey);
@ -57,25 +57,16 @@ export function ChatMessage({
const profile = useUserProfile(inView?.isIntersecting ? ev.pubkey : undefined);
const shouldShowMuteButton = ev.pubkey !== streamer && ev.pubkey !== login?.pubkey;
const zapTarget = profile?.lud16 ?? profile?.lud06;
const system = useContext(SnortContext);
const zaps = useMemo(() => {
return reactions
.filter(a => a.kind === EventKind.ZapReceipt)
.map(a => parseZap(a, system.ProfileLoader.Cache))
.filter(a => a && a.valid);
}, [reactions]);
const emojiReactions = 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 { zaps, reactions } = useEventReactions(link, related);
const emojiNames = emojiPacks.map(p => p.emojis).flat();
const hasReactions = emojiReactions.length > 0;
const filteredReactions = useMemo(() => {
return reactions.all.filter(a => link.isReplyToThis(a));
}, [ev, reactions.all]);
const hasReactions = filteredReactions.length > 0;
const totalZaps = useMemo(() => {
const messageZaps = zaps.filter(z => z.event === ev.id);
return messageZaps.reduce((acc, z) => acc + z.amount, 0);
return zaps.filter(a => a.event?.id === ev.id).reduce((acc, z) => acc + z.amount, 0);
}, [zaps, ev]);
const hasZaps = totalZaps > 0;
const awardedBadges = badges.filter(b => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey));
@ -139,6 +130,7 @@ export function ChatMessage({
<>
<div className={`message${streamer === ev.pubkey ? " streamer" : ""}`} ref={ref}>
<Profile
className="text-secondary"
icon={
ev.pubkey === streamer ? (
<Icon name="signal" size={16} />
@ -151,23 +143,23 @@ export function ChatMessage({
)
}
pubkey={ev.pubkey}
profile={profile}
/>
<Text tags={ev.tags} content={ev.content} customComponents={customComponents} />
&nbsp;
<Text tags={ev.tags} content={ev.content} eventComponent={CollapsibleEvent} />
{(hasReactions || hasZaps) && (
<div className="message-reactions">
{hasZaps && (
<div className="zap-pill">
<Icon name="zap-filled" className="zap-pill-icon" />
<Icon name="zap-filled" className="text-zap" size={12} />
<span className="zap-pill-amount">{formatSats(totalZaps)}</span>
</div>
)}
{emojiReactions.map(e => {
{dedupe(filteredReactions.map(v => emojifyReaction(v.content))).map(e => {
const isCustomEmojiReaction = e.length > 1 && e.startsWith(":") && e.endsWith(":");
const emojiName = e.replace(/:/g, "");
const emoji = isCustomEmojiReaction && getEmojiById(emojiName);
return (
<div className="message-reaction-container">
<div className="message-reaction-container" key={`${ev.id}-${emojiName}`}>
{isCustomEmojiReaction && emoji ? (
<span className="message-reaction">
<EmojiComponent name={emoji[1]} url={emoji[2]} />
@ -202,33 +194,35 @@ export function ChatMessage({
eTag={ev.id}
pubkey={ev.pubkey}
button={
<button className="message-zap-button">
<AsyncButton className="message-zap-button">
<Icon name="zap" className="message-zap-button-icon" />
</button>
</AsyncButton>
}
targetName={profile?.name || ev.pubkey}
/>
)}
<button className="message-zap-button" onClick={pickEmoji}>
<AsyncButton className="message-zap-button" onClick={pickEmoji}>
<Icon name="face" className="message-zap-button-icon" />
</button>
</AsyncButton>
{shouldShowMuteButton && (
<button className="message-zap-button" onClick={muteUser}>
<AsyncButton className="message-zap-button" onClick={muteUser}>
<Icon name="user-x" className="message-zap-button-icon" />
</button>
</AsyncButton>
)}
</div>
)}
</div>
{showEmojiPicker && (
<EmojiPicker
topOffset={topOffset ?? 0}
leftOffset={leftOffset ?? 0}
emojiPacks={emojiPacks}
onEmojiSelect={onEmojiSelect}
onClickOutside={() => setShowEmojiPicker(false)}
ref={emojiRef}
/>
<Suspense>
<EmojiPicker
topOffset={topOffset ?? 0}
leftOffset={leftOffset ?? 0}
emojiPacks={emojiPacks}
onEmojiSelect={onEmojiSelect}
onClickOutside={() => setShowEmojiPicker(false)}
ref={emojiRef}
/>
</Suspense>
)}
</>
);

126
src/element/clip-button.tsx Normal file
View File

@ -0,0 +1,126 @@
import * as Dialog from "@radix-ui/react-dialog";
import { useLogin } from "@/hooks/login";
import { useContext, useEffect, useRef, useState } from "react";
import { NostrStreamProvider } from "@/providers";
import { FormattedMessage } from "react-intl";
import { SnortContext } from "@snort/system-react";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import AsyncButton from "./async-button";
import { LIVE_STREAM_CLIP, StreamState } from "@/const";
import { extractStreamInfo } from "@/utils";
import { Icon } from "./icon";
import { unwrap } from "@snort/shared";
import { TimelineBar } from "./timeline";
export function ClipButton({ ev }: { ev: TaggedNostrEvent }) {
const system = useContext(SnortContext);
const { id, service, status } = extractStreamInfo(ev);
const ref = useRef<HTMLVideoElement | null>(null);
const login = useLogin();
const [open, setOpen] = useState(false);
const [tempClipId, setTempClipId] = useState<string>();
const [start, setStart] = useState(0);
const [length, setLength] = useState(0.1);
const [clipLength, setClipLength] = useState(0);
const [title, setTitle] = useState("");
const publisher = login?.publisher();
useEffect(() => {
if (ref.current) {
ref.current.currentTime = clipLength * start;
}
}, [ref.current, clipLength, start, length]);
useEffect(() => {
if (ref.current) {
ref.current.ontimeupdate = () => {
if (!ref.current) return;
console.debug(ref.current.currentTime);
const end = clipLength * (start + length);
if (ref.current.currentTime >= end) {
ref.current.pause();
}
};
}
}, [ref.current, clipLength, start, length]);
if (!service || status !== StreamState.Live) return;
const provider = new NostrStreamProvider("", service, publisher);
async function makeClip() {
if (!service || !id || !publisher) return;
const clip = await provider.prepareClip(id);
console.debug(clip);
setTempClipId(clip.id);
setClipLength(clip.length);
setOpen(true);
}
async function saveClip() {
if (!service || !id || !publisher || !tempClipId) return;
const newClip = await provider.createClip(id, tempClipId, clipLength * start, clipLength * length);
const ee = await publisher.generic(eb => {
return eb
.kind(LIVE_STREAM_CLIP)
.tag(unwrap(NostrLink.fromEvent(ev).toEventTag("root")))
.tag(["r", newClip.url])
.tag(["title", title])
.tag(["alt", `Live stream clip created on https://zap.stream\n${newClip.url}`]);
});
console.debug(ee);
await system.BroadcastEvent(ee);
setOpen(false);
}
return (
<>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<div className="contents">
<AsyncButton onClick={makeClip} className="btn btn-primary">
<Icon name="clapperboard" />
<span className="max-lg:hidden">
<FormattedMessage defaultMessage="Create Clip" id="PA0ej4" />
</span>
</AsyncButton>
</div>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<div className="content-inner">
<h1>
<FormattedMessage defaultMessage="Create Clip" id="PA0ej4" />
</h1>
{id && tempClipId && <video ref={ref} src={provider.getTempClipUrl(id, tempClipId)} controls muted />}
<TimelineBar
length={length}
offset={start}
width={300}
height={60}
setOffset={setStart}
setLength={setLength}
/>
<div className="flex flex-col gap-1">
<small>
<FormattedMessage defaultMessage="Clip title" id="YwzT/0" />
</small>
<div className="paper">
<input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="Epic combo!" />
</div>
</div>
<AsyncButton onClick={saveClip}>
<FormattedMessage defaultMessage="Publish Clip" id="jJLRgo" />
</AsyncButton>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</>
);
}

View File

@ -5,7 +5,7 @@
}
.collapsible-media a {
color: var(--text-link);
color: var(--primary);
word-wrap: break-word;
}
@ -15,7 +15,7 @@
}
.url-preview {
color: var(--text-link);
color: var(--primary);
cursor: zoom-in;
}

View File

@ -8,10 +8,11 @@ import * as Collapsible from "@radix-ui/react-collapsible";
import type { NostrLink } from "@snort/system";
import { Mention } from "element/mention";
import { NostrEvent, EventIcon } from "element/Event";
import { ExternalLink } from "element/external-link";
import { useEvent } from "hooks/event";
import { Mention } from "./mention";
import { EventIcon, NostrEvent } from "./Event";
import { ExternalLink } from "./external-link";
import { useEvent } from "@/hooks/event";
import AsyncButton from "./async-button";
interface MediaURLProps {
url: URL;
@ -31,9 +32,9 @@ export function MediaURL({ url, children }: MediaURLProps) {
{children}
</div>
<Dialog.Close asChild>
<button className="btn delete-button" aria-label="Close">
<FormattedMessage defaultMessage="Close" />
</button>
<AsyncButton className="btn delete-button" aria-label="Close">
<FormattedMessage defaultMessage="Close" id="rbrahO" />
</AsyncButton>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
@ -54,9 +55,13 @@ export function CollapsibleEvent({ link }: { link: NostrLink }) {
{author && <Mention pubkey={author} />}
</div>
<Collapsible.Trigger asChild>
<button className={`${open ? "btn btn-small delete-button" : "btn btn-small"}`}>
{open ? <FormattedMessage defaultMessage="Hide" /> : <FormattedMessage defaultMessage="Show" />}
</button>
<AsyncButton className={`${open ? "btn btn-small delete-button" : "btn btn-small"}`}>
{open ? (
<FormattedMessage defaultMessage="Hide" id="VA/Z1S" />
) : (
<FormattedMessage defaultMessage="Show" id="K7AkdL" />
)}
</AsyncButton>
</Collapsible.Trigger>
</div>
<Collapsible.Content>{open && event && <NostrEvent ev={event} />}</Collapsible.Content>

View File

@ -1,6 +1,7 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import AsyncButton from "./async-button";
export function isContentWarningAccepted() {
return Boolean(window.localStorage.getItem("accepted-content-warning"));
@ -19,18 +20,18 @@ export function ContentWarningOverlay() {
return (
<div className="fullscreen-exclusive age-check">
<h1>
<FormattedMessage defaultMessage="Sexually explicit material ahead!" />
<FormattedMessage defaultMessage="Sexually explicit material ahead!" id="rWBFZA" />
</h1>
<h2>
<FormattedMessage defaultMessage="Confirm your age" />
<FormattedMessage defaultMessage="Confirm your age" id="s7V+5p" />
</h2>
<div className="flex g24">
<button className="btn btn-warning" onClick={grownUp}>
<FormattedMessage defaultMessage="Yes, I am over 18" />
</button>
<button className="btn" onClick={() => navigate("/")}>
<FormattedMessage defaultMessage="No, I am under 18" />
</button>
<div className="flex gap-3">
<AsyncButton className="btn btn-warning" onClick={grownUp}>
<FormattedMessage defaultMessage="Yes, I am over 18" id="O2Cy6m" />
</AsyncButton>
<AsyncButton className="btn" onClick={() => navigate("/")}>
<FormattedMessage defaultMessage="No, I am under 18" id="KkIL3s" />
</AsyncButton>
</div>
</div>
);

View File

@ -1,5 +1,5 @@
import "./copy.css";
import { useCopy } from "hooks/copy";
import { useCopy } from "@/hooks/copy";
import { Icon } from "./icon";
export interface CopyProps {

View File

@ -1,16 +1,19 @@
import "./emoji-pack.css";
import { type NostrEvent } from "@snort/system";
import { useLogin } from "hooks/login";
import { toEmojiPack } from "hooks/emoji";
import AsyncButton from "element/async-button";
import { findTag } from "utils";
import { USER_EMOJIS } from "const";
import { Login, System } from "index";
import type { EmojiPack as EmojiPackType } from "types";
import { FormattedMessage } from "react-intl";
import { useContext } from "react";
import { SnortContext } from "@snort/system-react";
import { useLogin } from "@/hooks/login";
import { toEmojiPack } from "@/hooks/emoji";
import AsyncButton from "./async-button";
import { findTag } from "@/utils";
import { USER_EMOJIS } from "@/const";
import { Login } from "@/index";
import type { EmojiPack as EmojiPackType } from "@/types";
export function EmojiPack({ ev }: { ev: NostrEvent }) {
const system = useContext(SnortContext);
const login = useLogin();
const name = findTag(ev, "d");
const isUsed = login?.emojis.find(e => e.author === ev.pubkey && e.name === name);
@ -33,7 +36,7 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
return eb;
});
console.debug(ev);
System.BroadcastEvent(ev);
await system.BroadcastEvent(ev);
Login.setEmojis(newPacks);
}
}
@ -46,7 +49,11 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
<AsyncButton
className={`btn btn-small btn-primary ${isUsed ? "delete-button" : ""}`}
onClick={toggleEmojiPack}>
{isUsed ? <FormattedMessage defaultMessage="Remove" /> : <FormattedMessage defaultMessage="Add" />}
{isUsed ? (
<FormattedMessage defaultMessage="Remove" id="G/yZLu" />
) : (
<FormattedMessage defaultMessage="Add" id="2/2yg+" />
)}
</AsyncButton>
)}
</div>
@ -55,7 +62,7 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
const [, name, image] = e;
return (
<div className="emoji-definition">
<img alt={name} className="emoji" src={image} />
<img alt={name} className="custom-emoji" src={image} />
<span className="emoji-name">{name}</span>
</div>
);

View File

@ -1,7 +1,7 @@
import data, { Emoji } from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { RefObject } from "react";
import { EmojiPack } from "types";
import { EmojiPack } from "@/types";
interface EmojiPickerProps {
topOffset: number;
@ -13,7 +13,7 @@ interface EmojiPickerProps {
ref: RefObject<HTMLDivElement>;
}
export function EmojiPicker({
export default function EmojiPicker({
topOffset,
leftOffset,
onEmojiSelect,

View File

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

View File

@ -1,6 +1,6 @@
import "./emoji.css";
import { useMemo } from "react";
import { EmojiTag } from "types";
import { EmojiTag } from "@/types";
export type EmojiProps = {
name: string;
@ -8,7 +8,7 @@ export type EmojiProps = {
};
export function Emoji({ name, url }: EmojiProps) {
return <img alt={name} src={url} className="emoji" />;
return <img alt={name} title={name} src={url} className="custom-emoji" />;
}
export function Emojify({ content, emoji }: { content: string; emoji: EmojiTag[] }) {

View File

@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Icon } from "element/icon";
import { Icon } from "./icon";
interface ExternalLinkProps {
href: string;

View File

@ -3,6 +3,7 @@ import type { ChangeEvent } from "react";
import { VoidApi } from "@void-cat/api";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import AsyncButton from "./async-button";
const voidCatHost = "https://void.cat";
const fileExtensionRegex = /\.([\w]{1,7})$/i;
@ -13,7 +14,7 @@ type UploadResult = {
error?: string;
};
async function voidCatUpload(file: File | Blob): Promise<UploadResult> {
async function voidCatUpload(file: File): Promise<UploadResult> {
const uploader = voidCatApi.getUploader(file);
const rsp = await uploader.upload({
@ -79,16 +80,16 @@ export function FileUploader({ defaultImage, onClear, onFileUpload }: FileUpload
<label className="file-uploader">
<input type="file" onChange={onFileChange} />
{isUploading ? (
<FormattedMessage defaultMessage="Uploading..." />
<FormattedMessage defaultMessage="Uploading..." id="JEsxDw" />
) : (
<FormattedMessage defaultMessage="Add File" />
<FormattedMessage defaultMessage="Add File" id="fc2iho" />
)}
</label>
<div className="file-uploader-preview">
{img?.length > 0 && (
<button className="btn btn-primary clear-button" onClick={clearImage}>
<FormattedMessage defaultMessage="Clear" />
</button>
<AsyncButton className="btn btn-primary clear-button" onClick={clearImage}>
<FormattedMessage defaultMessage="Clear" id="/GCoTA" />
</AsyncButton>
)}
{img && <img className="image-preview" src={img} />}
</div>

View File

@ -1,11 +1,22 @@
import { EventKind } from "@snort/system";
import { useLogin } from "hooks/login";
import AsyncButton from "element/async-button";
import { Login, System } from "index";
import { FormattedMessage } from "react-intl";
import { useContext } from "react";
import { SnortContext } from "@snort/system-react";
export function LoggedInFollowButton({ tag, value }: { tag: "p" | "t"; value: string }) {
import { useLogin } from "@/hooks/login";
import AsyncButton from "./async-button";
import { Login } from "@/index";
export function LoggedInFollowButton({
tag,
value,
hideWhenFollowing,
}: {
tag: "p" | "t";
value: string;
hideWhenFollowing?: boolean;
}) {
const system = useContext(SnortContext);
const login = useLogin();
if (!login) return;
@ -25,7 +36,7 @@ export function LoggedInFollowButton({ tag, value }: { tag: "p" | "t"; value: st
return eb;
});
console.debug(ev);
System.BroadcastEvent(ev);
await system.BroadcastEvent(ev);
Login.setFollows(newFollows, content ?? "", ev.created_at);
}
}
@ -42,28 +53,33 @@ export function LoggedInFollowButton({ tag, value }: { tag: "p" | "t"; value: st
return eb;
});
console.debug(ev);
System.BroadcastEvent(ev);
await system.BroadcastEvent(ev);
Login.setFollows(newFollows, content ?? "", ev.created_at);
}
}
if (isFollowing && hideWhenFollowing) return;
return (
<AsyncButton
disabled={timestamp ? timestamp === 0 : true}
type="button"
className="btn btn-primary"
onClick={isFollowing ? unfollow : follow}>
{isFollowing ? <FormattedMessage defaultMessage="Unfollow" /> : <FormattedMessage defaultMessage="Follow" />}
{isFollowing ? (
<FormattedMessage defaultMessage="Unfollow" id="izWS4J" />
) : (
<FormattedMessage defaultMessage="Follow" id="ieGrWo" />
)}
</AsyncButton>
);
}
export function FollowTagButton({ tag }: { tag: string }) {
export function FollowTagButton({ tag, hideWhenFollowing }: { tag: string; hideWhenFollowing?: boolean }) {
const login = useLogin();
return login?.pubkey ? <LoggedInFollowButton tag={"t"} value={tag} /> : null;
return login?.pubkey ? <LoggedInFollowButton tag={"t"} value={tag} hideWhenFollowing={hideWhenFollowing} /> : null;
}
export function FollowButton({ pubkey }: { pubkey: string }) {
export function FollowButton({ pubkey, hideWhenFollowing }: { pubkey: string; hideWhenFollowing?: boolean }) {
const login = useLogin();
return login?.pubkey ? <LoggedInFollowButton tag={"p"} value={pubkey} /> : null;
return login?.pubkey ? <LoggedInFollowButton tag={"p"} value={pubkey} hideWhenFollowing={hideWhenFollowing} /> : null;
}

View File

@ -2,23 +2,23 @@ import "./goal.css";
import { useMemo } from "react";
import * as Progress from "@radix-ui/react-progress";
import Confetti from "react-confetti";
import { FormattedMessage } from "react-intl";
import { type NostrEvent } from "@snort/system";
import { type NostrEvent, NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { eventToLink, findTag } from "utils";
import { formatSats } from "number";
import usePreviousValue from "hooks/usePreviousValue";
import { SendZapsDialog } from "element/send-zap";
import { getName } from "element/profile";
import { findTag } from "@/utils";
import { formatSats } from "@/number";
import usePreviousValue from "@/hooks/usePreviousValue";
import { SendZapsDialog } from "./send-zap";
import { getName } from "./profile";
import { Icon } from "./icon";
import { FormattedMessage } from "react-intl";
import { useZaps } from "hooks/zaps";
import { useZaps } from "@/hooks/zaps";
export function Goal({ ev }: { ev: NostrEvent }) {
const profile = useUserProfile(ev.pubkey);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const link = eventToLink(ev);
const link = NostrLink.fromEvent(ev);
const zaps = useZaps(link, true);
const goalAmount = useMemo(() => {
const amount = findTag(ev, "amount");
@ -30,7 +30,9 @@ export function Goal({ ev }: { ev: NostrEvent }) {
}
const soFar = useMemo(() => {
return zaps.filter(z => z.receiver === ev.pubkey && z.event === ev.id).reduce((acc, z) => acc + z.amount, 0);
return zaps
.filter(z => z.receiver === ev.pubkey && z.targetEvents.some(a => a.matchesEvent(ev)))
.reduce((acc, z) => acc + z.amount, 0);
}, [zaps]);
const progress = Math.max(0, Math.min(100, (soFar / goalAmount) * 100));
@ -46,7 +48,7 @@ export function Goal({ ev }: { ev: NostrEvent }) {
{!isFinished && <span className="amount so-far">{formatSats(soFar)}</span>}
</Progress.Indicator>
<span className="amount target">
<FormattedMessage defaultMessage="Goal: {amount}" values={{ amount: formatSats(goalAmount) }} />
<FormattedMessage defaultMessage="Goal: {amount}" id="QceMQZ" values={{ amount: formatSats(goalAmount) }} />
</span>
</Progress.Root>
<div className="zap-circle">

View File

@ -1,12 +1,12 @@
import type { ReactNode } from "react";
import { NostrLink } from "element/nostr-link";
import { MediaURL } from "element/collapsible";
import { NostrLink } from "./nostr-link";
import { MediaURL } from "./collapsible";
const FileExtensionRegex = /\.([\w]+)$/i;
interface HyperTextProps {
link: string;
children: ReactNode;
children?: ReactNode;
}
export function HyperText({ link, children }: HyperTextProps) {

View File

@ -52,7 +52,6 @@
}
.live-chat > .write-message > div:nth-child(1) {
height: 40px;
flex-grow: 1;
}
@ -66,38 +65,20 @@
.live-chat .message {
word-wrap: break-word;
overflow-wrap: anywhere;
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 .message a {
display: inline-flex;
}
.live-chat .message .text a {
color: var(--primary);
overflow-wrap: anywhere;
}
.live-chat .messages {
@ -111,10 +92,6 @@
flex-wrap: wrap;
}
.live-chat .zap-content a {
color: var(--text-link);
}
.top-zappers {
display: flex;
flex-direction: column;
@ -150,28 +127,6 @@
}
}
.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;
@ -194,14 +149,6 @@
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;
@ -222,10 +169,6 @@
}
}
.zap-content {
margin-top: 8px;
}
.zap-pill {
border-radius: 100px;
background: rgba(255, 255, 255, 0.1);
@ -237,12 +180,6 @@
gap: 2px;
}
.zap-pill-icon {
width: 12px;
height: 12px;
color: #ff8d2b;
}
.message-zap-container {
display: flex;
padding: 8px;
@ -345,7 +282,7 @@
color: white;
}
.message .profile .badge-icon {
.message .badge-icon {
background: transparent;
width: 18px;
height: 18px;

View File

@ -1,30 +1,29 @@
import "./live-chat.css";
import { FormattedMessage } from "react-intl";
import { EventKind, NostrPrefix, NostrLink, ParsedZap, NostrEvent, parseZap, encodeTLV } from "@snort/system";
import { unixNow } from "@snort/shared";
import { useMemo } from "react";
import uniqBy from "lodash.uniqby";
import { EventKind, NostrEvent, NostrLink, ParsedZap, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useUserProfile } from "@snort/system-react";
import { unixNow, unwrap } from "@snort/shared";
import { useEffect, useMemo } from "react";
import { Icon } from "element/icon";
import Spinner from "element/spinner";
import { Text } from "element/text";
import { Profile } from "element/profile";
import { ChatMessage } from "element/chat-message";
import { Goal } from "element/goal";
import { Badge } from "element/badge";
import { NewGoalDialog } from "element/new-goal";
import { WriteMessage } from "element/write-message";
import useEmoji, { packId } from "hooks/emoji";
import { useLiveChatFeed } from "hooks/live-chat";
import { useMutedPubkeys } from "hooks/lists";
import { useBadges } from "hooks/badges";
import { useLogin } from "hooks/login";
import { useAddress } from "hooks/event";
import { formatSats } from "number";
import { WEEK, LIVE_STREAM_CHAT } from "const";
import { findTag, getTagValues, getHost } from "utils";
import { System } from "index";
import { TopZappers } from "element/top-zappers";
import { Icon } from "./icon";
import Spinner from "./spinner";
import { Text } from "./text";
import { Profile } from "./profile";
import { ChatMessage } from "./chat-message";
import { Goal } from "./goal";
import { Badge } from "./badge";
import { WriteMessage } from "./write-message";
import useEmoji, { packId } from "@/hooks/emoji";
import { useLiveChatFeed } from "@/hooks/live-chat";
import { useMutedPubkeys } from "@/hooks/lists";
import { useBadges } from "@/hooks/badges";
import { useLogin } from "@/hooks/login";
import { useAddress, useEvent } from "@/hooks/event";
import { formatSats } from "@/number";
import { LIVE_STREAM_CHAT, LIVE_STREAM_CLIP, LIVE_STREAM_RAID, WEEK } from "@/const";
import { findTag, getHost, getTagValues, uniqBy } from "@/utils";
import { TopZappers } from "./top-zappers";
import { Link, useNavigate } from "react-router-dom";
export interface LiveChatOptions {
canWrite?: boolean;
@ -80,15 +79,22 @@ export function LiveChat({
return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId);
}, [userEmojiPacks, channelEmojiPacks]);
const zaps = feed.zaps.map(ev => parseZap(ev, System.ProfileLoader.Cache)).filter(z => z && z.valid);
const reactions = useEventReactions(link, feed.reactions);
const events = useMemo(() => {
return [...feed.messages, ...feed.zaps, ...awards].sort((a, b) => b.created_at - a.created_at);
}, [feed.messages, feed.zaps, awards]);
const naddr = useMemo(() => {
if (ev) {
return encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev.kind, ev.pubkey);
const extra = [];
const starts = findTag(ev, "starts");
if (starts) {
extra.push({ kind: -1, created_at: Number(starts) } as TaggedNostrEvent);
}
}, [ev]);
const ends = findTag(ev, "ends");
if (ends) {
extra.push({ kind: -2, created_at: Number(ends) } as TaggedNostrEvent);
}
return [...feed.messages, ...feed.reactions, ...awards, ...extra]
.filter(a => a.created_at >= started)
.sort((a, b) => b.created_at - a.created_at);
}, [feed.messages, feed.reactions, awards]);
const filteredEvents = useMemo(() => {
return events.filter(e => !mutedPubkeys.has(e.pubkey) && !hostMutedPubkeys.has(e.pubkey));
}, [events, mutedPubkeys, hostMutedPubkeys]);
@ -98,33 +104,46 @@ export function LiveChat({
{(options?.showHeader ?? true) && (
<div className="header">
<h2 className="title">
<FormattedMessage defaultMessage="Stream Chat" />
<FormattedMessage defaultMessage="Stream Chat" id="BGxpTN" />
</h2>
<Icon
name="link"
className="secondary"
size={32}
onClick={() => window.open(`/chat/${naddr}?chat=true`, "_blank", "popup,width=400,height=800")}
onClick={() => window.open(`/chat/${link.encode()}?chat=true`, "_blank", "popup,width=400,height=800")}
/>
</div>
)}
{zaps.length > 0 && (
{reactions.zaps.length > 0 && (
<div className="top-zappers">
<h3>
<FormattedMessage defaultMessage="Top zappers" />
<FormattedMessage defaultMessage="Top zappers" id="wzWWzV" />
</h3>
<div className="top-zappers-container">
<TopZappers zaps={zaps} />
<TopZappers zaps={reactions.zaps} />
</div>
{goal && <Goal ev={goal} />}
{login?.pubkey === host && <NewGoalDialog link={link} />}
</div>
)}
<div className="messages">
{filteredEvents.map(a => {
switch (a.kind) {
case -1:
case -2: {
return (
<b
className="border px-3 py-2 text-center border-gray-2 rounded-xl bg-primary uppercase"
key={`${a.kind}-${a.created_at}`}>
{a.kind === -1 ? (
<FormattedMessage defaultMessage="Stream Started" id="5tM0VD" />
) : (
<FormattedMessage defaultMessage="Stream Ended" id="jkAQj5" />
)}
</b>
);
}
case EventKind.BadgeAward: {
return <BadgeAward ev={a} />;
return <BadgeAward ev={a} key={a.id} />;
}
case LIVE_STREAM_CHAT: {
return (
@ -134,12 +153,18 @@ export function LiveChat({
streamer={host}
ev={a}
key={a.id}
reactions={feed.reactions}
related={feed.reactions}
/>
);
}
case LIVE_STREAM_RAID: {
return <ChatRaid ev={a} link={link} key={a.id} />;
}
case LIVE_STREAM_CLIP: {
return <ChatClip ev={a} key={a.id} />;
}
case EventKind.ZapReceipt: {
const zap = zaps.find(b => b.id === a.id && b.receiver === host);
const zap = reactions.zaps.find(b => b.id === a.id && b.receiver === host);
if (zap) {
return <ChatZap zap={zap} key={a.id} />;
}
@ -155,7 +180,7 @@ export function LiveChat({
<WriteMessage emojiPacks={allEmojiPacks} link={link} />
) : (
<p>
<FormattedMessage defaultMessage="Please login to write messages!" />
<FormattedMessage defaultMessage="Please login to write messages!" id="RXQdxR" />
</p>
)}
</div>
@ -166,7 +191,7 @@ export function LiveChat({
const BIG_ZAP_THRESHOLD = 50_000;
function ChatZap({ zap }: { zap: ParsedZap }) {
export function ChatZap({ zap }: { zap: ParsedZap }) {
if (!zap.valid) {
return null;
}
@ -174,17 +199,18 @@ function ChatZap({ zap }: { zap: ParsedZap }) {
return (
<div className={`zap-container ${isBig ? "big-zap" : ""}`}>
<div className="zap">
<Icon name="zap-filled" className="zap-icon" />
<div className="flex gap-1 items-center">
<Icon name="zap-filled" className="text-zap" />
<FormattedMessage
defaultMessage="{person} zapped {amount} sats"
defaultMessage="<s>{person}</s> zapped <s>{amount}</s> sats"
id="q+zTWM"
values={{
s: c => <span className="text-zap">{c}</span>,
person: (
<Profile
pubkey={zap.anonZap ? "anon" : zap.sender ?? "anon"}
pubkey={zap.anonZap ? "anon" : zap.sender ?? ""}
options={{
showAvatar: !zap.anonZap,
overrideName: zap.anonZap ? "Anon" : undefined,
}}
/>
),
@ -192,11 +218,72 @@ function ChatZap({ zap }: { zap: ParsedZap }) {
}}
/>
</div>
{zap.content && (
<div className="zap-content">
<Text content={zap.content} tags={[]} />
</div>
)}
{zap.content && <Text content={zap.content} tags={[]} />}
</div>
);
}
export function ChatRaid({ link, ev }: { link: NostrLink; ev: TaggedNostrEvent }) {
const navigate = useNavigate();
const from = ev.tags.find(a => a[0] === "a" && a[3] === "root");
const to = ev.tags.find(a => a[0] === "a" && a[3] === "mention");
const isRaiding = link.toEventTag()?.at(1) === from?.at(1);
const otherLink = NostrLink.fromTag(unwrap(isRaiding ? to : from));
const otherEvent = useEvent(otherLink);
const otherProfile = useUserProfile(getHost(otherEvent));
useEffect(() => {
const raidDiff = Math.abs(unixNow() - ev.created_at);
if (isRaiding === true && raidDiff < 60) {
navigate(`/${otherLink.encode()}`);
}
}, [isRaiding]);
if (isRaiding) {
return (
<Link
to={`/${otherLink.encode()}`}
className="px-3 py-2 text-center rounded-xl bg-primary uppercase pointer font-bold">
<FormattedMessage
defaultMessage="Raiding {name}"
id="j/jueq"
values={{
name: otherProfile?.name,
}}
/>
</Link>
);
}
return (
<div className="px-3 py-2 text-center rounded-xl bg-primary uppercase pointer font-bold">
<FormattedMessage
defaultMessage="Raid from {name}"
id="69hmpj"
values={{
name: otherProfile?.name,
}}
/>
</div>
);
}
function ChatClip({ ev }: { ev: TaggedNostrEvent }) {
const profile = useUserProfile(ev.pubkey);
const rTag = findTag(ev, "r");
const title = findTag(ev, "title");
return (
<div className="px-3 py-2 text-center rounded-xl bg-primary pointer flex flex-col gap-2">
<div className="font-bold uppercase">
<FormattedMessage
defaultMessage="{name} created a clip"
id="BD0vyn"
values={{
name: profile?.name,
}}
/>
</div>
<div>{title}</div>
{rTag && <video src={rTag} controls playsInline={true} muted={true} />}
</div>
);
}

View File

@ -1,29 +1,55 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import Hls from "hls.js";
import { useEffect, useMemo, useRef, useState } from "react";
import { WISH } from "wish";
import { HTMLProps, useEffect, useMemo, useRef, useState } from "react";
import { FormattedMessage } from "react-intl";
import { Icon } from "./icon";
import { ProgressBar } from "./progress-bar";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { StreamState } from "@/const";
export enum VideoStatus {
Online = "online",
Offline = "offline",
}
export interface VideoPlayerProps {
type VideoPlayerProps = {
title?: string;
stream?: string;
status?: string;
poster?: string;
}
muted?: boolean;
} & HTMLProps<HTMLVideoElement>;
export function LiveVideoPlayer(props: VideoPlayerProps) {
export default function LiveVideoPlayer({
title,
stream,
status: pStatus,
poster,
muted: pMuted,
...props
}: VideoPlayerProps) {
const video = useRef<HTMLVideoElement>(null);
const streamCached = useMemo(() => props.stream, [props.stream]);
const hlsObj = useRef<Hls>(null);
const streamCached = useMemo(() => stream, [stream]);
const [status, setStatus] = useState<VideoStatus>();
const [src, setSrc] = useState<string>();
const [levels, setLevels] = useState<Array<{ level: number; height: number }>>();
const [level, setLevel] = useState<number>(-1);
const [playState, setPlayState] = useState<"loading" | "playing" | "paused">("playing");
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(pMuted ?? false);
const [position, setPosition] = useState<number>();
const [maxPosition, setMaxPosition] = useState<number>();
useEffect(() => {
if (streamCached && video.current) {
if (Hls.isSupported()) {
if (Hls.isSupported() && streamCached.endsWith(".m3u8")) {
try {
const hls = new Hls();
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
});
hls.loadSource(streamCached);
hls.attachMedia(video.current);
hls.on(Hls.Events.ERROR, (event, data) => {
@ -37,11 +63,28 @@ export function LiveVideoPlayer(props: VideoPlayerProps) {
});
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setStatus(VideoStatus.Online);
setLevels([
{
level: -1,
height: 0,
},
...hls.levels.map((a, i) => ({
level: i,
height: a.height,
})),
]);
});
hls.on(Hls.Events.LEVEL_SWITCHING, (e, l) => {
hls.on(Hls.Events.LEVEL_SWITCHING, (_, l) => {
console.debug("HLS Level Switch", l);
setMaxPosition(l.details?.totalduration);
});
return () => hls.destroy();
// @ts-ignore Can write anyway
hlsObj.current = hls;
return () => {
// @ts-ignore Can write anyway
hlsObj.current = null;
hls.destroy();
};
} catch (e) {
console.error(e);
setStatus(VideoStatus.Offline);
@ -53,60 +96,168 @@ export function LiveVideoPlayer(props: VideoPlayerProps) {
video.current.load();
}
}
}, [video, streamCached, props.status]);
}, [video, streamCached, pStatus]);
useEffect(() => {
if (hlsObj.current) {
hlsObj.current.nextLevel = level;
}
}, [hlsObj, level]);
useEffect(() => {
if (video.current) {
video.current.onplaying = () => setPlayState("playing");
video.current.onpause = () => setPlayState("paused");
video.current.onseeking = () => {
if (video.current?.paused) {
setPlayState("paused");
} else {
setPlayState("loading");
}
};
video.current.onplay = () => setPlayState("loading");
video.current.onvolumechange = () => setVolume(video.current?.volume ?? 1);
video.current.ontimeupdate = () => setPosition(video.current?.currentTime);
}
}, [video]);
useEffect(() => {
if (video.current) {
if (video.current.volume !== volume) {
video.current.volume = volume;
}
if (video.current.muted !== muted) {
video.current.muted = muted;
}
}
}, [video, volume, muted]);
function playStateToIcon() {
switch (playState) {
case "playing":
return "pause";
case "paused":
return "play";
case "loading":
return "loading";
}
}
function togglePlay() {
if (video.current) {
if (playState === "playing") {
video.current.pause();
} else if (playState === "paused") {
video.current.play();
}
}
}
function toggleMute() {
setMuted(s => !s);
}
function levelName(l: number) {
if (l === -1) {
return <FormattedMessage defaultMessage="AUTO" id="o8pHw3" />;
} else {
const h = levels?.find(a => a.level === l)?.height;
return <FormattedMessage defaultMessage="{n}p" id="YagVIe" values={{ n: h }} />;
}
}
function playerOverlay() {
return (
<>
{status === VideoStatus.Online && (
<div
className="absolute opacity-0 hover:opacity-90 transition-opacity w-full h-full z-20 bg-[#00000055] select-none"
onClick={() => togglePlay()}>
{/* TITLE */}
<div className="absolute top-2 w-full text-center">
<h2>{title}</h2>
</div>
{/* CENTER PLAY ICON */}
<div className="absolute w-full h-full flex items-center justify-center pointer">
<Icon name={playStateToIcon()} size={80} className={playState === "loading" ? "animate-spin" : ""} />
</div>
{/* PLAYER CONTROLS OVERLAY */}
<div
className="absolute flex items-center gap-1 bottom-0 w-full bg-primary h-[40px]"
onClick={e => e.stopPropagation()}>
<div className="flex grow gap-1 items-center">
<div className="px-5 py-2 pointer" onClick={() => togglePlay()}>
<Icon name={playStateToIcon()} className={playState === "loading" ? "animate-spin" : ""} />
</div>
<div className="px-3 py-2 uppercase font-bold tracking-wide hover:bg-primary-hover">{pStatus}</div>
{pStatus === StreamState.Ended && maxPosition !== undefined && position !== undefined && (
<ProgressBar
value={position / maxPosition}
setValue={v => {
const ct = maxPosition * v;
if (video.current) {
video.current.currentTime = ct;
}
setPosition(ct);
}}
marker={<div className="w-[16px] h-[16px] mt-[-8px] rounded-full bg-white"></div>}
style={{ width: "100%", height: "4px" }}
/>
)}
</div>
<div className="flex gap-1 items-center h-full py-2">
<Icon name={muted ? "volume-muted" : "volume"} onClick={toggleMute} />
<ProgressBar value={volume} setValue={v => setVolume(v)} style={{ width: "100px", height: "100%" }} />
</div>
<div>
<Menu
direction="top"
align="center"
menuButton={<div className="px-3 py-2 tracking-wide pointer">{levelName(level)}</div>}
menuClassName="bg-primary w-fit">
{levels?.map(v => (
<MenuItem
value={v.level}
key={v.level}
onClick={() => setLevel(v.level)}
className="bg-primary px-3 py-2 text-white">
{levelName(v.level)}
</MenuItem>
))}
</Menu>
</div>
<div
className="px-3 py-2 pointer"
onClick={() => {
if (video.current) {
video.current.requestFullscreen();
}
}}>
<Icon name="fullscreen" size={24} />
</div>
</div>
</div>
)}
{status === VideoStatus.Offline && (
<div className="absolute w-full h-full z-20 bg-[#000000aa] flex items-center justify-center text-3xl font-bold uppercase">
<FormattedMessage defaultMessage="Offline" id="7UOvbT" />
</div>
)}
</>
);
}
return (
<div className="video-overlay">
<div className={status}>
<div>{status}</div>
</div>
<div className="relative">
{playerOverlay()}
<video
{...props}
className={props.className}
ref={video}
autoPlay={true}
poster={props.poster}
poster={poster}
src={src}
playsInline={true}
controls={status === VideoStatus.Online}
/>
</div>
);
}
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] = 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="video-overlay">
<div className={status}>
<div>{status}</div>
</div>
<video ref={video} autoPlay={true} poster={props.poster} controls={status === VideoStatus.Online} />
</div>
);
}

View File

@ -1,26 +1,32 @@
import "./login-signup.css";
import LoginHeader from "../login-start.png";
import LoginVault from "../login-vault.png";
import LoginProfile from "../login-profile.png";
import LoginKey from "../login-key.png";
import LoginWallet from "../login-wallet.png";
import LoginHeader from "../login-start.jpg";
import LoginHeader2x from "../login-start@2x.jpg";
import LoginVault from "../login-vault.jpg";
import LoginVault2x from "../login-vault@2x.jpg";
import LoginProfile from "../login-profile.jpg";
import LoginProfile2x from "../login-profile@2x.jpg";
import LoginKey from "../login-key.jpg";
import LoginKey2x from "../login-key@2x.jpg";
import LoginWallet from "../login-wallet.jpg";
import LoginWallet2x from "../login-wallet@2x.jpg";
import { CSSProperties, useState } from "react";
import { CSSProperties, useContext, useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { EventPublisher, UserMetadata } from "@snort/system";
import { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/curves/abstract/utils";
import { LNURL, bech32ToHex, getPublicKey } from "@snort/shared";
import { LNURL, bech32ToHex, getPublicKey, hexToBech32 } from "@snort/shared";
import { VoidApi } from "@void-cat/api";
import { SnortContext } from "@snort/system-react";
import AsyncButton from "./async-button";
import { Login, System } from "index";
import { Login } from "@/index";
import { Icon } from "./icon";
import Copy from "./copy";
import { hexToBech32, openFile } from "utils";
import { LoginType } from "login";
import { DefaultProvider, StreamProviderInfo } from "providers";
import { Nip103StreamProvider } from "providers/zsz";
import { openFile } from "@/utils";
import { LoginType } from "@/login";
import { DefaultProvider, StreamProviderInfo } from "@/providers";
import { NostrStreamProvider } from "@/providers/zsz";
enum Stage {
Login = 0,
@ -31,6 +37,7 @@ enum Stage {
}
export function LoginSignup({ close }: { close: () => void }) {
const system = useContext(SnortContext);
const [error, setError] = useState("");
const [stage, setStage] = useState(Stage.Login);
const [username, setUsername] = useState("");
@ -74,7 +81,7 @@ export function LoginSignup({ close }: { close: () => void }) {
function createAccount() {
const newKey = bytesToHex(schnorr.utils.randomPrivateKey());
setNewKey(newKey);
setLnAddress(`${getPublicKey(newKey)}@zap.stream`)
setLnAddress(`${getPublicKey(newKey)}@zap.stream`);
setStage(Stage.Details);
}
@ -102,11 +109,11 @@ export function LoginSignup({ close }: { close: () => void }) {
}
async function setupProfile() {
const px = new Nip103StreamProvider(DefaultProvider.name, DefaultProvider.url, EventPublisher.privateKey(key));
const px = new NostrStreamProvider(DefaultProvider.name, DefaultProvider.url, EventPublisher.privateKey(key));
const info = await px.info();
setProviderInfo(info);
setStage(Stage.LnAddress)
setStage(Stage.LnAddress);
}
async function saveProfile() {
@ -116,9 +123,12 @@ export function LoginSignup({ close }: { close: () => void }) {
const lnurl = new LNURL(lnAddress);
await lnurl.load();
} catch {
throw new Error(formatMessage({
defaultMessage: "Hmm, your lightning address looks wrong"
}));
throw new Error(
formatMessage({
defaultMessage: "Hmm, your lightning address looks wrong",
id: "4l69eO",
})
);
}
const pub = EventPublisher.privateKey(key);
const profile = {
@ -129,7 +139,7 @@ export function LoginSignup({ close }: { close: () => void }) {
const ev = await pub.metadata(profile);
console.debug(ev);
System.BroadcastEvent(ev);
system.BroadcastEvent(ev);
setStage(Stage.SaveKey);
} catch (e) {
@ -145,31 +155,33 @@ export function LoginSignup({ close }: { close: () => void }) {
case Stage.Login: {
return (
<>
<img src={LoginHeader} className="header-image" />
<img src={LoginHeader as string} srcSet={`${LoginHeader2x} 2x`} className="header-image" />
<div className="content-inner">
<h2>
<FormattedMessage defaultMessage="Create an Account" />
<FormattedMessage defaultMessage="Create an Account" id="u6uD94" />
</h2>
<h3>
<FormattedMessage defaultMessage="No emails, just awesomeness!" />
<FormattedMessage defaultMessage="No emails, just awesomeness!" id="+AcVD+" />
</h3>
<button type="button" className="btn btn-primary btn-block" onClick={createAccount}>
<FormattedMessage defaultMessage="Create Account" />
</button>
<AsyncButton className="btn btn-primary btn-block" onClick={createAccount}>
<FormattedMessage defaultMessage="Create Account" id="5JcXdV" />
</AsyncButton>
<div className="or-divider">
<hr />
<FormattedMessage defaultMessage="OR" />
<FormattedMessage defaultMessage="OR" id="INlWvJ" />
<hr />
</div>
{hasNostrExtension && <>
<AsyncButton type="button" className="btn btn-primary btn-block" onClick={loginNip7}>
<FormattedMessage defaultMessage="Nostr Extension" />
</AsyncButton>
</>}
<button type="button" className="btn btn-primary btn-block" onClick={() => setStage(Stage.LoginInput)}>
<FormattedMessage defaultMessage="Login with Private Key (insecure)" />
</button>
{hasNostrExtension && (
<>
<AsyncButton className="btn btn-primary btn-block" onClick={loginNip7}>
<FormattedMessage defaultMessage="Nostr Extension" id="ebmhes" />
</AsyncButton>
</>
)}
<AsyncButton className="btn btn-primary btn-block" onClick={() => setStage(Stage.LoginInput)}>
<FormattedMessage defaultMessage="Login with Private Key (insecure)" id="feZ/kG" />
</AsyncButton>
{error && <b className="error">{error}</b>}
</div>
</>
@ -178,49 +190,62 @@ export function LoginSignup({ close }: { close: () => void }) {
case Stage.LoginInput: {
return (
<>
<img src={LoginVault} className="header-image" />
<img src={LoginVault as string} srcSet={`${LoginVault2x} 2x`} className="header-image" />
<div className="content-inner">
<h2>
<FormattedMessage defaultMessage="Login with private key" />
<FormattedMessage defaultMessage="Login with private key" id="3df560" />
</h2>
<p>
<FormattedMessage defaultMessage="This method is insecure. We recommend using a {nostrlink}" values={{
nostrlink: <a href="">
<FormattedMessage defaultMessage="nostr signer extension" />
</a>
}} />
<FormattedMessage
defaultMessage="This method is insecure. We recommend using a {nostrlink}"
id="Z8ZOEY"
values={{
nostrlink: (
<a href="">
<FormattedMessage defaultMessage="nostr signer extension" id="/EvlqN" />
</a>
),
}}
/>
</p>
<div className="paper">
<input type="text" value={key} onChange={e => setNewKey(e.target.value)} placeholder={formatMessage({ defaultMessage: "eg. nsec1xyz" })} />
<input
type="text"
value={key}
onChange={e => setNewKey(e.target.value)}
placeholder={formatMessage({ defaultMessage: "eg. nsec1xyz", id: "yzKwBQ" })}
/>
</div>
<div className="flex f-space">
<div className="flex justify-between">
<div></div>
<div className="flex g8">
<button type="button" className="btn btn-secondary" onClick={() => {
setNewKey("");
setStage(Stage.Login)
}}>
<FormattedMessage defaultMessage="Cancel" />
</button>
<div className="flex gap-1">
<AsyncButton
className="btn btn-secondary"
onClick={() => {
setNewKey("");
setStage(Stage.Login);
}}>
<FormattedMessage defaultMessage="Cancel" id="47FYwb" />
</AsyncButton>
<AsyncButton onClick={doLoginNsec} className="btn btn-primary">
<FormattedMessage defaultMessage="Log In" />
<FormattedMessage defaultMessage="Log In" id="r2Jjms" />
</AsyncButton>
</div>
</div>
{error && <b className="error">{error}</b>}
</div>
</>
)
);
}
case Stage.Details: {
return (
<>
<img src={LoginProfile} className="header-image" />
<img src={LoginProfile as string} srcSet={`${LoginProfile2x} 2x`} className="header-image" />
<div className="content-inner">
<h2>
<FormattedMessage defaultMessage="Setup Profile" />
<FormattedMessage defaultMessage="Setup Profile" id="nOaArs" />
</h2>
<div className="flex f-center">
<div className="flex items-center">
<div
className="avatar-input"
onClick={uploadAvatar}
@ -234,14 +259,19 @@ export function LoginSignup({ close }: { close: () => void }) {
</div>
<div className="username">
<div className="paper">
<input type="text" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} />
<input
type="text"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
/>
</div>
<small>
<FormattedMessage defaultMessage="You can change this later" />
<FormattedMessage defaultMessage="You can change this later" id="ZmqxZs" />
</small>
</div>
<AsyncButton type="button" className="btn btn-primary" onClick={setupProfile}>
<FormattedMessage defaultMessage="Save" />
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
</AsyncButton>
</div>
</>
@ -250,52 +280,69 @@ export function LoginSignup({ close }: { close: () => void }) {
case Stage.LnAddress: {
return (
<>
<img src={LoginWallet} className="header-image" />
<img src={LoginWallet as string} srcSet={`${LoginWallet2x} 2x`} className="header-image" />
<div className="content-inner">
<h2>
<FormattedMessage defaultMessage="Get paid by viewers" />
<FormattedMessage defaultMessage="Get paid by viewers" id="Fodi9+" />
</h2>
<p>
<FormattedMessage defaultMessage="We hooked you up with a lightning wallet so you can get paid by viewers right away!" />
<FormattedMessage
defaultMessage="We hooked you up with a lightning wallet so you can get paid by viewers right away!"
id="Oxqtyf"
/>
</p>
{providerInfo?.balance && <p>
<FormattedMessage defaultMessage="Oh, and you have {n} sats of free streaming on us! 💜" values={{
n: <FormattedNumber value={providerInfo.balance} />
}} />
</p>}
{providerInfo?.balance && (
<p>
<FormattedMessage
defaultMessage="Oh, and you have {n} sats of free streaming on us! 💜"
id="f6biFA"
values={{
n: <FormattedNumber value={providerInfo.balance} />,
}}
/>
</p>
)}
<div className="username">
<div className="paper">
<input type="text" placeholder={formatMessage({ defaultMessage: "eg. name@wallet.com" })} value={lnAddress} onChange={e => setLnAddress(e.target.value)} />
<input
type="text"
placeholder={formatMessage({ defaultMessage: "eg. name@wallet.com", id: "1qsXCO" })}
value={lnAddress}
onChange={e => setLnAddress(e.target.value)}
/>
</div>
<small>
<FormattedMessage defaultMessage="You can always replace it with your own address later." />
<FormattedMessage defaultMessage="You can always replace it with your own address later." id="FjDlus" />
</small>
</div>
{error && <b className="error">{error}</b>}
<AsyncButton type="button" className="btn btn-primary" onClick={saveProfile}>
<FormattedMessage defaultMessage="Amazing! Continue.." />
<FormattedMessage defaultMessage="Amazing! Continue.." id="tM6fNW" />
</AsyncButton>
</div>
</>
)
);
}
case Stage.SaveKey: {
return (
<>
<img src={LoginKey} className="header-image" />
<img src={LoginKey as string} srcSet={`${LoginKey2x} 2x`} className="header-image" />
<div className="content-inner">
<h2>
<FormattedMessage defaultMessage="Save Key" />
<FormattedMessage defaultMessage="Save Key" id="04lmFi" />
</h2>
<p>
<FormattedMessage defaultMessage="Save this and keep it safe! If you lose this key, you won't be able to access your account ever again. Yep, it's that serious!" />
<FormattedMessage
defaultMessage="Save this and keep it safe! If you lose this key, you won't be able to access your account ever again. Yep, it's that serious!"
id="H/bNs9"
/>
</p>
<div className="paper">
<Copy text={hexToBech32("nsec", key)} />
</div>
<button type="button" className="btn btn-primary" onClick={loginWithKey}>
<FormattedMessage defaultMessage="Ok, it's safe" />
</button>
<AsyncButton className="btn btn-primary" onClick={loginWithKey}>
<FormattedMessage defaultMessage="Ok, it's safe" id="My6HwN" />
</AsyncButton>
</div>
</>
);

View File

@ -1,20 +1,37 @@
.markdown a {
color: var(--text-link);
}
.markdown > ul,
.markdown > ol {
margin: 0;
padding: 0 12px;
.markdown {
font-size: 18px;
font-weight: 400;
line-height: 29px;
}
.markdown > p {
font-size: 18px;
font-style: normal;
overflow-wrap: break-word;
font-weight: 400;
line-height: 29px; /* 161.111% */
.markdown a {
color: var(--primary);
}
.markdown blockquote {
margin: 0;
color: var(--font-secondary-color);
border-left: 2px solid var(--font-secondary-color);
padding-left: 12px;
}
.markdown hr {
border: 0;
height: 1px;
background-image: var(--gray-gradient);
margin: 20px;
}
.markdown img:not(.custom-emoji),
.markdown video,
.markdown iframe,
.markdown audio {
width: 100%;
display: block;
}
.markdown iframe,
.markdown video {
width: -webkit-fill-available;
aspect-ratio: 16 / 9;
}

View File

@ -1,49 +1,97 @@
import "./markdown.css";
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import { HyperText } from "element/hypertext";
import { transformText, type Fragment } from "element/text";
import type { Tags } from "types";
import { ReactNode, forwardRef, useMemo } from "react";
import { Token, marked } from "marked";
import { HyperText } from "./hypertext";
import { Text } from "./text";
interface MarkdownProps {
content: string;
tags?: Tags;
tags?: Array<Array<string>>;
}
interface LinkProps {
href?: string;
children?: Array<Fragment>;
function renderToken(t: Token): ReactNode {
try {
switch (t.type) {
case "paragraph": {
return <p>{t.tokens ? t.tokens.map(renderToken) : t.raw}</p>;
}
case "image": {
return <img src={t.href} />;
}
case "heading": {
switch (t.depth) {
case 1:
return <h1>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h1>;
case 2:
return <h2>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h2>;
case 3:
return <h3>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h3>;
case 4:
return <h4>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h4>;
case 5:
return <h5>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h5>;
case 6:
return <h6>{t.tokens ? t.tokens.map(renderToken) : t.raw}</h6>;
}
throw new Error("Invalid heading");
}
case "codespan": {
return <code>{t.raw}</code>;
}
case "code": {
return <pre>{t.raw}</pre>;
}
case "br": {
return <br />;
}
case "hr": {
return <hr />;
}
case "blockquote": {
return <blockquote>{t.tokens ? t.tokens.map(renderToken) : t.raw}</blockquote>;
}
case "link": {
return <HyperText link={t.href}>{t.tokens ? t.tokens.map(renderToken) : t.raw}</HyperText>;
}
case "list": {
if (t.ordered) {
return <ol>{t.items.map(renderToken)}</ol>;
} else {
return <ul>{t.items.map(renderToken)}</ul>;
}
}
case "list_item": {
return <li>{t.tokens ? t.tokens.map(renderToken) : t.raw}</li>;
}
case "em": {
return <em>{t.tokens ? t.tokens.map(renderToken) : t.raw}</em>;
}
case "del": {
return <s>{t.tokens ? t.tokens.map(renderToken) : t.raw}</s>;
}
default: {
if ("tokens" in t) {
return (t.tokens as Array<Token>).map(renderToken);
}
return <Text content={t.raw} tags={[]} />;
}
}
} catch (e) {
console.error(e);
}
}
interface ComponentProps {
children?: Array<Fragment>;
}
const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps, ref) => {
const parsed = useMemo(() => {
return marked.lexer(props.content);
}, [props.content, props.tags]);
export function Markdown({ content, tags = [] }: MarkdownProps) {
const components = useMemo(() => {
return {
li: ({ children, ...props }: ComponentProps) => {
return children && <li {...props}>{transformText(children, tags)}</li>;
},
td: ({ children }: ComponentProps) => {
return children && <td>{transformText(children, tags)}</td>;
},
th: ({ children }: ComponentProps) => {
return children && <th>{transformText(children, tags)}</th>;
},
p: ({ children }: ComponentProps) => {
return children && <p>{transformText(children, tags)}</p>;
},
a: ({ href, children }: LinkProps) => {
return href && <HyperText link={href}>{children}</HyperText>;
},
};
}, [tags]);
return (
<div className="markdown">
<ReactMarkdown components={components}>{content}</ReactMarkdown>
<div className="markdown" ref={ref}>
{parsed.filter(a => a.type !== "footnote" && a.type !== "footnotes").map(a => renderToken(a))}
</div>
);
}
});
export default Markdown;

View File

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

View File

@ -1,11 +1,14 @@
import { useMemo } from "react";
import { useLogin } from "hooks/login";
import AsyncButton from "element/async-button";
import { Login, System } from "index";
import { MUTED } from "const";
import { useContext, useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { SnortContext } from "@snort/system-react";
import { useLogin } from "@/hooks/login";
import AsyncButton from "./async-button";
import { Login } from "@/index";
import { MUTED } from "@/const";
export function useMute(pubkey: string) {
const system = useContext(SnortContext);
const login = useLogin();
const { tags, content } = login?.muted ?? { tags: [] };
const muted = useMemo(() => tags.filter(t => t.at(0) === "p"), [tags]);
@ -23,7 +26,7 @@ export function useMute(pubkey: string) {
return eb;
});
console.debug(ev);
System.BroadcastEvent(ev);
await system.BroadcastEvent(ev);
Login.setMuted(newMuted, content ?? "", ev.created_at);
}
}
@ -40,7 +43,7 @@ export function useMute(pubkey: string) {
return eb;
});
console.debug(ev);
System.BroadcastEvent(ev);
await system.BroadcastEvent(ev);
Login.setMuted(newMuted, content ?? "", ev.created_at);
}
}
@ -52,8 +55,12 @@ export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
const { isMuted, mute, unmute } = useMute(pubkey);
return (
<AsyncButton type="button" className="btn delete-button" onClick={() => (isMuted ? unmute() : mute())}>
{isMuted ? <FormattedMessage defaultMessage="Unmute" /> : <FormattedMessage defaultMessage="Mute" />}
<AsyncButton onClick={() => (isMuted ? unmute() : mute())} className="font-bold">
{isMuted ? (
<FormattedMessage defaultMessage="Unmute" id="W9355R" />
) : (
<FormattedMessage defaultMessage="Mute" id="x82IOl" />
)}
</AsyncButton>
);
}

View File

@ -1,20 +1,17 @@
import "./new-goal.css";
import * as Dialog from "@radix-ui/react-dialog";
import { FormattedMessage } from "react-intl";
import { useContext, useState } from "react";
import { SnortContext } from "@snort/system-react";
import AsyncButton from "./async-button";
import { NostrLink } from "@snort/system";
import { Icon } from "element/icon";
import { useState } from "react";
import { System } from "index";
import { GOAL } from "const";
import { useLogin } from "hooks/login";
import { FormattedMessage } from "react-intl";
import { Icon } from "./icon";
import { GOAL } from "@/const";
import { useLogin } from "@/hooks/login";
import { defaultRelays } from "@/const";
interface NewGoalDialogProps {
link: NostrLink;
}
export function NewGoalDialog({ link }: NewGoalDialogProps) {
export function NewGoalDialog() {
const system = useContext(SnortContext);
const [open, setOpen] = useState(false);
const login = useLogin();
@ -26,16 +23,13 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
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)])
.tag(["relays", ...Object.keys(defaultRelays)])
.content(goalName);
if (link.relays?.length) {
eb.tag(["relays", ...link.relays]);
}
return eb;
});
console.debug(evNew);
System.BroadcastEvent(evNew);
await system.BroadcastEvent(evNew);
setOpen(false);
setGoalName("");
setGoalAmount("");
@ -46,28 +40,28 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button type="button" className="btn btn-primary">
<AsyncButton className="btn btn-primary">
<span>
<Icon name="zap-filled" size={12} />
<span>
<FormattedMessage defaultMessage="Add stream goal" />
<FormattedMessage defaultMessage="Add stream goal" id="wOy57k" />
</span>
</span>
</button>
</AsyncButton>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<div className="new-goal">
<div className="new-goal content-inner">
<div className="zap-goals">
<Icon name="zap-filled" className="stream-zap-goals-icon" size={16} />
<h3>
<FormattedMessage defaultMessage="Stream Zap Goals" />
<FormattedMessage defaultMessage="Stream Zap Goals" id="0GfNiL" />
</h3>
</div>
<div>
<p>
<FormattedMessage defaultMessage="Name" />
<FormattedMessage defaultMessage="Name" id="HAlOn1" />
</p>
<div className="paper">
<input
@ -80,7 +74,7 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
</div>
<div>
<p>
<FormattedMessage defaultMessage="Amount" />
<FormattedMessage defaultMessage="Amount" id="/0TOL5" />
</p>
<div className="paper">
<input
@ -95,7 +89,7 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
</div>
<div className="create-goal">
<AsyncButton type="button" className="btn btn-primary wide" disabled={!isValid} onClick={publishGoal}>
<FormattedMessage defaultMessage="Create Goal" />
<FormattedMessage defaultMessage="Create Goal" id="X2PZ7D" />
</AsyncButton>
</div>
</div>

View File

@ -48,7 +48,7 @@
.new-stream .tos-link {
cursor: pointer;
color: var(--text-link);
color: var(--primary);
}
.new-stream .tos-link:hover {

View File

@ -1,18 +1,21 @@
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, StreamProviders } from "providers";
import { useEffect, useState } from "react";
import { StreamEditor, StreamEditorProps } from "./stream-editor";
import { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { eventLink, findTag } from "utils";
import { NostrProviderDialog } from "./nostr-provider-dialog";
import { unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl";
import { SnortContext } from "@snort/system-react";
function NewStream({ ev, onFinish }: StreamEditorProps) {
import { Icon } from "./icon";
import { useStreamProvider } from "@/hooks/stream-provider";
import { NostrStreamProvider, StreamProvider, StreamProviders } from "@/providers";
import { StreamEditor, StreamEditorProps } from "./stream-editor";
import { eventLink, findTag } from "@/utils";
import { NostrProviderDialog } from "./nostr-provider-dialog";
import AsyncButton from "./async-button";
function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onFinish: () => void }) {
const system = useContext(SnortContext);
const providers = useStreamProvider();
const [currentProvider, setCurrentProvider] = useState<StreamProvider>();
const navigate = useNavigate();
@ -33,17 +36,18 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
return (
<StreamEditor
onFinish={ex => {
currentProvider.updateStreamInfo(ex);
currentProvider.updateStreamInfo(system, ex);
if (!ev) {
if (findTag(ex, "content-warning") && __XXX_HOST && __XXX === false) {
location.href = `${__XXX_HOST}/${eventLink(ex)}`;
} else {
navigate(`/${eventLink(ex)}`, {
state: ev,
state: ex,
});
onFinish?.();
}
} else {
onFinish?.(ev);
onFinish?.();
}
}}
ev={ev}
@ -51,7 +55,26 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
);
}
case StreamProviders.NostrType: {
return <NostrProviderDialog provider={currentProvider} onFinish={onFinish} ev={ev} />;
return (
<>
<AsyncButton
className="btn btn-secondary"
onClick={() => {
navigate("/settings");
onFinish?.();
}}>
<FormattedMessage defaultMessage="Get Stream Key" id="Vn2WiP" />
</AsyncButton>
<NostrProviderDialog
provider={currentProvider as NostrStreamProvider}
onFinish={onFinish}
ev={ev}
showEndpoints={false}
showEditor={true}
showForwards={false}
/>
</>
);
}
case StreamProviders.Owncast: {
return;
@ -62,9 +85,9 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
return (
<>
<p>
<FormattedMessage defaultMessage="Stream Providers" />
<FormattedMessage defaultMessage="Stream Providers" id="6Z2pvJ" />
</p>
<div className="flex g12">
<div className="flex gap-2">
{providers.map(v => (
<span className={`pill${v === currentProvider ? " active" : ""}`} onClick={() => setCurrentProvider(v)}>
{v.name}
@ -86,17 +109,17 @@ export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps)
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button type="button" className={props.btnClassName}>
<AsyncButton className={props.btnClassName}>
{props.text && props.text}
{!props.text && (
<>
<span className="hide-on-mobile">
<FormattedMessage defaultMessage="Stream" />
<span className="max-xl:hidden">
<FormattedMessage defaultMessage="Stream" id="uYw2LD" />
</span>
<Icon name="signal" />
</>
)}
</button>
</AsyncButton>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />

View File

@ -1,13 +1,28 @@
import { NostrEvent } from "@snort/system";
import { StreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "providers";
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { SnortContext } from "@snort/system-react";
import { NostrStreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "@/providers";
import { SendZaps } from "./send-zap";
import { StreamEditor, StreamEditorProps } from "./stream-editor";
import Spinner from "./spinner";
import AsyncButton from "./async-button";
import { FormattedMessage } from "react-intl";
import { unwrap } from "@snort/shared";
export function NostrProviderDialog({ provider, ...others }: { provider: StreamProvider } & StreamEditorProps) {
export function NostrProviderDialog({
provider,
showEndpoints,
showEditor,
showForwards,
...others
}: {
provider: NostrStreamProvider;
showEndpoints: boolean;
showEditor: boolean;
showForwards: boolean;
} & StreamEditorProps) {
const system = useContext(SnortContext);
const [topup, setTopup] = useState(false);
const [info, setInfo] = useState<StreamProviderInfo>();
const [ep, setEndpoint] = useState<StreamProviderEndpoint>();
@ -85,18 +100,19 @@ export function NostrProviderDialog({ provider, ...others }: { provider: StreamP
return (
<>
<div>
<div className="flex g12">
<div className="flex gap-2">
<input type="checkbox" checked={tos} onChange={e => setTos(e.target.checked)} />
<p>
<FormattedMessage
defaultMessage="I have read and agree with {provider}'s {terms}."
id="RJOmzk"
values={{
provider: info.name,
terms: (
<span
className="tos-link"
onClick={() => window.open(info.tosLink, "popup", "width=400,height=800")}>
<FormattedMessage defaultMessage="terms and conditions" />
<FormattedMessage defaultMessage="terms and conditions" id="thsiMl" />
</span>
),
}}
@ -106,101 +122,324 @@ export function NostrProviderDialog({ provider, ...others }: { provider: StreamP
</div>
<div>
<AsyncButton type="button" className="btn btn-primary wide" disabled={!tos} onClick={acceptTos}>
<FormattedMessage defaultMessage="Continue" />
<FormattedMessage defaultMessage="Continue" id="acrOoz" />
</AsyncButton>
</div>
</>
);
}
return (
<>
{info.endpoints.length > 1 && (
function streamEndpoints() {
if (!info) return;
return (
<>
{info.endpoints.length > 1 && (
<div>
<p>
<FormattedMessage defaultMessage="Endpoint" id="ljmS5P" />
</p>
<div className="flex gap-2">
{sortEndpoints(info.endpoints).map(a => (
<span
className={`pill bg-gray-1${ep?.name === a.name ? " active" : ""}`}
onClick={() => setEndpoint(a)}>
{a.name}
</span>
))}
</div>
</div>
)}
<div>
<p>
<FormattedMessage defaultMessage="Endpoint" />
<FormattedMessage defaultMessage="Server Url" id="5kx+2v" />
</p>
<div className="flex g12">
{sortEndpoints(info.endpoints).map(a => (
<span className={`pill${ep?.name === a.name ? " active" : ""}`} onClick={() => setEndpoint(a)}>
{a.name}
</span>
<div className="paper">
<input type="text" value={ep?.url} disabled />
</div>
</div>
<div>
<p>
<FormattedMessage defaultMessage="Stream Key" id="LknBsU" />
</p>
<div className="flex gap-2">
<div className="paper grow">
<input type="password" value={ep?.key} disabled />
</div>
<AsyncButton
className="btn btn-primary"
onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}>
<FormattedMessage defaultMessage="Copy" id="4l6vz1" />
</AsyncButton>
</div>
</div>
<div>
<p>
<FormattedMessage defaultMessage="Balance" id="H5+NAX" />
</p>
<div className="flex gap-2">
<div className="paper grow">
<FormattedMessage
defaultMessage="{amount} sats"
id="vrTOHJ"
values={{ amount: info.balance?.toLocaleString() }}
/>
</div>
<AsyncButton className="btn btn-primary" onClick={() => setTopup(true)}>
<FormattedMessage defaultMessage="Topup" id="nBCvvJ" />
</AsyncButton>
</div>
<small>
<FormattedMessage defaultMessage="About {estimate}" id="Q3au2v" values={{ estimate: calcEstimate() }} />
</small>
</div>
<div>
<p>
<FormattedMessage defaultMessage="Resolutions" id="4uI538" />
</p>
<div className="flex gap-2">
{ep?.capabilities?.map(a => (
<span className="pill bg-gray-1">{parseCapability(a)}</span>
))}
</div>
</div>
)}
<div>
<p>
<FormattedMessage defaultMessage="Server Url" />
</p>
<div className="paper">
<input type="text" value={ep?.url} disabled />
</div>
</div>
<div>
<p>
<FormattedMessage defaultMessage="Stream Key" />
</p>
<div className="flex g12">
<div className="paper f-grow">
<input type="password" value={ep?.key} disabled />
</div>
<button className="btn btn-primary" onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}>
<FormattedMessage defaultMessage="Copy" />
</button>
</div>
</div>
<div>
<p>
<FormattedMessage defaultMessage="Balance" />
</p>
<div className="flex g12">
<div className="paper f-grow">
<FormattedMessage defaultMessage="{amount} sats" values={{ amount: info.balance?.toLocaleString() }} />
</div>
<button className="btn btn-primary" onClick={() => setTopup(true)}>
<FormattedMessage defaultMessage="Topup" />
</button>
</div>
<small>
<FormattedMessage defaultMessage="About {estimate}" values={{ estimate: calcEstimate() }} />
</small>
</div>
<div>
<p>
<FormattedMessage defaultMessage="Resolutions" />
</p>
<div className="flex g12">
{ep?.capabilities?.map(a => (
<span className="pill">{parseCapability(a)}</span>
</>
);
}
function streamEditor() {
if (!info || !showEditor) return;
if (info.tosAccepted === false) {
return tosInput();
}
return (
<StreamEditor
onFinish={ex => {
provider.updateStreamInfo(system, ex);
others.onFinish?.(ex);
}}
ev={
{
tags: [
["title", info.streamInfo?.title ?? ""],
["summary", info.streamInfo?.summary ?? ""],
["image", info.streamInfo?.image ?? ""],
...(info.streamInfo?.goal ? [["goal", info.streamInfo.goal]] : []),
...(info.streamInfo?.content_warning ? [["content-warning", info.streamInfo?.content_warning]] : []),
...(info.streamInfo?.tags?.map(a => ["t", a]) ?? []),
],
} as NostrEvent
}
options={{
canSetStream: false,
canSetStatus: false,
}}
/>
);
}
function forwardInputs() {
if (!info || !showForwards) return;
return (
<div className="flex flex-col gap-4">
<h3>
<FormattedMessage defaultMessage="Stream Forwarding" id="W7DNWx" />
</h3>
<div className="grid grid-cols-2 gap-2">
{info.forwards?.map(a => (
<>
<div className="paper">{a.name}</div>
<AsyncButton
className="btn btn-primary"
onClick={async () => {
await provider.removeForward(a.id);
}}>
<FormattedMessage defaultMessage="Remove" id="G/yZLu" />
</AsyncButton>
</>
))}
</div>
<AddForwardInputs provider={provider} onAdd={() => {}} />
</div>
{info.tosAccepted === false ? (
tosInput()
) : (
<StreamEditor
onFinish={ex => {
provider.updateStreamInfo(ex);
others.onFinish?.(ex);
}}
ev={
{
tags: [
["title", info.streamInfo?.title ?? ""],
["summary", info.streamInfo?.summary ?? ""],
["image", info.streamInfo?.image ?? ""],
...(info.streamInfo?.content_warning ? [["content-warning", info.streamInfo?.content_warning]] : []),
...(info.streamInfo?.tags?.map(a => ["t", a]) ?? []),
],
} as NostrEvent
}
options={{
canSetStream: false,
canSetStatus: false,
}}
/>
)}
);
}
return (
<>
{showEndpoints && streamEndpoints()}
{streamEditor()}
{forwardInputs()}
</>
);
}
enum ForwardService {
Custom = "custom",
Twitch = "twitch",
Youtube = "youtube",
Facebook = "facebook",
Kick = "kick",
Trovo = "trovo",
}
function AddForwardInputs({
provider,
onAdd,
}: {
provider: NostrStreamProvider;
onAdd: (name: string, target: string) => void;
}) {
const [name, setName] = useState("");
const [target, setTarget] = useState("");
const [svc, setService] = useState(ForwardService.Twitch);
const [error, setError] = useState("");
const { formatMessage } = useIntl();
async function getTargetFull() {
if (svc === ForwardService.Custom) {
return target;
}
if (svc === ForwardService.Twitch) {
const urls = (await (await fetch("https://ingest.twitch.tv/ingests")).json()) as {
ingests: Array<{
availability: number;
name: string;
url_template: string;
}>;
};
const ingestsEurope = urls.ingests.filter(
a => a.name.toLowerCase().startsWith("europe:") && a.availability === 1
);
const random = ingestsEurope.at(ingestsEurope.length * Math.random());
return unwrap(random).url_template.replace("{stream_key}", target);
}
if (svc === ForwardService.Youtube) {
return `rtmp://a.rtmp.youtube.com:1935/live2/${target}`;
}
if (svc === ForwardService.Facebook) {
return `rtmps://live-api-s.facebook.com:443/rtmp/${target}`;
}
if (svc === ForwardService.Trovo) {
return `rtmp://livepush.trovo.live:1935/live/${target}`;
}
if (svc === ForwardService.Kick) {
return `rtmps://fa723fc1b171.global-contribute.live-video.net:443/app/${target}`;
}
}
async function doAdd() {
if (svc === ForwardService.Custom) {
if (!target.startsWith("rtmp://")) {
setError(
formatMessage({
defaultMessage: "Stream url must start with rtmp://",
id: "7+bCC1",
})
);
return;
}
try {
// stupid URL parser doesnt work for non-http protocols
const u = new URL(target.replace("rtmp://", "http://"));
console.debug(u);
if (u.host.length < 1) {
throw new Error();
}
if (u.pathname === "/") {
throw new Error();
}
} catch {
setError(
formatMessage({
defaultMessage: "Not a valid URL",
id: "1q4BO/",
})
);
return;
}
} else {
if (target.length < 2) {
setError(
formatMessage({
defaultMessage: "Stream Key is required",
id: "50+/JW",
})
);
return;
}
}
if (name.length < 2) {
setError(
formatMessage({
defaultMessage: "Name is required",
id: "Gvxoji",
})
);
return;
}
try {
const t = await getTargetFull();
if (!t)
throw new Error(
formatMessage({
defaultMessage: "Could not create stream URL",
id: "E9APoR",
})
);
await provider.addForward(name, t);
} catch (e) {
setError((e as Error).message);
}
setName("");
setTarget("");
onAdd(name, target);
}
return (
<div className="flex flex-col p-4 gap-2 bg-gray-3 rounded-xl">
<div className="flex gap-2">
<div className="paper flex-1">
<select value={svc} onChange={e => setService(e.target.value as ForwardService)} className="bg-gray-1">
<option value="twitch">Twitch</option>
<option value="youtube">Youtube</option>
<option value="facebook">Facebook Gaming</option>
<option value="kick">Kick</option>
<option value="trovo">Trovo</option>
<option value="custom">Custom</option>
</select>
</div>
<div className="paper flex-1">
<input
type="text"
placeholder={formatMessage({ defaultMessage: "Display name", id: "dOQCL8" })}
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
</div>
<div className="paper">
<input
type="text"
placeholder={
svc === ForwardService.Custom ? "rtmp://" : formatMessage({ defaultMessage: "Stream key", id: "QWlMq9" })
}
value={target}
onChange={e => setTarget(e.target.value)}
/>
</div>
<AsyncButton className="btn btn-primary" onClick={doAdd}>
<FormattedMessage defaultMessage="Add" id="2/2yg+" />
</AsyncButton>
{error && <b className="warning">{error}</b>}
</div>
);
}

View File

@ -4,6 +4,8 @@
font-size: 15px;
font-weight: 400;
line-height: 22px;
word-wrap: break-word;
overflow-wrap: anywhere;
}
.note .note-header {
@ -11,16 +13,6 @@
justify-content: space-between;
}
.note .note-header .profile {
font-size: 15px;
font-weight: 600;
}
.note .note-header .note-avatar {
width: 24px;
height: 24px;
}
.note .note-header .note-link-icon {
color: #909090;
}

View File

@ -1,24 +1,26 @@
import "./note.css";
import { type NostrEvent, NostrPrefix } from "@snort/system";
import { Suspense, lazy } from "react";
import { type NostrEvent, NostrLink } from "@snort/system";
import { Markdown } from "element/markdown";
import { ExternalIconLink } from "element/external-link";
import { Profile } from "element/profile";
import { hexToBech32 } from "utils";
const Markdown = lazy(() => import("./markdown"));
import { ExternalIconLink } from "./external-link";
import { Profile } from "./profile";
export function Note({ ev }: { ev: NostrEvent }) {
return (
<div className="surface note">
<div className="note-header">
<Profile avatarClassname="note-avatar" pubkey={ev.pubkey} />
<Profile pubkey={ev.pubkey} />
<ExternalIconLink
size={24}
className="note-link-icon"
href={`https://snort.social/e/${hexToBech32(NostrPrefix.Event, ev.id)}`}
href={`https://snort.social/e/${NostrLink.fromEvent(ev).encode()}`}
/>
</div>
<div className="note-content">
<Markdown tags={ev.tags} content={ev.content} />
<Suspense>
<Markdown tags={ev.tags} content={ev.content} />
</Suspense>
</div>
</div>
);

View File

@ -0,0 +1,87 @@
import AsyncButton from "./async-button";
import { useLogin } from "@/hooks/login";
import { NostrStreamProvider } from "@/providers";
import { base64 } from "@scure/base";
import { unwrap } from "@snort/shared";
import { useEffect, useState } from "react";
import { Icon } from "./icon";
export function NotificationsButton({ host, service }: { host: string; service: string }) {
const login = useLogin();
const publisher = login?.publisher();
const [subscribed, setSubscribed] = useState(false);
const api = new NostrStreamProvider("", service, publisher);
async function isSubscribed() {
const reg = await navigator.serviceWorker.ready;
if (reg) {
const sub = await reg.pushManager.getSubscription();
if (sub) {
const auth = base64.encode(new Uint8Array(unwrap(sub.getKey("auth"))));
const subs = await api.listStreamerSubscriptions(auth);
setSubscribed(subs.includes(host));
}
}
}
async function enableNotifications() {
// request permissions to send notifications
if ("Notification" in window) {
try {
if (Notification.permission !== "granted") {
const res = await Notification.requestPermission();
console.debug(res);
}
return Notification.permission === "granted";
} catch (e) {
console.error(e);
}
}
return false;
}
async function subscribe() {
if (await enableNotifications()) {
try {
if ("serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.ready;
if (reg && publisher) {
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: (await api.getNotificationsInfo()).publicKey,
});
await api.subscribeNotifications({
endpoint: sub.endpoint,
key: base64.encode(new Uint8Array(unwrap(sub.getKey("p256dh")))),
auth: base64.encode(new Uint8Array(unwrap(sub.getKey("auth")))),
scope: `${location.protocol}//${location.hostname}`,
});
await api.addStreamerSubscription(host);
setSubscribed(true);
}
} else {
console.warn("No service worker");
}
} catch (e) {
console.error(e);
}
}
}
async function unsubscribe() {
if (publisher) {
await api.removeStreamerSubscription(host);
setSubscribed(false);
}
}
useEffect(() => {
isSubscribed().catch(console.error);
}, []);
return (
<AsyncButton onClick={subscribed ? unsubscribe : subscribe}>
<Icon name={subscribed ? "bell-off" : "bell-ringing"} />
</AsyncButton>
);
}

View File

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

View File

@ -1,13 +1,11 @@
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 usePlaceholder from "hooks/placeholders";
import { useInView } from "react-intersection-observer";
import { Avatar } from "./avatar";
import classNames from "classnames";
export interface ProfileOptions {
showName?: boolean;
@ -31,47 +29,42 @@ export function getName(pk: string, user?: UserMetadata) {
export function Profile({
pubkey,
icon,
className,
avatarClassname,
options,
profile,
linkToProfile,
avatarSize,
gap,
}: {
pubkey: string;
icon?: ReactNode;
className?: string;
avatarClassname?: string;
options?: ProfileOptions;
profile?: UserMetadata;
linkToProfile?: boolean;
avatarSize?: number;
gap?: number;
}) {
const { inView, ref } = useInView();
const pLoaded = useUserProfile(inView && !profile ? pubkey : undefined) || profile;
const { inView, ref } = useInView({ triggerOnce: true });
const pLoaded = useUserProfile(inView ? pubkey : undefined);
const showAvatar = options?.showAvatar ?? true;
const showName = options?.showName ?? true;
const placeholder = usePlaceholder(pubkey);
const isAnon = pubkey === "anon";
const content = (
<>
{showAvatar &&
(pubkey === "anon" ? (
<Icon size={40} name="zap-filled" />
) : (
<img
alt={pLoaded?.name || pubkey}
className={avatarClassname ? avatarClassname : ""}
src={pLoaded?.picture ?? placeholder}
/>
))}
{showAvatar && <Avatar user={pLoaded} pubkey={pubkey} className={avatarClassname} size={avatarSize ?? 24} />}
{icon}
{showName && <span>{options?.overrideName ?? pubkey === "anon" ? "Anon" : getName(pubkey, pLoaded)}</span>}
{showName && <span>{isAnon ? options?.overrideName ?? "Anon" : getName(pubkey, pLoaded)}</span>}
</>
);
return pubkey === "anon" || linkToProfile === false ? (
<div className="profile" ref={ref}>
const cls = classNames("flex items-center align-bottom font-medium", `gap-${gap ?? 2}`, className);
return isAnon || linkToProfile === false ? (
<div className={cls} ref={ref}>
{content}
</div>
) : (
<Link to={`/p/${hexToBech32("npub", pubkey)}`} className="profile" ref={ref}>
<Link to={`/p/${hexToBech32("npub", pubkey)}`} className={cls} ref={ref}>
{content}
</Link>
);

View File

@ -0,0 +1,35 @@
import { HTMLProps, ReactNode } from "react";
type ProgressBarProps = {
value: number;
setValue: (n: number) => void;
marker?: ReactNode;
} & Omit<HTMLProps<HTMLDivElement>, "width">;
export function ProgressBar({ value, setValue, marker, ...props }: ProgressBarProps) {
function onValue(e: React.MouseEvent) {
const bb = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const x = e.clientX - bb.x;
const pos = Math.max(0, Math.min(1.0, x / bb.width));
setValue(pos);
}
return (
<div
{...props}
className="relative pointer border bg-[rgba(255,255,255,0.25)]"
onMouseDown={onValue}
onMouseMove={e => {
if (e.buttons > 0) {
onValue(e);
}
}}>
<div
className="absolute h-full bg-white"
style={{
width: `${Math.ceil(100 * value)}%`,
}}>
{marker && <div className="absolute right-0 flex items-center justify-center">{marker}</div>}
</div>
</div>
);
}

83
src/element/raid-menu.tsx Normal file
View File

@ -0,0 +1,83 @@
import { useStreamsFeed } from "@/hooks/live-streams";
import { getHost, getTagValues } from "@/utils";
import { dedupe, unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl";
import { Profile } from "./profile";
import { useLogin } from "@/hooks/login";
import { useContext, useState } from "react";
import { NostrLink, parseNostrLink } from "@snort/system";
import AsyncButton from "./async-button";
import { SnortContext } from "@snort/system-react";
import { LIVE_STREAM_RAID } from "@/const";
export function DashboardRaidMenu({ link, onClose }: { link: NostrLink; onClose: () => void }) {
const system = useContext(SnortContext);
const login = useLogin();
const { live } = useStreamsFeed();
const [raiding, setRaiding] = useState("");
const [msg, setMsg] = useState("");
const mutedHosts = new Set(getTagValues(login?.muted.tags ?? [], "p"));
const livePubkeys = dedupe(live.map(a => getHost(a))).filter(a => !mutedHosts.has(a));
async function raid() {
if (login) {
const ev = await login.publisher().generic(eb => {
return eb
.kind(LIVE_STREAM_RAID)
.tag(unwrap(link.toEventTag("root")))
.tag(unwrap(parseNostrLink(raiding).toEventTag("mention")))
.content(msg);
});
await system.BroadcastEvent(ev);
onClose();
}
}
return (
<div className="flex flex-col gap-4 p-6">
<h2>
<FormattedMessage defaultMessage="Start Raid" id="MTHO1W" />
</h2>
<div className="flex flex-col gap-1">
<p className="text-gray-3 uppercase font-semibold text-sm">
<FormattedMessage defaultMessage="Live now" id="+sdKx8" />
</p>
<div className="flex gap-2 flex-wrap">
{livePubkeys.map(a => (
<div
className="border border-gray-1 rounded-full px-4 py-2 bg-gray-2 pointer"
onClick={() => {
const liveEvent = live.find(b => getHost(b) === a);
if (liveEvent) {
setRaiding(NostrLink.fromEvent(liveEvent).encode());
}
}}>
<Profile pubkey={a} options={{ showAvatar: false }} linkToProfile={false} />
</div>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-gray-3 uppercase font-semibold text-sm">
<FormattedMessage defaultMessage="Raid target" id="Zse7yG" />
</p>
<div className="paper">
<input type="text" placeholder="naddr.." value={raiding} onChange={e => setRaiding(e.target.value)} />
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-gray-3 uppercase font-semibold text-sm">
<FormattedMessage defaultMessage="Raid Message" id="RS6smY" />
</p>
<div className="paper">
<input type="text" value={msg} onChange={e => setMsg(e.target.value)} />
</div>
</div>
<AsyncButton className="btn btn-primary" onClick={raid}>
<FormattedMessage defaultMessage="Raid!" id="aqjZxs" />
</AsyncButton>
</div>
);
}

View File

@ -6,7 +6,7 @@
.send-zap .amounts {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(5, 1fr);
justify-content: space-evenly;
gap: 8px;
}
@ -34,10 +34,6 @@
padding: 12px 16px;
}
.send-zap .btn > span {
justify-content: center;
}
.send-zap .qr {
align-self: center;
}

View File

@ -1,19 +1,20 @@
import "./send-zap.css";
import * as Dialog from "@radix-ui/react-dialog";
import { useEffect, useState, type ReactNode } from "react";
import { type ReactNode, useEffect, useState } from "react";
import { LNURL } from "@snort/shared";
import { NostrEvent, EventPublisher } from "@snort/system";
import { EventPublisher, NostrEvent } from "@snort/system";
import { secp256k1 } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/curves/abstract/utils";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { formatSats } from "../number";
import { Icon } from "./icon";
import AsyncButton from "./async-button";
import QrCode from "./qr-code";
import { useLogin } from "hooks/login";
import { useLogin } from "@/hooks/login";
import Copy from "./copy";
import { defaultRelays } from "const";
import { FormattedMessage } from "react-intl";
import { defaultRelays } from "@/const";
import { useRates } from "@/hooks/rates";
export interface LNURLLike {
get name(): string;
@ -33,11 +34,8 @@ export interface SendZapsProps {
}
export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: SendZapsProps) {
const UsdRate = 28_000;
const satsAmounts = [
21, 69, 121, 221, 420, 1_000, 2_100, 5_000, 6_666, 10_000, 21_000, 42_000, 69_000, 100_000, 210_000, 500_000,
1_000_000,
21, 69, 121, 420, 1_000, 2_100, 4_200, 10_000, 21_000, 42_000, 69_000, 100_000, 210_000, 500_000, 1_000_000,
];
const usdAmounts = [0.05, 0.5, 2, 5, 10, 50, 100, 200];
const [isFiat, setIsFiat] = useState(false);
@ -46,6 +44,7 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: Se
const [comment, setComment] = useState("");
const [invoice, setInvoice] = useState("");
const login = useLogin();
const rate = useRates("BTCUSD");
const relays = Object.keys(defaultRelays);
const name = targetName ?? svc?.name;
async function loadService(lnurl: string) {
@ -53,6 +52,7 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: Se
await s.load();
setSvc(s);
}
const usdRate = rate.time ? rate.ask : 26_000;
useEffect(() => {
if (!svc) {
@ -73,7 +73,7 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: Se
isAnon = true;
}
const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount;
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 => {
@ -109,7 +109,7 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: Se
if (invoice) return;
return (
<>
<div className="flex g12">
<div className="flex gap-2">
<span
className={`pill${isFiat ? "" : " active"}`}
onClick={() => {
@ -129,7 +129,24 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: Se
</div>
<div>
<small>
<FormattedMessage defaultMessage="Zap amount in {currency}" values={{ amount: isFiat ? "USD" : "sats" }} />
<FormattedMessage
defaultMessage="Zap amount in {currency}"
id="IJDKz3"
values={{ currency: isFiat ? "USD" : "SATS" }}
/>
{isFiat && (
<>
&nbsp;
<FormattedMessage
defaultMessage="@ {rate}"
id="YPh5Nq"
description="Showing zap amount in USD @ rate"
values={{
rate: <FormattedNumber value={usdRate} />,
}}
/>
</>
)}
</small>
<div className="amounts">
{(isFiat ? usdAmounts : satsAmounts).map(a => (
@ -142,7 +159,7 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: Se
{svc && (svc.maxCommentLength > 0 || svc.canZap) && (
<div>
<small>
<FormattedMessage defaultMessage="Your comment for {name}" values={{ name }} />
<FormattedMessage defaultMessage="Your comment for {name}" id="ESyhzp" values={{ name }} />
</small>
<div className="paper">
<textarea placeholder="Nice!" value={comment} onChange={e => setComment(e.target.value)} />
@ -151,7 +168,7 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: Se
)}
<div>
<AsyncButton onClick={send} className="btn btn-primary">
<FormattedMessage defaultMessage="Zap!" />
<FormattedMessage defaultMessage="Zap!" id="3HwrQo" />
</AsyncButton>
</div>
</>
@ -165,20 +182,20 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: Se
return (
<>
<QrCode data={link} link={link} />
<div className="flex f-center">
<div className="flex items-center">
<Copy text={invoice} />
</div>
<button className="btn btn-primary wide" onClick={() => onFinish()}>
<FormattedMessage defaultMessage="Back" />
</button>
<AsyncButton className="btn btn-primary wide" onClick={() => onFinish()}>
<FormattedMessage defaultMessage="Back" id="cyR7Kh" />
</AsyncButton>
</>
);
}
return (
<div className="send-zap">
<h3>
<FormattedMessage defaultMessage="Zap {name}" values={{ name }} />
<h3 className="flex gap-2 items-center">
<FormattedMessage defaultMessage="Zap {name}" id="oHPB8Q" values={{ name }} />
<Icon name="zap" />
</h3>
{input()}
@ -195,18 +212,20 @@ export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
{props.button ? (
props.button
) : (
<button className="btn btn-primary zap">
<span className="hide-on-mobile">
<FormattedMessage defaultMessage="Zap" />
<AsyncButton className="btn btn-primary zap">
<span className="max-xl:hidden">
<FormattedMessage defaultMessage="Zap" id="fBI91o" />
</span>
<Icon name="zap-filled" size={16} />
</button>
</AsyncButton>
)}
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<SendZaps {...props} onFinish={() => setIsOpen(false)} />
<div className="content-inner">
<SendZaps {...props} onFinish={() => setIsOpen(false)} />
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@ -2,19 +2,20 @@ import { Menu, MenuItem } from "@szhsin/react-menu";
import * as Dialog from "@radix-ui/react-dialog";
import { unwrap } from "@snort/shared";
import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system";
import { FormattedMessage } from "react-intl";
import { useContext, useState } from "react";
import { SnortContext } from "@snort/system-react";
import { Icon } from "./icon";
import { useState } from "react";
import { Textarea } from "./textarea";
import { findTag } from "utils";
import { findTag } from "@/utils";
import AsyncButton from "./async-button";
import { useLogin } from "hooks/login";
import { System } from "index";
import { FormattedMessage } from "react-intl";
import { useLogin } from "@/hooks/login";
type ShareOn = "nostr" | "twitter";
export function ShareMenu({ ev }: { ev: NostrEvent }) {
const system = useContext(SnortContext);
const [share, setShare] = useState<ShareOn>();
const [message, setMessage] = useState("");
const login = useLogin();
@ -27,7 +28,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
if (pub) {
const ev = await pub.note(message);
console.debug(ev);
System.BroadcastEvent(ev);
await system.BroadcastEvent(ev);
setShare(undefined);
}
}
@ -39,9 +40,9 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
gap={5}
menuClassName="ctx-menu"
menuButton={
<button type="button" className="btn btn-secondary">
<FormattedMessage defaultMessage="Share" />
</button>
<AsyncButton className="btn btn-secondary">
<FormattedMessage defaultMessage="Share" id="OKhRC6" />
</AsyncButton>
}>
<MenuItem
onClick={() => {
@ -49,30 +50,32 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
setShare("nostr");
}}>
<Icon name="nostrich" size={24} />
<FormattedMessage defaultMessage="Broadcast on Nostr" />
<FormattedMessage defaultMessage="Broadcast on Nostr" id="wCIL7o" />
</MenuItem>
</Menu>
<Dialog.Root open={Boolean(share)} onOpenChange={() => setShare(undefined)}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<h2>
<FormattedMessage defaultMessage="Share" />
</h2>
<div className="paper">
<Textarea
emojis={[]}
value={message}
onChange={e => setMessage(e.target.value)}
onKeyDown={() => {
//noop
}}
rows={15}
/>
<div className="content-inner">
<h2>
<FormattedMessage defaultMessage="Share" id="OKhRC6" />
</h2>
<div className="paper">
<Textarea
emojis={[]}
value={message}
onChange={e => setMessage(e.target.value)}
onKeyDown={() => {
//noop
}}
rows={15}
/>
</div>
<AsyncButton className="btn btn-primary" onClick={sendMessage}>
<FormattedMessage defaultMessage="Send" id="9WRlF4" />
</AsyncButton>
</div>
<AsyncButton className="btn btn-primary" onClick={sendMessage}>
<FormattedMessage defaultMessage="Send" />
</AsyncButton>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@ -1,6 +1,20 @@
import { HTMLProps } from "react";
import "./state-pill.css";
import { StreamState } from "index";
import classNames from "classnames";
import { StreamState } from "@/const";
export function StatePill({ state }: { state: StreamState }) {
return <span className={`state pill${state === StreamState.Live ? " live" : ""}`}>{state}</span>;
type StatePillProps = { state: StreamState } & HTMLProps<HTMLSpanElement>;
export function StatePill({ state, ...props }: StatePillProps) {
return (
<span
{...props}
className={classNames(
"uppercase font-white pill",
state === StreamState.Live ? "bg-primary" : "bg-gray-1",
props.className
)}>
{state}
</span>
);
}

View File

@ -48,6 +48,7 @@
gap: 16px;
flex: 1;
width: 100%;
overflow-wrap: break-word;
}
.stream-card.image-card {
@ -75,7 +76,7 @@
}
.add-card .add-icon {
color: #797979;
color: var(--text-muted);
cursor: pointer;
width: 24px;
height: 24px;
@ -137,7 +138,7 @@
}
.help-text a {
color: var(--text-link);
color: var(--primary);
}
.add-button {

View File

@ -1,24 +1,27 @@
import "./stream-cards.css";
import { useState, forwardRef } from "react";
import { Suspense, forwardRef, lazy, useContext, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import * as Dialog from "@radix-ui/react-dialog";
import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { TaggedNostrEvent } from "@snort/system";
import { removeUndefined, unwrap } from "@snort/shared";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { Toggle } from "element/toggle";
import { Icon } from "element/icon";
import { ExternalLink } from "element/external-link";
import { FileUploader } from "element/file-uploader";
import { Markdown } from "element/markdown";
import { useLogin } from "hooks/login";
import { useCards, useUserCards } from "hooks/cards";
import { CARD, USER_CARDS } from "const";
import { toTag, findTag } from "utils";
import { Login, System } from "index";
import type { Tags } from "types";
const Markdown = lazy(() => import("./markdown"));
import { Toggle } from "./toggle";
import { Icon } from "./icon";
import { ExternalLink } from "./external-link";
import { FileUploader } from "./file-uploader";
import { useLogin } from "@/hooks/login";
import { useCards, useUserCards } from "@/hooks/cards";
import { CARD, USER_CARDS } from "@/const";
import { findTag } from "@/utils";
import { Login } from "@/index";
import type { Tags } from "@/types";
import AsyncButton from "./async-button";
interface CardType {
identifier: string;
@ -55,7 +58,9 @@ const CardPreview = forwardRef(({ style, title, link, image, content }: CardPrev
) : (
<img className="card-image" src={image} alt={title} />
))}
<Markdown content={content} />
<Suspense>
<Markdown content={content} />
</Suspense>
</div>
);
});
@ -71,6 +76,7 @@ interface CardItem {
}
function Card({ canEdit, ev, cards }: CardProps) {
const system = useContext(SnortContext);
const login = useLogin();
const identifier = findTag(ev, "d") ?? "";
const title = findTag(ev, "title") || findTag(ev, "subject");
@ -78,7 +84,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
const link = findTag(ev, "r");
const content = ev.content;
const evCard = { title, image, link, content, identifier };
const tags = cards.map(toTag);
const tags = removeUndefined(cards.map(a => NostrLink.fromEvent(a).toEventTag()));
const [style, dragRef] = useDrag(
() => ({
type: "card",
@ -140,7 +146,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
return eb;
});
console.debug(userCardsEv);
System.BroadcastEvent(userCardsEv);
await system.BroadcastEvent(userCardsEv);
Login.setCards(newTags, userCardsEv.created_at);
}
},
@ -184,28 +190,28 @@ function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: CardDial
return (
<div className="new-card">
<h3>{header || <FormattedMessage defaultMessage="Add card" />}</h3>
<h3>{header || <FormattedMessage defaultMessage="Add card" id="nwA8Os" />}</h3>
<div className="form-control">
<label htmlFor="card-title">
<FormattedMessage defaultMessage="Title" />
<FormattedMessage defaultMessage="Title" id="9a9+ww" />
</label>
<input
id="card-title"
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder={formatMessage({ defaultMessage: "e.g. about me" })}
placeholder={formatMessage({ defaultMessage: "e.g. about me", id: "k21gTS" })}
/>
</div>
<div className="form-control">
<label htmlFor="card-image">
<FormattedMessage defaultMessage="Image" />
<FormattedMessage defaultMessage="Image" id="+0zv6g" />
</label>
<FileUploader defaultImage={image} onFileUpload={setImage} onClear={() => setImage("")} />
</div>
<div className="form-control">
<label htmlFor="card-image-link">
<FormattedMessage defaultMessage="Image Link" />
<FormattedMessage defaultMessage="Image Link" id="s5ksS7" />
</label>
<input
id="card-image-link"
@ -217,20 +223,21 @@ function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: CardDial
</div>
<div className="form-control">
<label htmlFor="card-content">
<FormattedMessage defaultMessage="Content" />
<FormattedMessage defaultMessage="Content" id="Jq3FDz" />
</label>
<textarea
placeholder={formatMessage({ defaultMessage: "Start typing" })}
placeholder={formatMessage({ defaultMessage: "Start typing", id: "w0Xm2F" })}
value={content}
onChange={e => setContent(e.target.value)}
/>
<span className="help-text">
<FormattedMessage
defaultMessage="Supports {markdown}"
id="I1kjHI"
values={{
markdown: (
<ExternalLink href="https://www.markdownguide.org/cheat-sheet">
<FormattedMessage defaultMessage="Markdown" />
<FormattedMessage defaultMessage="Markdown" id="jr4+vD" />
</ExternalLink>
),
}}
@ -238,12 +245,12 @@ function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: CardDial
</span>
</div>
<div className="new-card-buttons">
<button className="btn btn-primary add-button" onClick={() => onSave({ title, image, content, link })}>
{cta || <FormattedMessage defaultMessage="Add Card" />}
</button>
<button className="btn delete-button" onClick={onCancel}>
{cancelCta || <FormattedMessage defaultMessage="Cancel" />}
</button>
<AsyncButton className="btn btn-primary add-button" onClick={() => onSave({ title, image, content, link })}>
{cta || <FormattedMessage defaultMessage="Add Card" id="UJBFYK" />}
</AsyncButton>
<AsyncButton className="btn delete-button" onClick={onCancel}>
{cancelCta || <FormattedMessage defaultMessage="Cancel" id="47FYwb" />}
</AsyncButton>
</div>
</div>
);
@ -255,10 +262,11 @@ interface EditCardProps {
}
function EditCard({ card, cards }: EditCardProps) {
const system = useContext(SnortContext);
const login = useLogin();
const [isOpen, setIsOpen] = useState(false);
const identifier = card.identifier;
const tags = cards.map(toTag);
const tags = removeUndefined(cards.map(a => NostrLink.fromEvent(a).toEventTag()));
const { formatMessage } = useIntl();
async function editCard({ title, image, link, content }: CardType) {
@ -278,7 +286,7 @@ function EditCard({ card, cards }: EditCardProps) {
return eb;
});
console.debug(ev);
System.BroadcastEvent(ev);
await system.BroadcastEvent(ev);
setIsOpen(false);
}
}
@ -296,7 +304,7 @@ function EditCard({ card, cards }: EditCardProps) {
});
console.debug(userCardsEv);
System.BroadcastEvent(userCardsEv);
await system.BroadcastEvent(userCardsEv);
Login.setCards(newTags, userCardsEv.created_at);
setIsOpen(false);
}
@ -305,21 +313,23 @@ function EditCard({ card, cards }: EditCardProps) {
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button className="btn btn-primary">
<FormattedMessage defaultMessage="Edit" />
</button>
<AsyncButton className="btn btn-primary">
<FormattedMessage defaultMessage="Edit" id="wEQDC6" />
</AsyncButton>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<CardDialog
header={formatMessage({ defaultMessage: "Edit card" })}
cta={formatMessage({ defaultMessage: "Save card" })}
cancelCta={formatMessage({ defaultMessage: "Delete" })}
card={card}
onSave={editCard}
onCancel={onCancel}
/>
<div className="content-inner">
<CardDialog
header={formatMessage({ defaultMessage: "Edit card", id: "OWgHbg" })}
cta={formatMessage({ defaultMessage: "Save card", id: "rfC1Zq" })}
cancelCta={formatMessage({ defaultMessage: "Delete", id: "K3r6DQ" })}
card={card}
onSave={editCard}
onCancel={onCancel}
/>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@ -331,8 +341,9 @@ interface AddCardProps {
}
function AddCard({ cards }: AddCardProps) {
const system = useContext(SnortContext);
const login = useLogin();
const tags = cards.map(toTag);
const tags = removeUndefined(cards.map(a => NostrLink.fromEvent(a).toEventTag()));
const [isOpen, setIsOpen] = useState(false);
async function createCard({ title, image, link, content }: NewCard) {
@ -354,18 +365,16 @@ function AddCard({ cards }: AddCardProps) {
});
const userCardsEv = await pub.generic(eb => {
eb.kind(USER_CARDS).content("");
for (const tag of tags) {
eb.tag(tag);
}
eb.tag(toTag(ev));
tags.forEach(a => eb.tag(a));
eb.tag(unwrap(NostrLink.fromEvent(ev).toEventTag()));
return eb;
});
console.debug(ev);
console.debug(userCardsEv);
System.BroadcastEvent(ev);
System.BroadcastEvent(userCardsEv);
await system.BroadcastEvent(ev);
await system.BroadcastEvent(userCardsEv);
setIsOpen(false);
}
}
@ -383,7 +392,9 @@ function AddCard({ cards }: AddCardProps) {
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<CardDialog onSave={createCard} onCancel={onCancel} />
<div className="content-inner">
<CardDialog onSave={createCard} onCancel={onCancel} />
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@ -1,14 +1,16 @@
import "./stream-editor.css";
import { useEffect, useState, useCallback } from "react";
import { useCallback, useEffect, useState } from "react";
import { NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import { TagsInput } from "react-tag-input-component";
import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "./async-button";
import { StreamState } from "../index";
import { findTag } from "../utils";
import { useLogin } from "hooks/login";
import { FormattedMessage, useIntl } from "react-intl";
import { extractStreamInfo, findTag } from "@/utils";
import { useLogin } from "@/hooks/login";
import { NewGoalDialog } from "./new-goal";
import { useGoals } from "@/hooks/goals";
import { StreamState } from "@/const";
export interface StreamEditorProps {
ev?: NostrEvent;
@ -24,28 +26,55 @@ export interface StreamEditorProps {
};
}
interface GoalSelectorProps {
goal?: string;
pubkey: string;
onGoalSelect: (g: string) => void;
}
function GoalSelector({ goal, pubkey, onGoalSelect }: GoalSelectorProps) {
const goals = useGoals(pubkey, true);
const { formatMessage } = useIntl();
return (
<select onChange={ev => onGoalSelect(ev.target.value)}>
<option value={goal}>{formatMessage({ defaultMessage: "Select a goal...", id: "I/TubD" })}</option>
{goals?.map(x => (
<option key={x.id} value={x.id}>
{x.content}
</option>
))}
</select>
);
}
export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
const [image, setImage] = useState("");
const [stream, setStream] = useState("");
const [recording, setRecording] = 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);
const [goal, setGoal] = useState<string>();
const login = useLogin();
const { formatMessage } = useIntl();
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);
const { title, summary, image, stream, status, starts, tags, contentWarning, goal, recording } =
extractStreamInfo(ev);
setTitle(title ?? "");
setSummary(summary ?? "");
setImage(image ?? "");
setStream(stream ?? "");
setStatus(status ?? StreamState.Live);
setRecording(recording ?? "");
setStart(starts);
setTags(tags ?? []);
setContentWarning(contentWarning !== undefined);
setGoal(goal);
}, [ev?.id]);
const validate = useCallback(() => {
@ -78,11 +107,16 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
.tag(["title", title])
.tag(["summary", summary])
.tag(["image", image])
.tag(["streaming", stream])
.tag(["status", status])
.tag(["starts", starts]);
if (status === StreamState.Live) {
eb.tag(["streaming", stream]);
}
if (status === StreamState.Ended) {
eb.tag(["ends", ends]);
if (recording) {
eb.tag(["recording", recording]);
}
}
for (const tx of tags) {
eb.tag(["t", tx.trim()]);
@ -90,6 +124,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
if (contentWarning) {
eb.tag(["content-warning", "nsfw"]);
}
if (goal && goal.length > 0) {
eb.tag(["goal", goal]);
}
return eb;
});
console.debug(evNew);
@ -111,12 +148,12 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
{(options?.canSetTitle ?? true) && (
<div>
<p>
<FormattedMessage defaultMessage="Title" />
<FormattedMessage defaultMessage="Title" id="9a9+ww" />
</p>
<div className="paper">
<input
type="text"
placeholder={formatMessage({ defaultMessage: "What are we steaming today?" })}
placeholder={formatMessage({ defaultMessage: "What are we steaming today?", id: "QRHNuF" })}
value={title}
onChange={e => setTitle(e.target.value)}
/>
@ -126,12 +163,12 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
{(options?.canSetSummary ?? true) && (
<div>
<p>
<FormattedMessage defaultMessage="Summary" />
<FormattedMessage defaultMessage="Summary" id="RrCui3" />
</p>
<div className="paper">
<input
type="text"
placeholder={formatMessage({ defaultMessage: "A short description of the content" })}
placeholder={formatMessage({ defaultMessage: "A short description of the content", id: "mtNGwh" })}
value={summary}
onChange={e => setSummary(e.target.value)}
/>
@ -141,7 +178,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
{(options?.canSetImage ?? true) && (
<div>
<p>
<FormattedMessage defaultMessage="Cover Image" />
<FormattedMessage defaultMessage="Cover Image" id="Gq6x9o" />
</p>
<div className="paper">
<input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} />
@ -151,13 +188,13 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
{(options?.canSetStream ?? true) && (
<div>
<p>
<FormattedMessage defaultMessage="Stream URL" />
<FormattedMessage defaultMessage="Stream URL" id="QRRCp0" />
</p>
<div className="paper">
<input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
</div>
<small>
<FormattedMessage defaultMessage="Stream type should be HLS" />
<FormattedMessage defaultMessage="Stream type should be HLS" id="oZrFyI" />
</small>
</div>
)}
@ -165,9 +202,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
<>
<div>
<p>
<FormattedMessage defaultMessage="Status" />
<FormattedMessage defaultMessage="Status" id="tzMNF3" />
</p>
<div className="flex g12">
<div className="flex gap-2">
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => (
<span className={`pill${status === v ? " active" : ""}`} onClick={() => setStatus(v)} key={v}>
{v}
@ -178,7 +215,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
{status === StreamState.Planned && (
<div>
<p>
<FormattedMessage defaultMessage="Start Time" />
<FormattedMessage defaultMessage="Start Time" id="5QYdPU" />
</p>
<div className="paper">
<input
@ -189,34 +226,64 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
</div>
</div>
)}
{status === StreamState.Ended && (
<div>
<p>
<FormattedMessage defaultMessage="Recording URL" id="Y0DXJb" />
</p>
<div className="paper">
<input type="text" value={recording} onChange={e => setRecording(e.target.value)} />
</div>
</div>
)}
</>
)}
{(options?.canSetTags ?? true) && (
<div>
<p>
<FormattedMessage defaultMessage="Tags" />
<FormattedMessage defaultMessage="Tags" id="1EYCdR" />
</p>
<div className="paper">
<TagsInput value={tags} onChange={setTags} placeHolder="Music,DJ,English" separators={["Enter", ","]} />
</div>
</div>
)}
{login?.pubkey && (
<>
<div>
<p>
<FormattedMessage defaultMessage="Goal" id="0VV/sK" />
</p>
<div className="paper">
<GoalSelector goal={goal} pubkey={login?.pubkey} onGoalSelect={setGoal} />
</div>
</div>
<NewGoalDialog />
</>
)}
{(options?.canSetContentWarning ?? true) && (
<div className="flex g12 content-warning">
<div className="flex gap-2 content-warning">
<div>
<input type="checkbox" checked={contentWarning} onChange={e => setContentWarning(e.target.checked)} />
</div>
<div>
<div className="warning">
<FormattedMessage defaultMessage="NSFW Content" />
<FormattedMessage defaultMessage="NSFW Content" id="Atr2p4" />
</div>
<FormattedMessage defaultMessage="Check here if this stream contains nudity or pornographic content." />
<FormattedMessage
defaultMessage="Check here if this stream contains nudity or pornographic content."
id="lZpRMR"
/>
</div>
</div>
)}
<div>
<AsyncButton type="button" className="btn btn-primary wide" disabled={!isValid} onClick={publishStream}>
{ev ? <FormattedMessage defaultMessage="Save" /> : <FormattedMessage defaultMessage="Start Stream" />}
{ev ? (
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
) : (
<FormattedMessage defaultMessage="Start Stream" id="TaTRKo" />
)}
</AsyncButton>
</div>
</>

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import { findTag } from "../utils";
import { findTag } from "@/utils";
export function StreamTimer({ ev }: { ev?: NostrEvent }) {
const [time, setTime] = useState("");
@ -28,5 +28,5 @@ export function StreamTimer({ ev }: { ev?: NostrEvent }) {
return () => clearInterval(t);
}, []);
return time;
return <span className="tnum">{time}</span>;
}

View File

@ -0,0 +1,223 @@
import { LIVE_STREAM_CHAT, StreamState } from "@/const";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { useLiveChatFeed } from "@/hooks/live-chat";
import { formatSats } from "@/number";
import { extractStreamInfo } from "@/utils";
import { unixNow } from "@snort/shared";
import { NostrLink, NostrEvent, ParsedZap, EventKind } from "@snort/system";
import { useEventReactions } from "@snort/system-react";
import { useMemo } from "react";
import { FormattedMessage, FormattedNumber, FormattedDate } from "react-intl";
import { ResponsiveContainer, BarChart, XAxis, YAxis, Bar, Tooltip } from "recharts";
import { Profile } from "./profile";
import { StatePill } from "./state-pill";
interface StatSlot {
time: number;
zaps: number;
messages: number;
reactions: number;
}
export default function StreamSummary({ link, preload }: { link: NostrLink; preload?: NostrEvent }) {
const ev = useCurrentStreamFeed(link, true, preload);
const thisLink = ev ? NostrLink.fromEvent(ev) : undefined;
const data = useLiveChatFeed(thisLink, undefined, 5_000);
const reactions = useEventReactions(thisLink ?? link, data.reactions);
const chatSummary = useMemo(() => {
return Object.entries(
data.messages.reduce((acc, v) => {
acc[v.pubkey] ??= [];
acc[v.pubkey].push(v);
return acc;
}, {} as Record<string, Array<NostrEvent>>)
)
.map(([k, v]) => ({
pubkey: k,
messages: v,
}))
.sort((a, b) => (a.messages.length > b.messages.length ? -1 : 1));
}, [data.messages]);
const zapsSummary = useMemo(() => {
return Object.entries(
reactions.zaps.reduce((acc, v) => {
if (!v.sender) return acc;
acc[v.sender] ??= [];
acc[v.sender].push(v);
return acc;
}, {} as Record<string, Array<ParsedZap>>)
)
.map(([k, v]) => ({
pubkey: k,
zaps: v,
total: v.reduce((acc, vv) => acc + vv.amount, 0),
}))
.sort((a, b) => (a.total > b.total ? -1 : 1));
}, [reactions.zaps]);
const { title, summary, status, starts } = extractStreamInfo(ev);
const Day = 60 * 60 * 24;
const startTime = starts ? Number(starts) : ev?.created_at ?? unixNow();
const endTime = status === StreamState.Live ? unixNow() : ev?.created_at ?? unixNow();
const streamLength = endTime - startTime;
const windowSize = streamLength > Day ? Day : 60 * 10;
const stats = useMemo(() => {
let min = unixNow();
let max = 0;
const ret = [...data.messages, ...data.reactions]
.sort((a, b) => (a.created_at > b.created_at ? -1 : 1))
.reduce((acc, v) => {
const time = Math.floor(v.created_at - (v.created_at % windowSize));
if (time < min) {
min = time;
}
if (time > max) {
max = time;
}
const key = time.toString();
acc[key] ??= {
time,
zaps: 0,
messages: 0,
reactions: 0,
};
if (v.kind === LIVE_STREAM_CHAT) {
acc[key].messages++;
} else if (v.kind === EventKind.ZapReceipt) {
acc[key].zaps++;
} else if (v.kind === EventKind.Reaction) {
acc[key].reactions++;
} else {
console.debug("Uncounted stat", v);
}
return acc;
}, {} as Record<string, StatSlot>);
// fill empty time slots
for (let x = min; x < max; x += windowSize) {
ret[x.toString()] ??= {
time: x,
zaps: 0,
messages: 0,
reactions: 0,
};
}
return ret;
}, [data]);
return (
<div className="stream-summary">
<h1>{title}</h1>
<p>{summary}</p>
<div className="flex gap-1">
<StatePill state={status as StreamState} />
{streamLength > 0 && (
<FormattedMessage
defaultMessage="Stream Duration {duration} mins"
id="J/+m9y"
values={{
duration: <FormattedNumber value={streamLength / 60} maximumFractionDigits={2} />,
}}
/>
)}
</div>
<h2>
<FormattedMessage defaultMessage="Summary" id="RrCui3" />
</h2>
<ResponsiveContainer height={200}>
<BarChart data={Object.values(stats)} margin={{ left: 0, right: 0 }} style={{ userSelect: "none" }}>
<XAxis tick={false} />
<YAxis />
<Bar dataKey="messages" fill="green" stackId="" />
<Bar dataKey="zaps" fill="yellow" stackId="" />
<Bar dataKey="reactions" fill="red" stackId="" />
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.2)" }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload as StatSlot;
return (
<div className="plain-paper flex flex-col gap-2">
<div>
<FormattedDate value={data.time * 1000} timeStyle="short" dateStyle="short" />
</div>
<div className="flex justify-between">
<div>
<FormattedMessage defaultMessage="Messages" id="hMzcSq" />
</div>
<div>{data.messages}</div>
</div>
<div className="flex justify-between">
<div>
<FormattedMessage defaultMessage="Reactions" id="XgWvGA" />
</div>
<div>{data.reactions}</div>
</div>
<div className="flex justify-between">
<div>
<FormattedMessage defaultMessage="Zaps" id="OEW7yJ" />
</div>
<div>{data.zaps}</div>
</div>
</div>
);
}
return null;
}}
/>
</BarChart>
</ResponsiveContainer>
<div className="flex gap-1">
<div className="plain-paper flex-1">
<h3>
<FormattedMessage defaultMessage="Top Chatters" id="GGaJMU" />
</h3>
<div className="flex flex-col gap-2">
{chatSummary.slice(0, 5).map(a => (
<div className="flex justify-between items-center" key={a.pubkey}>
<Profile pubkey={a.pubkey} />
<div>
<FormattedMessage
defaultMessage="{n} messages"
id="gzsn7k"
values={{
n: <FormattedNumber value={a.messages.length} />,
}}
/>
</div>
</div>
))}
</div>
</div>
<div className="plain-paper flex-1">
<h3>
<FormattedMessage defaultMessage="Top Zappers" id="dVD/AR" />
</h3>
<div className="flex flex-col gap-2">
{zapsSummary.slice(0, 5).map(a => (
<div className="flex justify-between items-center" key={a.pubkey}>
<Profile pubkey={a.pubkey} />
<div>
<FormattedMessage
defaultMessage="{n} sats"
id="CsCUYo"
values={{
n: formatSats(a.total),
}}
/>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,15 +1,12 @@
import type { ReactNode } from "react";
import { FormattedMessage } from "react-intl";
import moment from "moment";
import { NostrEvent } from "@snort/system";
import { StreamState } from "index";
import { findTag, getTagValues } from "utils";
import { findTag, getTagValues } from "@/utils";
import { StreamState } from "@/const";
export function Tags({ children, max, ev }: { children?: ReactNode; max?: number; ev: NostrEvent }) {
const status = findTag(ev, "status");
const start = findTag(ev, "starts");
const hashtags = getTagValues(ev.tags, "t");
const tags = max ? hashtags.slice(0, max) : hashtags;
@ -17,13 +14,12 @@ export function Tags({ children, max, ev }: { children?: ReactNode; max?: number
<>
{children}
{status === StreamState.Planned && (
<span className="pill">
{status === StreamState.Planned ? <FormattedMessage defaultMessage="Starts " /> : ""}
{moment(Number(start) * 1000).fromNow()}
<span className="pill bg-gray-1">
{status === StreamState.Planned ? <FormattedMessage defaultMessage="Starts " id="0hNxBy" /> : ""}
</span>
)}
{tags.map(a => (
<a href={`/t/${encodeURIComponent(a)}`} className="pill" key={a}>
<a href={`/t/${encodeURIComponent(a)}`} className="pill bg-gray-1" key={a}>
{a}
</a>
))}

View File

@ -1,207 +1,67 @@
import { useMemo, type ReactNode, type FunctionComponent } from "react";
import { NostrLink, NostrPrefix, ParsedFragment, transformText, tryParseNostrLink } from "@snort/system";
import { FunctionComponent, useMemo } from "react";
import { Link } from "react-router-dom";
import { type NostrLink, parseNostrLink, validateNostrLink } from "@snort/system";
import { Emoji } from "./emoji";
import { Mention } from "./mention";
import { HyperText } from "./hypertext";
import { Event } from "./Event";
import { SendZapsDialog } from "./send-zap";
import { Event } from "element/Event";
import { Mention } from "element/mention";
import { Emoji } from "element/emoji";
import { HyperText } from "element/hypertext";
import { splitByUrl } from "utils";
import type { Tags } from "types";
export type Fragment = string | ReactNode;
const NostrPrefixRegex = /^nostr:/;
const EmojiRegex = /:([\w-]+):/g;
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:");
};
if (validateLink()) {
return <HyperText link={a}>{a}</HyperText>;
}
return a;
});
}
return f;
})
.flat();
}
function extractEmoji(fragments: Fragment[], tags: string[][]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(EmojiRegex).map(i => {
const t = tags.find(a => a[0] === "emoji" && a[1] === i);
if (t) {
return <Emoji name={t[1]} url={t[2]} />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractNprofiles(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(/(nostr:nprofile1[a-z0-9]+)/g).map(i => {
if (i.startsWith("nostr:nprofile1")) {
try {
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
return <Mention key={link.id} pubkey={link.id} />;
} catch (error) {
return i;
}
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractNpubs(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(/(nostr:npub1[a-z0-9]+)/g).map(i => {
if (i.startsWith("nostr:npub1")) {
try {
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
return <Mention key={link.id} pubkey={link.id} />;
} catch (error) {
return i;
}
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractNevents(fragments: Fragment[], Event: NostrComponent) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(/(nostr:nevent1[a-z0-9]+)/g).map(i => {
if (i.startsWith("nostr:nevent1")) {
try {
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
return <Event link={link} />;
} catch (error) {
return i;
}
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractNaddrs(fragments: Fragment[], Address: NostrComponent) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(/(nostr:naddr1[a-z0-9]+)/g).map(i => {
if (i.startsWith("nostr:naddr1")) {
try {
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
return <Address key={i} link={link} />;
} catch (error) {
console.error(error);
return i;
}
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractNoteIds(fragments: Fragment[], Event: NostrComponent) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(/(nostr:note1[a-z0-9]+)/g).map(i => {
if (i.startsWith("nostr:note1")) {
try {
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
return <Event link={link} />;
} catch (error) {
return i;
}
} else {
return i;
}
});
}
return f;
})
.flat();
}
export type NostrComponent = FunctionComponent<{ link: NostrLink }>;
export interface NostrComponents {
Event: NostrComponent;
}
const components: NostrComponents = {
Event,
};
export function transformText(ps: Fragment[], tags: Array<string[]>, customComponents = components) {
let fragments = extractEmoji(ps, tags);
fragments = extractNprofiles(fragments);
fragments = extractNevents(fragments, customComponents.Event);
fragments = extractNaddrs(fragments, customComponents.Event);
fragments = extractNoteIds(fragments, customComponents.Event);
fragments = extractNpubs(fragments);
fragments = extractLinks(fragments);
return fragments;
}
export type EventComponent = FunctionComponent<{ link: NostrLink }>;
interface TextProps {
content: string;
tags: Tags;
customComponents?: NostrComponents;
tags: Array<Array<string>>;
eventComponent?: EventComponent;
}
export function Text({ content, tags, customComponents }: TextProps) {
// todo: RTL langugage support
const element = useMemo(() => {
return <span className="text">{transformText([content], tags, customComponents)}</span>;
export function Text({ content, tags, eventComponent }: TextProps) {
const frags = useMemo(() => {
return transformText(content, tags);
}, [content, tags]);
return <>{element}</>;
function renderFrag(f: ParsedFragment) {
switch (f.type) {
case "custom_emoji":
return <Emoji name={f.content} url={f.content} />;
case "media":
case "link": {
if (f.content.startsWith("nostr:")) {
const link = tryParseNostrLink(f.content);
if (link) {
if (
link.type === NostrPrefix.Event ||
link?.type === NostrPrefix.Address ||
link?.type === NostrPrefix.Note
) {
return eventComponent?.({ link }) ?? <Event link={link} />;
} else {
return <Mention pubkey={link.id} />;
}
}
}
return (
<span className="text">
<HyperText link={f.content}>{f.content}</HyperText>
</span>
);
}
case "mention":
return <Mention pubkey={f.content} />;
case "hashtag":
return <Link to={`/t/${f.content}`}>#{f.content}</Link>;
default: {
if (f.content.startsWith("lnurlp:")) {
// LUD-17: https://github.com/lnurl/luds/blob/luds/17.md
const url = new URL(f.content);
url.protocol = "https:";
return <SendZapsDialog pubkey={undefined} lnurl={url.toString()} button={<Link to={""}>{f.content}</Link>} />;
}
return <span className="text">{f.content}</span>;
}
}
}
return frags.map(renderFrag);
}

View File

@ -10,7 +10,6 @@
}
.rta__entity--selected .emoji-item {
text-decoration: none;
background: #f838d9;
}
.emoji-item,

View File

@ -1,17 +1,17 @@
import "./textarea.css";
import type { KeyboardEvent, ChangeEvent } from "react";
import { useContext } from "react";
import ReactTextareaAutocomplete, { TriggerType } from "@webscopeio/react-textarea-autocomplete";
import "@webscopeio/react-textarea-autocomplete/style.css";
import uniqWith from "lodash/uniqWith";
import isEqual from "lodash/isEqual";
import { hexToBech32 } from "@snort/shared";
import { SnortContext } from "@snort/system-react";
import { MetadataCache, NostrPrefix, UserProfileCache } from "@snort/system";
import { Emoji } from "element/emoji";
import { Avatar } from "element/avatar";
import { hexToBech32 } from "utils";
import type { EmojiTag } from "types";
import { System } from "index";
import { Emoji } from "./emoji";
import { Avatar } from "./avatar";
import type { EmojiTag } from "@/types";
interface EmojiItemProps {
name: string;
@ -33,23 +33,18 @@ const UserItem = (metadata: MetadataCache) => {
const { pubkey, display_name, ...rest } = metadata;
return (
<div key={pubkey} className="user-item">
<Avatar avatarClassname="user-image" user={metadata} />
<Avatar className="user-image" user={metadata} pubkey={pubkey} />
<div className="user-details">{display_name || rest.name}</div>
</div>
);
};
interface TextareaProps {
emojis: EmojiTag[];
value: string;
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown: (e: KeyboardEvent<Element>) => void;
rows?: number;
}
type TextareaProps = { emojis: EmojiTag[] } & React.TextareaHTMLAttributes<HTMLTextAreaElement>;
export function Textarea({ emojis, ...props }: TextareaProps) {
const system = useContext(SnortContext);
const userDataProvider = async (token: string) => {
const cache = System.ProfileLoader.Cache;
const cache = system.ProfileLoader.Cache;
if (cache instanceof UserProfileCache) {
return await cache.search(token);
}

164
src/element/timeline.tsx Normal file
View File

@ -0,0 +1,164 @@
import { HTMLProps, useRef, useEffect } from "react";
type TimelineProps = {
length: number;
offset: number;
setLength: (n: number) => void;
setOffset: (n: number) => void;
} & Omit<HTMLProps<HTMLCanvasElement>, "ref">;
export function TimelineBar({
length: pLength,
offset: pOffset,
setLength: pSetLength,
setOffset: pSetOffset,
...props
}: TimelineProps) {
const ref = useRef<HTMLCanvasElement | null>(null);
function setupHandler(canvas: HTMLCanvasElement) {
const rect = canvas.getBoundingClientRect();
let draggingOffset = false;
let draggingLength = false;
let offset = pOffset;
let length = pLength;
function getBodyRect() {
const x = canvas.width * offset;
const w = Math.max(10, canvas.width * length);
return {
x,
y: 0,
w,
h: canvas.height,
};
}
function getDragHandleRect() {
const x = canvas.width * (offset + length);
const w = 5;
return {
x,
y: 0,
w,
h: canvas.height,
};
}
function render() {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 1;
ctx.strokeStyle = "white";
ctx.strokeRect(0, 0, canvas.width, canvas.height);
const drawBody = () => {
const { x, y, w, h } = getBodyRect();
ctx.fillStyle = "white";
ctx.fillRect(x, y, w, h);
};
const drawHandle = () => {
const { x, y, w, h } = getDragHandleRect();
ctx.fillStyle = "#ccc";
ctx.fillRect(x, y, w, h);
};
drawBody();
drawHandle();
requestAnimationFrame(render);
}
function scaleX(x: number) {
return (x / rect.width) * canvas.width;
}
function getEventLocation(event: MouseEvent | TouchEvent): { x: number } {
if (event instanceof TouchEvent) {
return {
x: scaleX(event.touches[0].clientX - rect.x),
};
} else {
// MouseEvent
return {
x: scaleX(event.clientX - rect.x),
};
}
}
function xOfBody(x: number) {
const { w } = getBodyRect();
return Math.min(1 - length, Math.max(0, (x - w / 2) / canvas.width));
}
function xOfHandle(x: number) {
const { w } = getDragHandleRect();
return Math.min(1, Math.max(0.1, (x - w / 2) / canvas.width - offset));
}
function handleStart(event: MouseEvent | TouchEvent) {
event.preventDefault();
const { x } = getEventLocation(event);
const body = getBodyRect();
if (x >= body.x && x <= body.x + body.w) {
draggingOffset = true;
console.debug("dragging offset");
}
const handle = getDragHandleRect();
if (x >= handle.x && x <= handle.x + handle.w) {
draggingLength = true;
console.debug("dragging length");
}
}
function handleMove(event: MouseEvent | TouchEvent) {
event.preventDefault();
const { x } = getEventLocation(event);
if (draggingLength) {
const newVal = xOfHandle(x);
length = newVal;
} else if (draggingOffset) {
const newVal = xOfBody(x);
offset = newVal;
}
}
function handleEnd(event: MouseEvent | TouchEvent) {
event.preventDefault();
const { x } = getEventLocation(event);
console.debug("drag end");
if (draggingLength) {
const newVal = xOfHandle(x);
pSetLength(newVal);
} else if (draggingOffset) {
const newVal = xOfBody(x);
pSetOffset(newVal);
}
draggingLength = false;
draggingOffset = false;
}
// Add mouse event listeners
canvas.addEventListener("mousedown", handleStart);
canvas.addEventListener("mousemove", handleMove);
canvas.addEventListener("mouseup", handleEnd);
canvas.addEventListener("mouseleave", handleEnd);
// Add touch event listeners
canvas.addEventListener("touchstart", handleStart);
canvas.addEventListener("touchmove", handleMove);
canvas.addEventListener("touchend", handleEnd);
requestAnimationFrame(render);
}
useEffect(() => {
if (ref.current) {
console.debug("Setup render loop");
setupHandler(ref.current);
}
}, [ref.current]);
return <canvas {...props} ref={ref}></canvas>;
}

View File

@ -23,5 +23,5 @@
color: white;
}
.toggle[data-state="on"] svg {
color: var(--text-link);
color: var(--primary);
}

View File

@ -1,6 +1,6 @@
import * as BaseToggle from "@radix-ui/react-toggle";
import "./toggle.css";
import { Icon } from "element/icon";
import { Icon } from "./icon";
interface ToggleProps {
label: string;

View File

@ -1,27 +1,12 @@
import { ParsedZap } from "@snort/system";
import useTopZappers from "hooks/top-zappers";
import { formatSats } from "number";
import { Icon } from "./icon";
import { Profile } from "./profile";
import useTopZappers from "@/hooks/top-zappers";
import { ZapperRow } from "./zapper-row";
export function TopZappers({ zaps, limit }: { zaps: ParsedZap[]; limit?: number }) {
const zappers = useTopZappers(zaps);
return (
<>
{zappers.slice(0, limit ?? 10).map(({ pubkey, total }) => {
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>
);
})}
</>
);
return zappers.slice(0, limit ?? 10).map(({ pubkey, total }) => (
<div className="border rounded-full px-2 py-1 border-gray-1 grow-0 shrink-0 basis-auto font-bold">
<ZapperRow pubkey={pubkey} total={total} key={pubkey} showName={false} />
</div>
));
}

View File

@ -1,17 +1,17 @@
import "./video-tile.css";
import { Link } from "react-router-dom";
import { Profile } from "./profile";
import "./video-tile.css";
import { NostrEvent, encodeTLV, NostrPrefix } from "@snort/system";
import { NostrEvent, NostrPrefix, encodeTLV } 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";
import { isContentWarningAccepted } from "./content-warning";
import { Tags } from "element/tags";
import { FormattedMessage } from "react-intl";
import { StatePill } from "./state-pill";
import { extractStreamInfo, findTag, getHost } from "@/utils";
import { formatSats } from "@/number";
import { isContentWarningAccepted } from "./content-warning";
import { Tags } from "./tags";
import { StreamState } from "@/const";
export function VideoTile({
ev,
showAuthor = true,
@ -23,26 +23,30 @@ export function VideoTile({
}) {
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 contentWarning = findTag(ev, "content-warning") && !isContentWarningAccepted();
const { title, image, status, participants, contentWarning } = extractStreamInfo(ev);
const host = getHost(ev);
const link = encodeTLV(NostrPrefix.Address, id, undefined, ev.kind, ev.pubkey);
return (
<div className="video-tile-container">
<Link to={`/${link}`} className={`video-tile${contentWarning ? " nsfw" : ""}`} ref={ref} state={ev}>
<Link
to={`/${link}`}
className={`video-tile${contentWarning && !isContentWarningAccepted() ? " nsfw" : ""}`}
ref={ref}
state={ev}>
<div
style={{
backgroundImage: `url(${inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : ""})`,
backgroundImage: `url(${inView ? ((image?.length ?? 0) > 0 ? image : "/zap-stream.svg") : ""})`,
}}></div>
<span className="pill-box">
{showStatus && <StatePill state={status as StreamState} />}
{viewers && (
<span className="pill viewers">
<FormattedMessage defaultMessage="{n} viewers" values={{ n: formatSats(Number(viewers)) }} />
{participants && (
<span className="pill viewers bg-gray-1">
<FormattedMessage
defaultMessage="{n} viewers"
id="3adEeb"
values={{ n: formatSats(Number(participants)) }}
/>
</span>
)}
</span>

View File

@ -1,17 +1,20 @@
import { NostrLink, EventKind } from "@snort/system";
import React, { useRef, useState } from "react";
import { EventKind, NostrLink } from "@snort/system";
import React, { Suspense, lazy, useContext, useRef, useState } from "react";
import { FormattedMessage } from "react-intl";
import { SnortContext } from "@snort/system-react";
import { unixNowMs } from "@snort/shared";
import { useLogin } from "hooks/login";
import AsyncButton from "element/async-button";
import { Icon } from "element/icon";
import { Textarea } from "element/textarea";
import { EmojiPicker } from "element/emoji-picker";
import type { EmojiPack, Emoji } from "types";
import { System } from "index";
import { LIVE_STREAM_CHAT } from "const";
const EmojiPicker = lazy(() => import("./emoji-picker"));
import { useLogin } from "@/hooks/login";
import AsyncButton from "./async-button";
import { Icon } from "./icon";
import { Textarea } from "./textarea";
import type { Emoji, EmojiPack } from "@/types";
import { LIVE_STREAM_CHAT } from "@/const";
import { TimeSync } from "@/index";
export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks: EmojiPack[] }) {
const system = useContext(SnortContext);
const ref = useRef<HTMLDivElement | null>(null);
const emojiRef = useRef(null);
const [chat, setChat] = useState("");
@ -38,6 +41,7 @@ export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks
const emoji = [...emojiNames].map(name => emojis.find(e => e.at(1) === name));
eb.kind(LIVE_STREAM_CHAT as EventKind)
.content(chat)
.createdAt(Math.floor((unixNowMs() - TimeSync) / 1000))
.tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"])
.processContent();
for (const e of emoji) {
@ -49,7 +53,7 @@ export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks
});
if (reply) {
console.debug(reply);
System.BroadcastEvent(reply);
system.BroadcastEvent(reply);
}
setChat("");
}
@ -65,7 +69,7 @@ export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks
}
async function onKeyDown(e: React.KeyboardEvent) {
if (e.code === "Enter") {
if (e.code === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault();
await sendChatMessage();
}
@ -79,23 +83,25 @@ export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks
return (
<>
<div className="paper" ref={ref}>
<Textarea emojis={emojis} value={chat} onKeyDown={onKeyDown} onChange={e => setChat(e.target.value)} />
<Textarea emojis={emojis} value={chat} onKeyDown={onKeyDown} onChange={e => setChat(e.target.value)} rows={2} />
<div onClick={pickEmoji}>
<Icon name="face" className="write-emoji-button" />
</div>
{showEmojiPicker && (
<EmojiPicker
topOffset={topOffset ?? 0}
leftOffset={leftOffset ?? 0}
emojiPacks={emojiPacks}
onEmojiSelect={onEmojiSelect}
onClickOutside={() => setShowEmojiPicker(false)}
ref={emojiRef}
/>
<Suspense>
<EmojiPicker
topOffset={topOffset ?? 0}
leftOffset={leftOffset ?? 0}
emojiPacks={emojiPacks}
onEmojiSelect={onEmojiSelect}
onClickOutside={() => setShowEmojiPicker(false)}
ref={emojiRef}
/>
</Suspense>
)}
</div>
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
<FormattedMessage defaultMessage="Send" />
<FormattedMessage defaultMessage="Send" id="9WRlF4" />
</AsyncButton>
</>
);

View File

@ -0,0 +1,22 @@
import { formatSats } from "@/number";
import { Icon } from "./icon";
import { Profile } from "./profile";
import { FormattedMessage } from "react-intl";
export function ZapperRow({ pubkey, total, showName }: { pubkey: string; total: number; showName?: boolean }) {
return (
<div className="flex gap-1 justify-between items-center">
{pubkey === "anon" ? (
<span>
<FormattedMessage defaultMessage="Anon" id="bfvyfs" />
</span>
) : (
<Profile pubkey={pubkey} options={{ showName }} />
)}
<div className="flex items-center gap-2">
<Icon name="zap-filled" className="text-zap" />
<span>{formatSats(total)}</span>
</div>
</div>
);
}

View File

@ -1,10 +1,10 @@
import { useMemo } from "react";
import { TaggedNostrEvent, EventKind, NoteCollection, RequestBuilder } from "@snort/system";
import { EventKind, NoteCollection, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { findTag, toAddress, getTagValues } from "utils";
import type { Badge } from "types";
import { findTag, getTagValues, toAddress } from "@/utils";
import type { Badge } from "@/types";
export function useBadges(
pubkey: string,
@ -12,10 +12,10 @@ export function useBadges(
leaveOpen = true
): { badges: Badge[]; awards: TaggedNostrEvent[] } {
const rb = useMemo(() => {
if (!pubkey) return null;
const rb = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`);
rb.withOptions({ leaveOpen });
rb.withFilter().authors([pubkey]).kinds([EventKind.Badge]);
rb.withFilter().authors([pubkey]).kinds([EventKind.BadgeAward]).since(since);
rb.withFilter().authors([pubkey]).kinds([EventKind.Badge, EventKind.BadgeAward]);
return rb;
}, [pubkey, since]);

View File

@ -1,10 +1,10 @@
import { useMemo } from "react";
import { TaggedNostrEvent, ReplaceableNoteStore, NoteCollection, RequestBuilder } from "@snort/system";
import { NoteCollection, ReplaceableNoteStore, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { USER_CARDS, CARD } from "const";
import { findTag } from "utils";
import { CARD, USER_CARDS } from "@/const";
import { findTag } from "@/utils";
export function useUserCards(pubkey: string, userCards: Array<string[]>, leaveOpen = false): TaggedNostrEvent[] {
const related = useMemo(() => {
@ -49,9 +49,10 @@ export function useUserCards(pubkey: string, userCards: Array<string[]>, leaveOp
return cards;
}
export function useCards(pubkey: string, leaveOpen = false): TaggedNostrEvent[] {
export function useCards(pubkey?: string, leaveOpen = false): TaggedNostrEvent[] {
const sub = useMemo(() => {
const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`);
if (!pubkey) return null;
const b = new RequestBuilder(`user-cards:${pubkey?.slice(0, 12)}`);
b.withOptions({
leaveOpen,
})

View File

@ -1,9 +1,10 @@
import { unwrap } from "@snort/shared";
import { NostrEvent, NostrLink, NostrPrefix, NoteCollection, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { LIVE_STREAM } from "const";
import { useMemo } from "react";
import { LIVE_STREAM } from "@/const";
export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: NostrEvent) {
const author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
const sub = useMemo(() => {

View File

@ -1,11 +1,10 @@
import { useMemo } from "react";
import uniqBy from "lodash.uniqby";
import { RequestBuilder, ReplaceableNoteStore, NoteCollection, NostrEvent } from "@snort/system";
import { NostrEvent, NoteCollection, ReplaceableNoteStore, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { findTag } from "utils";
import { EMOJI_PACK, USER_EMOJIS } from "const";
import type { EmojiPack, Tags, EmojiTag } from "types";
import { findTag, uniqBy } from "@/utils";
import { EMOJI_PACK, USER_EMOJIS } from "@/const";
import type { EmojiPack, EmojiTag, Tags } from "@/types";
function cleanShortcode(shortcode?: string) {
return shortcode?.replace(/\s+/g, "_").replace(/_$/, "");

View File

@ -1,5 +1,5 @@
import { useMemo } from "react";
import { NostrPrefix, RequestBuilder, ReplaceableNoteStore, NostrLink } from "@snort/system";
import { NostrLink, NostrPrefix, ReplaceableNoteStore, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
export default function useEventFeed(link: NostrLink, leaveOpen = false) {

View File

@ -1,6 +1,6 @@
import { useMemo } from "react";
import { NostrPrefix, ReplaceableNoteStore, RequestBuilder, type NostrLink } from "@snort/system";
import { type NostrLink, NostrPrefix, ReplaceableNoteStore, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
export function useAddress(kind: number, pubkey: string, identifier: string) {

View File

@ -1,22 +1,31 @@
import { useMemo } from "react";
import { RequestBuilder, ReplaceableNoteStore, NostrLink } from "@snort/system";
import { FlatNoteStore, ReplaceableNoteStore, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { unwrap } from "@snort/shared";
import { GOAL } from "const";
import { GOAL } from "@/const";
export function useZapGoal(host: string, link?: NostrLink, leaveOpen = false) {
export function useZapGoal(id?: string) {
const sub = useMemo(() => {
if (!link) return null;
const b = new RequestBuilder(`goals:${host.slice(0, 12)}`);
b.withOptions({ leaveOpen });
b.withFilter()
.kinds([GOAL])
.authors([host])
.tag("a", [`${link.kind}:${unwrap(link.author)}:${link.id}`]);
if (!id) return null;
const b = new RequestBuilder(`goal:${id.slice(0, 12)}`);
b.withFilter().kinds([GOAL]).ids([id]);
return b;
}, [link, leaveOpen]);
}, [id]);
const { data } = useRequestBuilder(ReplaceableNoteStore, sub);
return data;
}
export function useGoals(pubkey?: string, leaveOpen = false) {
const sub = useMemo(() => {
if (!pubkey) return null;
const b = new RequestBuilder(`goals:${pubkey.slice(0, 12)}`);
b.withOptions({ leaveOpen });
b.withFilter().kinds([GOAL]).authors([pubkey]);
return b;
}, [pubkey, leaveOpen]);
const { data } = useRequestBuilder(FlatNoteStore, sub);
return data;
}

33
src/hooks/lang.ts Normal file
View File

@ -0,0 +1,33 @@
import { ExternalStore } from "@snort/shared";
import { useSyncExternalStore } from "react";
export const DefaultLocale = "en-US";
class LangStore extends ExternalStore<string> {
setLang(lang: string) {
localStorage.setItem("lang", lang);
this.notifyChange();
}
takeSnapshot(): string {
return localStorage.getItem("lang") ?? getLocale();
}
}
const LangSelector = new LangStore();
export function useLang() {
const store = useSyncExternalStore(
c => LangSelector.hook(c),
() => LangSelector.snapshot()
);
return {
lang: store,
setLang: (l: string) => LangSelector.setLang(l),
};
}
export const getLocale = () => {
return (navigator.languages && navigator.languages[0]) ?? navigator.language ?? DefaultLocale;
};

View File

@ -1,10 +1,10 @@
import { useMemo } from "react";
import { RequestBuilder, ReplaceableNoteStore } from "@snort/system";
import { ReplaceableNoteStore, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { MUTED } from "const";
import { getTagValues } from "utils";
import { MUTED } from "@/const";
import { getTagValues } from "@/utils";
export function useMutedPubkeys(host?: string, leaveOpen = false) {
const mutedSub = useMemo(() => {

Some files were not shown because too many files have changed in this diff Show More