Merge pull request 'design-v2' (#617) from design-v2 into main

Reviewed-on: Kieran/snort#617
This commit is contained in:
Kieran 2023-08-24 16:03:08 +00:00
commit 1fb075b118
247 changed files with 21832 additions and 13837 deletions

12
.gitignore vendored
View File

@ -1,8 +1,14 @@
node_modules/
.idea
.yarn
yarn.lock
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
dist/
*.tgz
*.log
.DS_Store
.DS_Store
.pnp*

6
.prettierignore Normal file
View File

@ -0,0 +1,6 @@
.yarn/
build/
.vscode/
.github/
transifex.yml
dist/

6
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"recommendations": [
"arcanis.vscode-zipfs",
"dbaeumer.vscode-eslint"
]
}

10
.vscode/settings.json vendored
View File

@ -7,5 +7,13 @@
"**/.DS_Store": true,
"**/Thumbs.db": true,
"**/node_modules": true
}
},
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"typescript.tsdk": ".yarn/sdks/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"eslint.nodePath": ".yarn/sdks",
"prettier.prettierPath": ".yarn/sdks/prettier/index.js"
}

874
.yarn/releases/yarn-3.6.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

20
.yarn/sdks/eslint/bin/eslint.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 eslint/bin/eslint.js
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/bin/eslint.js your application uses
module.exports = absRequire(`eslint/bin/eslint.js`);

20
.yarn/sdks/eslint/lib/api.js vendored Normal 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 eslint
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint your application uses
module.exports = absRequire(`eslint`);

6
.yarn/sdks/eslint/package.json vendored Normal file
View File

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

5
.yarn/sdks/integrations.yml vendored Normal file
View File

@ -0,0 +1,5 @@
# This file is automatically generated by @yarnpkg/sdks.
# Manual changes might be lost!
integrations:
- vscode

20
.yarn/sdks/typescript/bin/tsc 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 typescript/bin/tsc
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsc your application uses
module.exports = absRequire(`typescript/bin/tsc`);

20
.yarn/sdks/typescript/bin/tsserver 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 typescript/bin/tsserver
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsserver your application uses
module.exports = absRequire(`typescript/bin/tsserver`);

20
.yarn/sdks/typescript/lib/tsc.js vendored Normal 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 typescript/lib/tsc.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = absRequire(`typescript/lib/tsc.js`);

225
.yarn/sdks/typescript/lib/tsserver.js vendored Normal file
View File

@ -0,0 +1,225 @@
#!/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);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const 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
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
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))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// 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.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
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;
// 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;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
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 `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;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// 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() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
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 parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
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)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
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 tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View File

@ -0,0 +1,225 @@
#!/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);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const 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
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
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))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// 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.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
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;
// 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;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
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 `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;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// 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() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
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 parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
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)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
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 tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserverlibrary.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserverlibrary.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`));

20
.yarn/sdks/typescript/lib/typescript.js vendored Normal 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 typescript/lib/typescript.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/typescript.js your application uses
module.exports = absRequire(`typescript/lib/typescript.js`);

6
.yarn/sdks/typescript/package.json vendored Normal file
View File

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

5
.yarnrc Normal file
View File

@ -0,0 +1,5 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
yarn-path ".yarn/releases/yarn-1.22.19.cjs"

4
.yarnrc.yml Normal file
View File

@ -0,0 +1,4 @@
yarnPath: .yarn/releases/yarn-3.6.1.cjs
npmScopes:
void-cat:
npmRegistryServer: https://git.v0l.io/api/packages/Kieran/npm/

View File

@ -9,12 +9,18 @@
"test": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/app test && yarn workspace @snort/system test"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20230307.0",
"@tauri-apps/cli": "^1.2.3",
"@cloudflare/workers-types": "^4.20230307.0"
"prettier": "^3.0.0"
},
"prettier": {
"printWidth": 120,
"bracketSameLine": true,
"arrowParens": "avoid"
},
"packageManager": "yarn@3.6.1",
"dependencies": {
"eslint": "^8.44.0",
"typescript": "^5.1.6"
}
}

View File

@ -1,3 +0,0 @@
build/
.github/
transifex.yml

View File

@ -1,4 +0,0 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": [["formatjs"]]
}

View File

@ -29,3 +29,8 @@ declare module "translations/*.json" {
const value: Record<string, string>;
export default value;
}
declare module "emojilib" {
const value: Record<string, Array<string>>;
export default value;
}

View File

@ -1,40 +1,44 @@
{
"name": "@snort/app",
"version": "0.1.10",
"private": true,
"dependencies": {
"@cashu/cashu-ts": "^0.6.1",
"@jukben/emoji-search": "^2.0.1",
"@lightninglabs/lnc-web": "^0.2.3-alpha",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.2.0",
"@reduxjs/toolkit": "^1.9.1",
"@scure/base": "^1.1.1",
"@scure/bip32": "^1.3.0",
"@scure/bip39": "^1.1.1",
"@snort/shared": "workspace:*",
"@snort/system": "workspace:*",
"@snort/system-react": "workspace:*",
"@szhsin/react-menu": "^3.3.1",
"@void-cat/api": "^1.0.4",
"debug": "^4.3.4",
"dexie": "^3.2.4",
"dns-over-http-resolver": "^2.1.1",
"hls.js": "^1.4.6",
"emojilib": "^3.0.10",
"light-bolt11-decoder": "^2.1.0",
"match-sorter": "^6.3.1",
"qr-code-styling": "^1.6.0-rc.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-intersection-observer": "^9.4.1",
"react-intl": "^6.2.8",
"react-intl": "^6.4.4",
"react-redux": "^8.0.5",
"react-router-dom": "^6.5.0",
"react-textarea-autosize": "^8.4.0",
"react-twitter-embed": "^4.0.4",
"use-long-press": "^2.0.3",
"uuid": "^9.0.0",
"workbox-core": "^6.4.2",
"workbox-routing": "^6.4.2",
"workbox-strategies": "^6.4.2"
},
"scripts": {
"start": "webpack serve",
"build": "webpack --node-env=production",
"start": "webpack serve --node-env=development --mode=development",
"build": "webpack --node-env=production --mode=production",
"test": "jest --runInBand",
"intl-extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file src/lang.json --flatten true",
"intl-compile": "formatjs compile src/lang.json --out-file src/translations/en.json",
@ -62,18 +66,24 @@
]
},
"devDependencies": {
"@babel/core": "^7.22.9",
"@babel/plugin-syntax-import-assertions": "^7.20.0",
"@babel/preset-env": "^7.21.5",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.22.5",
"@babel/runtime": "^7.22.6",
"@formatjs/cli": "^6.0.1",
"@formatjs/ts-transformer": "^3.13.1",
"@jest/globals": "^29.6.1",
"@types/debug": "^4.1.8",
"@types/jest": "^29.5.1",
"@types/node": "^18.11.18",
"@types/node": "^20.4.1",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/uuid": "^9.0.2",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"@types/webtorrent": "^0.109.3",
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"@webbtc/webln-types": "^1.0.10",
"@webpack-cli/generators": "^3.0.4",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
@ -83,6 +93,7 @@
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^5.0.0",
"customize-cra": "^1.0.0",
"eslint": "^8.44.0",
"eslint-plugin-formatjs": "^4.10.1",
"eslint-webpack-plugin": "^4.0.1",
"html-webpack-plugin": "^5.5.1",
@ -92,9 +103,11 @@
"lint-staged": ">=10",
"mini-css-extract-plugin": "^2.7.5",
"prettier": "2.8.3",
"prop-types": "^15.8.1",
"source-map-loader": "^4.0.1",
"terser-webpack-plugin": "^5.3.9",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.2",
"typescript": "^5.0.4",
"typescript": "^5.1.6",
"webpack": "^5.82.1",
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^5.1.1",

View File

@ -40,7 +40,7 @@
<path d="M8 15.5H2.5M5.5 10H1M8 4.5H3M16 1L9.40357 10.235C9.1116 10.6438 8.96562 10.8481 8.97194 11.0185C8.97744 11.1669 9.04858 11.3051 9.1661 11.3958C9.30108 11.5 9.55224 11.5 10.0546 11.5H15L14 19L20.5964 9.76499C20.8884 9.35624 21.0344 9.15187 21.0281 8.98147C21.0226 8.83312 20.9514 8.69489 20.8339 8.60418C20.6989 8.5 20.4478 8.5 19.9454 8.5H15L16 1Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="zapCircle" viewBox="0 0 33 32" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5 1.33301C8.39986 1.33301 1.83337 7.8995 1.83337 15.9997C1.83337 24.0999 8.39986 30.6663 16.5 30.6663C24.6002 30.6663 31.1667 24.0999 31.1667 15.9997C31.1667 7.8995 24.6002 1.33301 16.5 1.33301ZM10.3155 16.3287L16.5 7.33301V13.9997H21.8056C22.4627 13.9997 22.7913 13.9997 22.9705 14.1364C23.1265 14.2555 23.2221 14.4372 23.2318 14.6333C23.243 14.8583 23.0569 15.1291 22.6845 15.6706L16.5 24.6663V17.9997H11.1944C10.5373 17.9997 10.2087 17.9997 10.0295 17.863C9.87353 17.7439 9.77791 17.5621 9.76818 17.3661C9.75699 17.141 9.94315 16.8702 10.3155 16.3287Z" fill="currentColor" fill-opacity="0.21" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5 1.33301C8.39986 1.33301 1.83337 7.8995 1.83337 15.9997C1.83337 24.0999 8.39986 30.6663 16.5 30.6663C24.6002 30.6663 31.1667 24.0999 31.1667 15.9997C31.1667 7.8995 24.6002 1.33301 16.5 1.33301ZM10.3155 16.3287L16.5 7.33301V13.9997H21.8056C22.4627 13.9997 22.7913 13.9997 22.9705 14.1364C23.1265 14.2555 23.2221 14.4372 23.2318 14.6333C23.243 14.8583 23.0569 15.1291 22.6845 15.6706L16.5 24.6663V17.9997H11.1944C10.5373 17.9997 10.2087 17.9997 10.0295 17.863C9.87353 17.7439 9.77791 17.5621 9.76818 17.3661C9.75699 17.141 9.94315 16.8702 10.3155 16.3287Z" fill="currentColor" />
</symbol>
<symbol id="search" viewBox="0 0 20 21" fill="none">
<path d="M19 19.5L14.65 15.15M17 9.5C17 13.9183 13.4183 17.5 9 17.5C4.58172 17.5 1 13.9183 1 9.5C1 5.08172 4.58172 1.5 9 1.5C13.4183 1.5 17 5.08172 17 9.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
@ -169,14 +169,133 @@
<symbol id="wifi" viewBox="0 0 24 18" fill="none">
<path d="M12 16.5H12.01M22.8064 5.70076C19.9595 3.09199 16.1656 1.5 11.9999 1.5C7.83414 1.5 4.04023 3.09199 1.19336 5.70076M4.73193 9.24297C6.67006 7.53566 9.21407 6.5 12 6.5C14.7859 6.5 17.3299 7.53566 19.268 9.24297M15.6983 12.7751C14.6792 11.9763 13.3952 11.5 11.9999 11.5C10.5835 11.5 9.28172 11.9908 8.25537 12.8116" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="snort-by" viewBox="0 0 1715.5309 201.961" fill="none">
<g transform="translate(-0.001,-634.57194)">
<path fill="currentColor" d="m 24.77,750.25 c 2.722,-36.745 14.697,-72.401 32.934,-108.058 47.089,-5.444 92.544,-7.35 136.91,-7.35 45.183,0 89.821,0.816 134.188,4.628 l -14.97,49.537 c -30.757,-1.36 -59.064,-3.266 -100.165,-3.266 -38.65,0 -66.686,1.36 -97.17,3.538 -2.722,4.899 -4.627,9.526 -5.172,16.876 64.508,3.266 131.466,0.816 194.069,14.153 -3.267,35.929 -14.698,72.401 -33.207,108.603 -47.36,5.443 -93.632,7.349 -138.815,7.349 -44.639,0 -89.005,-1.089 -133.371,-4.627 l 14.971,-49.538 c 31.029,1.633 60.97,2.994 103.703,2.994 38.378,0 66.141,-1.361 93.903,-3.267 2.45,-4.627 4.899,-11.159 5.444,-17.964 C 152.152,761.954 87.916,762.227 24.77,750.25 Z" />
<path fill="currentColor" d="m 640.713,833.539 h -78.934 l -87.1,-138.543 c -4.083,-0.544 -9.799,-0.816 -15.243,-0.816 L 416.975,833.539 H 337.77 l 49.811,-163.312 -31.846,-26.946 1.905,-5.717 h 49.266 c 42.461,0 81.384,1.089 132.282,3.812 l 53.893,89.549 28.58,-93.36 h 78.934 z" />
<path fill="currentColor" d="m 835.313,836.533 c -49.538,0 -97.715,-2.722 -145.62,-8.982 13.882,-61.786 32.663,-122.212 56.343,-183.998 51.716,-6.26 101.525,-8.981 151.063,-8.981 49.538,0 97.715,2.722 145.619,8.981 -14.153,62.059 -32.662,122.212 -56.342,183.998 -51.716,6.261 -101.525,8.982 -151.063,8.982 z m -47.906,-59.881 c 20.959,2.178 43.55,3.266 65.325,3.266 21.774,0 44.91,-1.088 66.958,-3.266 11.704,-27.491 19.869,-54.438 25.313,-82.2 -20.958,-2.178 -43.55,-3.267 -65.324,-3.267 -21.775,0 -44.911,1.089 -66.958,3.267 -5.988,14.153 -10.888,26.946 -14.97,41.1 -4.355,14.154 -7.622,26.946 -10.344,41.1 z" />
<path fill="currentColor" d="m 1124.904,833.539 h -75.939 l 50.082,-164.4 -30.757,-25.857 1.905,-5.717 h 181.275 c 41.645,0 98.26,0.817 139.088,7.35 -6.261,43.822 -16.604,88.188 -41.101,134.46 -13.882,0.544 -29.668,2.178 -45.183,3.267 l 43.277,50.898 h -95.81 l -79.751,-91.727 1.634,-5.988 h 33.479 c 31.029,0 55.799,0 82.2,-1.361 7.077,-16.331 11.977,-31.301 14.698,-47.36 -22.047,-1.089 -41.372,-1.089 -68.047,-1.089 h -66.141 z" />
<path fill="currentColor" d="m 1698.113,694.724 c -53.893,-1.633 -85.466,-2.449 -106.425,-2.722 l -43.005,141.537 h -79.479 l 43.006,-141.537 c -21.231,0.272 -53.077,1.089 -108.059,2.722 l 17.42,-56.342 c 50.082,-2.723 99.076,-3.539 148.069,-3.539 49.266,0 97.442,0.816 145.892,3.539 z" />
<!-- V2 -->
<symbol id="mail" viewBox="0 0 24 24" fill="none">
<g>
<path d="M2.13352 8.18144C1.83359 7.9672 1.68363 7.86008 1.55288 7.84617C1.35735 7.82537 1.16139 7.92622 1.06467 8.09741C0.999992 8.21189 0.999995 8.39416 1 8.75869V15.2413C0.999988 16.0463 0.999978 16.7106 1.04419 17.2518C1.09012 17.8139 1.18868 18.3306 1.43598 18.816C1.81947 19.5686 2.43139 20.1805 3.18404 20.564C3.66937 20.8113 4.18608 20.9099 4.74818 20.9558C5.28937 21 5.95372 21 6.75868 21H17.2413C18.0463 21 18.7106 21 19.2518 20.9558C19.8139 20.9099 20.3306 20.8113 20.816 20.564C21.5686 20.1805 22.1805 19.5686 22.564 18.816C22.8113 18.3306 22.9099 17.8139 22.9558 17.2518C23 16.7106 23 16.0463 23 15.2413V8.75868C23 8.58001 23 8.49068 22.9836 8.42829C22.9096 8.14627 22.603 7.98561 22.329 8.08531C22.2684 8.10737 22.1941 8.15886 22.0453 8.26184L14.3032 13.6219C13.7542 14.0032 13.2722 14.3379 12.7247 14.4706C12.2458 14.5867 11.7456 14.583 11.2685 14.4599C10.7229 14.3191 10.2459 13.9774 9.70265 13.5881L2.13352 8.18144Z" fill="currentColor"/>
<path d="M22.1328 5.76872C22.3174 5.64092 22.4097 5.57702 22.4664 5.47424C22.5104 5.39443 22.5356 5.26804 22.5255 5.17745C22.5125 5.06079 22.463 4.98377 22.3638 4.82973C21.9839 4.23964 21.4373 3.75256 20.816 3.43598C20.3306 3.18868 19.8139 3.09012 19.2518 3.04419C18.7106 2.99998 18.0463 2.99999 17.2413 3H6.7587C5.95374 2.99999 5.28937 2.99998 4.74818 3.04419C4.18608 3.09012 3.66937 3.18868 3.18404 3.43598C2.62501 3.72082 2.1418 4.1326 1.77436 4.63335C1.65877 4.79089 1.60097 4.86966 1.58192 4.98921C1.56703 5.08269 1.58783 5.21257 1.63116 5.29674C1.68657 5.40436 1.78269 5.47302 1.97493 5.61033L10.75 11.8783C11.4773 12.3977 11.6316 12.4881 11.7681 12.5233C11.9272 12.5644 12.0939 12.5656 12.2535 12.5269C12.3906 12.4937 12.5463 12.4056 13.281 11.8969L22.1328 5.76872Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="bell-v2" viewBox="0 0 24 24" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.99974 21C8.99974 20.4477 9.44745 20 9.99974 20H13.9997C14.552 20 14.9997 20.4477 14.9997 21C14.9997 21.5523 14.552 22 13.9997 22H9.99974C9.44745 22 8.99974 21.5523 8.99974 21Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.04999 3.05025C8.36275 1.7375 10.1432 1 11.9997 1C13.8563 1 15.6367 1.7375 16.9495 3.05025C18.2622 4.36301 18.9997 6.14349 18.9997 8C18.9997 10.9127 19.7317 12.8439 20.4991 14.0771L20.511 14.0962C20.8683 14.6704 21.1507 15.1243 21.3411 15.4547C21.4366 15.6202 21.5235 15.7797 21.5879 15.9215C21.62 15.9922 21.6559 16.079 21.684 16.1733C21.7073 16.2515 21.7517 16.4187 21.7351 16.6223C21.7239 16.7591 21.696 16.9928 21.5618 17.2343C21.4277 17.4758 21.244 17.623 21.1337 17.7047C20.8834 17.8904 20.596 17.9329 20.5001 17.947L20.4957 17.9477C20.3482 17.9695 20.181 17.9804 20.0122 17.9869C19.677 18 19.2128 18 18.6356 18H5.36388C4.78666 18 4.32252 18 3.98726 17.9869C3.81851 17.9804 3.65123 17.9695 3.50382 17.9477L3.49939 17.947C3.40347 17.9329 3.11604 17.8904 2.86574 17.7047C2.7555 17.623 2.57178 17.4758 2.43762 17.2343C2.30347 16.9928 2.27558 16.7591 2.26439 16.6223C2.24774 16.4187 2.29214 16.2515 2.31545 16.1733C2.34354 16.079 2.37948 15.9922 2.41161 15.9215C2.47598 15.7797 2.56291 15.6202 2.65833 15.4547C2.84876 15.1243 3.13124 14.6703 3.48856 14.0961L3.50035 14.0771C4.26773 12.8439 4.99974 10.9127 4.99974 8C4.99974 6.14348 5.73724 4.36301 7.04999 3.05025Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="user-v2" viewBox="0 0 20 20" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.587 2.51896C12.7918 2.10681 13.292 1.93874 13.7041 2.14356C15.2113 2.89254 16.2499 4.44922 16.2499 6.25002C16.2499 8.05082 15.2113 9.6075 13.7041 10.3565C13.292 10.5613 12.7918 10.3932 12.587 9.98108C12.3822 9.56893 12.5502 9.06878 12.9624 8.86396C13.9248 8.38569 14.5833 7.39399 14.5833 6.25002C14.5833 5.10605 13.9248 4.11435 12.9624 3.63608C12.5502 3.43126 12.3822 2.93111 12.587 2.51896Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.2407 13.6285C14.4304 13.2091 14.9242 13.0231 15.3435 13.2128C16.7417 13.8455 17.9783 14.8661 18.9885 16.1518C19.2729 16.5137 19.21 17.0376 18.8481 17.322C18.4862 17.6063 17.9623 17.5434 17.678 17.1815C16.8091 16.0756 15.7772 15.2384 14.6564 14.7312C14.2371 14.5415 14.051 14.0478 14.2407 13.6285Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.33326 6.25002C3.33326 3.71872 5.38529 1.66669 7.91659 1.66669C10.4479 1.66669 12.4999 3.71872 12.4999 6.25002C12.4999 8.78133 10.4479 10.8334 7.91659 10.8334C5.38529 10.8334 3.33326 8.78133 3.33326 6.25002Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.01132 16.1518C2.75447 13.9332 5.18086 12.5 7.91659 12.5C10.6523 12.5 13.0787 13.9332 14.8219 16.1518C15.019 16.4027 15.0555 16.7441 14.9161 17.031C14.7766 17.3179 14.4856 17.5 14.1666 17.5H1.66659C1.34758 17.5 1.05656 17.3179 0.917099 17.031C0.777643 16.7441 0.814228 16.4027 1.01132 16.1518Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="fire" viewBox="0 0 16 20" fill="none">
<path d="M10.1549 7.86583C10.1549 7.86583 11.0832 2.38167 7.28153 0C7.225 0.942437 6.9703 1.86235 6.53406 2.69965C6.09782 3.53696 5.48985 4.27281 4.74987 4.85917C3.12403 6.28833 0.0673665 9.5 0.0998665 12.925C0.0891862 14.3893 0.487627 15.8274 1.2503 17.0774C2.01297 18.3275 3.10957 19.3397 4.41653 20C4.46323 19.339 4.64141 18.6939 4.94062 18.1027C5.23984 17.5114 5.65407 16.9858 6.15903 16.5567C6.58707 16.2276 6.94526 15.8166 7.21267 15.3476C7.48009 14.8785 7.65138 14.361 7.71653 13.825C8.83546 14.4196 9.78091 15.2945 10.4604 16.364C11.1399 17.4335 11.5303 18.6611 11.5932 19.9267V19.9433C12.8258 19.3775 13.878 18.482 14.6334 17.3556C15.3889 16.2292 15.8182 14.9159 15.874 13.5608C16.144 10.3417 14.3807 5.9675 12.8174 4.5375C12.2272 5.85522 11.3108 7.00073 10.1549 7.86583Z" fill="currentColor"/>
</symbol>
<symbol id="message-chat-circle" viewBox="0 0 21 20" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.55256 13.75C1.55256 10.7776 3.90969 8.33331 6.85958 8.33331C9.80947 8.33331 12.1666 10.7776 12.1666 13.75C12.1666 16.7224 9.80947 19.1666 6.85958 19.1666C6.2532 19.1666 5.66906 19.0625 5.12448 18.8703C5.03447 18.8385 4.97864 18.8189 4.93769 18.8053C4.93026 18.8029 4.92418 18.8009 4.91931 18.7994L4.91479 18.7999C4.8862 18.803 4.84724 18.8083 4.77503 18.8181L2.27936 19.159C2.00931 19.1959 1.7383 19.0984 1.55365 18.8979C1.369 18.6974 1.29405 18.4193 1.35296 18.1532L1.86241 15.8518C1.88012 15.7718 1.88959 15.7287 1.89567 15.6969C1.89602 15.6951 1.89662 15.6919 1.89662 15.6919C1.89511 15.6867 1.89319 15.6801 1.89079 15.6722C1.87707 15.6267 1.85689 15.5648 1.82408 15.4644C1.64768 14.9244 1.55256 14.3476 1.55256 13.75Z" fill="currentColor"/>
<path d="M4.78953 6.97714C5.43752 6.77557 6.12821 6.66665 6.84815 6.66665C10.776 6.66665 13.8334 9.90887 13.8334 13.75C13.8334 14.428 13.7381 15.0874 13.5596 15.7136C14.0113 15.6324 14.4493 15.5113 14.8698 15.3538C14.9291 15.3316 14.9882 15.3089 15.0481 15.2884C15.0965 15.294 15.1446 15.3015 15.1928 15.3086L17.8703 15.7011C17.9966 15.7196 18.1401 15.7407 18.2651 15.7478C18.4035 15.7557 18.6187 15.7548 18.8467 15.6568C19.1315 15.5343 19.3606 15.3105 19.4896 15.0286C19.5929 14.8029 19.5987 14.5878 19.594 14.4492C19.5898 14.324 19.572 14.1801 19.5564 14.0533L19.218 11.3051C19.2092 11.2336 19.2047 11.1961 19.202 11.1686C19.2167 11.1084 19.2442 11.0501 19.2662 10.9924C19.5819 10.1649 19.7543 9.26819 19.7543 8.33331C19.7543 4.18603 16.3717 0.833313 12.2105 0.833313C8.51397 0.833313 5.43183 3.47901 4.78953 6.97714Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="hash" viewBox="0 0 21 20" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.16667 1.66669C7.6269 1.66669 8 2.03978 8 2.50002V5.83335H13V2.50002C13 2.03978 13.3731 1.66669 13.8333 1.66669C14.2936 1.66669 14.6667 2.03978 14.6667 2.50002V5.83335H17.1667C17.6269 5.83335 18 6.20645 18 6.66669C18 7.12692 17.6269 7.50002 17.1667 7.50002H14.6667V12.5H17.1667C17.6269 12.5 18 12.8731 18 13.3334C18 13.7936 17.6269 14.1667 17.1667 14.1667H14.6667V17.5C14.6667 17.9603 14.2936 18.3334 13.8333 18.3334C13.3731 18.3334 13 17.9603 13 17.5V14.1667H8V17.5C8 17.9603 7.6269 18.3334 7.16667 18.3334C6.70643 18.3334 6.33333 17.9603 6.33333 17.5V14.1667H3.83333C3.3731 14.1667 3 13.7936 3 13.3334C3 12.8731 3.3731 12.5 3.83333 12.5H6.33333V7.50002H3.83333C3.3731 7.50002 3 7.12692 3 6.66669C3 6.20645 3.3731 5.83335 3.83333 5.83335H6.33333V2.50002C6.33333 2.03978 6.70643 1.66669 7.16667 1.66669ZM13 12.5V7.50002H8V12.5H13Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="user-up" viewBox="0 0 21 20" fill="none">
<g>
<path d="M16.9226 1.91076C16.7663 1.75448 16.5544 1.66669 16.3334 1.66669C16.1124 1.66669 15.9004 1.75448 15.7441 1.91076L13.2441 4.41076C12.9187 4.7362 12.9187 5.26384 13.2441 5.58928C13.5696 5.91471 14.0972 5.91471 14.4226 5.58928L15.5 4.51186V7.50002C15.5 7.96026 15.8731 8.33335 16.3334 8.33335C16.7936 8.33335 17.1667 7.96026 17.1667 7.50002V4.51187L18.2441 5.58928C18.5696 5.91471 19.0972 5.91471 19.4226 5.58928C19.7481 5.26384 19.7481 4.7362 19.4226 4.41076L16.9226 1.91076Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.13224 11.6667H9.86774C10.5386 11.6667 11.0922 11.6667 11.5432 11.7035C12.0116 11.7418 12.4422 11.8239 12.8466 12.03C13.4738 12.3496 13.9838 12.8595 14.3033 13.4867C14.5094 13.8912 14.5916 14.3218 14.6298 14.7902C14.6667 15.2412 14.6667 15.7948 14.6667 16.4656C14.6667 16.7012 14.6774 16.9399 14.6582 17.1749C14.6483 17.2959 14.623 17.4692 14.5304 17.6508C14.4106 17.886 14.2194 18.0773 13.9841 18.1971C13.8025 18.2897 13.6292 18.315 13.5082 18.3249C13.4038 18.3334 13.2852 18.3334 13.1859 18.3334C9.72864 18.3321 6.27134 18.3321 2.81404 18.3334C2.71478 18.3334 2.59623 18.3334 2.4918 18.3249C2.37079 18.315 2.19746 18.2897 2.01584 18.1971C1.78063 18.0773 1.58941 17.886 1.46957 17.6508C1.37703 17.4692 1.35166 17.2959 1.34177 17.1749C1.32257 16.9399 1.33333 16.7011 1.33332 16.4656C1.33331 15.7948 1.33331 15.2412 1.37015 14.7902C1.40842 14.3218 1.49056 13.8912 1.69664 13.4867C2.01621 12.8595 2.52615 12.3496 3.15336 12.03C3.5578 11.8239 3.98839 11.7418 4.4568 11.7035C4.9078 11.6667 5.46143 11.6667 6.13224 11.6667Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.24999 6.25002C4.24999 4.17895 5.92892 2.50002 7.99999 2.50002C10.0711 2.50002 11.75 4.17895 11.75 6.25002C11.75 8.32109 10.0711 10 7.99999 10C5.92892 10 4.24999 8.32109 4.24999 6.25002Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="thumbs-up" viewBox="0 0 21 20" fill="none">
<g >
<path d="M10.1117 0.833313C9.51171 0.833313 8.96798 1.18667 8.72429 1.73497L5.92366 8.03638C5.85572 8.18926 5.82024 8.2681 5.79115 8.32361L5.78857 8.3285L5.78304 8.32888C5.72051 8.33291 5.63405 8.33331 5.46676 8.33331L4.80113 8.33331C4.36184 8.3333 3.98287 8.33328 3.67073 8.35879C3.34128 8.3857 3.01359 8.44513 2.69828 8.6058C2.22787 8.84548 1.84542 9.22793 1.60574 9.69834C1.44508 10.0137 1.38565 10.3413 1.35873 10.6708C1.33323 10.9829 1.33324 11.3619 1.33325 11.8012V15.6988C1.33324 16.138 1.33323 16.517 1.35873 16.8292C1.38565 17.1586 1.44508 17.4863 1.60574 17.8016C1.84542 18.272 2.22787 18.6545 2.69828 18.8942C3.01359 19.0548 3.34128 19.1143 3.67073 19.1412C3.98287 19.1667 4.36183 19.1667 4.80112 19.1666C5.18705 19.1666 5.49992 18.8538 5.49992 18.4678L5.49992 11.6666C5.49992 11.2064 5.87302 10.8333 6.33325 10.8333C6.79349 10.8333 7.16659 11.2064 7.16659 11.6666L7.16659 17.8333C7.16659 18.3 7.16659 18.5334 7.25741 18.7116C7.33731 18.8684 7.46479 18.9959 7.62159 19.0758C7.79985 19.1666 8.03321 19.1666 8.49992 19.1666H13.7662C14.3486 19.1667 14.832 19.1667 15.2298 19.1364C15.6451 19.1048 16.0279 19.037 16.3975 18.8695C16.9755 18.6076 17.4667 18.1862 17.8135 17.6547C18.0352 17.3149 18.1604 16.9468 18.2548 16.5411C18.3452 16.1526 18.4187 15.6748 18.5073 15.0993L18.9536 12.198C19.0709 11.4356 19.1671 10.8105 19.2011 10.3003C19.2362 9.77397 19.2133 9.28283 19.0337 8.81206C18.758 8.08921 18.2394 7.48477 17.5669 7.10235C17.1289 6.85329 16.647 6.75592 16.1214 6.71058C15.612 6.66662 14.9796 6.66663 14.2081 6.66665H13.4999C13.2528 6.66665 13.1176 6.666 13.0204 6.65806L13.0095 6.65711L13.0085 6.64612C13.0006 6.54896 12.9999 6.41375 12.9999 6.16665V3.72151C12.9999 2.1264 11.7068 0.833313 10.1117 0.833313Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="globe" viewBox="0 0 21 20" fill="none">
<g>
<path d="M12.0782 10.484L14.2784 11.427C14.3867 11.4733 14.5118 11.5269 14.6163 11.5829C14.734 11.6459 14.9055 11.7521 15.0449 11.9394C15.2212 12.1763 15.3079 12.4681 15.2896 12.7629C15.2752 12.9959 15.1896 13.1785 15.1255 13.2956C15.0685 13.3996 14.993 13.5127 14.9276 13.6108C14.5186 14.2243 14.1508 15.0722 13.5706 15.549C13.4437 15.6533 13.2974 15.7316 13.1401 15.7794C12.4133 16.0001 11.0896 16.0403 10.3427 15.7466C10.148 15.67 9.97558 15.5457 9.84133 15.3852C9.33755 14.7828 9.20386 13.7474 8.96333 13.0255L8.95692 13.0065C8.93327 12.9372 8.88441 12.7939 8.87107 12.6395C8.85979 12.5088 8.86914 12.3771 8.8988 12.2494C9.02171 11.7197 9.36389 11.1667 9.63857 10.7086C9.69587 10.613 9.78448 10.4842 9.92647 10.3697C10.1131 10.2192 10.3392 10.1256 10.5776 10.1C10.7589 10.0805 10.9127 10.109 11.0208 10.136C11.3758 10.2249 11.7429 10.3403 12.0782 10.484Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4999 0.833313C5.43731 0.833313 1.33325 4.93737 1.33325 9.99998C1.33325 15.0626 5.43731 19.1666 10.4999 19.1666C15.5625 19.1666 19.6666 15.0626 19.6666 9.99998C19.6666 4.93737 15.5625 0.833313 10.4999 0.833313ZM3.4212 7.51555C3.14831 8.2931 2.99992 9.12921 2.99992 9.99998C2.99992 14.1421 6.35778 17.5 10.4999 17.5C14.6421 17.5 17.9999 14.1421 17.9999 9.99998C17.9999 6.80623 16.0037 4.07873 13.1909 2.99718C13.1217 4.03512 13.4842 5.67357 12.6141 6.4745C11.7016 7.31445 10.0684 7.67798 8.95316 8.17356L8.21274 9.16081C8.15449 9.23853 8.08464 9.33174 8.01635 9.40895C7.93696 9.49869 7.81503 9.62 7.63588 9.70944C7.40325 9.82557 7.1402 9.86628 6.88336 9.82589C6.68555 9.79478 6.53265 9.716 6.42986 9.65445C6.34141 9.60149 6.24668 9.53377 6.16766 9.47729L3.4212 7.51555Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="pencil" viewBox="0 0 16 17" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.4714 2.02846C13.4747 1.03173 11.8587 1.03173 10.862 2.02846L3.31758 9.57282C3.1619 9.72843 3.04412 9.84615 2.94092 9.97913C2.84971 10.0967 2.76849 10.2217 2.69812 10.3528C2.6185 10.5011 2.55877 10.6565 2.4798 10.862L1.04446 14.5939C1.01453 14.6717 0.99999 14.7528 1 14.8332C1.00001 15.0068 1.06793 15.1773 1.19528 15.3046C1.38158 15.4909 1.66011 15.5501 1.90601 15.4555L5.4925 14.0761C5.51286 14.0682 5.53364 14.0602 5.55452 14.0522L5.63792 14.0201C5.84336 13.9411 5.99883 13.8814 6.14713 13.8018C6.27823 13.7314 6.40321 13.6502 6.52075 13.559C6.65374 13.4558 6.77144 13.338 6.92704 13.1823L14.4714 5.63794C15.4682 4.64121 15.4682 3.02519 14.4714 2.02846ZM3.58733 11.6967L2.82738 13.6725L4.80321 12.9126L3.58733 11.6967Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="stars" viewBox="0 0 16 17" fill="none">
<g>
<path d="M3.66663 1.83332C3.66663 1.46513 3.36815 1.16666 2.99996 1.16666C2.63177 1.16666 2.33329 1.46513 2.33329 1.83332V2.83332H1.33329C0.965103 2.83332 0.666626 3.1318 0.666626 3.49999C0.666626 3.86818 0.965103 4.16666 1.33329 4.16666H2.33329V5.16666C2.33329 5.53485 2.63177 5.83332 2.99996 5.83332C3.36815 5.83332 3.66663 5.53485 3.66663 5.16666V4.16666H4.66663C5.03482 4.16666 5.33329 3.86818 5.33329 3.49999C5.33329 3.1318 5.03482 2.83332 4.66663 2.83332H3.66663V1.83332Z" fill="currentColor"/>
<path d="M3.66663 11.8333C3.66663 11.4651 3.36815 11.1667 2.99996 11.1667C2.63177 11.1667 2.33329 11.4651 2.33329 11.8333V12.8333H1.33329C0.965103 12.8333 0.666626 13.1318 0.666626 13.5C0.666626 13.8682 0.965103 14.1667 1.33329 14.1667H2.33329V15.1667C2.33329 15.5348 2.63177 15.8333 2.99996 15.8333C3.36815 15.8333 3.66663 15.5348 3.66663 15.1667V14.1667H4.66663C5.03482 14.1667 5.33329 13.8682 5.33329 13.5C5.33329 13.1318 5.03482 12.8333 4.66663 12.8333H3.66663V11.8333Z" fill="currentColor"/>
<path d="M9.28886 2.26067C9.18983 2.00321 8.94247 1.83332 8.66663 1.83332C8.39078 1.83332 8.14342 2.00321 8.04439 2.26067L6.88828 5.26658C6.68801 5.78728 6.62508 5.93732 6.539 6.05838C6.45262 6.17986 6.34649 6.28599 6.22502 6.37236C6.10396 6.45844 5.95391 6.52137 5.43321 6.72164L2.42731 7.87776C2.16985 7.97678 1.99996 8.22414 1.99996 8.49999C1.99996 8.77584 2.16985 9.0232 2.42731 9.12222L5.43322 10.2783C5.95391 10.4786 6.10396 10.5415 6.22502 10.6276C6.34649 10.714 6.45262 10.8201 6.539 10.9416C6.62508 11.0627 6.68801 11.2127 6.88828 11.7334L8.0444 14.7393C8.14342 14.9968 8.39078 15.1667 8.66663 15.1667C8.94247 15.1667 9.18983 14.9968 9.28886 14.7393L10.445 11.7334C10.6452 11.2127 10.7082 11.0627 10.7943 10.9416C10.8806 10.8201 10.9868 10.714 11.1082 10.6276C11.2293 10.5415 11.3793 10.4786 11.9 10.2783L14.9059 9.12222C15.1634 9.0232 15.3333 8.77584 15.3333 8.49999C15.3333 8.22414 15.1634 7.97678 14.9059 7.87776L11.9 6.72164C11.3793 6.52137 11.2293 6.45844 11.1082 6.37236C10.9868 6.28599 10.8806 6.17986 10.7943 6.05838C10.7082 5.93732 10.6452 5.78728 10.445 5.26658L9.28886 2.26067Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="link-02" viewBox="0 0 17 16" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.47432 8C4.47432 7.63181 4.7728 7.33333 5.14099 7.33333L11.8077 7.33333C12.1758 7.33333 12.4743 7.63181 12.4743 8C12.4743 8.36819 12.1758 8.66667 11.8077 8.66667L5.14099 8.66667C4.7728 8.66667 4.47432 8.36819 4.47432 8Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.14099 5.33333C3.66823 5.33333 2.47432 6.52724 2.47432 8C2.47432 9.47276 3.66823 10.6667 5.14099 10.6667H6.47432C6.84251 10.6667 7.14099 10.9651 7.14099 11.3333C7.14099 11.7015 6.84251 12 6.47432 12H5.14099C2.93185 12 1.14099 10.2091 1.14099 8C1.14099 5.79086 2.93185 4 5.14099 4H6.47432C6.84251 4 7.14099 4.29848 7.14099 4.66667C7.14099 5.03486 6.84251 5.33333 6.47432 5.33333H5.14099Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.80766 4.66667C9.80766 4.29848 10.1061 4 10.4743 4H11.8077C14.0168 4 15.8077 5.79086 15.8077 8C15.8077 10.2091 14.0168 12 11.8077 12H10.4743C10.1061 12 9.80766 11.7015 9.80766 11.3333C9.80766 10.9651 10.1061 10.6667 10.4743 10.6667H11.8077C13.2804 10.6667 14.4743 9.47276 14.4743 8C14.4743 6.52724 13.2804 5.33333 11.8077 5.33333H10.4743C10.1061 5.33333 9.80766 5.03486 9.80766 4.66667Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="bookmark-solid" viewBox="0 0 16 17" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.50582 1.83334H9.49422C10.0309 1.83334 10.4738 1.83333 10.8346 1.86281C11.2093 1.89342 11.5538 1.95913 11.8773 2.12399C12.3791 2.37966 12.787 2.7876 13.0427 3.28937C13.2076 3.61293 13.2733 3.9574 13.3039 4.33213C13.3334 4.69293 13.3334 5.13584 13.3334 5.67249V14.5C13.3334 14.7377 13.2068 14.9573 13.0013 15.0766C12.7958 15.1959 12.5423 15.1967 12.3359 15.0788L8.00002 12.6012L3.66411 15.0788C3.45778 15.1967 3.20428 15.1959 2.99874 15.0766C2.79319 14.9573 2.66669 14.7377 2.66669 14.5L2.66669 5.67248C2.66668 5.13583 2.66667 4.69292 2.69615 4.33213C2.72677 3.9574 2.79248 3.61293 2.95734 3.28937C3.213 2.7876 3.62095 2.37966 4.12271 2.12399C4.44627 1.95913 4.79074 1.89342 5.16547 1.86281C5.52627 1.83333 5.96917 1.83334 6.50582 1.83334Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="zap-solid" viewBox="0 0 16 17" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.93212 1.2218C9.2036 1.33964 9.36488 1.62235 9.32818 1.91602L8.75518 6.5L12.8852 6.49999C13.0458 6.49996 13.2086 6.49993 13.3415 6.51197C13.4673 6.52336 13.708 6.55347 13.9168 6.72221C14.1559 6.91539 14.2928 7.20776 14.2882 7.51507C14.2841 7.78351 14.1531 7.98776 14.0814 8.09164C14.0055 8.20149 13.9013 8.32648 13.7984 8.44989L7.84547 15.5935C7.65601 15.8208 7.33934 15.896 7.06786 15.7782C6.79638 15.6604 6.6351 15.3776 6.6718 15.084L7.2448 10.5L3.11481 10.5C2.95415 10.5 2.79142 10.5001 2.65847 10.488C2.53273 10.4766 2.29195 10.4465 2.08314 10.2778C1.84409 10.0846 1.70715 9.79223 1.71178 9.48492C1.71583 9.21648 1.84684 9.01223 1.91859 8.90835C1.99445 8.79851 2.09866 8.67351 2.20153 8.55011C2.20663 8.54399 2.21172 8.53788 2.21681 8.53178L8.15451 1.40654C8.34397 1.17918 8.66064 1.10395 8.93212 1.2218Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="repeat" viewBox="0 0 18 19" fill="none">
<g>
<path d="M12.2197 1.65717C12.5126 1.36428 12.9874 1.36428 13.2803 1.65717L16.2803 4.65717C16.5732 4.95006 16.5732 5.42494 16.2803 5.71783L13.2803 8.71783C12.9874 9.01072 12.5126 9.01072 12.2197 8.71783C11.9268 8.42494 11.9268 7.95006 12.2197 7.65717L13.9393 5.9375H5.85C5.20757 5.9375 4.77085 5.93808 4.43328 5.96566C4.10447 5.99253 3.93632 6.04122 3.81902 6.10099C3.53677 6.2448 3.3073 6.47427 3.16349 6.75652C3.10372 6.87381 3.05503 7.04197 3.02816 7.37078C3.00058 7.70835 3 8.14507 3 8.7875V8.9375C3 9.35171 2.66421 9.6875 2.25 9.6875C1.83579 9.6875 1.5 9.35171 1.5 8.9375V8.75653C1.49999 8.15281 1.49998 7.65452 1.53315 7.24863C1.56759 6.82706 1.64151 6.43953 1.82698 6.07553C2.1146 5.51104 2.57354 5.0521 3.13803 4.76448C3.50203 4.57901 3.88956 4.50509 4.31113 4.47065C4.71703 4.43748 5.2153 4.43749 5.81903 4.4375L13.9393 4.4375L12.2197 2.71783C11.9268 2.42494 11.9268 1.95006 12.2197 1.65717Z" fill="currentColor"/>
<path d="M15.75 9.6875C15.3358 9.6875 15 10.0233 15 10.4375V10.5875C15 11.2299 14.9994 11.6667 14.9718 12.0042C14.945 12.333 14.8963 12.5012 14.8365 12.6185C14.6927 12.9007 14.4632 13.1302 14.181 13.274C14.0637 13.3338 13.8955 13.3825 13.5667 13.4093C13.2292 13.4369 12.7924 13.4375 12.15 13.4375H4.06066L5.78033 11.7178C6.07322 11.4249 6.07322 10.9501 5.78033 10.6572C5.48744 10.3643 5.01256 10.3643 4.71967 10.6572L1.71967 13.6572C1.42678 13.9501 1.42678 14.4249 1.71967 14.7178L4.71967 17.7178C5.01256 18.0107 5.48744 18.0107 5.78033 17.7178C6.07322 17.4249 6.07322 16.9501 5.78033 16.6572L4.06066 14.9375H12.181C12.7847 14.9375 13.283 14.9375 13.6889 14.9044C14.1104 14.8699 14.498 14.796 14.862 14.6105C15.4265 14.3229 15.8854 13.864 16.173 13.2995C16.3585 12.9355 16.4324 12.5479 16.4669 12.1264C16.5 11.7205 16.5 11.2222 16.5 10.6185V10.4375C16.5 10.0233 16.1642 9.6875 15.75 9.6875Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="check-verified" viewBox="0 0 16 17" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.35368 8.68746C1.35348 8.59824 1.33596 8.50912 1.30112 8.42515L0.820156 7.26063C0.718546 7.01538 0.666058 6.75212 0.666016 6.48665C0.665973 6.22104 0.718266 5.95803 0.819908 5.71263C0.92155 5.46724 1.07055 5.24428 1.25839 5.05649C1.44618 4.86875 1.66912 4.71985 1.91446 4.61828L3.07708 4.13669C3.24546 4.06707 3.37975 3.93337 3.44985 3.76519L3.93155 2.60222C4.13674 2.10683 4.53031 1.71323 5.0257 1.50803C5.52109 1.30283 6.0777 1.30282 6.57309 1.50803L7.73517 1.9894C7.9038 2.05901 8.09336 2.05892 8.26187 1.989L8.26319 1.98845L9.42629 1.50874C9.92151 1.30381 10.4782 1.30375 10.9734 1.50885C11.4687 1.71402 11.8622 2.1075 12.0674 2.60276L12.5376 3.73781C12.5417 3.74677 12.5457 3.75585 12.5496 3.76507C12.6192 3.93362 12.753 4.06762 12.9213 4.13766L14.0846 4.61953C14.58 4.82473 14.9736 5.21833 15.1788 5.71372C15.384 6.20911 15.384 6.76572 15.1788 7.26111L14.6972 8.42378C14.6622 8.50822 14.6448 8.59818 14.6447 8.68766C14.6448 8.77714 14.6622 8.8667 14.6972 8.95114L15.1788 10.1138C15.384 10.6092 15.384 11.1658 15.1788 11.6612C14.9736 12.1566 14.58 12.5502 14.0846 12.7554L12.9213 13.2373C12.753 13.3073 12.6192 13.4413 12.5496 13.6099C12.5457 13.6191 12.5417 13.6282 12.5376 13.6371L12.0674 14.7722C11.8622 15.2674 11.4687 15.6609 10.9734 15.8661C10.4782 16.0712 9.92151 16.0711 9.42629 15.8662L8.26319 15.3865L8.26187 15.3859C8.09336 15.316 7.9038 15.3159 7.73517 15.3855L6.57309 15.8669C6.0777 16.0721 5.52109 16.0721 5.0257 15.8669C4.53031 15.6617 4.13674 15.2681 3.93155 14.7727L3.44985 13.6097C3.37975 13.4415 3.24546 13.3079 3.07708 13.2382L1.91446 12.7566C1.66912 12.6551 1.44618 12.5062 1.25839 12.3184C1.07055 12.1306 0.92155 11.9077 0.819908 11.6623C0.718266 11.4169 0.665973 11.1539 0.666016 10.8883C0.666058 10.6228 0.718546 10.3595 0.820156 10.1143L1.30112 8.94977C1.33596 8.8658 1.35348 8.77668 1.35368 8.68746ZM10.8041 7.4922C11.0644 7.23185 11.0644 6.80974 10.8041 6.54939C10.5437 6.28904 10.1216 6.28904 9.86128 6.54939L7.33268 9.07798L6.47075 8.21605C6.2104 7.95571 5.78829 7.95571 5.52794 8.21605C5.2676 8.4764 5.2676 8.89851 5.52794 9.15886L6.86128 10.4922C7.12163 10.7525 7.54374 10.7525 7.80409 10.4922L10.8041 7.4922Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="copy-solid" viewBox="0 0 17 16" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9049 2.70076C11.4937 2.66717 10.9655 2.66665 10.2077 2.66665H5.47441C5.10622 2.66665 4.80774 2.36817 4.80774 1.99998C4.80774 1.63179 5.10622 1.33331 5.47441 1.33331L10.2363 1.33331C10.9588 1.33331 11.5416 1.3333 12.0135 1.37186C12.4994 1.41156 12.9262 1.49543 13.321 1.69662C13.9482 2.0162 14.4582 2.52614 14.7778 3.15334C14.979 3.54821 15.0628 3.975 15.1025 4.4609C15.1411 4.93282 15.1411 5.51559 15.1411 6.23813V11C15.1411 11.3682 14.8426 11.6666 14.4744 11.6666C14.1062 11.6666 13.8077 11.3682 13.8077 11V6.26665C13.8077 5.50891 13.8072 4.9807 13.7736 4.56947C13.7407 4.16603 13.6792 3.93424 13.5898 3.75867C13.398 3.38234 13.092 3.07638 12.7157 2.88463C12.5402 2.79518 12.3084 2.73373 11.9049 2.70076Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.58204 3.66665H10.0334C10.3849 3.66663 10.688 3.66662 10.9378 3.68703C11.2013 3.70856 11.4635 3.7561 11.7157 3.88463C12.092 4.07638 12.398 4.38234 12.5898 4.75867C12.7183 5.01092 12.7658 5.27306 12.7874 5.53663C12.8078 5.78635 12.8078 6.08953 12.8077 6.44096V11.8923C12.8078 12.2438 12.8078 12.5469 12.7874 12.7967C12.7658 13.0602 12.7183 13.3224 12.5898 13.5746C12.398 13.951 12.092 14.2569 11.7157 14.4487C11.4635 14.5772 11.2013 14.6247 10.9378 14.6463C10.688 14.6667 10.3849 14.6667 10.0334 14.6666H4.58206C4.23062 14.6667 3.92744 14.6667 3.67772 14.6463C3.41416 14.6247 3.15201 14.5772 2.89976 14.4487C2.52344 14.2569 2.21747 13.951 2.02573 13.5746C1.8972 13.3224 1.84965 13.0602 1.82812 12.7967C1.80772 12.547 1.80773 12.2438 1.80774 11.8923V6.44095C1.80773 6.08952 1.80772 5.78634 1.82812 5.53663C1.84965 5.27306 1.8972 5.01092 2.02573 4.75867C2.21747 4.38234 2.52344 4.07638 2.89976 3.88463C3.15201 3.7561 3.41416 3.70856 3.67772 3.68703C3.92744 3.66662 4.23061 3.66663 4.58204 3.66665Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="heart-solid" viewBox="0 0 19 18" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.49466 2.78774C7.73973 1.25408 5.14439 0.940234 3.12891 2.6623C0.948817 4.52502 0.63207 7.66213 2.35603 9.88052C3.01043 10.7226 4.28767 11.9877 5.51513 13.1462C6.75696 14.3184 7.99593 15.426 8.60692 15.9671C8.61074 15.9705 8.61463 15.9739 8.61859 15.9774C8.67603 16.0283 8.74753 16.0917 8.81608 16.1433C8.89816 16.2052 9.01599 16.2819 9.17334 16.3288C9.38253 16.3912 9.60738 16.3912 9.81656 16.3288C9.97391 16.2819 10.0917 16.2052 10.1738 16.1433C10.2424 16.0917 10.3139 16.0283 10.3713 15.9774C10.3753 15.9739 10.3792 15.9705 10.383 15.9671C10.994 15.426 12.2329 14.3184 13.4748 13.1462C14.7022 11.9877 15.9795 10.7226 16.6339 9.88052C18.3512 7.67065 18.0834 4.50935 15.8532 2.65572C13.8153 0.961905 11.2476 1.25349 9.49466 2.78774Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="x-close" viewBox="0 0 24 24" fill="none">
<g>
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</symbol>
<symbol id="reverse-left" viewBox="0 0 24 24" fill="none">
<g>
<path d="M8.70711 3.70711C9.09763 3.31658 9.09763 2.68342 8.70711 2.29289C8.31658 1.90237 7.68342 1.90237 7.29289 2.29289L3.29289 6.29289C2.90237 6.68342 2.90237 7.31658 3.29289 7.70711L7.29289 11.7071C7.68342 12.0976 8.31658 12.0976 8.70711 11.7071C9.09763 11.3166 9.09763 10.6834 8.70711 10.2929L6.41421 8L14 8C16.7614 8 19 10.2386 19 13C19 15.7614 16.7614 18 14 18H4C3.44772 18 3 18.4477 3 19C3 19.5523 3.44772 20 4 20H14C17.866 20 21 16.866 21 13C21 9.13401 17.866 6 14 6L6.41421 6L8.70711 3.70711Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="upload-01" viewBox="0 0 16 16" fill="none">
<g>
<path d="M7.52876 1.52925C7.78911 1.2689 8.21122 1.2689 8.47157 1.52925L11.8049 4.86258C12.0652 5.12293 12.0652 5.54504 11.8049 5.80539C11.5446 6.06574 11.1224 6.06574 10.8621 5.80539L8.66683 3.61013L8.66683 10.0007C8.66683 10.3688 8.36835 10.6673 8.00016 10.6673C7.63197 10.6673 7.3335 10.3688 7.3335 10.0007L7.3335 3.61013L5.13823 5.80539C4.87788 6.06574 4.45577 6.06574 4.19543 5.80539C3.93508 5.54504 3.93508 5.12293 4.19543 4.86258L7.52876 1.52925Z" fill="currentColor"/>
<path d="M2.00016 9.33398C2.36835 9.33398 2.66683 9.63246 2.66683 10.0007V10.8007C2.66683 11.3717 2.66735 11.7599 2.69186 12.06C2.71574 12.3522 2.75903 12.5017 2.81215 12.606C2.93999 12.8569 3.14396 13.0608 3.39484 13.1887C3.49911 13.2418 3.64858 13.2851 3.94086 13.3089C4.24091 13.3335 4.62911 13.334 5.20016 13.334H10.8002C11.3712 13.334 11.7594 13.3335 12.0595 13.3089C12.3517 13.2851 12.5012 13.2418 12.6055 13.1887C12.8564 13.0608 13.0603 12.8569 13.1882 12.606C13.2413 12.5017 13.2846 12.3522 13.3085 12.06C13.333 11.7599 13.3335 11.3717 13.3335 10.8007V10.0007C13.3335 9.63246 13.632 9.33398 14.0002 9.33398C14.3684 9.33398 14.6668 9.63246 14.6668 10.0007V10.8282C14.6668 11.3648 14.6668 11.8077 14.6374 12.1685C14.6067 12.5433 14.541 12.8877 14.3762 13.2113C14.1205 13.7131 13.7126 14.121 13.2108 14.3767C12.8872 14.5415 12.5428 14.6072 12.168 14.6379C11.8073 14.6673 11.3643 14.6673 10.8277 14.6673H5.17263C4.63598 14.6673 4.19308 14.6673 3.83228 14.6379C3.45755 14.6072 3.11308 14.5415 2.78952 14.3767C2.28776 14.121 1.87981 13.7131 1.62415 13.2113C1.45929 12.8877 1.39358 12.5433 1.36296 12.1685C1.33348 11.8077 1.33349 11.3648 1.3335 10.8282V10.0007C1.3335 9.63246 1.63197 9.33398 2.00016 9.33398Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="image-plus" viewBox="0 0 24 24" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.5 8.5C5.5 6.84315 6.84315 5.5 8.5 5.5C10.1569 5.5 11.5 6.84315 11.5 8.5C11.5 10.1569 10.1569 11.5 8.5 11.5C6.84315 11.5 5.5 10.1569 5.5 8.5Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5 2H7.7587C6.95374 1.99999 6.28937 1.99998 5.74818 2.04419C5.18608 2.09012 4.66937 2.18868 4.18404 2.43598C3.43139 2.81947 2.81947 3.43139 2.43598 4.18404C2.18868 4.66937 2.09012 5.18608 2.04419 5.74818C1.99998 6.28937 1.99999 6.95372 2 7.75869V16.2413C1.99999 17.0463 1.99998 17.7106 2.04419 18.2518C2.09012 18.8139 2.18868 19.3306 2.43598 19.816C2.81947 20.5686 3.43139 21.1805 4.18404 21.564C4.66937 21.8113 5.18608 21.9099 5.74818 21.9558C5.92356 21.9701 6.11188 21.9798 6.31374 21.9864C6.52305 22.0003 6.7734 22.0002 7.03144 22.0002C10.3543 22.0002 13.6771 22 17 22C17.0465 22 17.0924 22 17.1376 22C17.933 22.0005 18.5236 22.0008 19.0353 21.8637C20.4156 21.4938 21.4938 20.4156 21.8637 19.0353C22.039 18.381 22.0002 17.6805 22 17.0095C22.0018 16.8202 22.0001 16.6308 22.0001 16.4415C22.0006 15.9726 22.0011 15.5594 21.8923 15.1647C21.7969 14.8182 21.6399 14.4917 21.429 14.2007C21.1887 13.8692 20.8658 13.6114 20.4993 13.3189L17.6683 11.0541C17.4984 10.9182 17.3304 10.7838 17.1779 10.6797C17.0083 10.5639 16.7995 10.4436 16.5382 10.3766C16.1709 10.2824 15.7843 10.2946 15.4237 10.4118C15.1671 10.4951 14.9663 10.6283 14.8043 10.7545C14.6586 10.8681 14.4995 11.0128 14.3385 11.1592L5.83046 18.8938C5.61698 19.0878 5.41061 19.2754 5.2589 19.4395C5.19807 19.5054 5.10567 19.6077 5.01929 19.743C4.67627 19.5501 4.39723 19.2598 4.21799 18.908C4.1383 18.7516 4.07337 18.5274 4.03755 18.089C4.00078 17.6389 4 17.0566 4 16.2V7.8C4 6.94342 4.00078 6.36113 4.03755 5.91104C4.07337 5.47262 4.1383 5.24842 4.21799 5.09202C4.40973 4.7157 4.7157 4.40973 5.09202 4.21799C5.24842 4.1383 5.47262 4.07337 5.91104 4.03755C6.36113 4.00078 6.94342 4 7.8 4H12.5C13.0523 4 13.5 3.55229 13.5 3C13.5 2.44772 13.0523 2 12.5 2Z" fill="currentColor"/>
<path d="M20 2C20 1.44772 19.5523 1 19 1C18.4477 1 18 1.44772 18 2V4H16C15.4477 4 15 4.44772 15 5C15 5.55228 15.4477 6 16 6H18V8C18 8.55228 18.4477 9 19 9C19.5523 9 20 8.55228 20 8V6H22C22.5523 6 23 5.55228 23 5C23 4.44772 22.5523 4 22 4H20V2Z" fill="currentColor"/>
</g>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@ -12,7 +12,7 @@
<link rel="preconnect" href="https://imgproxy.snort.social" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="apple-touch-icon" href="/logo_512.png" />
<link rel="apple-touch-icon" href="/nostrich_512.png" />
<link rel="manifest" href="/manifest.json" />
<title>Snort - Nostr</title>
</head>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -2,20 +2,27 @@
"short_name": "Snort",
"name": "snort.social - Nostr interface",
"description": "Fast nostr web ui",
"id": "/",
"icons": [
{
"src": "logo_256.png",
"src": "nostrich_256.png",
"type": "image/png",
"sizes": "256x256"
},
{
"src": "logo_512.png",
"src": "nostrich_512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"start_url": "/",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#000000"
"background_color": "#000000",
"protocol_handlers": [
{
"protocol": "web+nostr",
"url": "/%s"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

View File

@ -0,0 +1,39 @@
import { EventKind, NostrEvent, RequestBuilder, TaggedRawEvent } from "@snort/system";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
import { LoginSession } from "Login";
import { unixNow } from "SnortUtils";
import { db } from "Db";
export class NotificationsCache extends RefreshFeedCache<NostrEvent> {
#kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt];
constructor() {
super("notifications", db.notifications);
}
buildSub(session: LoginSession, rb: RequestBuilder) {
if (session.publicKey) {
const newest = this.newest();
rb.withFilter()
.kinds(this.#kinds)
.tag("p", [session.publicKey])
.since(newest === 0 ? unixNow() - 60 * 60 * 24 * 30 : newest);
}
}
async onEvent(evs: readonly TaggedRawEvent[]) {
const filtered = evs.filter(a => this.#kinds.includes(a.kind) && a.tags.some(b => b[0] === "p"));
if (filtered.length > 0) {
await this.bulkSet(filtered);
this.notifyChange(filtered.map(v => this.key(v)));
}
}
key(of: TWithCreated<NostrEvent>): string {
return of.id;
}
takeSnapshot(): TWithCreated<NostrEvent>[] {
return [...this.cache.values()];
}
}

View File

@ -0,0 +1,25 @@
import { FeedCache } from "@snort/shared";
import { RequestBuilder, TaggedRawEvent } from "@snort/system";
import { LoginSession } from "Login";
export type TWithCreated<T> = T & { created_at: number };
export abstract class RefreshFeedCache<T> extends FeedCache<TWithCreated<T>> {
abstract buildSub(session: LoginSession, rb: RequestBuilder): void;
abstract onEvent(evs: Readonly<Array<TaggedRawEvent>>): void;
/**
* Get latest event
*/
protected newest() {
let ret = 0;
this.cache.forEach(v => (ret = v.created_at > ret ? v.created_at : ret));
return ret;
}
override async preload(): Promise<void> {
await super.preload();
// load all dms to memory
await this.buffer([...this.onTable]);
}
}

View File

@ -3,6 +3,7 @@ import { EventInteractionCache } from "./EventInteractionCache";
import { ChatCache } from "./ChatCache";
import { Payments } from "./PaymentsCache";
import { GiftWrapCache } from "./GiftWrapCache";
import { NotificationsCache } from "./Notifications";
export const UserCache = new UserProfileCache();
export const UserRelays = new UserRelaysCache();
@ -11,6 +12,7 @@ export const Chats = new ChatCache();
export const PaymentsCache = new Payments();
export const InteractionCache = new EventInteractionCache();
export const GiftsCache = new GiftWrapCache();
export const Notifications = new NotificationsCache();
export async function preload(follows?: Array<string>) {
const preloads = [
@ -20,6 +22,7 @@ export async function preload(follows?: Array<string>) {
UserRelays.preload(follows),
RelayMetrics.preload(),
GiftsCache.preload(),
Notifications.preload(),
];
await Promise.all(preloads);
}

View File

@ -2,7 +2,7 @@ import Dexie, { Table } from "dexie";
import { HexKey, NostrEvent, u256 } from "@snort/system";
export const NAME = "snortDB";
export const VERSION = 12;
export const VERSION = 13;
export interface SubCache {
id: string;
@ -40,6 +40,7 @@ const STORES = {
eventInteraction: "++id",
payments: "++url",
gifts: "++id",
notifications: "++id",
};
export class SnortDB extends Dexie {
@ -48,6 +49,7 @@ export class SnortDB extends Dexie {
eventInteraction!: Table<EventInteraction>;
payments!: Table<Payment>;
gifts!: Table<UnwrappedGift>;
notifications!: Table<NostrEvent>;
constructor() {
super(NAME);

View File

@ -0,0 +1,37 @@
import Icon from "Icons/Icon";
import Spinner from "Icons/Spinner";
import { HTMLProps, useState } from "react";
export interface AsyncIconProps extends HTMLProps<HTMLDivElement> {
iconName: string;
iconSize?: number;
loading?: boolean;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => Promise<void>;
}
export function AsyncIcon(props: AsyncIconProps) {
const [loading, setLoading] = useState(props.loading ?? false);
async function handleClick(e: React.MouseEvent<HTMLDivElement>) {
setLoading(true);
try {
if (props.onClick) {
await props.onClick(e);
}
} catch (ex) {
console.error(ex);
}
setLoading(false);
}
const mergedProps = { ...props } as Record<string, unknown>;
delete mergedProps["iconName"];
delete mergedProps["iconSize"];
delete mergedProps["loading"];
return (
<div {...mergedProps} onClick={e => handleClick(e)}>
{loading ? <Spinner /> : <Icon name={props.iconName} size={props.iconSize} />}
{props.children}
</div>
);
}

View File

@ -1,33 +1,44 @@
import "./Avatar.css";
import Nostrich from "public/logo_256.png";
import { CSSProperties, useEffect, useState } from "react";
import type { UserMetadata } from "@snort/system";
import useImgProxy from "Hooks/useImgProxy";
import { getDisplayName } from "Element/ProfileImage";
import { defaultAvatar } from "SnortUtils";
interface AvatarProps {
pubkey: string;
user?: UserMetadata;
onClick?: () => void;
size?: number;
image?: string;
}
const Avatar = ({ user, size, onClick }: AvatarProps) => {
const [url, setUrl] = useState<string>(Nostrich);
const Avatar = ({ pubkey, user, size, onClick, image }: AvatarProps) => {
const [url, setUrl] = useState("");
const { proxy } = useImgProxy();
useEffect(() => {
if (user?.picture) {
const url = proxy(user.picture, size ?? 120);
setUrl(url);
const url = image ?? user?.picture;
if (url) {
const proxyUrl = proxy(url, size ?? 120);
setUrl(proxyUrl);
} else {
setUrl(Nostrich);
setUrl(defaultAvatar(pubkey));
}
}, [user]);
}, [user, image]);
const backgroundImage = `url(${url})`;
const style = { "--img-url": backgroundImage } as CSSProperties;
const domain = user?.nip05 && user.nip05.split("@")[1];
return <div onClick={onClick} style={style} className="avatar" data-domain={domain?.toLowerCase()}></div>;
return (
<div
onClick={onClick}
style={style}
className="avatar"
data-domain={domain?.toLowerCase()}
title={getDisplayName(user, "")}></div>
);
};
export default Avatar;

View File

@ -0,0 +1,20 @@
.avatar .edit,
.banner .edit {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background-color: var(--bg-color);
cursor: pointer;
opacity: 0;
border-radius: 100%;
}
.avatar .edit.new {
opacity: 0.5;
}
.avatar .edit:hover {
opacity: 0.5;
}

View File

@ -1,7 +1,9 @@
import "./AvatarEditor.css";
import Icon from "Icons/Icon";
import { useState } from "react";
import useFileUpload from "Upload";
import { openFile, unwrap } from "SnortUtils";
import Spinner from "Icons/Spinner";
interface AvatarEditorProps {
picture?: string;
@ -11,9 +13,11 @@ interface AvatarEditorProps {
export default function AvatarEditor({ picture, onPictureChange }: AvatarEditorProps) {
const uploader = useFileUpload();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function uploadFile() {
setError("");
setLoading(true);
try {
const f = await openFile();
if (f) {
@ -32,6 +36,7 @@ export default function AvatarEditor({ picture, onPictureChange }: AvatarEditorP
setError(`Upload failed`);
}
}
setLoading(false);
}
return (
@ -39,7 +44,7 @@ export default function AvatarEditor({ picture, onPictureChange }: AvatarEditorP
<div className="flex f-center">
<div style={{ backgroundImage: `url(${picture})` }} className="avatar">
<div className={`edit${picture ? "" : " new"}`} onClick={() => uploadFile().catch(console.error)}>
<Icon name={picture ? "edit" : "camera-plus"} />
{loading ? <Spinner /> : <Icon name={picture ? "edit" : "camera-plus"} />}
</div>
</div>
</div>

View File

@ -3,7 +3,6 @@ import { FormattedMessage } from "react-intl";
import useLogin from "Hooks/useLogin";
import { useUserProfile } from "@snort/system-react";
import { System } from "index";
interface Token {
token: Array<{
@ -17,7 +16,7 @@ interface Token {
export default function CashuNuts({ token }: { token: string }) {
const login = useLogin();
const profile = useUserProfile(System, login.publicKey);
const profile = useUserProfile(login.publicKey);
async function copyToken(e: React.MouseEvent<HTMLButtonElement>, token: string) {
e.stopPropagation();

View File

@ -16,7 +16,7 @@ export default function Copy({ text, maxSize = 32, className }: CopyProps) {
<div className={`flex flex-row copy ${className}`} onClick={() => copy(text)}>
<span className="body">{trimmed}</span>
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
{copied ? <Icon name="check" size={14} /> : <Icon name="copy" size={14} />}
{copied ? <Icon name="check" size={14} /> : <Icon name="copy-solid" size={14} />}
</span>
</div>
);

View File

@ -39,7 +39,8 @@ export default function DM(props: DMProps) {
}
function sender() {
if (props.chat.type !== ChatType.DirectMessage && !isMe) {
const isGroup = props.chat.type === ChatType.PrivateGroupChat || props.chat.type === ChatType.PublicGroupChat;
if (isGroup && !isMe) {
return <ProfileImage pubkey={msg.from} />;
}
}

View File

@ -3,6 +3,9 @@
flex-direction: column;
height: 100%;
}
.dm-window > div:nth-child(1) {
padding: 12px 0;
}
.dm-window > div:nth-child(2) {
overflow-y: auto;
@ -15,7 +18,6 @@
.dm-window > div:nth-child(3) {
display: flex;
align-items: center;
background-color: var(--bg-color);
gap: 10px;
padding: 5px 10px;
}

View File

@ -6,14 +6,13 @@ import DM from "Element/DM";
import NoteToSelf from "Element/NoteToSelf";
import useLogin from "Hooks/useLogin";
import WriteMessage from "Element/WriteMessage";
import { Chat, ChatParticipant, ChatType, useChatSystem } from "chat";
import { Nip4ChatSystem } from "chat/nip4";
import { Chat, ChatParticipant, createEmptyChatObject, useChatSystem } from "chat";
import { FormattedMessage } from "react-intl";
export default function DmWindow({ id }: { id: string }) {
const pubKey = useLogin().publicKey;
const dms = useChatSystem();
const chat = dms.find(a => a.id === id) ?? Nip4ChatSystem.createChatObj(id, []);
const chat = dms.find(a => a.id === id) ?? createEmptyChatObject(id);
function participant(p: ChatParticipant) {
if (p.id === pubKey) {
@ -37,7 +36,7 @@ export default function DmWindow({ id }: { id: string }) {
{chat.participants.map(v => (
<ProfileImage pubkey={v.id} showUsername={false} />
))}
{chat.title ?? <FormattedMessage defaultMessage="Group Chat" />}
{chat.title ?? <FormattedMessage defaultMessage="Secret Group Chat" />}
</div>
);
}

View File

@ -0,0 +1,34 @@
.fixed-tabs {
display: flex;
align-items: center;
flex-direction: row;
white-space: nowrap;
text-align: center;
user-select: none;
}
.fixed-tabs > a {
flex: 1;
padding: 16px;
color: var(--font-tertiary-color);
font-weight: 500;
font-size: 16px;
letter-spacing: 0.2px;
cursor: pointer;
text-decoration: none;
}
.fixed-tabs > a.active {
border-bottom: 1px solid var(--highlight);
color: var(--font-color);
}
.fixed-tabs > a.disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
.fixed-tabs > a:hover {
border-color: var(--highlight);
}

View File

@ -0,0 +1,6 @@
import "./FixedTabs.css";
import { ReactNode } from "react";
export function FixedTabs({ children }: { children: ReactNode }) {
return <div className="fixed-tabs">{children}</div>;
}

View File

@ -16,6 +16,7 @@ export interface FollowListBaseProps {
showAbout?: boolean;
className?: string;
actions?: ReactNode;
profileActions?: (pk: string) => ReactNode;
}
export default function FollowListBase({
@ -25,6 +26,7 @@ export default function FollowListBase({
showAbout,
className,
actions,
profileActions,
}: FollowListBaseProps) {
const publisher = useEventPublisher();
const { follows, relays } = useLogin();
@ -48,7 +50,7 @@ export default function FollowListBase({
</div>
)}
{pubkeys?.map(a => (
<ProfilePreview pubkey={a} key={a} options={{ about: showAbout }} />
<ProfilePreview pubkey={a} key={a} options={{ about: showAbout }} actions={profileActions?.(a)} />
))}
</div>
);

View File

@ -1,7 +1,6 @@
import { TwitterTweetEmbed } from "react-twitter-embed";
import {
FileExtensionRegex,
YoutubeUrlRegex,
TweetUrlRegex,
TidalRegex,
@ -23,17 +22,14 @@ import AppleMusicEmbed from "Element/AppleMusicEmbed";
import WavlakeEmbed from "Element/WavlakeEmbed";
import LinkPreview from "Element/LinkPreview";
import NostrLink from "Element/NostrLink";
import RevealMedia from "Element/RevealMedia";
import MagnetLink from "Element/MagnetLink";
interface HypeTextProps {
link: string;
creator: string;
depth?: number;
disableMediaSpotlight?: boolean;
}
export default function HyperText({ link, creator, depth, disableMediaSpotlight }: HypeTextProps) {
export default function HyperText({ link, depth }: HypeTextProps) {
const a = link;
try {
const url = new URL(a);
@ -47,10 +43,7 @@ export default function HyperText({ link, creator, depth, disableMediaSpotlight
const isAppleMusicLink = AppleMusicRegex.test(a);
const isNostrNestsLink = NostrNestsRegex.test(a);
const isWavlakeLink = WavlakeRegex.test(a);
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
if (extension && !isAppleMusicLink) {
return <RevealMedia link={a} creator={creator} disableSpotlight={disableMediaSpotlight} />;
} else if (tweetId) {
if (tweetId) {
return (
<div className="tweet" key={tweetId}>
<TwitterTweetEmbed tweetId={tweetId} />

View File

@ -1,6 +1,6 @@
.link-preview-container {
border: 1px solid var(--gray);
border-radius: 10px;
border-radius: 12px;
background: #151515;
overflow: hidden;
}
@ -14,11 +14,26 @@
.link-preview-title {
padding: 0 10px 10px 10px;
line-height: 21px;
}
.link-preview-title > h1 {
padding: 0;
font-size: 16px;
font-weight: 700;
}
.link-preview-container:hover .link-preview-title > h1 {
color: var(--highlight);
}
.link-preview-title > small {
color: var(--font-secondary-color);
font-size: small;
font-size: 14px;
}
.link-preview-title > small.host {
font-size: 12px;
}
.link-preview-image {
@ -30,3 +45,7 @@
background-size: cover;
background-position: center;
}
.light .link-preview-container {
background: #ddd;
}

View File

@ -73,15 +73,12 @@ const LinkPreview = ({ url }: { url: string }) => {
{preview && (
<a href={url} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{previewElement()}
<p className="link-preview-title">
{preview?.title}
{preview?.description && (
<>
<br />
<small>{preview.description.slice(0, 160)}</small>
</>
)}
</p>
<div className="link-preview-title">
<h1>{preview?.title}</h1>
{preview?.description && <small>{preview.description.slice(0, 160)}</small>}
<br />
<small className="host">{new URL(url).host}</small>
</div>
</a>
)}
{!preview && <Spinner className="f-center" />}

View File

@ -1,47 +0,0 @@
.live-chat {
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
}
.live-chat > div:nth-child(1) {
font-size: 24px;
line-height: 29px;
font-weight: bold;
}
.live-chat > div:nth-child(2) {
flex-grow: 1;
display: flex;
gap: 16px;
flex-direction: column-reverse;
margin-block-end: 20px;
overflow-y: auto;
}
.live-chat > div:nth-child(3) {
display: flex;
gap: 10px;
}
.live-chat .message {
display: inline-flex;
align-items: center;
gap: 8px;
}
.live-chat .message .name {
display: inline-flex;
align-items: center;
color: var(--highlight);
font-weight: 600;
cursor: pointer;
user-select: none;
}
.live-chat .message .avatar {
width: 24px;
height: 24px;
display: inline-block;
margin-right: 8px;
}

View File

@ -1,92 +0,0 @@
import "./LiveChat.css";
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import Textarea from "Element/Textarea";
import { useLiveChatFeed } from "Feed/LiveChatFeed";
import useEventPublisher from "Feed/EventPublisher";
import { getDisplayName } from "Element/ProfileImage";
import Avatar from "Element/Avatar";
import AsyncButton from "Element/AsyncButton";
import Text from "Element/Text";
import { System } from "index";
import { profileLink } from "SnortUtils";
export function LiveChat({ ev, link }: { ev: TaggedNostrEvent; link: NostrLink }) {
const [chat, setChat] = useState("");
const messages = useLiveChatFeed(link);
const pub = useEventPublisher();
const { formatMessage } = useIntl();
async function sendChatMessage() {
if (chat.length > 1) {
const reply = await pub?.generic(eb => {
return eb
.kind(1311 as EventKind)
.content(chat)
.tag(["a", `${link.kind}:${link.author}:${link.id}`])
.processContent();
});
if (reply) {
console.debug(reply);
System.BroadcastEvent(reply);
}
setChat("");
}
}
return (
<div className="live-chat">
<div>
<FormattedMessage defaultMessage="Stream Chat" />
</div>
<div>
{[...(messages.data ?? [])]
.sort((a, b) => b.created_at - a.created_at)
.map(a => (
<ChatMessage ev={a} key={a.id} />
))}
</div>
<div>
<Textarea
autoFocus={false}
className=""
onChange={v => setChat(v.target.value)}
value={chat}
onFocus={() => {}}
placeholder={formatMessage({
defaultMessage: "Message...",
})}
onKeyDown={async e => {
if (e.code === "Enter") {
e.preventDefault();
await sendChatMessage();
}
}}
/>
<AsyncButton onClick={sendChatMessage}>
<FormattedMessage defaultMessage="Send" />
</AsyncButton>
</div>
</div>
);
}
function ChatMessage({ ev }: { ev: TaggedNostrEvent }) {
const profile = useUserProfile(System, ev.pubkey);
const navigate = useNavigate();
return (
<div className="message">
<div className="name" onClick={() => navigate(profileLink(ev.pubkey, ev.relays))}>
<Avatar user={profile} />
{getDisplayName(profile, ev.pubkey)}:
</div>
<span>
<Text disableMedia={true} content={ev.content} creator={ev.pubkey} tags={ev.tags} />
</span>
</div>
);
}

View File

@ -13,7 +13,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
<h3>{title}</h3>
</div>
<div>
<Link to={`/live/${encodeTLV(NostrPrefix.Address, d, undefined, ev.kind, ev.pubkey)}`}>
<Link to={`https://zap.stream/${encodeTLV(NostrPrefix.Address, d, undefined, ev.kind, ev.pubkey)}`}>
<button className="primary" type="button">
<FormattedMessage defaultMessage="Watch Live!" />
</button>

View File

@ -0,0 +1,54 @@
.stream-list {
display: flex;
gap: 4px;
overflow-x: auto;
}
.stream-list::-webkit-scrollbar {
height: 6.25px;
}
.stream-event {
display: flex;
padding: 8px 12px;
gap: 8px;
text-decoration: none;
}
.stream-event > div:first-of-type {
border-radius: 8px;
height: 49px;
width: 65px;
background-color: var(--gray-light);
background-image: var(--img);
background-position: center;
background-size: cover;
}
.stream-event span.live {
display: flex;
padding: 4px 6px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 9px;
background: var(--live);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.stream-event .details .reactions {
color: var(--font-secondary-color);
}
.stream-event .details > div:nth-of-type(2) {
width: 100px;
min-width: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}

View File

@ -0,0 +1,58 @@
import "./LiveStreams.css";
import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system";
import { findTag } from "SnortUtils";
import { CSSProperties, useMemo } from "react";
import { Link } from "react-router-dom";
import useImgProxy from "Hooks/useImgProxy";
import Icon from "Icons/Icon";
export function LiveStreams({ evs }: { evs: Array<NostrEvent> }) {
const streams = useMemo(() => {
return [...evs].sort((a, b) => {
const aStarts = Number(findTag(a, "starts") ?? a.created_at);
const bStarts = Number(findTag(b, "starts") ?? b.created_at);
return aStarts > bStarts ? -1 : 1;
});
}, [evs]);
if (streams.length === 0) return null;
return (
<div className="stream-list">
{streams.map(v => (
<LiveStreamEvent ev={v} key={`${v.kind}:${v.pubkey}:${findTag(v, "d")}`} />
))}
</div>
);
}
function LiveStreamEvent({ ev }: { ev: NostrEvent }) {
const { proxy } = useImgProxy();
const title = findTag(ev, "title");
const image = findTag(ev, "image");
const status = findTag(ev, "status");
const link = encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev.kind, ev.pubkey);
const imageProxy = proxy(image ?? "");
return (
<Link className="stream-event" to={`https://zap.stream/${link}`} target="_blank">
<div
style={
{
"--img": `url(${imageProxy})`,
} as CSSProperties
}></div>
<div className="flex f-col details">
<div className="flex g2">
<span className="live">{status}</span>
<div className="reaction-pill">
<Icon name="zap" size={24} />
<div className="reaction-pill-number">0</div>
</div>
</div>
<div>{title}</div>
</div>
</Link>
);
}

View File

@ -1,15 +0,0 @@
.modal.spotlight .modal-body {
max-width: 100vw;
width: unset;
}
.modal.spotlight img,
.modal.spotlight video {
max-width: 90vw;
max-height: 90vh;
aspect-ratio: unset;
}
.modal.spotlight .close {
text-align: right;
}

View File

@ -1,19 +1,6 @@
import { ProxyImg } from "Element/ProxyImg";
import React, { MouseEvent, useEffect, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { Link } from "react-router-dom";
import { decodeInvoice, InvoiceDetails } from "@snort/shared";
import React from "react";
import "./MediaElement.css";
import Modal from "Element/Modal";
import Icon from "Icons/Icon";
import { kvToObject } from "SnortUtils";
import AsyncButton from "Element/AsyncButton";
import { useWallet } from "Wallet";
import { PaymentsCache } from "Cache";
import { Payment } from "Db";
import PageSpinner from "Element/PageSpinner";
import { LiveVideoPlayer } from "Element/LiveVideoPlayer";
/*
[
"imeta",
@ -28,161 +15,16 @@ interface MediaElementProps {
magnet?: string;
sha256?: string;
blurHash?: string;
disableSpotlight?: boolean;
}
interface L402Object {
macaroon: string;
invoice: string;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
}
export function MediaElement(props: MediaElementProps) {
const [invoice, setInvoice] = useState<InvoiceDetails>();
const [l402, setL402] = useState<L402Object>();
const [auth, setAuth] = useState<Payment>();
const [error, setError] = useState("");
const [url, setUrl] = useState(props.url);
const [loading, setLoading] = useState(false);
const wallet = useWallet();
async function probeFor402() {
const cached = await PaymentsCache.get(props.url);
if (cached) {
setAuth(cached);
return;
}
const req = new Request(props.url, {
method: "OPTIONS",
headers: {
accept: "L402",
},
});
const rsp = await fetch(req);
if (rsp.status === 402) {
const auth = rsp.headers.get("www-authenticate");
if (auth?.startsWith("L402")) {
const vals = kvToObject<L402Object>(auth.substring(5));
console.debug(vals);
setL402(vals);
if (vals.invoice) {
const decoded = decodeInvoice(vals.invoice);
setInvoice(decoded);
}
}
}
}
async function payInvoice() {
if (wallet.wallet && l402) {
try {
const res = await wallet.wallet.payInvoice(l402.invoice);
console.debug(res);
if (res.preimage) {
const pmt = {
pr: l402.invoice,
url: props.url,
macaroon: l402.macaroon,
preimage: res.preimage,
};
await PaymentsCache.set(pmt);
setAuth(pmt);
}
} catch (e) {
if (e instanceof Error) {
setError(e.message);
}
}
}
}
async function loadMedia() {
if (!auth) return;
setLoading(true);
const mediaReq = new Request(props.url, {
headers: {
Authorization: `L402 ${auth.macaroon}:${auth.preimage}`,
},
});
const rsp = await fetch(mediaReq);
if (rsp.ok) {
const buf = await rsp.blob();
setUrl(URL.createObjectURL(buf));
}
setLoading(false);
}
useEffect(() => {
if (auth) {
loadMedia().catch(console.error);
}
}, [auth]);
if (auth && loading) {
return <PageSpinner />;
}
if (invoice) {
return (
<div className="note-invoice">
<h3>
<FormattedMessage defaultMessage="Payment Required" />
</h3>
<div className="flex f-row">
<div className="f-grow">
<FormattedMessage
defaultMessage="You must pay {n} sats to access this file."
values={{
n: <FormattedNumber value={(invoice.amount ?? 0) / 1000} />,
}}
/>
</div>
<div>
{wallet.wallet && (
<AsyncButton onClick={() => payInvoice()}>
<FormattedMessage defaultMessage="Pay Now" />
</AsyncButton>
)}
</div>
</div>
{!wallet.wallet && (
<b>
<FormattedMessage
defaultMessage="Please connect a wallet {here} to be able to pay this invoice"
values={{
here: (
<Link to="/settings/wallet" onClick={e => e.stopPropagation()}>
<FormattedMessage defaultMessage="here" description="Inline link text pointing to another page" />
</Link>
),
}}
/>
</b>
)}
{error && <b className="error">{error}</b>}
</div>
);
}
if (props.mime.startsWith("image/")) {
if (!(props.disableSpotlight ?? false)) {
return (
<SpotlightMedia>
<ProxyImg key={props.url} src={url} onError={() => probeFor402()} />
</SpotlightMedia>
);
} else {
return <ProxyImg key={props.url} src={url} onError={() => probeFor402()} />;
}
return <ProxyImg key={props.url} src={props.url} onClick={props.onMediaClick} />;
} else if (props.mime.startsWith("audio/")) {
return <audio key={props.url} src={url} controls onError={() => probeFor402()} />;
return <audio key={props.url} src={props.url} controls />;
} else if (props.mime.startsWith("video/")) {
if (props.url.endsWith(".m3u8")) {
return <LiveVideoPlayer stream={props.url} />;
}
return <video key={props.url} src={url} controls onError={() => probeFor402()} />;
return <video key={props.url} src={props.url} controls />;
} else {
return (
<a
@ -197,33 +39,3 @@ export function MediaElement(props: MediaElementProps) {
);
}
}
export function SpotlightMedia({ children }: { children: React.ReactNode }) {
const [showModal, setShowModal] = useState(false);
function onClick(e: MouseEvent<HTMLDivElement>) {
e.stopPropagation();
e.preventDefault();
setShowModal(s => !s);
}
function onClose(e: MouseEvent<HTMLDivElement>) {
e.stopPropagation();
e.preventDefault();
setShowModal(false);
}
return (
<>
{showModal && (
<Modal onClose={onClose} className="spotlight">
<div className="close" onClick={onClose}>
<Icon name="close" />
</div>
{children}
</Modal>
)}
<div onClick={onClick}>{children}</div>
</>
);
}

View File

@ -5,10 +5,9 @@ import { HexKey } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { profileLink } from "SnortUtils";
import { getDisplayName } from "Element/ProfileImage";
import { System } from "index";
export default function Mention({ pubkey, relays }: { pubkey: HexKey; relays?: Array<string> | string }) {
const user = useUserProfile(System, pubkey);
const user = useUserProfile(pubkey);
const name = useMemo(() => {
return getDisplayName(user, pubkey);

View File

@ -12,11 +12,16 @@
}
.modal-body {
background-color: var(--note-bg);
padding: 10px;
border-radius: 10px;
background-color: var(--gray-superdark);
padding: 16px 24px;
border-radius: 12px;
display: flex;
flex-direction: column;
width: 500px;
border: 1px solid var(--font-tertiary-color);
margin-top: auto;
margin-bottom: auto;
}
.modal-body button.secondary:hover {
background-color: var(--gray);
}

View File

@ -1,12 +1,9 @@
import "./Nip05.css";
import { HexKey } from "@snort/system";
import Icon from "Icons/Icon";
import { useUserProfile } from "@snort/system-react";
import { System } from "index";
export function useIsVerified(pubkey: HexKey, bypassCheck?: boolean) {
const profile = useUserProfile(System, pubkey);
const profile = useUserProfile(pubkey);
return { isVerified: bypassCheck || profile?.isNostrAddressValid };
}
@ -29,7 +26,6 @@ const Nip05 = ({ nip05, pubkey, verifyNip = true }: Nip05Params) => {
<span className="domain" data-domain={domain?.toLowerCase()}>
{domain}
</span>
<Icon name="badge" className="badge" size={16} />
</>
)}
</div>

View File

@ -44,7 +44,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
const { helpText = true } = props;
const { formatMessage } = useIntl();
const pubkey = useLogin().publicKey;
const user = useUserProfile(System, pubkey);
const user = useUserProfile(pubkey);
const publisher = useEventPublisher();
const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
@ -225,7 +225,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
UserCache.set(newMeta);
}
if (helpText) {
navigate("/settings");
navigate("/settings/profile");
}
}
}

View File

@ -1,5 +1,8 @@
.note {
min-height: 110px;
display: flex;
flex-direction: column;
gap: 12px;
}
.note:hover {
@ -19,16 +22,17 @@
text-decoration-color: var(--highlight);
}
.note > .header > .info {
.note .header .info {
font-size: var(--font-size);
margin-left: 4px;
white-space: nowrap;
color: var(--font-secondary-color);
display: flex;
align-items: center;
gap: 8px;
}
.note > .header > .info .saved {
.note .header .info .saved {
margin-right: 12px;
font-weight: 600;
font-size: 10px;
@ -39,11 +43,11 @@
align-items: center;
}
.note > .header > .info .saved svg {
.note .header .info .saved svg {
margin-right: 8px;
}
.note > .header > .pinned {
.note .header .pinned {
font-size: var(--font-size-small);
color: var(--font-secondary-color);
font-weight: 500;
@ -53,33 +57,23 @@
align-items: center;
}
.note > .header > .pinned svg {
.note .header .pinned svg {
margin-right: 8px;
}
.note-quote {
border: 1px solid var(--gray);
border-radius: 10px;
padding: 5px;
border: 1px solid var(--gray-superdark);
border-radius: 12px;
padding: 8px 16px 16px 16px;
}
.note-quote.note > .body {
padding-left: 0;
}
.note > .body {
margin-top: 4px;
margin-bottom: 24px;
padding-left: 56px;
.note > .body .text-frag {
text-overflow: ellipsis;
white-space: pre-wrap;
word-break: normal;
overflow-x: hidden;
overflow-y: visible;
}
.note > .footer {
padding-left: 46px;
display: inline;
}
.note .footer .footer-reactions {
@ -88,8 +82,7 @@
align-items: center;
justify-content: center;
margin-left: auto;
gap: 1em;
padding-left: 0.8em;
gap: 48px;
}
@media (min-width: 720px) {
@ -98,54 +91,6 @@
}
}
.note > .footer .ctx-menu {
color: var(--font-secondary-color);
background: transparent;
box-shadow: 0px 8px 20px rgba(0, 0, 0, 0.4);
min-width: 0;
margin: 0;
padding: 0;
border-radius: 16px;
}
.note > .footer .ctx-menu li {
background: #1e1e1e;
padding-top: 8px;
padding-bottom: 8px;
display: grid;
grid-template-columns: 2rem auto;
}
.light .note > .footer .ctx-menu li {
background: var(--note-bg);
}
.note > .footer .ctx-menu li:first-of-type {
padding-top: 12px;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.note > .footer .ctx-menu li:last-of-type {
padding-bottom: 12px;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.note > .footer .ctx-menu li:hover {
color: white;
background: #2a2a2a;
}
.light .note > .footer .ctx-menu li:hover {
color: white;
background: var(--font-secondary-color);
}
.ctx-menu .red {
color: var(--error);
}
.note > .header img:hover,
.note > .header .name > .reply:hover {
cursor: pointer;
@ -196,11 +141,7 @@
user-select: none;
color: var(--font-secondary-color);
font-feature-settings: "tnum";
}
.reaction-pill .reaction-pill-number {
margin-left: 8px;
font-feature-settings: "tnum";
gap: 5px;
}
.reaction-pill.reacted {
@ -244,7 +185,6 @@
.note.active {
border-left: 1px solid var(--highlight);
border-bottom-left-radius: 0;
margin-left: -1px;
}
@ -261,14 +201,6 @@
text-decoration: underline;
}
.close-menu {
position: absolute;
width: 100vw;
height: 100vh;
top: -400px;
left: -600px;
}
.close-menu-container {
position: absolute;
.note .body > .text > a {
color: var(--highlight);
}

View File

@ -1,5 +1,5 @@
import "./Note.css";
import React, { useMemo, useState, useLayoutEffect, ReactNode } from "react";
import React, { useMemo, useState, ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useInView } from "react-intersection-observer";
import { useIntl, FormattedMessage } from "react-intl";
@ -20,7 +20,7 @@ import {
Reaction,
profileLink,
} from "SnortUtils";
import NoteFooter, { Translation } from "Element/NoteFooter";
import NoteFooter from "Element/NoteFooter";
import NoteTime from "Element/NoteTime";
import Reveal from "Element/Reveal";
import useModeration from "Hooks/useModeration";
@ -32,6 +32,9 @@ import { NostrFileElement } from "Element/NostrFileHeader";
import ZapstrEmbed from "Element/ZapstrEmbed";
import PubkeyList from "Element/PubkeyList";
import { LiveEvent } from "Element/LiveEvent";
import { NoteContextMenu, NoteTranslation } from "Element/NoteContextMenu";
import Reactions from "Element/Reactions";
import { ZapGoal } from "Element/ZapGoal";
import messages from "./messages";
@ -45,11 +48,13 @@ export interface NoteProps {
depth?: number;
options?: {
showHeader?: boolean;
showContextMenu?: boolean;
showTime?: boolean;
showPinned?: boolean;
showBookmarked?: boolean;
showFooter?: boolean;
showReactionsLink?: boolean;
showMedia?: boolean;
canUnpin?: boolean;
canUnbookmark?: boolean;
canClick?: boolean;
@ -89,6 +94,9 @@ export default function Note(props: NoteProps) {
if (ev.kind === EventKind.LiveEvent) {
return <LiveEvent ev={ev} />;
}
if (ev.kind === (9041 as EventKind)) {
return <ZapGoal ev={ev} />;
}
const baseClassName = `note card${className ? ` ${className}` : ""}`;
const navigate = useNavigate();
@ -96,13 +104,11 @@ export default function Note(props: NoteProps) {
const deletions = useMemo(() => getReactions(related, ev.id, EventKind.Deletion), [related]);
const { isMuted } = useModeration();
const isOpMuted = isMuted(ev?.pubkey);
const { ref, inView, entry } = useInView({ triggerOnce: true });
const [extendable, setExtendable] = useState<boolean>(false);
const [showMore, setShowMore] = useState<boolean>(false);
const { ref, inView } = useInView({ triggerOnce: true });
const login = useLogin();
const { pinned, bookmarked } = login;
const publisher = useEventPublisher();
const [translated, setTranslated] = useState<Translation>();
const [translated, setTranslated] = useState<NoteTranslation>();
const { formatMessage } = useIntl();
const reactions = useMemo(() => getReactions(related, ev.id, EventKind.Reaction), [related, ev]);
const groupReactions = useMemo(() => {
@ -147,6 +153,7 @@ export default function Note(props: NoteProps) {
showFooter: true,
canUnpin: false,
canUnbookmark: false,
showContextMenu: true,
...opt,
};
@ -205,18 +212,17 @@ export default function Note(props: NoteProps) {
</Reveal>
);
}
return <Text content={body} tags={ev.tags} creator={ev.pubkey} depth={props.depth} />;
return (
<Text
content={body}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
disableMedia={!(options.showMedia ?? true)}
/>
);
};
useLayoutEffect(() => {
if (entry && inView && extendable === false) {
const h = (entry?.target as HTMLDivElement)?.offsetHeight ?? 0;
if (h > 650) {
setExtendable(true);
}
}
}, [inView, entry, extendable]);
function goToEvent(
e: React.MouseEvent,
eTarget: TaggedNostrEvent,
@ -342,21 +348,33 @@ export default function Note(props: NoteProps) {
subHeader={replyTag() ?? undefined}
link={opt?.canClick === undefined ? undefined : ""}
/>
{(options.showTime || options.showBookmarked) && (
<div className="info">
{options.showBookmarked && (
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark(ev.id)}>
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
</div>
)}
{!options.showBookmarked && <NoteTime from={ev.created_at * 1000} />}
</div>
)}
{options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.id)}>
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
</div>
)}
<div className="info">
{(options.showTime || options.showBookmarked) && (
<>
{options.showBookmarked && (
<div
className={`saved ${options.canUnbookmark ? "pointer" : ""}`}
onClick={() => unbookmark(ev.id)}>
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
</div>
)}
{!options.showBookmarked && <NoteTime from={ev.created_at * 1000} />}
</>
)}
{options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.id)}>
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
</div>
)}
{options.showContextMenu && (
<NoteContextMenu
ev={ev}
react={async () => {}}
onTranslated={t => setTranslated(t)}
setShowReactions={setShowReactions}
/>
)}
</div>
</div>
)}
<div className="body" onClick={e => goToEvent(e, ev, true)}>
@ -369,32 +387,21 @@ export default function Note(props: NoteProps) {
</div>
)}
</div>
{extendable && !showMore && (
<span className="expand-note mt10 flex f-center" onClick={() => setShowMore(true)}>
<FormattedMessage {...messages.ShowMore} />
</span>
)}
{options.showFooter && (
<NoteFooter
ev={ev}
positive={positive}
negative={negative}
reposts={reposts}
zaps={zaps}
onTranslated={t => setTranslated(t)}
showReactions={showReactions}
setShowReactions={setShowReactions}
/>
)}
{options.showFooter && <NoteFooter ev={ev} positive={positive} reposts={reposts} zaps={zaps} />}
<Reactions
show={showReactions}
setShow={setShowReactions}
positive={positive}
negative={negative}
reposts={reposts}
zaps={zaps}
/>
</>
);
}
const note = (
<div
className={`${baseClassName}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`}
onClick={e => goToEvent(e, ev)}
ref={ref}>
<div className={`${baseClassName}${highlight ? " active " : " "}`} onClick={e => goToEvent(e, ev)} ref={ref}>
{content()}
</div>
);

View File

@ -0,0 +1,220 @@
import { FormattedMessage, useIntl } from "react-intl";
import { HexKey, Lists, NostrPrefix, TaggedRawEvent, encodeTLV } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useDispatch, useSelector } from "react-redux";
import { TranslateHost } from "Const";
import { System } from "index";
import Icon from "Icons/Icon";
import { setPinned, setBookmarked } from "Login";
import {
setNote as setReBroadcastNote,
setShow as setReBroadcastShow,
reset as resetReBroadcast,
} from "State/ReBroadcast";
import messages from "Element/messages";
import useLogin from "Hooks/useLogin";
import useModeration from "Hooks/useModeration";
import useEventPublisher from "Feed/EventPublisher";
import { RootState } from "State/Store";
import { ReBroadcaster } from "./ReBroadcaster";
export interface NoteTranslation {
text: string;
fromLanguage: string;
confidence: number;
}
interface NosteContextMenuProps {
ev: TaggedRawEvent;
setShowReactions(b: boolean): void;
react(content: string): Promise<void>;
onTranslated?: (t: NoteTranslation) => void;
}
export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const login = useLogin();
const { pinned, bookmarked, publicKey, preferences: prefs } = login;
const { mute, block } = useModeration();
const publisher = useEventPublisher();
const showReBroadcastModal = useSelector((s: RootState) => s.reBroadcast.show);
const reBroadcastNote = useSelector((s: RootState) => s.reBroadcast.note);
const willRenderReBroadcast = showReBroadcastModal && reBroadcastNote && reBroadcastNote?.id === ev.id;
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const isMine = ev.pubkey === publicKey;
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
const evDelete = await publisher.delete(ev.id);
System.BroadcastEvent(evDelete);
}
}
async function share() {
const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
const url = `${window.location.protocol}//${window.location.host}/e/${link}`;
if ("share" in window.navigator) {
await window.navigator.share({
title: "Snort",
url: url,
});
} else {
await navigator.clipboard.writeText(url);
}
}
async function translate() {
const res = await fetch(`${TranslateHost}/translate`, {
method: "POST",
body: JSON.stringify({
q: ev.content,
source: "auto",
target: lang.split("-")[0],
}),
headers: { "Content-Type": "application/json" },
});
if (res.ok) {
const result = await res.json();
if (typeof props.onTranslated === "function" && result) {
props.onTranslated({
text: result.translatedText,
fromLanguage: langNames.of(result.detectedLanguage.language),
confidence: result.detectedLanguage.confidence,
} as NoteTranslation);
}
}
}
async function copyId() {
const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
await navigator.clipboard.writeText(link);
}
async function pin(id: HexKey) {
if (publisher) {
const es = [...pinned.item, id];
const ev = await publisher.noteList(es, Lists.Pinned);
System.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
async function bookmark(id: HexKey) {
if (publisher) {
const es = [...bookmarked.item, id];
const ev = await publisher.noteList(es, Lists.Bookmarked);
System.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
async function copyEvent() {
await navigator.clipboard.writeText(JSON.stringify(ev, undefined, " "));
}
const handleReBroadcastButtonClick = () => {
if (reBroadcastNote?.id !== ev.id) {
dispatch(resetReBroadcast());
}
dispatch(setReBroadcastNote(ev));
dispatch(setReBroadcastShow(!showReBroadcastModal));
};
function menuItems() {
return (
<>
<div className="close-menu-container">
{/* This menu item serves as a "close menu" button;
it allows the user to click anywhere nearby the menu to close it. */}
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={() => props.setShowReactions(true)}>
<Icon name="heart" />
<FormattedMessage {...messages.Reactions} />
</MenuItem>
<MenuItem onClick={() => share()}>
<Icon name="share" />
<FormattedMessage {...messages.Share} />
</MenuItem>
{!pinned.item.includes(ev.id) && (
<MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!bookmarked.item.includes(ev.id) && (
<MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />
</MenuItem>
)}
<MenuItem onClick={() => copyId()}>
<Icon name="copy" />
<FormattedMessage {...messages.CopyID} />
</MenuItem>
<MenuItem onClick={() => mute(ev.pubkey)}>
<Icon name="mute" />
<FormattedMessage {...messages.Mute} />
</MenuItem>
{prefs.enableReactions && (
<MenuItem onClick={() => props.react("-")}>
<Icon name="dislike" />
<FormattedMessage {...messages.DislikeAction} />
</MenuItem>
)}
{ev.pubkey === publicKey && (
<MenuItem onClick={handleReBroadcastButtonClick}>
<Icon name="relay" />
<FormattedMessage {...messages.ReBroadcast} />
</MenuItem>
)}
{ev.pubkey !== publicKey && (
<MenuItem onClick={() => block(ev.pubkey)}>
<Icon name="block" />
<FormattedMessage {...messages.Block} />
</MenuItem>
)}
<MenuItem onClick={() => translate()}>
<Icon name="translate" />
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />
</MenuItem>
{prefs.showDebugMenus && (
<MenuItem onClick={() => copyEvent()}>
<Icon name="json" />
<FormattedMessage {...messages.CopyJSON} />
</MenuItem>
)}
{isMine && (
<MenuItem onClick={() => deleteEvent()}>
<Icon name="trash" className="red" />
<FormattedMessage {...messages.Delete} />
</MenuItem>
)}
</>
);
}
return (
<>
<Menu
menuButton={
<div className="reaction-pill">
<Icon name="dots" size={15} />
</div>
}
menuClassName="ctx-menu">
{menuItems()}
</Menu>
{willRenderReBroadcast && <ReBroadcaster />}
</>
);
}

View File

@ -1,22 +1,43 @@
.note-creator {
margin-bottom: 10px;
background-color: var(--note-bg);
border: none;
border-radius: 10px;
padding: 6px;
position: relative;
border: 1px solid transparent;
border-radius: 12px;
box-shadow: 0px 0px 6px 1px rgba(182, 108, 156, 0.3);
background: linear-gradient(var(--gray-superdark), var(--gray-superdark)) padding-box,
linear-gradient(90deg, #ef9644, #fd7c49, #ff5e58, #ff3b70, #ff088e, #eb00b1, #c31ed5, #7b41f6) border-box;
}
.note-reply {
margin: 10px;
.note-creator-modal .modal-body {
gap: 16px;
}
.note-creator-modal .note.card {
padding: 8px 12px;
border-radius: 12px;
background-color: var(--gray-dark);
}
.note-creator-modal h4 {
font-size: 11px;
font-weight: 600;
letter-spacing: 1.21px;
text-transform: uppercase;
color: var(--gray-light);
margin: 0;
}
.note-creator-relay {
background-color: var(--gray-dark);
border-radius: 12px;
}
.note-creator textarea {
border: none;
outline: none;
resize: none;
background-color: var(--note-bg);
border-radius: 10px 10px 0 0;
padding: 0;
border-radius: 0;
margin: 8px 12px;
background-color: var(--gray-superdark);
min-height: 100px;
width: stretch;
width: -webkit-fill-available;
@ -30,67 +51,9 @@
line-height: 24px;
}
@media (min-width: 520px) {
.note-creator textarea {
min-height: 210px;
}
}
@media (min-width: 720px) {
.note-creator textarea {
min-height: 321px;
}
}
.note-creator.poll textarea {
min-height: 120px;
}
.note-creator-actions {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
margin-bottom: 5px;
}
.note-creator .insert {
display: flex;
justify-content: flex-end;
width: stretch;
width: -webkit-fill-available;
width: -moz-available;
}
.note-creator .insert > button {
width: 48px;
height: 36px;
background: var(--gray-dark);
color: white;
border-radius: 17px;
margin-right: 5px;
display: flex;
justify-content: center;
align-items: center;
}
.note-creator .attachment:hover {
background: var(--font-color);
color: var(--gray-dark);
}
.light .note-creator .attachment {
background: var(--gray-light);
}
.light .note-creator .attachment:hover {
background: var(--gray-dark);
color: white;
}
.note-creator-actions button:not(:last-child) {
margin-right: 4px;
}
.note-creator .error {
position: absolute;
@ -101,30 +64,36 @@
font-size: 16px;
}
.note-creator-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
}
.note-creator-icon.pfp .avatar {
width: 32px;
height: 32px;
}
.note-create-button {
width: 48px;
height: 48px;
background-color: var(--highlight);
color: white;
border: none;
border-radius: 100%;
position: fixed;
bottom: 50px;
right: 16px;
right: calc(((100vw - 640px) / 2) - 60px);
display: flex;
align-items: center;
justify-content: center;
}
.note-creator-modal .modal-body {
background: var(--modal-bg-color);
}
.note-preview {
word-break: break-all;
}
.note-preview-body {
text-overflow: ellipsis;
padding: 4px 4px 0 56px;
font-size: 14px;
@media (max-width: 768px) {
.note-create-button {
right: 16px;
}
}

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
@ -36,22 +37,8 @@ import { LoginStore } from "Login";
import { getCurrentSubscription } from "Subscription";
import useLogin from "Hooks/useLogin";
import { System } from "index";
interface NotePreviewProps {
note: TaggedNostrEvent;
}
function NotePreview({ note }: NotePreviewProps) {
return (
<div className="note-preview">
<ProfileImage pubkey={note.pubkey} />
<div className="note-preview-body">
{note.content.slice(0, 136)}
{note.content.length > 140 && "..."}
</div>
</div>
);
}
import AsyncButton from "Element/AsyncButton";
import { AsyncIcon } from "Element/AsyncIcon";
export function NoteCreator() {
const { formatMessage } = useIntl();
@ -71,7 +58,6 @@ export function NoteCreator() {
selectedCustomRelays,
error,
} = useSelector((s: RootState) => s.noteCreator);
const [uploadInProgress, setUploadInProgress] = useState(false);
const dispatch = useDispatch();
const sub = getCurrentSubscription(LoginStore.allSubscriptions());
const login = useLogin();
@ -137,7 +123,6 @@ export function NoteCreator() {
}
async function uploadFile(file: File | Blob) {
setUploadInProgress(true);
try {
if (file) {
const rx = await uploader.upload(file, file.name);
@ -155,8 +140,6 @@ export function NoteCreator() {
if (error instanceof Error) {
dispatch(setError(error?.message));
}
} finally {
setUploadInProgress(false);
}
}
@ -174,9 +157,9 @@ export function NoteCreator() {
dispatch(reset());
}
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
async function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
ev.stopPropagation();
sendNote().catch(console.warn);
await sendNote();
}
async function loadPreview() {
@ -197,8 +180,10 @@ export function NoteCreator() {
data={preview as TaggedNostrEvent}
related={[]}
options={{
showContextMenu: false,
showFooter: false,
canClick: false,
showTime: false,
}}
/>
);
@ -253,14 +238,12 @@ export function NoteCreator() {
function renderRelayCustomisation() {
return (
<div>
<div className="flex-column g8">
{Object.keys(relays.item || {})
.filter(el => relays.item[el].write)
.map((r, i, a) => (
<div className="card flex">
<div className="flex f-col f-grow">
<div>{r}</div>
</div>
<div className="p flex f-space note-creator-relay">
<div>{r}</div>
<div>
<input
type="checkbox"
@ -321,103 +304,97 @@ export function NoteCreator() {
<>
{show && (
<Modal className="note-creator-modal" onClose={() => dispatch(setShow(false))}>
{replyTo && <NotePreview note={replyTo} />}
{replyTo && (
<Note
data={replyTo}
related={[]}
options={{
showFooter: false,
showContextMenu: false,
showTime: false,
canClick: false,
showMedia: false,
}}
/>
)}
{preview && getPreviewNote()}
{!preview && (
<div
onPaste={handlePaste}
className={`flex note-creator${replyTo ? " note-reply" : ""}${pollOptions ? " poll" : ""}`}>
<div className="flex f-col f-grow">
<Textarea
autoFocus
className={`textarea ${active ? "textarea--focused" : ""}`}
onChange={onChange}
value={note}
onFocus={() => dispatch(setActive(true))}
onKeyDown={e => {
if (e.key === "Enter" && e.metaKey) {
sendNote().catch(console.warn);
}
}}
/>
{renderPollOptions()}
<div className="insert">
{sub && (
<Menu
menuButton={
<button>
<Icon name="code-circle" />
</button>
}
menuClassName="ctx-menu">
{listAccounts()}
</Menu>
)}
{pollOptions === undefined && !replyTo && (
<button onClick={() => dispatch(setPollOptions(["A", "B"]))}>
<Icon name="pie-chart" />
</button>
)}
<button onClick={attachFile}>
<Icon name="attachment" />
</button>
</div>
</div>
{error && <span className="error">{error}</span>}
<div onPaste={handlePaste} className={`note-creator${pollOptions ? " poll" : ""}`}>
<Textarea
autoFocus
className={`textarea ${active ? "textarea--focused" : ""}`}
onChange={onChange}
value={note}
onFocus={() => dispatch(setActive(true))}
onKeyDown={e => {
if (e.key === "Enter" && e.metaKey) {
sendNote().catch(console.warn);
}
}}
/>
{renderPollOptions()}
</div>
)}
<div className="note-creator-actions">
{uploadInProgress && <Spinner />}
<button className="secondary" onClick={() => dispatch(setShowAdvanced(!showAdvanced))}>
<FormattedMessage defaultMessage="Advanced" />
</button>
<button className="secondary" onClick={cancel}>
<FormattedMessage {...messages.Cancel} />
</button>
<button onClick={onSubmit}>
{replyTo ? <FormattedMessage {...messages.Reply} /> : <FormattedMessage {...messages.Send} />}
</button>
<div className="flex f-space">
<div className="flex g8">
<ProfileImage pubkey={login.publicKey ?? ""} className="note-creator-icon" link="" showUsername={false} />
{pollOptions === undefined && !replyTo && (
<div className="note-creator-icon">
<Icon name="pie-chart" onClick={() => dispatch(setPollOptions(["A", "B"]))} size={24} />
</div>
)}
<AsyncIcon iconName="image-plus" iconSize={24} onClick={attachFile} className="note-creator-icon" />
<button className="secondary" onClick={() => dispatch(setShowAdvanced(!showAdvanced))}>
<FormattedMessage defaultMessage="Advanced" />
</button>
</div>
<div className="flex g8">
<button className="secondary" onClick={cancel}>
<FormattedMessage defaultMessage="Cancel" />
</button>
<AsyncButton onClick={onSubmit}>
{replyTo ? <FormattedMessage defaultMessage="Reply" /> : <FormattedMessage defaultMessage="Send" />}
</AsyncButton>
</div>
</div>
{error && <span className="error">{error}</span>}
{showAdvanced && (
<div>
<>
<button className="secondary" onClick={loadPreview}>
<FormattedMessage defaultMessage="Toggle Preview" />
</button>
<h4>
<FormattedMessage defaultMessage="Custom Relays" />
</h4>
<p>
<FormattedMessage defaultMessage="Send note to a subset of your write relays" />
</p>
{renderRelayCustomisation()}
<h4>
<FormattedMessage defaultMessage="Forward Zaps" />
</h4>
<p>
<div>
<h4>
<FormattedMessage defaultMessage="Custom Relays" />
</h4>
<p>
<FormattedMessage defaultMessage="Send note to a subset of your write relays" />
</p>
{renderRelayCustomisation()}
</div>
<div className="flex-column g8">
<h4>
<FormattedMessage defaultMessage="Forward Zaps" />
</h4>
<FormattedMessage defaultMessage="All zaps sent to this note will be received by the following LNURL" />
</p>
<b className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
</b>
<input
type="text"
className="w-max"
placeholder={formatMessage({
defaultMessage: "LNURL to forward zaps to",
})}
value={zapForward}
onChange={e => dispatch(setZapForward(e.target.value))}
/>
<h4>
<FormattedMessage defaultMessage="Sensitive Content" />
</h4>
<p>
<input
type="text"
className="w-max"
placeholder={formatMessage({
defaultMessage: "LNURL to forward zaps to",
})}
value={zapForward}
onChange={e => dispatch(setZapForward(e.target.value))}
/>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
</span>
</div>
<div className="flex-column g8">
<h4>
<FormattedMessage defaultMessage="Sensitive Content" />
</h4>
<FormattedMessage defaultMessage="Users must accept the content warning to show the content of your note." />
</p>
<b className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
</b>
<div className="flex">
<input
className="w-max"
type="text"
@ -429,8 +406,11 @@ export function NoteCreator() {
defaultMessage: "Reason",
})}
/>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
</span>
</div>
</div>
</>
)}
</Modal>
)}

View File

@ -1,35 +1,23 @@
import React, { useEffect, useState } from "react";
import React, { HTMLProps, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useIntl, FormattedMessage } from "react-intl";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
import { TaggedNostrEvent, HexKey, u256, encodeTLV, NostrPrefix, Lists, ParsedZap } from "@snort/system";
import { TaggedNostrEvent, HexKey, u256, ParsedZap, countLeadingZeros } from "@snort/system";
import { LNURL } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
import Icon from "Icons/Icon";
import Spinner from "Icons/Spinner";
import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher";
import { delay, normalizeReaction, unwrap } from "SnortUtils";
import { delay, findTag, normalizeReaction, unwrap } from "SnortUtils";
import { NoteCreator } from "Element/NoteCreator";
import { ReBroadcaster } from "Element/ReBroadcaster";
import Reactions from "Element/Reactions";
import SendSats from "Element/SendSats";
import { ZapsSummary } from "Element/Zap";
import { RootState } from "State/Store";
import { setReplyTo, setShow, reset } from "State/NoteCreator";
import {
setNote as setReBroadcastNote,
setShow as setReBroadcastShow,
reset as resetReBroadcast,
} from "State/ReBroadcast";
import useModeration from "Hooks/useModeration";
import { TranslateHost } from "Const";
import { AsyncIcon } from "Element/AsyncIcon";
import { useWallet } from "Wallet";
import useLogin from "Hooks/useLogin";
import { setBookmarked, setPinned } from "Login";
import { useInteractionCache } from "Hooks/useInteractionCache";
import { ZapPoolController } from "ZapPoolController";
import { System } from "index";
@ -49,49 +37,31 @@ const barrierZapper = async <T,>(then: () => Promise<T>): Promise<T> => {
}
};
export interface Translation {
text: string;
fromLanguage: string;
confidence: number;
}
export interface NoteFooterProps {
reposts: TaggedNostrEvent[];
zaps: ParsedZap[];
positive: TaggedNostrEvent[];
negative: TaggedNostrEvent[];
showReactions: boolean;
setShowReactions(b: boolean): void;
ev: TaggedNostrEvent;
onTranslated?: (content: Translation) => void;
}
export default function NoteFooter(props: NoteFooterProps) {
const { ev, showReactions, setShowReactions, positive, negative, reposts, zaps } = props;
const { ev, positive, reposts, zaps } = props;
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const login = useLogin();
const { pinned, bookmarked, publicKey, preferences: prefs, relays } = login;
const { mute, block } = useModeration();
const author = useUserProfile(System, ev.pubkey);
const { publicKey, preferences: prefs, relays } = login;
const author = useUserProfile(ev.pubkey);
const interactionCache = useInteractionCache(publicKey, ev.id);
const publisher = useEventPublisher();
const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show);
const showReBroadcastModal = useSelector((s: RootState) => s.reBroadcast.show);
const reBroadcastNote = useSelector((s: RootState) => s.reBroadcast.note);
const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo);
const willRenderNoteCreator = showNoteCreatorModal && replyTo?.id === ev.id;
const willRenderReBroadcast = showReBroadcastModal && reBroadcastNote && reBroadcastNote?.id === ev.id;
const [tip, setTip] = useState(false);
const [zapping, setZapping] = useState(false);
const walletState = useWallet();
const wallet = walletState.wallet;
const isMine = ev.pubkey === publicKey;
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = interactionCache.data.zapped || zaps.some(a => a.sender === publicKey);
const longPress = useLongPress(
@ -123,13 +93,6 @@ export default function NoteFooter(props: NoteFooterProps) {
}
}
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
const evDelete = await publisher.delete(ev.id);
System.BroadcastEvent(evDelete);
}
}
async function repost() {
if (!hasReposted() && publisher) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
@ -208,16 +171,27 @@ export default function NoteFooter(props: NoteFooterProps) {
}
}, [prefs.autoZap, author, zapping]);
function powIcon() {
const pow = findTag(ev, "nonce") ? countLeadingZeros(ev.id) : undefined;
if (pow) {
return (
<AsyncFooterIcon title={formatMessage({ defaultMessage: "Proof of Work" })} iconName="diamond" value={pow} />
);
}
}
function tipButton() {
const service = getLNURL();
if (service) {
return (
<>
<div className={`reaction-pill ${didZap ? "reacted" : ""}`} {...longPress()} onClick={e => fastZap(e)}>
{zapping ? <Spinner /> : wallet?.isReady() ? <Icon name="zapFast" /> : <Icon name="zap" />}
{zapTotal > 0 && <div className="reaction-pill-number">{formatShort(zapTotal)}</div>}
</div>
</>
<AsyncFooterIcon
className={didZap ? "reacted" : ""}
{...longPress()}
title={formatMessage({ defaultMessage: "Zap" })}
iconName={wallet?.isReady() ? "zapFast" : "zap"}
value={zapTotal}
onClick={e => fastZap(e)}
/>
);
}
return null;
@ -225,165 +199,41 @@ export default function NoteFooter(props: NoteFooterProps) {
function repostIcon() {
return (
<div className={`reaction-pill ${hasReposted() ? "reacted" : ""}`} onClick={() => repost()}>
<Icon name="repost" size={17} />
{reposts.length > 0 && <div className="reaction-pill-number">{formatShort(reposts.length)}</div>}
</div>
<AsyncFooterIcon
className={hasReposted() ? "reacted" : ""}
iconName="repeat"
title={formatMessage({ defaultMessage: "Repost" })}
value={reposts.length}
onClick={() => repost()}
/>
);
}
function reactionIcons() {
function reactionIcon() {
if (!prefs.enableReactions) {
return null;
}
const reacted = hasReacted("+");
return (
<>
<div
className={`reaction-pill ${hasReacted("+") ? "reacted" : ""} `}
onClick={() => react(prefs.reactionEmoji)}>
<Icon name="heart" />
<div className="reaction-pill-number">{formatShort(positive.length)}</div>
</div>
</>
<AsyncFooterIcon
className={reacted ? "reacted" : ""}
iconName={reacted ? "heart-solid" : "heart"}
title={formatMessage({ defaultMessage: "Like" })}
value={positive.length}
onClick={() => react(prefs.reactionEmoji)}
/>
);
}
async function share() {
const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
const url = `${window.location.protocol}//${window.location.host}/e/${link}`;
if ("share" in window.navigator) {
await window.navigator.share({
title: "Snort",
url: url,
});
} else {
await navigator.clipboard.writeText(url);
}
}
async function translate() {
const res = await fetch(`${TranslateHost}/translate`, {
method: "POST",
body: JSON.stringify({
q: ev.content,
source: "auto",
target: lang.split("-")[0],
}),
headers: { "Content-Type": "application/json" },
});
if (res.ok) {
const result = await res.json();
if (typeof props.onTranslated === "function" && result) {
props.onTranslated({
text: result.translatedText,
fromLanguage: langNames.of(result.detectedLanguage.language),
confidence: result.detectedLanguage.confidence,
} as Translation);
}
}
}
async function copyId() {
const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
await navigator.clipboard.writeText(link);
}
async function pin(id: HexKey) {
if (publisher) {
const es = [...pinned.item, id];
const ev = await publisher.noteList(es, Lists.Pinned);
System.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
async function bookmark(id: HexKey) {
if (publisher) {
const es = [...bookmarked.item, id];
const ev = await publisher.noteList(es, Lists.Bookmarked);
System.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
async function copyEvent() {
await navigator.clipboard.writeText(JSON.stringify(ev, undefined, " "));
}
function menuItems() {
function replyIcon() {
return (
<>
<div className="close-menu-container">
{/* This menu item serves as a "close menu" button;
it allows the user to click anywhere nearby the menu to close it. */}
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={() => setShowReactions(true)}>
<Icon name="heart" />
<FormattedMessage {...messages.Reactions} />
</MenuItem>
<MenuItem onClick={() => share()}>
<Icon name="share" />
<FormattedMessage {...messages.Share} />
</MenuItem>
{!pinned.item.includes(ev.id) && (
<MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!bookmarked.item.includes(ev.id) && (
<MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />
</MenuItem>
)}
<MenuItem onClick={() => copyId()}>
<Icon name="copy" />
<FormattedMessage {...messages.CopyID} />
</MenuItem>
<MenuItem onClick={() => mute(ev.pubkey)}>
<Icon name="mute" />
<FormattedMessage {...messages.Mute} />
</MenuItem>
{prefs.enableReactions && (
<MenuItem onClick={() => react("-")}>
<Icon name="dislike" />
<FormattedMessage {...messages.DislikeAction} />
</MenuItem>
)}
{ev.pubkey === publicKey && (
<MenuItem onClick={handleReBroadcastButtonClick}>
<Icon name="relay" />
<FormattedMessage {...messages.ReBroadcast} />
</MenuItem>
)}
{ev.pubkey !== publicKey && (
<MenuItem onClick={() => block(ev.pubkey)}>
<Icon name="block" />
<FormattedMessage {...messages.Block} />
</MenuItem>
)}
<MenuItem onClick={() => translate()}>
<Icon name="translate" />
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />
</MenuItem>
{prefs.showDebugMenus && (
<MenuItem onClick={() => copyEvent()}>
<Icon name="json" />
<FormattedMessage {...messages.CopyJSON} />
</MenuItem>
)}
{isMine && (
<MenuItem onClick={() => deleteEvent()}>
<Icon name="trash" className="red" />
<FormattedMessage {...messages.Delete} />
</MenuItem>
)}
</>
<AsyncFooterIcon
className={showNoteCreatorModal ? "reacted" : ""}
iconName="reply"
title={formatMessage({ defaultMessage: "Reply" })}
value={0}
onClick={async () => handleReplyButtonClick()}
/>
);
}
@ -396,45 +246,17 @@ export default function NoteFooter(props: NoteFooterProps) {
dispatch(setShow(!showNoteCreatorModal));
};
const handleReBroadcastButtonClick = () => {
if (reBroadcastNote?.id !== ev.id) {
dispatch(resetReBroadcast());
}
dispatch(setReBroadcastNote(ev));
dispatch(setReBroadcastShow(!showReBroadcastModal));
};
return (
<>
<div className="footer">
<div className="footer-reactions">
{tipButton()}
{reactionIcons()}
{reactionIcon()}
{repostIcon()}
<div className={`reaction-pill ${showNoteCreatorModal ? "reacted" : ""}`} onClick={handleReplyButtonClick}>
<Icon name="reply" size={17} />
</div>
<Menu
menuButton={
<div className="reaction-pill">
<Icon name="dots" size={15} />
</div>
}
menuClassName="ctx-menu">
{menuItems()}
</Menu>
{replyIcon()}
{powIcon()}
</div>
{willRenderNoteCreator && <NoteCreator />}
{willRenderReBroadcast && <ReBroadcaster />}
<Reactions
show={showReactions}
setShow={setShowReactions}
positive={positive}
negative={negative}
reposts={reposts}
zaps={zaps}
/>
<SendSats
lnurl={getLNURL()}
onClose={() => setTip(false)}
@ -445,9 +267,27 @@ export default function NoteFooter(props: NoteFooterProps) {
allocatePool={true}
/>
</div>
<div className="zaps-container">
<ZapsSummary zaps={zaps} />
</div>
<ZapsSummary zaps={zaps} />
</>
);
}
interface AsyncFooterIconProps extends HTMLProps<HTMLDivElement> {
iconName: string;
value: number;
loading?: boolean;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => Promise<void>;
}
function AsyncFooterIcon(props: AsyncFooterIconProps) {
const mergedProps = {
...props,
iconSize: 18,
className: `reaction-pill${props.className ? ` ${props.className}` : ""}`,
};
return (
<AsyncIcon {...mergedProps}>
{props.value > 0 && <div className="reaction-pill-number">{formatShort(props.value)}</div>}
</AsyncIcon>
);
}

View File

@ -1,23 +1,14 @@
.reaction {
}
.reaction > .note {
margin: 10px 0;
}
.reaction > .header {
display: flex;
flex-direction: row;
justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.reaction > .header .reply {
font-size: var(--font-size-small);
.reaction > div:nth-child(1) {
font-size: 16px;
font-weight: 600;
}
.reaction > .header > .info {
font-size: var(--font-size);
white-space: nowrap;
color: var(--font-secondary-color);
margin-right: 24px;
.reaction > div:nth-child(1) svg {
opacity: 0.5;
}

View File

@ -1,13 +1,15 @@
import "./NoteReaction.css";
import { Link } from "react-router-dom";
import { useMemo } from "react";
import { EventKind, NostrEvent, TaggedNostrEvent, NostrPrefix, EventExt } from "@snort/system";
import { EventKind, NostrEvent, TaggedNostrEvent, NostrPrefix } from "@snort/system";
import Note from "Element/Note";
import ProfileImage from "Element/ProfileImage";
import { getDisplayName } from "Element/ProfileImage";
import { eventLink, hexToBech32 } from "SnortUtils";
import NoteTime from "Element/NoteTime";
import useModeration from "Hooks/useModeration";
import { FormattedMessage } from "react-intl";
import Icon from "Icons/Icon";
import { useUserProfile } from "@snort/system-react";
export interface NoteReactionProps {
data: TaggedNostrEvent;
@ -16,6 +18,7 @@ export interface NoteReactionProps {
export default function NoteReaction(props: NoteReactionProps) {
const { data: ev } = props;
const { isMuted } = useModeration();
const profile = useUserProfile(ev.pubkey);
const refEvent = useMemo(() => {
if (ev) {
@ -60,12 +63,15 @@ export default function NoteReaction(props: NoteReactionProps) {
};
return shouldNotBeRendered ? null : (
<div className="reaction">
<div className="header flex">
<ProfileImage pubkey={EventExt.getRootPubKey(ev)} />
<div className="info">
<NoteTime from={ev.created_at * 1000} />
</div>
<div className="card reaction">
<div className="flex g4">
<Icon name="repeat" size={18} />
<FormattedMessage
defaultMessage="{name} reposted"
values={{
name: getDisplayName(profile, ev.pubkey),
}}
/>
</div>
{root ? <Note data={root} options={opt} related={[]} /> : null}
{!root && refEvent ? (

View File

@ -12,7 +12,6 @@ import { formatShort } from "Number";
import Spinner from "Icons/Spinner";
import SendSats from "Element/SendSats";
import useLogin from "Hooks/useLogin";
import { System } from "index";
interface PollProps {
ev: TaggedNostrEvent;
@ -24,7 +23,7 @@ export default function Poll(props: PollProps) {
const publisher = useEventPublisher();
const { wallet } = useWallet();
const { preferences: prefs, publicKey: myPubKey, relays } = useLogin();
const pollerProfile = useUserProfile(System, props.ev.pubkey);
const pollerProfile = useUserProfile(props.ev.pubkey);
const [error, setError] = useState("");
const [invoice, setInvoice] = useState("");
const [voting, setVoting] = useState<number>();

View File

@ -5,11 +5,7 @@
text-decoration: none;
user-select: none;
min-width: 0;
}
.pfp .avatar-wrapper {
margin-right: 8px;
z-index: 2;
gap: 8px;
}
.pfp .avatar {
@ -23,9 +19,6 @@ a.pfp {
}
.pfp .username {
display: flex;
flex-direction: column;
align-items: flex-start;
font-weight: 600;
}

View File

@ -8,7 +8,6 @@ import { useUserProfile } from "@snort/system-react";
import { hexToBech32, profileLink } from "SnortUtils";
import Avatar from "Element/Avatar";
import Nip05 from "Element/Nip05";
import { System } from "index";
export interface ProfileImageProps {
pubkey: HexKey;
@ -20,6 +19,8 @@ export interface ProfileImageProps {
verifyNip?: boolean;
overrideUsername?: string;
profile?: UserMetadata;
size?: number;
onClick?: (e: React.MouseEvent) => void;
}
export default function ProfileImage({
@ -32,8 +33,10 @@ export default function ProfileImage({
verifyNip,
overrideUsername,
profile,
size,
onClick,
}: ProfileImageProps) {
const user = profile ?? useUserProfile(System, pubkey);
const user = useUserProfile(profile ? "" : pubkey) ?? profile;
const nip05 = defaultNip ? defaultNip : user?.nip05;
const name = useMemo(() => {
@ -43,29 +46,45 @@ export default function ProfileImage({
function handleClick(e: React.MouseEvent) {
if (link === "") {
e.preventDefault();
onClick?.(e);
}
}
return (
<Link
className={`pfp${className ? ` ${className}` : ""}`}
to={link === undefined ? profileLink(pubkey) : link}
onClick={handleClick}
replace={true}>
<div className="avatar-wrapper">
<Avatar user={user} />
</div>
{showUsername && (
<div className="f-ellipsis">
<div className="username">
<div>{name.trim()}</div>
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} verifyNip={verifyNip} />}
</div>
<div className="subheader">{subHeader}</div>
function inner() {
return (
<>
<div className="avatar-wrapper">
<Avatar pubkey={pubkey} user={user} size={size} />
</div>
)}
</Link>
);
{showUsername && (
<div className="f-ellipsis">
<div className="flex g4 username">
<div>{name.trim()}</div>
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} verifyNip={verifyNip} />}
</div>
<div className="subheader">{subHeader}</div>
</div>
)}
</>
);
}
if (link === "") {
return (
<div className={`pfp${className ? ` ${className}` : ""}`} onClick={handleClick}>
{inner()}
</div>
);
} else {
return (
<Link
className={`pfp${className ? ` ${className}` : ""}`}
to={link === undefined ? profileLink(pubkey) : link}
onClick={handleClick}>
{inner()}
</Link>
);
}
}
export function getDisplayName(user: UserMetadata | undefined, pubkey: HexKey) {

View File

@ -6,32 +6,42 @@ import { useInView } from "react-intersection-observer";
import ProfileImage from "Element/ProfileImage";
import FollowButton from "Element/FollowButton";
import { System } from "index";
export interface ProfilePreviewProps {
pubkey: HexKey;
options?: {
about?: boolean;
linkToProfile?: boolean;
};
actions?: ReactNode;
className?: string;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
}
export default function ProfilePreview(props: ProfilePreviewProps) {
const pubkey = props.pubkey;
const { ref, inView } = useInView({ triggerOnce: true });
const user = useUserProfile(System, inView ? pubkey : undefined);
const user = useUserProfile(inView ? pubkey : undefined);
const options = {
about: true,
...props.options,
};
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
if (props.onClick) {
e.stopPropagation();
e.preventDefault();
props.onClick(e);
}
}
return (
<>
<div className={`profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref}>
<div className={`profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref} onClick={handleClick}>
{inView && (
<>
<ProfileImage
pubkey={pubkey}
link={options.linkToProfile ?? true ? undefined : ""}
subHeader={options.about ? <div className="about">{user?.about}</div> : undefined}
/>
{props.actions ?? (

View File

@ -1,5 +1,5 @@
import useImgProxy from "Hooks/useImgProxy";
import { useEffect, useState } from "react";
import React, { useState } from "react";
import { FormattedMessage } from "react-intl";
import { getUrlHostname } from "SnortUtils";

View File

@ -2,7 +2,7 @@ import { NostrEvent } from "@snort/system";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { LNURL } from "@snort/shared";
import { dedupe, hexToBech32, unixNow } from "SnortUtils";
import { dedupe, hexToBech32 } from "SnortUtils";
import FollowListBase from "Element/FollowListBase";
import AsyncButton from "Element/AsyncButton";
import { useWallet } from "Wallet";

View File

@ -7,7 +7,7 @@
}
.light .reactions-modal .modal-body {
background-color: var(--note-bg);
background-color: var(--gray-superdark);
}
@media (max-width: 720px) {

View File

@ -1,5 +1,5 @@
import "./RelaysMetadata.css";
import Nostrich from "public/logo_256.png";
import Nostrich from "nostrich.webp";
import { useState } from "react";
import { FullRelaySettings } from "@snort/system";

View File

@ -8,7 +8,7 @@ import { MediaElement } from "Element/MediaElement";
interface RevealMediaProps {
creator: string;
link: string;
disableSpotlight?: boolean;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
}
export default function RevealMedia(props: RevealMediaProps) {
@ -42,7 +42,6 @@ export default function RevealMedia(props: RevealMediaProps) {
case "avi":
case "m4v":
case "webm":
case "m3u8":
return "video";
default:
return "unknown";
@ -53,12 +52,10 @@ export default function RevealMedia(props: RevealMediaProps) {
return (
<Reveal
message={<FormattedMessage defaultMessage="Click to load content from {link}" values={{ link: hostname }} />}>
<MediaElement mime={`${type}/${extension}`} url={url.toString()} disableSpotlight={props.disableSpotlight} />
<MediaElement mime={`${type}/${extension}`} url={url.toString()} onMediaClick={props.onMediaClick} />
</Reveal>
);
} else {
return (
<MediaElement mime={`${type}/${extension}`} url={url.toString()} disableSpotlight={props.disableSpotlight} />
);
return <MediaElement mime={`${type}/${extension}`} url={url.toString()} onMediaClick={props.onMediaClick} />;
}
}

View File

@ -22,7 +22,7 @@
}
.light .lnurl-tip {
background-color: var(--note-bg);
background-color: var(--gray-superdark);
}
.lnurl-tip h3 {
@ -95,7 +95,7 @@
.sat-amount.active {
font-weight: bold;
color: var(--note-bg);
color: var(--gray-superdark);
background-color: var(--font-color);
}

View File

@ -5,7 +5,6 @@ import { useIntl, FormattedMessage } from "react-intl";
import { HexKey, NostrEvent, EventPublisher } from "@snort/system";
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "@snort/shared";
import { System } from "index";
import { formatShort } from "Number";
import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher";
@ -18,6 +17,7 @@ import { useWallet } from "Wallet";
import useLogin from "Hooks/useLogin";
import { generateRandomKey } from "Login";
import { ZapPoolController } from "ZapPoolController";
import AsyncButton from "Element/AsyncButton";
import messages from "./messages";
@ -122,8 +122,8 @@ export default function SendSats(props: SendSatsProps) {
setAmount(a);
};
async function loadInvoice() {
if (!amount || !handler || !publisher) return null;
async function loadInvoice(): Promise<void> {
if (!amount || !handler || !publisher) return;
let zap: NostrEvent | undefined;
if (author && zapType !== ZapType.NonZap) {
@ -251,7 +251,7 @@ export default function SendSats(props: SendSatsProps) {
</div>
{zapTypeSelector()}
{(amount ?? 0) > 0 && (
<button type="button" className="zap-action" onClick={() => loadInvoice()}>
<AsyncButton className="zap-action" onClick={() => loadInvoice()}>
<div className="zap-action-container">
<Icon name="zap" />
{target ? (
@ -260,7 +260,7 @@ export default function SendSats(props: SendSatsProps) {
<FormattedMessage {...messages.ZapSats} values={{ n: formatShort(amount) }} />
)}
</div>
</button>
</AsyncButton>
)}
</>
);

View File

@ -1,46 +0,0 @@
.skeleton {
display: inline-block;
height: 1em;
position: relative;
overflow: hidden;
background-color: var(--note-bg);
border-radius: 16px;
}
html.light .skeleton {
background-color: var(--gray-secondary);
}
.skeleton::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.02) 20%,
rgba(255, 255, 255, 0.05) 60%,
rgba(255, 255, 255, 0)
);
animation: shimmer 2s infinite;
content: "";
}
html.light .skeleton::after {
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.2) 20%,
rgba(255, 255, 255, 0.5) 60%,
rgba(255, 255, 255, 0)
);
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}

View File

@ -1,21 +0,0 @@
import "./Skeleton.css";
interface ISkepetonProps {
children?: React.ReactNode;
loading?: boolean;
width?: string;
height?: string;
margin?: string;
}
export default function Skeleton({ children, width, height, margin, loading = true }: ISkepetonProps) {
if (!loading) {
return <>{children}</>;
}
return (
<div className="skeleton" style={{ width: width, height: height, margin: margin }}>
{children}
</div>
);
}

View File

@ -0,0 +1,45 @@
.modal.spotlight .modal-body {
border: none;
border-radius: unset;
width: unset;
height: unset;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
.modal.spotlight img,
.modal.spotlight video {
max-width: 100vw;
max-height: 100vh;
aspect-ratio: unset;
width: unset;
}
.modal.spotlight .details {
text-align: right;
position: absolute;
top: 28px;
right: 28px;
gap: 18px;
display: flex;
font-size: 15px;
font-weight: 400;
line-height: 24px;
align-items: center;
}
.modal.spotlight .left {
position: absolute;
left: 24px;
top: 50vh;
transform: rotate(180deg);
}
.modal.spotlight .right {
position: absolute;
right: 24px;
top: 50vh;
}

View File

@ -0,0 +1,54 @@
import "./SpotlightMedia.css";
import { useMemo, useState } from "react";
import Modal from "Element/Modal";
import Icon from "Icons/Icon";
import { ProxyImg } from "Element/ProxyImg";
interface SpotlightMediaProps {
images: Array<string>;
idx: number;
onClose: () => void;
}
export function SpotlightMedia(props: SpotlightMediaProps) {
const [idx, setIdx] = useState(props.idx);
const image = useMemo(() => {
return props.images.at(idx % props.images.length);
}, [idx, props]);
function dec() {
setIdx(s => {
if (s - 1 === -1) {
return props.images.length - 1;
} else {
return s - 1;
}
});
}
function inc() {
setIdx(s => {
if (s + 1 === props.images.length) {
return 0;
} else {
return s + 1;
}
});
}
return (
<Modal onClose={props.onClose} className="spotlight">
<ProxyImg src={image} />
<div className="details">
{idx + 1}/{props.images.length}
<Icon name="x-close" size={24} onClick={props.onClose} />
</div>
{props.images.length > 1 && (
<>
<Icon className="left" name="arrowFront" size={24} onClick={() => dec()} />
<Icon className="right" name="arrowFront" size={24} onClick={() => inc()} />
</>
)}
</Modal>
);
}

View File

@ -55,9 +55,6 @@ export default function SuggestedProfiles() {
return (
<>
<h3>
<FormattedMessage defaultMessage="Suggested Follows" />
</h3>
<div className="card flex f-space">
<FormattedMessage defaultMessage="Provider" />
<select onChange={e => setProvider(Number(e.target.value))}>

View File

@ -5,8 +5,9 @@
overflow-x: scroll;
-ms-overflow-style: none; /* for Internet Explorer, Edge */
scrollbar-width: none; /* Firefox */
margin-bottom: 18px;
white-space: nowrap;
gap: 8px;
padding: 16px 12px;
}
.tabs::-webkit-scrollbar {
@ -14,23 +15,21 @@
}
.tab {
background: var(--gray-ultradark);
color: var(--font-tertiary-color);
border: 1px solid var(--border-color);
border-radius: 16px;
border-radius: 100px;
font-weight: 600;
font-size: 14px;
padding: 6px 12px;
text-align: center;
font-feature-settings: "tnum";
}
.tab:not(:last-of-type) {
margin-right: 8px;
font-size: 16px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-items: center;
gap: 6px;
}
.tab.active {
border-color: var(--font-color);
color: var(--font-color);
color: black;
background: white;
}
.tabs > div {

View File

@ -1,11 +1,11 @@
import { ReactNode } from "react";
import "./Tabs.css";
import useHorizontalScroll from "Hooks/useHorizontalScroll";
export interface Tab {
text: string;
text: ReactNode;
value: number;
disabled?: boolean;
data?: string;
}
interface TabsProps {
@ -21,7 +21,7 @@ interface TabElementProps extends Omit<TabsProps, "tabs"> {
export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
return (
<div
className={`tab ${tab.value === t.value ? "active" : ""} ${t.disabled ? "disabled" : ""}`}
className={`tab${tab.value === t.value ? " active" : ""}${t.disabled ? " disabled" : ""}`}
onClick={() => !t.disabled && setTab(t)}>
{t.text}
</div>
@ -33,7 +33,7 @@ const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
return (
<div className="tabs" ref={horizontalScroll}>
{tabs.map(t => (
<TabElement key={t.value} tab={tab} setTab={setTab} t={t} />
<TabElement tab={tab} setTab={setTab} t={t} />
))}
</div>
);

View File

@ -1,16 +1,18 @@
.text {
font-size: var(--font-size);
line-height: 24px;
white-space: pre-wrap;
word-break: break-word;
}
.text > a {
.text .text-frag > a {
color: var(--highlight);
text-decoration: none;
}
.text a:hover {
.text .text-frag > a:hover {
text-decoration: underline;
}
.text .text-frag .hashtag:hover {
text-decoration: underline;
}
@ -65,11 +67,8 @@
.text video,
.text iframe,
.text audio {
max-width: 100%;
max-height: 500px;
margin: 10px auto;
width: 100%;
display: block;
border-radius: 12px;
}
.text iframe,

View File

@ -1,23 +1,14 @@
import "./Text.css";
import { useMemo } from "react";
import { Link, useLocation } from "react-router-dom";
import { HexKey, NostrPrefix, validateNostrLink } from "@snort/system";
import { useMemo, useState } from "react";
import { HexKey, ParsedFragment, transformText } from "@snort/system";
import { MentionRegex, InvoiceRegex, HashtagRegex, CashuRegex } from "Const";
import { eventLink, hexToBech32, splitByUrl } from "SnortUtils";
import Invoice from "Element/Invoice";
import Hashtag from "Element/Hashtag";
import Mention from "Element/Mention";
import HyperText from "Element/HyperText";
import CashuNuts from "Element/CashuNuts";
import { ProxyImg } from "Element/ProxyImg";
export type Fragment = string | React.ReactNode;
export interface TextFragment {
body: React.ReactNode[];
tags: Array<Array<string>>;
}
import RevealMedia from "./RevealMedia";
import { ProxyImg } from "./ProxyImg";
import { SpotlightMedia } from "./SpotlightMedia";
export interface TextProps {
content: string;
@ -29,168 +20,62 @@ export interface TextProps {
}
export default function Text({ content, tags, creator, disableMedia, depth, disableMediaSpotlight }: TextProps) {
const location = useLocation();
const [showSpotlight, setShowSpotlight] = useState(false);
const [imageIdx, setImageIdx] = useState(0);
function extractLinks(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return splitByUrl(f).map(a => {
const validateLink = () => {
const normalizedStr = a.toLowerCase();
if (normalizedStr.startsWith("web+nostr:") || normalizedStr.startsWith("nostr:")) {
return validateNostrLink(normalizedStr);
}
return (
normalizedStr.startsWith("http:") ||
normalizedStr.startsWith("https:") ||
normalizedStr.startsWith("magnet:")
);
};
if (validateLink()) {
if ((disableMedia ?? false) && !a.startsWith("nostr:")) {
return (
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
</a>
);
}
return (
<HyperText link={a} creator={creator} depth={depth} disableMediaSpotlight={disableMediaSpotlight} />
);
}
return a;
});
}
return f;
})
.flat();
}
function extractCashuTokens(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string" && f.includes("cashuA")) {
return f.split(CashuRegex).map(a => {
return <CashuNuts token={a} />;
});
}
return f;
})
.flat();
}
function extractMentions(frag: TextFragment) {
return frag.body
.map(f => {
if (typeof f === "string") {
return f.split(MentionRegex).map(match => {
const matchTag = match.match(/#\[(\d+)\]/);
if (matchTag && matchTag.length === 2) {
const idx = parseInt(matchTag[1]);
const ref = frag.tags?.[idx];
if (ref) {
switch (ref[0]) {
case "p": {
return <Mention pubkey={ref[1] ?? ""} relays={ref[2]} />;
}
case "e": {
const eText = hexToBech32(NostrPrefix.Event, ref[1]).substring(0, 12);
return (
ref[1] && (
<Link
to={eventLink(ref[1], ref[2])}
onClick={e => e.stopPropagation()}
state={{ from: location.pathname }}>
#{eText}
</Link>
)
);
}
case "t": {
return <Hashtag tag={ref[1] ?? ""} />;
}
}
}
return <b style={{ color: "var(--error)" }}>{matchTag[0]}?</b>;
} else {
return match;
}
});
}
return f;
})
.flat();
}
function extractInvoices(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(InvoiceRegex).map(i => {
if (i.toLowerCase().startsWith("lnbc")) {
return <Invoice invoice={i} />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractHashtags(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(HashtagRegex).map(i => {
if (i.toLowerCase().startsWith("#")) {
return <Hashtag tag={i.substring(1)} />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractCustomEmoji(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(/:(\w+):/g).map(i => {
const t = tags.find(a => a[0] === "emoji" && a[1] === i);
if (t) {
return <ProxyImg src={t[2]} size={15} className="custom-emoji" />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function transformText(frag: TextFragment) {
let fragments = extractMentions(frag);
fragments = extractLinks(fragments);
fragments = extractInvoices(fragments);
fragments = extractHashtags(fragments);
fragments = extractCashuTokens(fragments);
fragments = extractCustomEmoji(fragments);
return fragments;
}
const element = useMemo(() => {
return <div className="text">{transformText({ body: [content], tags })}</div>;
const elements = useMemo(() => {
return transformText(content, tags);
}, [content]);
return <div dir="auto">{element}</div>;
const images = elements.filter(a => a.type === "media" && a.mimeType?.startsWith("image")).map(a => a.content);
function renderChunk(a: ParsedFragment) {
if (a.type === "media" && !a.mimeType?.startsWith("unknown")) {
if (disableMedia ?? false) {
return (
<a href={a.content} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a.content}
</a>
);
}
return (
<RevealMedia
link={a.content}
creator={creator}
onMediaClick={e => {
if (!disableMediaSpotlight) {
e.stopPropagation();
e.preventDefault();
setShowSpotlight(true);
const selected = images.findIndex(b => b === a.content);
setImageIdx(selected === -1 ? 0 : selected);
}
}}
/>
);
} else {
switch (a.type) {
case "invoice":
return <Invoice invoice={a.content} />;
case "hashtag":
return <Hashtag tag={a.content} />;
case "cashu":
return <CashuNuts token={a.content} />;
case "media":
case "link":
return <HyperText link={a.content} depth={depth} />;
case "custom_emoji":
return <ProxyImg src={a.content} size={15} className="custom-emoji" />;
default:
return <div className="text-frag">{a.content}</div>;
}
}
}
return (
<div dir="auto" className="text">
{elements.map(a => renderChunk(a))}
{showSpotlight && <SpotlightMedia images={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />}
</div>
);
}

View File

@ -13,7 +13,7 @@
.user-item,
.emoji-item {
color: var(--font-color);
background: var(--note-bg);
background: var(--gray-superdark);
display: flex;
flex-direction: row;
align-items: center;

View File

@ -10,6 +10,7 @@ import Avatar from "Element/Avatar";
import Nip05 from "Element/Nip05";
import { hexToBech32 } from "SnortUtils";
import { UserCache } from "Cache";
import searchEmoji from "emoji-search";
import messages from "./messages";
@ -60,11 +61,7 @@ const Textarea = (props: TextareaProps) => {
};
const emojiDataProvider = async (token: string) => {
const emoji = await import("@jukben/emoji-search");
return emoji
.default(token)
.slice(0, 5)
.map(({ name, char }) => ({ name, char }));
return (await searchEmoji(token)).slice(0, 5).map(({ name, char }) => ({ name, char }));
};
return (

View File

@ -1,7 +1,3 @@
.thread-container {
margin: 12px 0 150px 0;
}
.thread-container .hidden-note {
margin: 0;
border-radius: 0;
@ -11,11 +7,6 @@
box-shadow: none;
}
.thread-root.note > .body {
margin-top: 8px;
padding-left: 8px;
}
.thread-root.note > .body .text {
font-size: 19px;
}
@ -31,12 +22,13 @@
}
.thread-note.note {
border-radius: 0;
margin-bottom: 0;
border: 0;
}
.light .thread-note.note.card {
box-shadow: none;
.thread-note.note .zaps-summary,
.thread-note.note .footer,
.thread-note.note .body {
margin-left: 61px;
}
.thread-container .hidden-note {
@ -45,7 +37,7 @@
}
.thread-container .show-more {
background: var(--note-bg);
background: var(--gray-superdark);
padding-left: 76px;
width: 100%;
text-align: left;
@ -58,110 +50,59 @@
position: relative;
}
.line-container {
background: var(--note-bg);
}
.subthread-container.subthread-multi .line-container:before {
content: "";
position: absolute;
left: 36px;
left: calc(48px / 2 + 16px);
top: 48px;
border-left: 1px solid var(--gray-superdark);
height: 100%;
z-index: -1;
}
@media (min-width: 720px) {
.subthread-container.subthread-multi .line-container:before {
left: 48px;
}
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
content: "";
position: absolute;
left: 36px;
top: 48px;
border-left: 1px solid var(--gray-superdark);
height: 100%;
}
@media (min-width: 720px) {
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
left: 48px;
}
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
.subthread-container.subthread-mid:not(.subthread-last) .line-container:before {
content: "";
position: absolute;
border-left: 1px solid var(--gray-superdark);
left: 36px;
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
}
@media (min-width: 720px) {
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
left: 48px;
}
z-index: -1;
}
.subthread-container.subthread-last .line-container:before {
content: "";
position: absolute;
border-left: 1px solid var(--gray-superdark);
left: 36px;
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
}
@media (min-width: 720px) {
.subthread-container.subthread-last .line-container:before {
left: 48px;
}
z-index: -1;
}
.divider-container {
background: var(--note-bg);
margin-right: 16px;
}
.divider {
height: 1px;
background: var(--gray-superdark);
margin-left: 28px;
margin-right: 22px;
}
.divider.divider-small {
margin-left: 80px;
margin-left: calc(16px + 61px);
}
.thread-container .collapsed,
.thread-container .show-more-container {
background: var(--note-bg);
background: var(--gray-superdark);
min-height: 48px;
}
.thread-note.is-last-note {
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.thread-container .collapsed {
background-color: var(--note-bg);
background-color: var(--gray-superdark);
}
.thread-container .hidden-note {
padding-left: 48px;
}
.thread-root.thread-root-single.note {
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.thread-root.ghost-root {
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}

View File

@ -374,9 +374,11 @@ export default function Thread() {
description: "Navigate back button on threads view",
});
return (
<div className="main-content mt10">
<BackButton onClick={goBack} text={parent ? parentText : backText} />
<div className="thread-container">
<>
<div className="main-content p">
<BackButton onClick={goBack} text={parent ? parentText : backText} />
</div>
<div className="main-content">
{root && renderRoot(root)}
{root && renderChain(root.id)}
@ -392,7 +394,7 @@ export default function Thread() {
);
})}
</div>
</div>
</>
);
}

View File

@ -6,30 +6,23 @@
align-items: center;
padding: 6px 24px;
gap: 8px;
position: absolute;
width: 261px;
left: calc(50% - 261px / 2 + 0.5px);
top: 0;
color: white;
background: var(--highlight);
box-shadow: 0px 0px 15px rgba(78, 0, 255, 0.6);
border-radius: 100px;
z-index: 42;
opacity: 0.9;
}
.latest-notes-fixed {
position: fixed;
left: calc(50% - 261px / 2 + 0.5px);
top: 12px;
width: 261px;
left: calc(50% - 261px / 2 + 0.5px);
z-index: 42;
opacity: 0.9;
box-shadow: 0px 0px 15px rgba(78, 0, 255, 0.6);
color: white;
background: var(--highlight);
border-radius: 100px;
}
@media (max-width: 520px) {
.latest-notes {
width: 200px;
left: calc(50% - 110px);
padding: 6px 12px;
}
.latest-notes-fixed {
width: 200px;
padding: 6px 12px;

View File

@ -14,6 +14,7 @@ import NoteReaction from "Element/NoteReaction";
import useModeration from "Hooks/useModeration";
import ProfilePreview from "Element/ProfilePreview";
import { UserCache } from "Cache";
import { LiveStreams } from "Element/LiveStreams";
export interface TimelineProps {
postsOnly: boolean;
@ -44,7 +45,7 @@ const Timeline = (props: TimelineProps) => {
const filterPosts = useCallback(
(nts: readonly TaggedNostrEvent[]) => {
const a = [...nts];
const a = [...nts.filter(a => a.kind !== EventKind.LiveEvent)];
props.noSort || a.sort((a, b) => b.created_at - a.created_at);
return a
?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : true))
@ -65,6 +66,10 @@ const Timeline = (props: TimelineProps) => {
},
[feed.related]
);
const liveStreams = useMemo(() => {
return (feed.main ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
}, [feed]);
const findRelated = useCallback(
(id?: u256) => {
if (!id) return undefined;
@ -112,9 +117,10 @@ const Timeline = (props: TimelineProps) => {
return (
<>
<LiveStreams evs={liveStreams} />
{latestFeed.length > 0 && (
<>
<div className="card latest-notes pointer" onClick={() => onShowLatest()} ref={ref}>
<div className="card latest-notes" onClick={() => onShowLatest()} ref={ref}>
{latestAuthors.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} />;
})}

View File

@ -1,13 +1,14 @@
import { useEffect, useState } from "react";
import { NostrEvent, TaggedNostrEvent } from "@snort/system";
import { FormattedMessage } from "react-intl";
import PageSpinner from "Element/PageSpinner";
import Note from "Element/Note";
import NostrBandApi from "External/NostrBand";
import { useReactions } from "Feed/FeedReactions";
export default function TrendingNotes() {
const [posts, setPosts] = useState<Array<NostrEvent>>();
const related = useReactions("trending", posts?.map(a => a.id) ?? []);
async function loadTrendingNotes() {
const api = new NostrBandApi();
@ -23,11 +24,8 @@ export default function TrendingNotes() {
return (
<>
<h3>
<FormattedMessage defaultMessage="Trending Notes" />
</h3>
{posts.map(e => (
<Note key={e.id} data={e as TaggedNostrEvent} related={[]} depth={0} />
<Note key={e.id} data={e as TaggedNostrEvent} related={related?.data ?? []} depth={0} />
))}
</>
);

View File

@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import { HexKey } from "@snort/system";
import { FormattedMessage } from "react-intl";
import FollowListBase from "Element/FollowListBase";
import PageSpinner from "Element/PageSpinner";
@ -24,9 +23,6 @@ export default function TrendingUsers() {
return (
<>
<h3>
<FormattedMessage defaultMessage="Trending People" />
</h3>
<FollowListBase pubkeys={userList} showAbout={true} />
</>
);

View File

@ -5,10 +5,9 @@ import { HexKey } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { profileLink } from "SnortUtils";
import { System } from "index";
export default function Username({ pubkey, onLinkVisit }: { pubkey: HexKey; onLinkVisit(): void }) {
const user = useUserProfile(System, pubkey);
const user = useUserProfile(pubkey);
const navigate = useNavigate();
function onClick(ev: MouseEvent) {

View File

@ -45,10 +45,8 @@
}
.zaps-summary {
margin-top: 8px;
display: flex;
flex-direction: row;
margin-left: 56px;
}
.note.thread-root .zaps-summary {

View File

@ -5,10 +5,19 @@ import { useUserProfile } from "@snort/system-react";
import SendSats from "Element/SendSats";
import Icon from "Icons/Icon";
import { System } from "index";
const ZapButton = ({ pubkey, lnurl, children }: { pubkey: HexKey; lnurl?: string; children?: React.ReactNode }) => {
const profile = useUserProfile(System, pubkey);
const ZapButton = ({
pubkey,
lnurl,
children,
event,
}: {
pubkey: HexKey;
lnurl?: string;
children?: React.ReactNode;
event?: string;
}) => {
const profile = useUserProfile(pubkey);
const [zap, setZap] = useState(false);
const service = lnurl ?? (profile?.lud16 || profile?.lud06);
if (!service) return null;
@ -25,6 +34,7 @@ const ZapButton = ({ pubkey, lnurl, children }: { pubkey: HexKey; lnurl?: string
show={zap}
onClose={() => setZap(false)}
author={pubkey}
note={event}
/>
</>
);

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