Setup lang
This commit is contained in:
parent
80d0e4975f
commit
2669af3250
6
.vscode/extensions.json
vendored
6
.vscode/extensions.json
vendored
@ -1,7 +1,3 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"arcanis.vscode-zipfs",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
"recommendations": ["arcanis.vscode-zipfs", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
||||
|
50
.yarn/sdks/typescript/lib/tsserver.js
vendored
50
.yarn/sdks/typescript/lib/tsserver.js
vendored
@ -9,7 +9,7 @@ const relPnpApiPath = "../../../../.pnp.cjs";
|
||||
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
|
||||
const absRequire = createRequire(absPnpApiPath);
|
||||
|
||||
const moduleWrapper = (tsserver) => {
|
||||
const moduleWrapper = tsserver => {
|
||||
if (!process.versions.pnp) {
|
||||
return tsserver;
|
||||
}
|
||||
@ -17,12 +17,12 @@ const moduleWrapper = (tsserver) => {
|
||||
const { isAbsolute } = require(`path`);
|
||||
const pnpApi = require(`pnpapi`);
|
||||
|
||||
const isVirtual = (str) => str.match(/\/(\$\$virtual|__virtual__)\//);
|
||||
const isPortal = (str) => str.startsWith("portal:/");
|
||||
const normalize = (str) => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
|
||||
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
|
||||
const isPortal = str => str.startsWith("portal:/");
|
||||
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
|
||||
|
||||
const dependencyTreeRoots = new Set(
|
||||
pnpApi.getDependencyTreeRoots().map((locator) => {
|
||||
pnpApi.getDependencyTreeRoots().map(locator => {
|
||||
return `${locator.name}@${locator.reference}`;
|
||||
})
|
||||
);
|
||||
@ -33,11 +33,7 @@ const moduleWrapper = (tsserver) => {
|
||||
|
||||
function toEditorPath(str) {
|
||||
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
|
||||
if (
|
||||
isAbsolute(str) &&
|
||||
!str.match(/^\^?(zip:|\/zip\/)/) &&
|
||||
(str.match(/\.zip\//) || isVirtual(str))
|
||||
) {
|
||||
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
|
||||
// We also take the opportunity to turn virtual paths into physical ones;
|
||||
// this makes it much easier to work with workspaces that list peer
|
||||
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
|
||||
@ -53,8 +49,7 @@ const moduleWrapper = (tsserver) => {
|
||||
const locator = pnpApi.findPackageLocator(resolved);
|
||||
if (
|
||||
locator &&
|
||||
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) ||
|
||||
isPortal(locator.reference))
|
||||
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))
|
||||
) {
|
||||
str = resolved;
|
||||
}
|
||||
@ -149,9 +144,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
|
||||
// So in order to convert it back, we use .* to match all the thing
|
||||
// before `zipfile:`
|
||||
return process.platform === `win32`
|
||||
? str.replace(/^.*zipfile:\//, ``)
|
||||
: str.replace(/^.*zipfile:/, ``);
|
||||
return process.platform === `win32` ? str.replace(/^.*zipfile:\//, ``) : str.replace(/^.*zipfile:/, ``);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -166,10 +159,7 @@ const moduleWrapper = (tsserver) => {
|
||||
case `vscode`:
|
||||
default:
|
||||
{
|
||||
return str.replace(
|
||||
/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/,
|
||||
process.platform === `win32` ? `` : `/`
|
||||
);
|
||||
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -183,8 +173,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// TypeScript already does local loads and if this code is running the user trusts the workspace
|
||||
// https://github.com/microsoft/vscode/issues/45856
|
||||
const ConfiguredProject = tsserver.server.ConfiguredProject;
|
||||
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } =
|
||||
ConfiguredProject.prototype;
|
||||
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = ConfiguredProject.prototype;
|
||||
ConfiguredProject.prototype.enablePluginsWithOptions = function () {
|
||||
this.projectService.allowLocalPluginLoads = true;
|
||||
return originalEnablePluginsWithOptions.apply(this, arguments);
|
||||
@ -195,8 +184,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// like an absolute path of ours and normalize it.
|
||||
|
||||
const Session = tsserver.server.Session;
|
||||
const { onMessage: originalOnMessage, send: originalSend } =
|
||||
Session.prototype;
|
||||
const { onMessage: originalOnMessage, send: originalSend } = Session.prototype;
|
||||
let hostInfo = `unknown`;
|
||||
|
||||
Object.assign(Session.prototype, {
|
||||
@ -231,19 +219,11 @@ const moduleWrapper = (tsserver) => {
|
||||
}
|
||||
}
|
||||
|
||||
const processedMessageJSON = JSON.stringify(
|
||||
parsedMessage,
|
||||
(key, value) => {
|
||||
return typeof value === "string" ? fromEditorPath(value) : value;
|
||||
}
|
||||
);
|
||||
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
|
||||
return typeof value === "string" ? fromEditorPath(value) : value;
|
||||
});
|
||||
|
||||
return originalOnMessage.call(
|
||||
this,
|
||||
isStringMessage
|
||||
? processedMessageJSON
|
||||
: JSON.parse(processedMessageJSON)
|
||||
);
|
||||
return originalOnMessage.call(this, isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON));
|
||||
},
|
||||
|
||||
send(/** @type {any} */ msg) {
|
||||
|
50
.yarn/sdks/typescript/lib/tsserverlibrary.js
vendored
50
.yarn/sdks/typescript/lib/tsserverlibrary.js
vendored
@ -9,7 +9,7 @@ const relPnpApiPath = "../../../../.pnp.cjs";
|
||||
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
|
||||
const absRequire = createRequire(absPnpApiPath);
|
||||
|
||||
const moduleWrapper = (tsserver) => {
|
||||
const moduleWrapper = tsserver => {
|
||||
if (!process.versions.pnp) {
|
||||
return tsserver;
|
||||
}
|
||||
@ -17,12 +17,12 @@ const moduleWrapper = (tsserver) => {
|
||||
const { isAbsolute } = require(`path`);
|
||||
const pnpApi = require(`pnpapi`);
|
||||
|
||||
const isVirtual = (str) => str.match(/\/(\$\$virtual|__virtual__)\//);
|
||||
const isPortal = (str) => str.startsWith("portal:/");
|
||||
const normalize = (str) => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
|
||||
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
|
||||
const isPortal = str => str.startsWith("portal:/");
|
||||
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
|
||||
|
||||
const dependencyTreeRoots = new Set(
|
||||
pnpApi.getDependencyTreeRoots().map((locator) => {
|
||||
pnpApi.getDependencyTreeRoots().map(locator => {
|
||||
return `${locator.name}@${locator.reference}`;
|
||||
})
|
||||
);
|
||||
@ -33,11 +33,7 @@ const moduleWrapper = (tsserver) => {
|
||||
|
||||
function toEditorPath(str) {
|
||||
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
|
||||
if (
|
||||
isAbsolute(str) &&
|
||||
!str.match(/^\^?(zip:|\/zip\/)/) &&
|
||||
(str.match(/\.zip\//) || isVirtual(str))
|
||||
) {
|
||||
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
|
||||
// We also take the opportunity to turn virtual paths into physical ones;
|
||||
// this makes it much easier to work with workspaces that list peer
|
||||
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
|
||||
@ -53,8 +49,7 @@ const moduleWrapper = (tsserver) => {
|
||||
const locator = pnpApi.findPackageLocator(resolved);
|
||||
if (
|
||||
locator &&
|
||||
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) ||
|
||||
isPortal(locator.reference))
|
||||
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))
|
||||
) {
|
||||
str = resolved;
|
||||
}
|
||||
@ -149,9 +144,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
|
||||
// So in order to convert it back, we use .* to match all the thing
|
||||
// before `zipfile:`
|
||||
return process.platform === `win32`
|
||||
? str.replace(/^.*zipfile:\//, ``)
|
||||
: str.replace(/^.*zipfile:/, ``);
|
||||
return process.platform === `win32` ? str.replace(/^.*zipfile:\//, ``) : str.replace(/^.*zipfile:/, ``);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -166,10 +159,7 @@ const moduleWrapper = (tsserver) => {
|
||||
case `vscode`:
|
||||
default:
|
||||
{
|
||||
return str.replace(
|
||||
/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/,
|
||||
process.platform === `win32` ? `` : `/`
|
||||
);
|
||||
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -183,8 +173,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// TypeScript already does local loads and if this code is running the user trusts the workspace
|
||||
// https://github.com/microsoft/vscode/issues/45856
|
||||
const ConfiguredProject = tsserver.server.ConfiguredProject;
|
||||
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } =
|
||||
ConfiguredProject.prototype;
|
||||
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } = ConfiguredProject.prototype;
|
||||
ConfiguredProject.prototype.enablePluginsWithOptions = function () {
|
||||
this.projectService.allowLocalPluginLoads = true;
|
||||
return originalEnablePluginsWithOptions.apply(this, arguments);
|
||||
@ -195,8 +184,7 @@ const moduleWrapper = (tsserver) => {
|
||||
// like an absolute path of ours and normalize it.
|
||||
|
||||
const Session = tsserver.server.Session;
|
||||
const { onMessage: originalOnMessage, send: originalSend } =
|
||||
Session.prototype;
|
||||
const { onMessage: originalOnMessage, send: originalSend } = Session.prototype;
|
||||
let hostInfo = `unknown`;
|
||||
|
||||
Object.assign(Session.prototype, {
|
||||
@ -231,19 +219,11 @@ const moduleWrapper = (tsserver) => {
|
||||
}
|
||||
}
|
||||
|
||||
const processedMessageJSON = JSON.stringify(
|
||||
parsedMessage,
|
||||
(key, value) => {
|
||||
return typeof value === "string" ? fromEditorPath(value) : value;
|
||||
}
|
||||
);
|
||||
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
|
||||
return typeof value === "string" ? fromEditorPath(value) : value;
|
||||
});
|
||||
|
||||
return originalOnMessage.call(
|
||||
this,
|
||||
isStringMessage
|
||||
? processedMessageJSON
|
||||
: JSON.parse(processedMessageJSON)
|
||||
);
|
||||
return originalOnMessage.call(this, isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON));
|
||||
},
|
||||
|
||||
send(/** @type {any} */ msg) {
|
||||
|
12
package.json
12
package.json
@ -38,6 +38,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-intersection-observer": "^9.5.1",
|
||||
"react-intl": "^6.4.4",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-router-dom": "^6.13.0",
|
||||
"react-tag-input-component": "^2.0.2",
|
||||
@ -54,7 +55,9 @@
|
||||
"start": "webpack serve",
|
||||
"build": "webpack --node-env=production --mode=production",
|
||||
"deploy": "__XXX='false' && yarn build && npx wrangler pages publish --project-name nostr-live build",
|
||||
"deploy:xxzap": "__XXX='true' && yarn build && npx wrangler pages publish --project-name xxzap build"
|
||||
"deploy:xxzap": "__XXX='true' && yarn build && npx wrangler pages publish --project-name xxzap build",
|
||||
"intl-extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file src/lang.json --flatten true",
|
||||
"intl-compile": "formatjs compile src/lang.json --out-file src/translations/en.json"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
@ -114,5 +117,10 @@
|
||||
"webpack-dev-server": "^4.15.0",
|
||||
"workbox-webpack-plugin": "^7.0.0"
|
||||
},
|
||||
"packageManager": "yarn@3.6.3"
|
||||
"packageManager": "yarn@3.6.3",
|
||||
"prettier": {
|
||||
"printWidth": 120,
|
||||
"bracketSameLine": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,6 @@
|
||||
import "./event.css";
|
||||
|
||||
import {
|
||||
type NostrLink,
|
||||
type NostrEvent as NostrEventType,
|
||||
EventKind,
|
||||
} from "@snort/system";
|
||||
import { type NostrLink, type NostrEvent as NostrEventType, EventKind } from "@snort/system";
|
||||
|
||||
import { Icon } from "element/icon";
|
||||
import { Goal } from "element/goal";
|
||||
|
@ -2,8 +2,7 @@ import "./async-button.css";
|
||||
import { useState } from "react";
|
||||
import Spinner from "element/spinner";
|
||||
|
||||
interface AsyncButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
disabled?: boolean;
|
||||
onClick(e: React.MouseEvent): Promise<void> | void;
|
||||
children?: React.ReactNode;
|
||||
@ -29,15 +28,8 @@ export default function AsyncButton(props: AsyncButtonProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || props.disabled}
|
||||
{...props}
|
||||
onClick={handle}
|
||||
>
|
||||
<span style={{ visibility: loading ? "hidden" : "visible" }}>
|
||||
{props.children}
|
||||
</span>
|
||||
<button type="button" disabled={loading || props.disabled} {...props} onClick={handle}>
|
||||
<span style={{ visibility: loading ? "hidden" : "visible" }}>{props.children}</span>
|
||||
{loading && (
|
||||
<span className="spinner-wrapper">
|
||||
<Spinner />
|
||||
|
@ -1,17 +1,5 @@
|
||||
import { MetadataCache } from "@snort/system";
|
||||
|
||||
export function Avatar({
|
||||
user,
|
||||
avatarClassname,
|
||||
}: {
|
||||
user: MetadataCache;
|
||||
avatarClassname: string;
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
className={avatarClassname}
|
||||
alt={user?.name || user?.pubkey}
|
||||
src={user?.picture ?? ""}
|
||||
/>
|
||||
);
|
||||
export function Avatar({ user, avatarClassname }: { user: MetadataCache; avatarClassname: string }) {
|
||||
return <img className={avatarClassname} alt={user?.name || user?.pubkey} src={user?.picture ?? ""} />;
|
||||
}
|
||||
|
@ -12,9 +12,7 @@ export function Badge({ ev }: { ev: NostrEvent }) {
|
||||
<img className="badge-thumbnail" src={thumb || image} alt={name} />
|
||||
<div className="badge-details">
|
||||
<h4 className="badge-name">{name}</h4>
|
||||
{description?.length > 0 && (
|
||||
<p className="badge-description">{description}</p>
|
||||
)}
|
||||
{description?.length > 0 && <p className="badge-description">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,12 +1,7 @@
|
||||
import { useUserProfile, SnortContext } from "@snort/system-react";
|
||||
import { NostrEvent, parseZap, EventKind } from "@snort/system";
|
||||
import React, { useRef, useState, useMemo, useContext } from "react";
|
||||
import {
|
||||
useMediaQuery,
|
||||
useHover,
|
||||
useOnClickOutside,
|
||||
useIntersectionObserver,
|
||||
} from "usehooks-ts";
|
||||
import { useMediaQuery, useHover, useOnClickOutside, useIntersectionObserver } from "usehooks-ts";
|
||||
|
||||
import { EmojiPicker } from "element/emoji-picker";
|
||||
import { Icon } from "element/icon";
|
||||
@ -59,36 +54,31 @@ export function ChatMessage({
|
||||
const [showZapDialog, setShowZapDialog] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const login = useLogin();
|
||||
const profile = useUserProfile(
|
||||
inView?.isIntersecting ? ev.pubkey : undefined
|
||||
);
|
||||
const shouldShowMuteButton =
|
||||
ev.pubkey !== streamer && ev.pubkey !== login?.pubkey;
|
||||
const profile = useUserProfile(inView?.isIntersecting ? ev.pubkey : undefined);
|
||||
const shouldShowMuteButton = ev.pubkey !== streamer && ev.pubkey !== login?.pubkey;
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
const system = useContext(SnortContext);
|
||||
const zaps = useMemo(() => {
|
||||
return reactions
|
||||
.filter((a) => a.kind === EventKind.ZapReceipt)
|
||||
.map((a) => parseZap(a, system.ProfileLoader.Cache))
|
||||
.filter((a) => a && a.valid);
|
||||
.filter(a => a.kind === EventKind.ZapReceipt)
|
||||
.map(a => parseZap(a, system.ProfileLoader.Cache))
|
||||
.filter(a => a && a.valid);
|
||||
}, [reactions]);
|
||||
const emojiReactions = useMemo(() => {
|
||||
const emojified = reactions
|
||||
.filter((e) => e.kind === EventKind.Reaction && findTag(e, "e") === ev.id)
|
||||
.map((ev) => emojifyReaction(ev.content));
|
||||
.filter(e => e.kind === EventKind.Reaction && findTag(e, "e") === ev.id)
|
||||
.map(ev => emojifyReaction(ev.content));
|
||||
return [...new Set(emojified)];
|
||||
}, [ev, reactions]);
|
||||
const emojiNames = emojiPacks.map((p) => p.emojis).flat();
|
||||
const emojiNames = emojiPacks.map(p => p.emojis).flat();
|
||||
|
||||
const hasReactions = emojiReactions.length > 0;
|
||||
const totalZaps = useMemo(() => {
|
||||
const messageZaps = zaps.filter((z) => z.event === ev.id);
|
||||
const messageZaps = zaps.filter(z => z.event === ev.id);
|
||||
return messageZaps.reduce((acc, z) => acc + z.amount, 0);
|
||||
}, [zaps, ev]);
|
||||
const hasZaps = totalZaps > 0;
|
||||
const awardedBadges = badges.filter(
|
||||
(b) => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey)
|
||||
);
|
||||
const awardedBadges = badges.filter(b => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey));
|
||||
|
||||
useOnClickOutside(ref, () => {
|
||||
setShowZapDialog(false);
|
||||
@ -99,7 +89,7 @@ export function ChatMessage({
|
||||
});
|
||||
|
||||
function getEmojiById(id: string) {
|
||||
return emojiNames.find((e) => e.at(1) === id);
|
||||
return emojiNames.find(e => e.at(1) === id);
|
||||
}
|
||||
|
||||
async function onEmojiSelect(emoji: Emoji) {
|
||||
@ -113,7 +103,7 @@ export function ChatMessage({
|
||||
} else if (emoji.id) {
|
||||
const e = getEmojiById(emoji.id);
|
||||
if (e) {
|
||||
reply = await pub?.generic((eb) => {
|
||||
reply = await pub?.generic(eb => {
|
||||
return eb
|
||||
.kind(EventKind.Reaction)
|
||||
.content(`:${emoji.id}:`)
|
||||
@ -147,23 +137,15 @@ export function ChatMessage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`message${streamer === ev.pubkey ? " streamer" : ""}`}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={`message${streamer === ev.pubkey ? " streamer" : ""}`} ref={ref}>
|
||||
<Profile
|
||||
icon={
|
||||
ev.pubkey === streamer ? (
|
||||
<Icon name="signal" size={16} />
|
||||
) : (
|
||||
awardedBadges.map((badge) => {
|
||||
awardedBadges.map(badge => {
|
||||
return (
|
||||
<img
|
||||
key={badge.name}
|
||||
className="badge-icon"
|
||||
src={badge.thumb || badge.image}
|
||||
alt={badge.name}
|
||||
/>
|
||||
<img key={badge.name} className="badge-icon" src={badge.thumb || badge.image} alt={badge.name} />
|
||||
);
|
||||
})
|
||||
)
|
||||
@ -171,11 +153,7 @@ export function ChatMessage({
|
||||
pubkey={ev.pubkey}
|
||||
profile={profile}
|
||||
/>
|
||||
<Text
|
||||
tags={ev.tags}
|
||||
content={ev.content}
|
||||
customComponents={customComponents}
|
||||
/>
|
||||
<Text tags={ev.tags} content={ev.content} customComponents={customComponents} />
|
||||
{(hasReactions || hasZaps) && (
|
||||
<div className="message-reactions">
|
||||
{hasZaps && (
|
||||
@ -184,9 +162,8 @@ export function ChatMessage({
|
||||
<span className="zap-pill-amount">{formatSats(totalZaps)}</span>
|
||||
</div>
|
||||
)}
|
||||
{emojiReactions.map((e) => {
|
||||
const isCustomEmojiReaction =
|
||||
e.length > 1 && e.startsWith(":") && e.endsWith(":");
|
||||
{emojiReactions.map(e => {
|
||||
const isCustomEmojiReaction = e.length > 1 && e.startsWith(":") && e.endsWith(":");
|
||||
const emojiName = e.replace(/:/g, "");
|
||||
const emoji = isCustomEmojiReaction && getEmojiById(emojiName);
|
||||
return (
|
||||
@ -216,11 +193,9 @@ export function ChatMessage({
|
||||
top: topOffset ? topOffset - 12 : 0,
|
||||
left: leftOffset ? leftOffset - 32 : 0,
|
||||
opacity: showZapDialog || isHovering ? 1 : 0,
|
||||
pointerEvents:
|
||||
showZapDialog || isHovering ? "auto" : "none",
|
||||
pointerEvents: showZapDialog || isHovering ? "auto" : "none",
|
||||
}
|
||||
}
|
||||
>
|
||||
}>
|
||||
{zapTarget && (
|
||||
<SendZapsDialog
|
||||
lnurl={zapTarget}
|
||||
|
@ -2,6 +2,7 @@ import "./collapsible.css";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
|
||||
@ -31,7 +32,7 @@ export function MediaURL({ url, children }: MediaURLProps) {
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button className="btn delete-button" aria-label="Close">
|
||||
Close
|
||||
<FormattedMessage defaultMessage="Close" />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</Dialog.Content>
|
||||
@ -46,29 +47,19 @@ export function CollapsibleEvent({ link }: { link: NostrLink }) {
|
||||
const author = event?.pubkey || link.author;
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
className="collapsible"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Collapsible.Root className="collapsible" open={open} onOpenChange={setOpen}>
|
||||
<div className="collapsed-event">
|
||||
<div className="collapsed-event-header">
|
||||
{event && <EventIcon kind={event.kind} />}
|
||||
{author && <Mention pubkey={author} />}
|
||||
</div>
|
||||
<Collapsible.Trigger asChild>
|
||||
<button
|
||||
className={`${
|
||||
open ? "btn btn-small delete-button" : "btn btn-small"
|
||||
}`}
|
||||
>
|
||||
{open ? "Hide" : "Show"}
|
||||
<button className={`${open ? "btn btn-small delete-button" : "btn btn-small"}`}>
|
||||
{open ? <FormattedMessage defaultMessage="Hide" /> : <FormattedMessage defaultMessage="Show" />}
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
{open && event && <NostrEvent ev={event} />}
|
||||
</Collapsible.Content>
|
||||
<Collapsible.Content>{open && event && <NostrEvent ev={event} />}</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function isContentWarningAccepted() {
|
||||
@ -17,14 +18,18 @@ export function ContentWarningOverlay() {
|
||||
|
||||
return (
|
||||
<div className="fullscreen-exclusive age-check">
|
||||
<h1>Sexually explicit material ahead!</h1>
|
||||
<h2>Confirm your age</h2>
|
||||
<h1>
|
||||
<FormattedMessage defaultMessage="Sexually explicit material ahead!" />
|
||||
</h1>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Confirm your age" />
|
||||
</h2>
|
||||
<div className="flex g24">
|
||||
<button className="btn btn-warning" onClick={grownUp}>
|
||||
Yes, I am over 18
|
||||
<FormattedMessage defaultMessage="Yes, I am over 18" />
|
||||
</button>
|
||||
<button className="btn" onClick={() => navigate("/")}>
|
||||
No, I am under 18
|
||||
<FormattedMessage defaultMessage="No, I am under 18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,26 +10,13 @@ export interface CopyProps {
|
||||
export default function Copy({ text, maxSize = 32, className }: CopyProps) {
|
||||
const { copy, copied } = useCopy();
|
||||
const sliceLength = maxSize / 2;
|
||||
const trimmed =
|
||||
text.length > maxSize
|
||||
? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}`
|
||||
: text;
|
||||
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`copy${className ? ` ${className}` : ""}`}
|
||||
onClick={() => copy(text)}
|
||||
>
|
||||
<div className={`copy${className ? ` ${className}` : ""}`} onClick={() => copy(text)}>
|
||||
<span className="body">{trimmed}</span>
|
||||
<span
|
||||
className="icon"
|
||||
style={{ color: copied ? "var(--success)" : "var(--highlight)" }}
|
||||
>
|
||||
{copied ? (
|
||||
<Icon name="check" size={14} />
|
||||
) : (
|
||||
<Icon name="copy" size={14} />
|
||||
)}
|
||||
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
|
||||
{copied ? <Icon name="check" size={14} /> : <Icon name="copy" size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
@ -8,28 +8,24 @@ import { findTag } from "utils";
|
||||
import { USER_EMOJIS } from "const";
|
||||
import { Login, System } from "index";
|
||||
import type { EmojiPack as EmojiPackType } from "types";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function EmojiPack({ ev }: { ev: NostrEvent }) {
|
||||
const login = useLogin();
|
||||
const name = findTag(ev, "d");
|
||||
const isUsed = login?.emojis.find(
|
||||
(e) => e.author === ev.pubkey && e.name === name
|
||||
);
|
||||
const emoji = ev.tags.filter((e) => e.at(0) === "emoji");
|
||||
const isUsed = login?.emojis.find(e => e.author === ev.pubkey && e.name === name);
|
||||
const emoji = ev.tags.filter(e => e.at(0) === "emoji");
|
||||
|
||||
async function toggleEmojiPack() {
|
||||
let newPacks = [] as EmojiPackType[];
|
||||
if (isUsed) {
|
||||
newPacks =
|
||||
login?.emojis.filter(
|
||||
(e) => e.author !== ev.pubkey && e.name !== name
|
||||
) ?? [];
|
||||
newPacks = login?.emojis.filter(e => e.author !== ev.pubkey && e.name !== name) ?? [];
|
||||
} else {
|
||||
newPacks = [...(login?.emojis ?? []), toEmojiPack(ev)];
|
||||
}
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const ev = await pub.generic((eb) => {
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(USER_EMOJIS).content("");
|
||||
for (const e of newPacks) {
|
||||
eb.tag(["a", e.address]);
|
||||
@ -48,17 +44,14 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
|
||||
<h4>{name}</h4>
|
||||
{login?.pubkey && (
|
||||
<AsyncButton
|
||||
className={`btn btn-small btn-primary ${
|
||||
isUsed ? "delete-button" : ""
|
||||
}`}
|
||||
onClick={toggleEmojiPack}
|
||||
>
|
||||
{isUsed ? "Remove" : "Add"}
|
||||
className={`btn btn-small btn-primary ${isUsed ? "delete-button" : ""}`}
|
||||
onClick={toggleEmojiPack}>
|
||||
<FormattedMessage defaultMessage={isUsed ? "Remove" : "Add"} />
|
||||
</AsyncButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="emoji-pack-emojis">
|
||||
{emoji.map((e) => {
|
||||
{emoji.map(e => {
|
||||
const [, name, image] = e;
|
||||
return (
|
||||
<div className="emoji-definition">
|
||||
|
@ -22,11 +22,11 @@ export function EmojiPicker({
|
||||
height = 300,
|
||||
ref,
|
||||
}: EmojiPickerProps) {
|
||||
const customEmojiList = emojiPacks.map((pack) => {
|
||||
const customEmojiList = emojiPacks.map(pack => {
|
||||
return {
|
||||
id: pack.address,
|
||||
name: pack.name,
|
||||
emojis: pack.emojis.map((e) => {
|
||||
emojis: pack.emojis.map(e => {
|
||||
const [, name, url] = e;
|
||||
return {
|
||||
id: name,
|
||||
@ -45,8 +45,7 @@ export function EmojiPicker({
|
||||
left: leftOffset,
|
||||
zIndex: 1,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
ref={ref}>
|
||||
<style>
|
||||
{`
|
||||
em-emoji-picker { max-height: ${height}px; }
|
||||
|
@ -11,16 +11,10 @@ export function Emoji({ name, url }: EmojiProps) {
|
||||
return <img alt={name} src={url} className="emoji" />;
|
||||
}
|
||||
|
||||
export function Emojify({
|
||||
content,
|
||||
emoji,
|
||||
}: {
|
||||
content: string;
|
||||
emoji: EmojiTag[];
|
||||
}) {
|
||||
export function Emojify({ content, emoji }: { content: string; emoji: EmojiTag[] }) {
|
||||
const emojified = useMemo(() => {
|
||||
return content.split(/:(\w+):/g).map((i) => {
|
||||
const t = emoji.find((t) => t[1] === i);
|
||||
return content.split(/:(\w+):/g).map(i => {
|
||||
const t = emoji.find(t => t[1] === i);
|
||||
if (t) {
|
||||
return <Emoji name={t[1]} url={t[2]} />;
|
||||
} else {
|
||||
|
@ -19,19 +19,10 @@ interface ExternalIconLinkProps extends Omit<ExternalLinkProps, "children"> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function ExternalIconLink({
|
||||
size = 32,
|
||||
href,
|
||||
...rest
|
||||
}: ExternalIconLinkProps) {
|
||||
export function ExternalIconLink({ size = 32, href, ...rest }: ExternalIconLinkProps) {
|
||||
return (
|
||||
<span style={{ cursor: "pointer" }}>
|
||||
<Icon
|
||||
name="link"
|
||||
size={size}
|
||||
onClick={() => window.open(href, "_blank")}
|
||||
{...rest}
|
||||
/>
|
||||
<Icon name="link" size={size} onClick={() => window.open(href, "_blank")} {...rest} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import "./file-uploader.css";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { VoidApi } from "@void-cat/api";
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const voidCatHost = "https://void.cat";
|
||||
const fileExtensionRegex = /\.([\w]{1,7})$/i;
|
||||
@ -23,9 +24,7 @@ async function voidCatUpload(file: File | Blob): Promise<UploadResult> {
|
||||
if (rsp.file?.metadata?.mimeType === "image/webp") {
|
||||
ext = ["", "webp"];
|
||||
}
|
||||
const resultUrl =
|
||||
rsp.file?.metadata?.url ??
|
||||
`${voidCatHost}/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`;
|
||||
const resultUrl = rsp.file?.metadata?.url ?? `${voidCatHost}/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`;
|
||||
|
||||
const ret = {
|
||||
url: resultUrl,
|
||||
@ -45,11 +44,7 @@ interface FileUploaderProps {
|
||||
onFileUpload(url: string): void;
|
||||
}
|
||||
|
||||
export function FileUploader({
|
||||
defaultImage,
|
||||
onClear,
|
||||
onFileUpload,
|
||||
}: FileUploaderProps) {
|
||||
export function FileUploader({ defaultImage, onClear, onFileUpload }: FileUploaderProps) {
|
||||
const [img, setImg] = useState<string>(defaultImage ?? "");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
@ -88,7 +83,7 @@ export function FileUploader({
|
||||
<div className="file-uploader-preview">
|
||||
{img?.length > 0 && (
|
||||
<button className="btn btn-primary clear-button" onClick={clearImage}>
|
||||
Clear
|
||||
<FormattedMessage defaultMessage="Clear" />
|
||||
</button>
|
||||
)}
|
||||
{img && <img className="image-preview" src={img} />}
|
||||
|
@ -3,26 +3,21 @@ import { EventKind } from "@snort/system";
|
||||
import { useLogin } from "hooks/login";
|
||||
import AsyncButton from "element/async-button";
|
||||
import { Login, System } from "index";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function LoggedInFollowButton({
|
||||
tag,
|
||||
value,
|
||||
}: {
|
||||
tag: "p" | "t";
|
||||
value: string;
|
||||
}) {
|
||||
export function LoggedInFollowButton({ tag, value }: { tag: "p" | "t"; value: string }) {
|
||||
const login = useLogin();
|
||||
if (!login) return;
|
||||
|
||||
const { tags, content, timestamp } = login.follows;
|
||||
const follows = tags.filter((t) => t.at(0) === tag);
|
||||
const isFollowing = follows.find((t) => t.at(1) === value);
|
||||
const follows = tags.filter(t => t.at(0) === tag);
|
||||
const isFollowing = follows.find(t => t.at(1) === value);
|
||||
|
||||
async function unfollow() {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const newFollows = tags.filter((t) => t.at(1) !== value);
|
||||
const ev = await pub.generic((eb) => {
|
||||
const newFollows = tags.filter(t => t.at(1) !== value);
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(EventKind.ContactList).content(content ?? "");
|
||||
for (const t of newFollows) {
|
||||
eb.tag(t);
|
||||
@ -39,7 +34,7 @@ export function LoggedInFollowButton({
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const newFollows = [...tags, [tag, value]];
|
||||
const ev = await pub.generic((eb) => {
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(EventKind.ContactList).content(content ?? "");
|
||||
for (const tag of newFollows) {
|
||||
eb.tag(tag);
|
||||
@ -57,9 +52,8 @@ export function LoggedInFollowButton({
|
||||
disabled={timestamp ? timestamp === 0 : true}
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={isFollowing ? unfollow : follow}
|
||||
>
|
||||
{isFollowing ? "Unfollow" : "Follow"}
|
||||
onClick={isFollowing ? unfollow : follow}>
|
||||
<FormattedMessage defaultMessage={isFollowing ? "Unfollow" : "Follow"} />
|
||||
</AsyncButton>
|
||||
);
|
||||
}
|
||||
@ -71,7 +65,5 @@ export function FollowTagButton({ tag }: { tag: string }) {
|
||||
|
||||
export function FollowButton({ pubkey }: { pubkey: string }) {
|
||||
const login = useLogin();
|
||||
return login?.pubkey ? (
|
||||
<LoggedInFollowButton tag={"p"} value={pubkey} />
|
||||
) : null;
|
||||
return login?.pubkey ? <LoggedInFollowButton tag={"p"} value={pubkey} /> : null;
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import { SendZapsDialog } from "element/send-zap";
|
||||
import { useZaps } from "hooks/goals";
|
||||
import { getName } from "element/profile";
|
||||
import { Icon } from "./icon";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function Goal({ ev }: { ev: NostrEvent }) {
|
||||
const profile = useUserProfile(ev.pubkey);
|
||||
@ -28,9 +29,7 @@ export function Goal({ ev }: { ev: NostrEvent }) {
|
||||
}
|
||||
|
||||
const soFar = useMemo(() => {
|
||||
return zaps
|
||||
.filter((z) => z.receiver === ev.pubkey && z.event === ev.id)
|
||||
.reduce((acc, z) => acc + z.amount, 0);
|
||||
return zaps.filter(z => z.receiver === ev.pubkey && z.event === ev.id).reduce((acc, z) => acc + z.amount, 0);
|
||||
}, [zaps]);
|
||||
|
||||
const progress = Math.max(0, Math.min(100, (soFar / goalAmount) * 100));
|
||||
@ -42,26 +41,18 @@ export function Goal({ ev }: { ev: NostrEvent }) {
|
||||
{ev.content.length > 0 && <p>{ev.content}</p>}
|
||||
<div className={`progress-container ${isFinished ? "finished" : ""}`}>
|
||||
<Progress.Root className="progress-root" value={progress}>
|
||||
<Progress.Indicator
|
||||
className="progress-indicator"
|
||||
style={{ transform: `translateX(-${100 - progress}%)` }}
|
||||
>
|
||||
{!isFinished && (
|
||||
<span className="amount so-far">{formatSats(soFar)}</span>
|
||||
)}
|
||||
<Progress.Indicator className="progress-indicator" style={{ transform: `translateX(-${100 - progress}%)` }}>
|
||||
{!isFinished && <span className="amount so-far">{formatSats(soFar)}</span>}
|
||||
</Progress.Indicator>
|
||||
<span className="amount target">Goal: {formatSats(goalAmount)}</span>
|
||||
<span className="amount target">
|
||||
<FormattedMessage defaultMessage="Goal: {amount}" values={{ amount: formatSats(goalAmount) }} />
|
||||
</span>
|
||||
</Progress.Root>
|
||||
<div className="zap-circle">
|
||||
<Icon
|
||||
name="zap-filled"
|
||||
className={isFinished ? "goal-finished" : "goal-unfinished"}
|
||||
/>
|
||||
<Icon name="zap-filled" className={isFinished ? "goal-finished" : "goal-unfinished"} />
|
||||
</div>
|
||||
</div>
|
||||
{isFinished && previousValue === false && (
|
||||
<Confetti numberOfPieces={2100} recycle={false} />
|
||||
)}
|
||||
{isFinished && previousValue === false && <Confetti numberOfPieces={2100} recycle={false} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -12,8 +12,7 @@ interface HyperTextProps {
|
||||
export function HyperText({ link, children }: HyperTextProps) {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
const extension =
|
||||
FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
|
||||
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
|
||||
|
||||
if (extension) {
|
||||
switch (extension) {
|
||||
@ -25,11 +24,7 @@ export function HyperText({ link, children }: HyperTextProps) {
|
||||
case "webp": {
|
||||
return (
|
||||
<MediaURL url={url}>
|
||||
<img
|
||||
src={url.toString()}
|
||||
alt={url.toString()}
|
||||
style={{ objectFit: "contain" }}
|
||||
/>
|
||||
<img src={url.toString()} alt={url.toString()} style={{ objectFit: "contain" }} />
|
||||
</MediaURL>
|
||||
);
|
||||
}
|
||||
|
@ -12,12 +12,7 @@ export function Icon(props: Props) {
|
||||
const href = `/icons.svg#` + props.name;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={props.className}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<svg width={size} height={size} className={props.className} onClick={props.onClick}>
|
||||
<use href={href} />
|
||||
</svg>
|
||||
);
|
||||
|
@ -203,15 +203,7 @@
|
||||
}
|
||||
|
||||
.zap-container.big-zap:before {
|
||||
background: linear-gradient(
|
||||
60deg,
|
||||
#2bd9ff,
|
||||
#8c8ded,
|
||||
#f838d9,
|
||||
#f83838,
|
||||
#ff902b,
|
||||
#ddf838
|
||||
);
|
||||
background: linear-gradient(60deg, #2bd9ff, #8c8ded, #f838d9, #f83838, #ff902b, #ddf838);
|
||||
animation: animatedgradient 3s ease alternate infinite;
|
||||
background-size: 300% 300%;
|
||||
}
|
||||
|
@ -1,13 +1,5 @@
|
||||
import "./live-chat.css";
|
||||
import {
|
||||
EventKind,
|
||||
NostrPrefix,
|
||||
NostrLink,
|
||||
ParsedZap,
|
||||
NostrEvent,
|
||||
parseZap,
|
||||
encodeTLV,
|
||||
} from "@snort/system";
|
||||
import { EventKind, NostrPrefix, NostrLink, ParsedZap, NostrEvent, parseZap, encodeTLV } from "@snort/system";
|
||||
import { unixNow, unwrap } from "@snort/shared";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import uniqBy from "lodash.uniqby";
|
||||
@ -32,6 +24,7 @@ import { formatSats } from "number";
|
||||
import { WEEK, LIVE_STREAM_CHAT } from "const";
|
||||
import { findTag, getTagValues, getHost } from "utils";
|
||||
import { System } from "index";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export interface LiveChatOptions {
|
||||
canWrite?: boolean;
|
||||
@ -48,7 +41,7 @@ function BadgeAward({ ev }: { ev: NostrEvent }) {
|
||||
{event && <Badge ev={event} />}
|
||||
<p>awarded to</p>
|
||||
<div className="badge-awardees">
|
||||
{awardees.map((pk) => (
|
||||
{awardees.map(pk => (
|
||||
<Profile key={pk} pubkey={pk} />
|
||||
))}
|
||||
</div>
|
||||
@ -95,9 +88,7 @@ export function LiveChat({
|
||||
const feed = useLiveChatFeed(link, goal ? [goal.id] : undefined);
|
||||
const login = useLogin();
|
||||
useEffect(() => {
|
||||
const pubkeys = [
|
||||
...new Set(feed.zaps.flatMap((a) => [a.pubkey, unwrap(findTag(a, "p"))])),
|
||||
];
|
||||
const pubkeys = [...new Set(feed.zaps.flatMap(a => [a.pubkey, unwrap(findTag(a, "p"))]))];
|
||||
System.ProfileLoader.TrackMetadata(pubkeys);
|
||||
return () => System.ProfileLoader.UntrackMetadata(pubkeys);
|
||||
}, [feed.zaps]);
|
||||
@ -116,54 +107,40 @@ export function LiveChat({
|
||||
return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId);
|
||||
}, [userEmojiPacks, channelEmojiPacks]);
|
||||
|
||||
const zaps = feed.zaps
|
||||
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
||||
.filter((z) => z && z.valid);
|
||||
const zaps = feed.zaps.map(ev => parseZap(ev, System.ProfileLoader.Cache)).filter(z => z && z.valid);
|
||||
const events = useMemo(() => {
|
||||
return [...feed.messages, ...feed.zaps, ...awards].sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
return [...feed.messages, ...feed.zaps, ...awards].sort((a, b) => b.created_at - a.created_at);
|
||||
}, [feed.messages, feed.zaps, awards]);
|
||||
const streamer = getHost(ev);
|
||||
const naddr = useMemo(() => {
|
||||
if (ev) {
|
||||
return encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
findTag(ev, "d") ?? "",
|
||||
undefined,
|
||||
ev.kind,
|
||||
ev.pubkey
|
||||
);
|
||||
return encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev.kind, ev.pubkey);
|
||||
}
|
||||
}, [ev]);
|
||||
const filteredEvents = useMemo(() => {
|
||||
return events.filter(
|
||||
(e) => !mutedPubkeys.has(e.pubkey) && !hostMutedPubkeys.has(e.pubkey)
|
||||
);
|
||||
return events.filter(e => !mutedPubkeys.has(e.pubkey) && !hostMutedPubkeys.has(e.pubkey));
|
||||
}, [events, mutedPubkeys, hostMutedPubkeys]);
|
||||
|
||||
return (
|
||||
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
|
||||
{(options?.showHeader ?? true) && (
|
||||
<div className="header">
|
||||
<h2 className="title">Stream Chat</h2>
|
||||
<h2 className="title">
|
||||
<FormattedMessage defaultMessage="Stream Chat" />
|
||||
</h2>
|
||||
<Icon
|
||||
name="link"
|
||||
className="secondary"
|
||||
size={32}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/chat/${naddr}?chat=true`,
|
||||
"_blank",
|
||||
"popup,width=400,height=800"
|
||||
)
|
||||
}
|
||||
onClick={() => window.open(`/chat/${naddr}?chat=true`, "_blank", "popup,width=400,height=800")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{zaps.length > 0 && (
|
||||
<div className="top-zappers">
|
||||
<h3>Top zappers</h3>
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Top zappers" />
|
||||
</h3>
|
||||
<div className="top-zappers-container">
|
||||
<TopZappers zaps={zaps} />
|
||||
</div>
|
||||
@ -172,7 +149,7 @@ export function LiveChat({
|
||||
</div>
|
||||
)}
|
||||
<div className="messages">
|
||||
{filteredEvents.map((a) => {
|
||||
{filteredEvents.map(a => {
|
||||
switch (a.kind) {
|
||||
case EventKind.BadgeAward: {
|
||||
return <BadgeAward ev={a} />;
|
||||
@ -190,9 +167,7 @@ export function LiveChat({
|
||||
);
|
||||
}
|
||||
case EventKind.ZapReceipt: {
|
||||
const zap = zaps.find(
|
||||
(b) => b.id === a.id && b.receiver === streamer
|
||||
);
|
||||
const zap = zaps.find(b => b.id === a.id && b.receiver === streamer);
|
||||
if (zap) {
|
||||
return <ChatZap zap={zap} key={a.id} />;
|
||||
}
|
||||
@ -207,7 +182,9 @@ export function LiveChat({
|
||||
{login ? (
|
||||
<WriteMessage emojiPacks={allEmojiPacks} link={link} />
|
||||
) : (
|
||||
<p>Please login to write messages!</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Please login to write messages!" />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -227,16 +204,21 @@ function ChatZap({ zap }: { zap: ParsedZap }) {
|
||||
<div className={`zap-container ${isBig ? "big-zap" : ""}`}>
|
||||
<div className="zap">
|
||||
<Icon name="zap-filled" className="zap-icon" />
|
||||
<Profile
|
||||
pubkey={zap.anonZap ? "anon" : zap.sender ?? "anon"}
|
||||
options={{
|
||||
showAvatar: !zap.anonZap,
|
||||
overrideName: zap.anonZap ? "Anon" : undefined,
|
||||
<FormattedMessage
|
||||
defaultMessage="{person} zapped {amount} sats"
|
||||
values={{
|
||||
person: (
|
||||
<Profile
|
||||
pubkey={zap.anonZap ? "anon" : zap.sender ?? "anon"}
|
||||
options={{
|
||||
showAvatar: !zap.anonZap,
|
||||
overrideName: zap.anonZap ? "Anon" : undefined,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
amount: <span className="zap-amount">{formatSats(zap.amount)}</span>,
|
||||
}}
|
||||
/>
|
||||
zapped
|
||||
<span className="zap-amount">{formatSats(zap.amount)}</span>
|
||||
sats
|
||||
</div>
|
||||
{zap.content && (
|
||||
<div className="zap-content">
|
||||
|
@ -75,8 +75,7 @@ export function LiveVideoPlayer(props: VideoPlayerProps) {
|
||||
export function WebRTCPlayer(props: VideoPlayerProps) {
|
||||
const video = useRef<HTMLVideoElement>(null);
|
||||
const streamCached = useMemo(
|
||||
() =>
|
||||
"https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play",
|
||||
() => "https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play",
|
||||
[props.stream]
|
||||
);
|
||||
const [status] = useState<VideoStatus>();
|
||||
@ -90,7 +89,7 @@ export function WebRTCPlayer(props: VideoPlayerProps) {
|
||||
|
||||
client
|
||||
.Play()
|
||||
.then((s) => {
|
||||
.then(s => {
|
||||
if (video.current) {
|
||||
video.current.srcObject = s;
|
||||
}
|
||||
@ -107,12 +106,7 @@ export function WebRTCPlayer(props: VideoPlayerProps) {
|
||||
<div className={status}>
|
||||
<div>{status}</div>
|
||||
</div>
|
||||
<video
|
||||
ref={video}
|
||||
autoPlay={true}
|
||||
poster={props.poster}
|
||||
controls={status === VideoStatus.Online}
|
||||
/>
|
||||
<video ref={video} autoPlay={true} poster={props.poster} controls={status === VideoStatus.Online} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import Copy from "./copy";
|
||||
import { hexToBech32, openFile } from "utils";
|
||||
import { VoidApi } from "@void-cat/api";
|
||||
import { LoginType } from "login";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
enum Stage {
|
||||
Login = 0,
|
||||
@ -63,8 +64,7 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
"V-Strip-Metadata": "true",
|
||||
});
|
||||
if (result.ok) {
|
||||
const resultUrl =
|
||||
result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
|
||||
const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
|
||||
setAvatar(resultUrl);
|
||||
} else {
|
||||
setError(result.errorMessage ?? "Upload failed");
|
||||
@ -91,22 +91,16 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
case Stage.Login: {
|
||||
return (
|
||||
<>
|
||||
<h2>Login</h2>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Login" />
|
||||
</h2>
|
||||
{"nostr" in window && (
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={doLogin}
|
||||
>
|
||||
Nostr Extension
|
||||
<AsyncButton type="button" className="btn btn-primary" onClick={doLogin}>
|
||||
<FormattedMessage defaultMessage="Nostr Extension" />
|
||||
</AsyncButton>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={createAccount}
|
||||
>
|
||||
Create Account
|
||||
<button type="button" className="btn btn-primary" onClick={createAccount}>
|
||||
<FormattedMessage defaultMessage="Create Account" />
|
||||
</button>
|
||||
{error && <b className="error">{error}</b>}
|
||||
</>
|
||||
@ -115,7 +109,9 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
case Stage.Details: {
|
||||
return (
|
||||
<>
|
||||
<h2>Setup Profile</h2>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Setup Profile" />
|
||||
</h2>
|
||||
<div className="flex f-center">
|
||||
<div
|
||||
className="avatar-input"
|
||||
@ -124,28 +120,20 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
{
|
||||
"--img": `url(${avatar})`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
}>
|
||||
<Icon name="camera-plus" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input type="text" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} />
|
||||
</div>
|
||||
<small>You can change this later</small>
|
||||
<small>
|
||||
<FormattedMessage defaultMessage="You can change this later" />
|
||||
</small>
|
||||
</div>
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={saveProfile}
|
||||
>
|
||||
Save
|
||||
<AsyncButton type="button" className="btn btn-primary" onClick={saveProfile}>
|
||||
<FormattedMessage defaultMessage="Save" />
|
||||
</AsyncButton>
|
||||
</>
|
||||
);
|
||||
@ -153,20 +141,17 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
case Stage.SaveKey: {
|
||||
return (
|
||||
<>
|
||||
<h2>Save Key</h2>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Save Key" />
|
||||
</h2>
|
||||
<p>
|
||||
Nostr uses private keys, please save yours, if you lose this key you
|
||||
wont be able to login to your account anymore!
|
||||
<FormattedMessage defaultMessage="Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore!" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<Copy text={hexToBech32("nsec", key)} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={loginWithKey}
|
||||
>
|
||||
Ok, it's safe
|
||||
<button type="button" className="btn btn-primary" onClick={loginWithKey}>
|
||||
<FormattedMessage defaultMessage="Ok, it's safe" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
@ -3,21 +3,19 @@ import { useLogin } from "hooks/login";
|
||||
import AsyncButton from "element/async-button";
|
||||
import { Login, System } from "index";
|
||||
import { MUTED } from "const";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function useMute(pubkey: string) {
|
||||
const login = useLogin();
|
||||
const { tags, content } = login?.muted ?? { tags: [] };
|
||||
const muted = useMemo(() => tags.filter((t) => t.at(0) === "p"), [tags]);
|
||||
const isMuted = useMemo(
|
||||
() => muted.find((t) => t.at(1) === pubkey),
|
||||
[pubkey, muted]
|
||||
);
|
||||
const muted = useMemo(() => tags.filter(t => t.at(0) === "p"), [tags]);
|
||||
const isMuted = useMemo(() => muted.find(t => t.at(1) === pubkey), [pubkey, muted]);
|
||||
|
||||
async function unmute() {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const newMuted = tags.filter((t) => t.at(1) !== pubkey);
|
||||
const ev = await pub.generic((eb) => {
|
||||
const newMuted = tags.filter(t => t.at(1) !== pubkey);
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(MUTED).content(content ?? "");
|
||||
for (const t of newMuted) {
|
||||
eb.tag(t);
|
||||
@ -34,7 +32,7 @@ export function useMute(pubkey: string) {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const newMuted = [...tags, ["p", pubkey]];
|
||||
const ev = await pub.generic((eb) => {
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(MUTED).content(content ?? "");
|
||||
for (const tag of newMuted) {
|
||||
eb.tag(tag);
|
||||
@ -54,12 +52,8 @@ export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
|
||||
const { isMuted, mute, unmute } = useMute(pubkey);
|
||||
|
||||
return (
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn delete-button"
|
||||
onClick={() => (isMuted ? unmute() : mute())}
|
||||
>
|
||||
{isMuted ? "Unmute" : "Mute"}
|
||||
<AsyncButton type="button" className="btn delete-button" onClick={() => (isMuted ? unmute() : mute())}>
|
||||
<FormattedMessage defaultMessage={isMuted ? "Unmute" : "Mute"} />
|
||||
</AsyncButton>
|
||||
);
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import { useState } from "react";
|
||||
import { System } from "index";
|
||||
import { GOAL } from "const";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface NewGoalDialogProps {
|
||||
link: NostrLink;
|
||||
@ -23,7 +24,7 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
|
||||
async function publishGoal() {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const evNew = await pub.generic((eb) => {
|
||||
const evNew = await pub.generic(eb => {
|
||||
eb.kind(GOAL)
|
||||
.tag(["a", `${link.kind}:${link.author}:${link.id}`])
|
||||
.tag(["amount", String(Number(goalAmount) * 1000)])
|
||||
@ -48,7 +49,9 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
|
||||
<button type="button" className="btn btn-primary">
|
||||
<span>
|
||||
<Icon name="zap-filled" size={12} />
|
||||
<span>Add stream goal</span>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Add stream goal" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
@ -57,26 +60,28 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
|
||||
<Dialog.Content className="dialog-content">
|
||||
<div className="new-goal">
|
||||
<div className="zap-goals">
|
||||
<Icon
|
||||
name="zap-filled"
|
||||
className="stream-zap-goals-icon"
|
||||
size={16}
|
||||
/>
|
||||
<h3>Stream Zap Goals</h3>
|
||||
<Icon name="zap-filled" className="stream-zap-goals-icon" size={16} />
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Stream Zap Goals" />
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<p>Name</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Name" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
value={goalName}
|
||||
placeholder="e.g. New Laptop"
|
||||
onChange={(e) => setGoalName(e.target.value)}
|
||||
onChange={e => setGoalName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Amount</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Amount" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="number"
|
||||
@ -84,18 +89,13 @@ export function NewGoalDialog({ link }: NewGoalDialogProps) {
|
||||
min="1"
|
||||
max="2100000000000000"
|
||||
value={goalAmount}
|
||||
onChange={(e) => setGoalAmount(e.target.value)}
|
||||
onChange={e => setGoalAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="create-goal">
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-primary wide"
|
||||
disabled={!isValid}
|
||||
onClick={publishGoal}
|
||||
>
|
||||
Create goal
|
||||
<AsyncButton type="button" className="btn btn-primary wide" disabled={!isValid} onClick={publishGoal}>
|
||||
<FormattedMessage defaultMessage="Create Goal" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,6 +10,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { eventLink, findTag } from "utils";
|
||||
import { NostrProviderDialog } from "./nostr-provider-dialog";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
function NewStream({ ev, onFinish }: StreamEditorProps) {
|
||||
const providers = useStreamProvider();
|
||||
@ -19,9 +20,7 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
|
||||
useEffect(() => {
|
||||
if (!currentProvider) {
|
||||
setCurrentProvider(
|
||||
ev !== undefined
|
||||
? unwrap(providers.find((a) => a.name.toLowerCase() === "manual"))
|
||||
: providers.at(0)
|
||||
ev !== undefined ? unwrap(providers.find(a => a.name.toLowerCase() === "manual")) : providers.at(0)
|
||||
);
|
||||
}
|
||||
}, [providers, currentProvider]);
|
||||
@ -33,14 +32,10 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
|
||||
case StreamProviders.Manual: {
|
||||
return (
|
||||
<StreamEditor
|
||||
onFinish={(ex) => {
|
||||
onFinish={ex => {
|
||||
currentProvider.updateStreamInfo(ex);
|
||||
if (!ev) {
|
||||
if (
|
||||
findTag(ex, "content-warning") &&
|
||||
__XXX_HOST &&
|
||||
__XXX === false
|
||||
) {
|
||||
if (findTag(ex, "content-warning") && __XXX_HOST && __XXX === false) {
|
||||
location.href = `${__XXX_HOST}/${eventLink(ex)}`;
|
||||
} else {
|
||||
navigate(`/${eventLink(ex)}`, {
|
||||
@ -56,13 +51,7 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
|
||||
);
|
||||
}
|
||||
case StreamProviders.NostrType: {
|
||||
return (
|
||||
<NostrProviderDialog
|
||||
provider={currentProvider}
|
||||
onFinish={onFinish}
|
||||
ev={ev}
|
||||
/>
|
||||
);
|
||||
return <NostrProviderDialog provider={currentProvider} onFinish={onFinish} ev={ev} />;
|
||||
}
|
||||
case StreamProviders.Owncast: {
|
||||
return;
|
||||
@ -72,13 +61,12 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>Stream Providers</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Stream Providers" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
{providers.map((v) => (
|
||||
<span
|
||||
className={`pill${v === currentProvider ? " active" : ""}`}
|
||||
onClick={() => setCurrentProvider(v)}
|
||||
>
|
||||
{providers.map(v => (
|
||||
<span className={`pill${v === currentProvider ? " active" : ""}`} onClick={() => setCurrentProvider(v)}>
|
||||
{v.name}
|
||||
</span>
|
||||
))}
|
||||
@ -93,9 +81,7 @@ interface NewStreamDialogProps {
|
||||
btnClassName?: string;
|
||||
}
|
||||
|
||||
export function NewStreamDialog(
|
||||
props: NewStreamDialogProps & StreamEditorProps
|
||||
) {
|
||||
export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
@ -104,7 +90,9 @@ export function NewStreamDialog(
|
||||
{props.text && props.text}
|
||||
{!props.text && (
|
||||
<>
|
||||
<span className="hide-on-mobile">Stream</span>
|
||||
<span className="hide-on-mobile">
|
||||
<FormattedMessage defaultMessage="Stream" />
|
||||
</span>
|
||||
<Icon name="signal" />
|
||||
</>
|
||||
)}
|
||||
|
@ -3,10 +3,7 @@ import { Mention } from "./mention";
|
||||
|
||||
export function NostrLink({ link }: { link: string }) {
|
||||
const nav = tryParseNostrLink(link);
|
||||
if (
|
||||
nav?.type === NostrPrefix.PublicKey ||
|
||||
nav?.type === NostrPrefix.Profile
|
||||
) {
|
||||
if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) {
|
||||
return <Mention pubkey={nav.id} relays={nav.relays} />;
|
||||
} else {
|
||||
<a href={link} target="_blank" rel="noreferrer" className="ext">
|
||||
|
@ -1,19 +1,13 @@
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import {
|
||||
StreamProvider,
|
||||
StreamProviderEndpoint,
|
||||
StreamProviderInfo,
|
||||
} from "providers";
|
||||
import { StreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "providers";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SendZaps } from "./send-zap";
|
||||
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
||||
import Spinner from "./spinner";
|
||||
import AsyncButton from "./async-button";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function NostrProviderDialog({
|
||||
provider,
|
||||
...others
|
||||
}: { provider: StreamProvider } & StreamEditorProps) {
|
||||
export function NostrProviderDialog({ provider, ...others }: { provider: StreamProvider } & StreamEditorProps) {
|
||||
const [topup, setTopup] = useState(false);
|
||||
const [info, setInfo] = useState<StreamProviderInfo>();
|
||||
const [ep, setEndpoint] = useState<StreamProviderEndpoint>();
|
||||
@ -24,7 +18,7 @@ export function NostrProviderDialog({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
provider.info().then((v) => {
|
||||
provider.info().then(v => {
|
||||
setInfo(v);
|
||||
setTos(v.tosAccepted ?? true);
|
||||
setEndpoint(sortEndpoints(v.endpoints)[0]);
|
||||
@ -42,13 +36,13 @@ export function NostrProviderDialog({
|
||||
name: provider.name,
|
||||
canZap: false,
|
||||
maxCommentLength: 0,
|
||||
getInvoice: async (amount) => {
|
||||
getInvoice: async amount => {
|
||||
const pr = await provider.topup(amount);
|
||||
return { pr };
|
||||
},
|
||||
}}
|
||||
onFinish={() => {
|
||||
provider.info().then((v) => {
|
||||
provider.info().then(v => {
|
||||
setInfo(v);
|
||||
setTopup(false);
|
||||
});
|
||||
@ -92,33 +86,27 @@ export function NostrProviderDialog({
|
||||
<>
|
||||
<div>
|
||||
<div className="flex g12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tos}
|
||||
onChange={(e) => setTos(e.target.checked)}
|
||||
/>
|
||||
<input type="checkbox" checked={tos} onChange={e => setTos(e.target.checked)} />
|
||||
<p>
|
||||
I have read and agree with {info.name}'s{" "}
|
||||
<span
|
||||
className="tos-link"
|
||||
onClick={() =>
|
||||
window.open(info.tosLink, "popup", "width=400,height=800")
|
||||
}
|
||||
>
|
||||
terms and conditions
|
||||
</span>
|
||||
.
|
||||
<FormattedMessage
|
||||
defaultMessage="I have read and agree with {provider}'s {terms}."
|
||||
values={{
|
||||
provider: info.name,
|
||||
terms: (
|
||||
<span
|
||||
className="tos-link"
|
||||
onClick={() => window.open(info.tosLink, "popup", "width=400,height=800")}>
|
||||
<FormattedMessage defaultMessage="terms and conditions" />
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-primary wide"
|
||||
disabled={!tos}
|
||||
onClick={acceptTos}
|
||||
>
|
||||
Continue
|
||||
<AsyncButton type="button" className="btn btn-primary wide" disabled={!tos} onClick={acceptTos}>
|
||||
<FormattedMessage defaultMessage="Continue" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
@ -129,13 +117,12 @@ export function NostrProviderDialog({
|
||||
<>
|
||||
{info.endpoints.length > 1 && (
|
||||
<div>
|
||||
<p>Endpoint</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Endpoint" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
{sortEndpoints(info.endpoints).map((a) => (
|
||||
<span
|
||||
className={`pill${ep?.name === a.name ? " active" : ""}`}
|
||||
onClick={() => setEndpoint(a)}
|
||||
>
|
||||
{sortEndpoints(info.endpoints).map(a => (
|
||||
<span className={`pill${ep?.name === a.name ? " active" : ""}`} onClick={() => setEndpoint(a)}>
|
||||
{a.name}
|
||||
</span>
|
||||
))}
|
||||
@ -143,41 +130,48 @@ export function NostrProviderDialog({
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p>Stream Url</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Server Url" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input type="text" value={ep?.url} disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Stream Key</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Stream Key" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
<div className="paper f-grow">
|
||||
<input type="password" value={ep?.key} disabled />
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}
|
||||
>
|
||||
Copy
|
||||
<button className="btn btn-primary" onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}>
|
||||
<FormattedMessage defaultMessage="Copy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Balance</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Balance" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
<div className="paper f-grow">
|
||||
{info.balance?.toLocaleString()} sats
|
||||
<FormattedMessage defaultMessage="{amount} sats" values={{ amount: info.balance?.toLocaleString() }} />
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setTopup(true)}>
|
||||
Topup
|
||||
<FormattedMessage defaultMessage="Topup" />
|
||||
</button>
|
||||
</div>
|
||||
<small>About {calcEstimate()}</small>
|
||||
<small>
|
||||
<FormattedMessage defaultMessage="About {estimate}" values={{ estimate: calcEstimate() }} />
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<p>Resolutions</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Resolutions" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
{ep?.capabilities?.map((a) => (
|
||||
{ep?.capabilities?.map(a => (
|
||||
<span className="pill">{parseCapability(a)}</span>
|
||||
))}
|
||||
</div>
|
||||
@ -186,7 +180,7 @@ export function NostrProviderDialog({
|
||||
tosInput()
|
||||
) : (
|
||||
<StreamEditor
|
||||
onFinish={(ex) => {
|
||||
onFinish={ex => {
|
||||
provider.updateStreamInfo(ex);
|
||||
others.onFinish?.(ex);
|
||||
}}
|
||||
@ -196,10 +190,8 @@ export function NostrProviderDialog({
|
||||
["title", info.streamInfo?.title ?? ""],
|
||||
["summary", info.streamInfo?.summary ?? ""],
|
||||
["image", info.streamInfo?.image ?? ""],
|
||||
...(info.streamInfo?.content_warning
|
||||
? [["content-warning", info.streamInfo?.content_warning]]
|
||||
: []),
|
||||
...(info.streamInfo?.tags?.map((a) => ["t", a]) ?? []),
|
||||
...(info.streamInfo?.content_warning ? [["content-warning", info.streamInfo?.content_warning]] : []),
|
||||
...(info.streamInfo?.tags?.map(a => ["t", a]) ?? []),
|
||||
],
|
||||
} as NostrEvent
|
||||
}
|
||||
|
@ -14,10 +14,7 @@ export function Note({ ev }: { ev: NostrEvent }) {
|
||||
<ExternalIconLink
|
||||
size={24}
|
||||
className="note-link-icon"
|
||||
href={`https://snort.social/e/${hexToBech32(
|
||||
NostrPrefix.Event,
|
||||
ev.id
|
||||
)}`}
|
||||
href={`https://snort.social/e/${hexToBech32(NostrPrefix.Event, ev.id)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="note-content">
|
||||
|
@ -44,8 +44,7 @@ export function Profile({
|
||||
linkToProfile?: boolean;
|
||||
}) {
|
||||
const { inView, ref } = useInView();
|
||||
const pLoaded =
|
||||
useUserProfile(inView && !profile ? pubkey : undefined) || profile;
|
||||
const pLoaded = useUserProfile(inView && !profile ? pubkey : undefined) || profile;
|
||||
const showAvatar = options?.showAvatar ?? true;
|
||||
const showName = options?.showName ?? true;
|
||||
const placeholder = usePlaceholder(pubkey);
|
||||
@ -63,13 +62,7 @@ export function Profile({
|
||||
/>
|
||||
))}
|
||||
{icon}
|
||||
{showName && (
|
||||
<span>
|
||||
{options?.overrideName ?? pubkey === "anon"
|
||||
? "Anon"
|
||||
: getName(pubkey, pLoaded)}
|
||||
</span>
|
||||
)}
|
||||
{showName && <span>{options?.overrideName ?? pubkey === "anon" ? "Anon" : getName(pubkey, pLoaded)}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -78,11 +71,7 @@ export function Profile({
|
||||
{content}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to={`/p/${hexToBech32("npub", pubkey)}`}
|
||||
className="profile"
|
||||
ref={ref}
|
||||
>
|
||||
<Link to={`/p/${hexToBech32("npub", pubkey)}`} className="profile" ref={ref}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
|
@ -46,10 +46,5 @@ export default function QrCode(props: QrCodeProps) {
|
||||
}
|
||||
}, [props.data, props.link, props.width, props.height, props.avatar]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`qr${props.className ? ` ${props.className}` : ""}`}
|
||||
ref={qrRef}
|
||||
></div>
|
||||
);
|
||||
return <div className={`qr${props.className ? ` ${props.className}` : ""}`} ref={qrRef}></div>;
|
||||
}
|
||||
|
@ -13,16 +13,13 @@ import QrCode from "./qr-code";
|
||||
import { useLogin } from "hooks/login";
|
||||
import Copy from "./copy";
|
||||
import { defaultRelays } from "const";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export interface LNURLLike {
|
||||
get name(): string;
|
||||
get maxCommentLength(): number;
|
||||
get canZap(): boolean;
|
||||
getInvoice(
|
||||
amountInSats: number,
|
||||
comment?: string,
|
||||
zap?: NostrEvent
|
||||
): Promise<{ pr?: string }>;
|
||||
getInvoice(amountInSats: number, comment?: string, zap?: NostrEvent): Promise<{ pr?: string }>;
|
||||
}
|
||||
|
||||
export interface SendZapsProps {
|
||||
@ -35,19 +32,12 @@ export interface SendZapsProps {
|
||||
button?: ReactNode;
|
||||
}
|
||||
|
||||
export function SendZaps({
|
||||
lnurl,
|
||||
pubkey,
|
||||
aTag,
|
||||
eTag,
|
||||
targetName,
|
||||
onFinish,
|
||||
}: SendZapsProps) {
|
||||
export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: SendZapsProps) {
|
||||
const UsdRate = 28_000;
|
||||
|
||||
const satsAmounts = [
|
||||
21, 69, 121, 221, 420, 1_000, 2_100, 5_000, 6_666, 10_000, 21_000, 42_000,
|
||||
69_000, 100_000, 210_000, 500_000, 1_000_000,
|
||||
21, 69, 121, 221, 420, 1_000, 2_100, 5_000, 6_666, 10_000, 21_000, 42_000, 69_000, 100_000, 210_000, 500_000,
|
||||
1_000_000,
|
||||
];
|
||||
const usdAmounts = [0.05, 0.5, 2, 5, 10, 50, 100, 200];
|
||||
const [isFiat, setIsFiat] = useState(false);
|
||||
@ -79,34 +69,25 @@ export function SendZaps({
|
||||
let pub = login?.publisher();
|
||||
let isAnon = false;
|
||||
if (!pub) {
|
||||
pub = EventPublisher.privateKey(
|
||||
bytesToHex(secp256k1.utils.randomPrivateKey())
|
||||
);
|
||||
pub = EventPublisher.privateKey(bytesToHex(secp256k1.utils.randomPrivateKey()));
|
||||
isAnon = true;
|
||||
}
|
||||
|
||||
const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount;
|
||||
let zap: NostrEvent | undefined;
|
||||
if (pubkey) {
|
||||
zap = await pub.zap(
|
||||
amountInSats * 1000,
|
||||
pubkey,
|
||||
relays,
|
||||
undefined,
|
||||
comment,
|
||||
(eb) => {
|
||||
if (aTag) {
|
||||
eb.tag(["a", aTag]);
|
||||
}
|
||||
if (eTag) {
|
||||
eb.tag(["e", eTag]);
|
||||
}
|
||||
if (isAnon) {
|
||||
eb.tag(["anon", ""]);
|
||||
}
|
||||
return eb;
|
||||
zap = await pub.zap(amountInSats * 1000, pubkey, relays, undefined, comment, eb => {
|
||||
if (aTag) {
|
||||
eb.tag(["a", aTag]);
|
||||
}
|
||||
);
|
||||
if (eTag) {
|
||||
eb.tag(["e", eTag]);
|
||||
}
|
||||
if (isAnon) {
|
||||
eb.tag(["anon", ""]);
|
||||
}
|
||||
return eb;
|
||||
});
|
||||
}
|
||||
const invoice = await svc.getInvoice(amountInSats, comment, zap);
|
||||
if (!invoice.pr) return;
|
||||
@ -134,8 +115,7 @@ export function SendZaps({
|
||||
onClick={() => {
|
||||
setIsFiat(false);
|
||||
setAmount(satsAmounts[0]);
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
SATS
|
||||
</span>
|
||||
<span
|
||||
@ -143,20 +123,20 @@ export function SendZaps({
|
||||
onClick={() => {
|
||||
setIsFiat(true);
|
||||
setAmount(usdAmounts[0]);
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
USD
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<small>Zap amount in {isFiat ? "USD" : "sats"}</small>
|
||||
<small>
|
||||
<FormattedMessage
|
||||
defaultMessage={"Zap amount in {currency}"}
|
||||
values={{ amount: isFiat ? "USD" : "sats" }}
|
||||
/>
|
||||
</small>
|
||||
<div className="amounts">
|
||||
{(isFiat ? usdAmounts : satsAmounts).map((a) => (
|
||||
<span
|
||||
key={a}
|
||||
className={`pill${a === amount ? " active" : ""}`}
|
||||
onClick={() => setAmount(a)}
|
||||
>
|
||||
{(isFiat ? usdAmounts : satsAmounts).map(a => (
|
||||
<span key={a} className={`pill${a === amount ? " active" : ""}`} onClick={() => setAmount(a)}>
|
||||
{isFiat ? `$${a.toLocaleString()}` : formatSats(a)}
|
||||
</span>
|
||||
))}
|
||||
@ -164,19 +144,17 @@ export function SendZaps({
|
||||
</div>
|
||||
{svc && (svc.maxCommentLength > 0 || svc.canZap) && (
|
||||
<div>
|
||||
<small>Your comment for {name}</small>
|
||||
<small>
|
||||
<FormattedMessage defaultMessage="Your comment for {name}" values={{ name }} />
|
||||
</small>
|
||||
<div className="paper">
|
||||
<textarea
|
||||
placeholder="Nice!"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
<textarea placeholder="Nice!" value={comment} onChange={e => setComment(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<AsyncButton onClick={send} className="btn btn-primary">
|
||||
Zap!
|
||||
<FormattedMessage defaultMessage="Zap!" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
@ -194,7 +172,7 @@ export function SendZaps({
|
||||
<Copy text={invoice} />
|
||||
</div>
|
||||
<button className="btn btn-primary wide" onClick={() => onFinish()}>
|
||||
Back
|
||||
<FormattedMessage defaultMessage="Back" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
@ -203,7 +181,7 @@ export function SendZaps({
|
||||
return (
|
||||
<div className="send-zap">
|
||||
<h3>
|
||||
Zap {name}
|
||||
<FormattedMessage defaultMessage="Zap {name}" values={{ name }} />
|
||||
<Icon name="zap" />
|
||||
</h3>
|
||||
{input()}
|
||||
@ -221,7 +199,9 @@ export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
|
||||
props.button
|
||||
) : (
|
||||
<button className="btn btn-primary zap">
|
||||
<span className="hide-on-mobile">Zap</span>
|
||||
<span className="hide-on-mobile">
|
||||
<FormattedMessage defaultMessage="Zap" />
|
||||
</span>
|
||||
<Icon name="zap-filled" size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
@ -10,6 +10,7 @@ import { findTag } from "utils";
|
||||
import AsyncButton from "./async-button";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { System } from "index";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
type ShareOn = "nostr" | "twitter";
|
||||
|
||||
@ -18,13 +19,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
|
||||
const [message, setMessage] = useState("");
|
||||
const login = useLogin();
|
||||
|
||||
const naddr = encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
unwrap(findTag(ev, "d")),
|
||||
undefined,
|
||||
ev.kind,
|
||||
ev.pubkey
|
||||
);
|
||||
const naddr = encodeTLV(NostrPrefix.Address, unwrap(findTag(ev, "d")), undefined, ev.kind, ev.pubkey);
|
||||
const link = `https://zap.stream/${naddr}`;
|
||||
|
||||
async function sendMessage() {
|
||||
@ -45,35 +40,30 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
|
||||
menuClassName="ctx-menu"
|
||||
menuButton={
|
||||
<button type="button" className="btn btn-secondary">
|
||||
Share
|
||||
<FormattedMessage defaultMessage="Share" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setMessage(
|
||||
`Come check out my stream on zap.stream!\n\n${link}\n\nnostr:${naddr}`
|
||||
);
|
||||
setMessage(`Come check out my stream on zap.stream!\n\n${link}\n\nnostr:${naddr}`);
|
||||
setShare("nostr");
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
<Icon name="nostrich" size={24} />
|
||||
Broadcast on Nostr
|
||||
<FormattedMessage defaultMessage="Broadcast on Nostr" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<Dialog.Root
|
||||
open={Boolean(share)}
|
||||
onOpenChange={() => setShare(undefined)}
|
||||
>
|
||||
<Dialog.Root open={Boolean(share)} onOpenChange={() => setShare(undefined)}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-overlay" />
|
||||
<Dialog.Content className="dialog-content">
|
||||
<h2>Share</h2>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Share" />
|
||||
</h2>
|
||||
<div className="paper">
|
||||
<Textarea
|
||||
emojis={[]}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
onKeyDown={() => {
|
||||
//noop
|
||||
}}
|
||||
@ -81,7 +71,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
|
||||
/>
|
||||
</div>
|
||||
<AsyncButton className="btn btn-primary" onClick={sendMessage}>
|
||||
Send
|
||||
<FormattedMessage defaultMessage="Send" />
|
||||
</AsyncButton>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
|
@ -7,13 +7,7 @@ export interface IconProps {
|
||||
}
|
||||
|
||||
const Spinner = (props: IconProps) => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
{...props}
|
||||
>
|
||||
<svg width="20" height="20" stroke="currentColor" viewBox="0 0 20 20" {...props}>
|
||||
<g className="spinner_V8m1">
|
||||
<circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle>
|
||||
</g>
|
||||
|
@ -2,9 +2,5 @@ import "./state-pill.css";
|
||||
import { StreamState } from "index";
|
||||
|
||||
export function StatePill({ state }: { state: StreamState }) {
|
||||
return (
|
||||
<span className={`state pill${state === StreamState.Live ? " live" : ""}`}>
|
||||
{state}
|
||||
</span>
|
||||
);
|
||||
return <span className={`state pill${state === StreamState.Live ? " live" : ""}`}>{state}</span>;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import "./stream-cards.css";
|
||||
|
||||
import { useState, forwardRef } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { DndProvider, useDrag, useDrop } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
@ -37,30 +38,27 @@ interface CardPreviewProps extends NewCard {
|
||||
style: object;
|
||||
}
|
||||
|
||||
const CardPreview = forwardRef(
|
||||
({ style, title, link, image, content }: CardPreviewProps, ref) => {
|
||||
const isImageOnly = !isEmpty(image) && isEmpty(content) && isEmpty(title);
|
||||
return (
|
||||
<div
|
||||
className={`stream-card ${isImageOnly ? "image-card" : ""}`}
|
||||
// @ts-expect-error: Type 'ForwardRef<unknown>'
|
||||
ref={ref}
|
||||
style={style}
|
||||
>
|
||||
{title && <h1 className="card-title">{title}</h1>}
|
||||
{image &&
|
||||
(link && link?.length > 0 ? (
|
||||
<ExternalLink href={link}>
|
||||
<img className="card-image" src={image} alt={title} />
|
||||
</ExternalLink>
|
||||
) : (
|
||||
const CardPreview = forwardRef(({ style, title, link, image, content }: CardPreviewProps, ref) => {
|
||||
const isImageOnly = !isEmpty(image) && isEmpty(content) && isEmpty(title);
|
||||
return (
|
||||
<div
|
||||
className={`stream-card ${isImageOnly ? "image-card" : ""}`}
|
||||
// @ts-expect-error: Type 'ForwardRef<unknown>'
|
||||
ref={ref}
|
||||
style={style}>
|
||||
{title && <h1 className="card-title">{title}</h1>}
|
||||
{image &&
|
||||
(link && link?.length > 0 ? (
|
||||
<ExternalLink href={link}>
|
||||
<img className="card-image" src={image} alt={title} />
|
||||
))}
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
</ExternalLink>
|
||||
) : (
|
||||
<img className="card-image" src={image} alt={title} />
|
||||
))}
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface CardProps {
|
||||
canEdit?: boolean;
|
||||
@ -88,7 +86,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
canDrag: () => {
|
||||
return Boolean(canEdit);
|
||||
},
|
||||
collect: (monitor) => {
|
||||
collect: monitor => {
|
||||
const isDragging = monitor.isDragging();
|
||||
return {
|
||||
opacity: isDragging ? 0.1 : 1,
|
||||
@ -100,7 +98,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
);
|
||||
|
||||
function findTagByIdentifier(d: string) {
|
||||
return tags.find((t) => t[1].endsWith(`:${d}`));
|
||||
return tags.find(t => t[1].endsWith(`:${d}`));
|
||||
}
|
||||
|
||||
const [dropStyle, dropRef] = useDrop(
|
||||
@ -109,7 +107,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
canDrop: () => {
|
||||
return Boolean(canEdit);
|
||||
},
|
||||
collect: (monitor) => {
|
||||
collect: monitor => {
|
||||
const isOvering = monitor.isOver({ shallow: true });
|
||||
return {
|
||||
opacity: isOvering ? 0.3 : 1,
|
||||
@ -123,7 +121,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
}
|
||||
const newItem = findTagByIdentifier(typed.identifier);
|
||||
const oldItem = findTagByIdentifier(identifier);
|
||||
const newTags = tags.map((t) => {
|
||||
const newTags = tags.map(t => {
|
||||
if (t === oldItem) {
|
||||
return newItem;
|
||||
}
|
||||
@ -134,7 +132,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
}) as Tags;
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const userCardsEv = await pub.generic((eb) => {
|
||||
const userCardsEv = await pub.generic(eb => {
|
||||
eb.kind(USER_CARDS).content("");
|
||||
for (const tag of newTags) {
|
||||
eb.tag(tag);
|
||||
@ -151,14 +149,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
||||
);
|
||||
|
||||
const card = (
|
||||
<CardPreview
|
||||
ref={dropRef}
|
||||
title={title}
|
||||
link={link}
|
||||
image={image}
|
||||
content={content}
|
||||
style={dropStyle}
|
||||
/>
|
||||
<CardPreview ref={dropRef} title={title} link={link} image={image} content={content} style={dropStyle} />
|
||||
);
|
||||
const editor = canEdit && (
|
||||
<div className="editor-buttons">
|
||||
@ -184,14 +175,7 @@ interface CardDialogProps {
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function CardDialog({
|
||||
header,
|
||||
cta,
|
||||
cancelCta,
|
||||
card,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: CardDialogProps) {
|
||||
function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: CardDialogProps) {
|
||||
const [title, setTitle] = useState(card?.title ?? "");
|
||||
const [image, setImage] = useState(card?.image ?? "");
|
||||
const [content, setContent] = useState(card?.content ?? "");
|
||||
@ -199,58 +183,63 @@ function CardDialog({
|
||||
|
||||
return (
|
||||
<div className="new-card">
|
||||
<h3>{header || "Add card"}</h3>
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage={header || "Add card"} />
|
||||
</h3>
|
||||
<div className="form-control">
|
||||
<label htmlFor="card-title">Title</label>
|
||||
<label htmlFor="card-title">
|
||||
<FormattedMessage defaultMessage="Title" />
|
||||
</label>
|
||||
<input
|
||||
id="card-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="e.g. about me"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label htmlFor="card-image">Image</label>
|
||||
<FileUploader
|
||||
defaultImage={image}
|
||||
onFileUpload={setImage}
|
||||
onClear={() => setImage("")}
|
||||
/>
|
||||
<label htmlFor="card-image">
|
||||
<FormattedMessage defaultMessage="Image" />
|
||||
</label>
|
||||
<FileUploader defaultImage={image} onFileUpload={setImage} onClear={() => setImage("")} />
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label htmlFor="card-image-link">Image Link</label>
|
||||
<label htmlFor="card-image-link">
|
||||
<FormattedMessage defaultMessage="Image Link" />
|
||||
</label>
|
||||
<input
|
||||
id="card-image-link"
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
onChange={e => setLink(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label htmlFor="card-content">Content</label>
|
||||
<textarea
|
||||
placeholder="Start typing..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="card-content">
|
||||
<FormattedMessage defaultMessage="Content" />
|
||||
</label>
|
||||
<textarea placeholder="Start typing..." value={content} onChange={e => setContent(e.target.value)} />
|
||||
<span className="help-text">
|
||||
Supports{" "}
|
||||
<ExternalLink href="https://www.markdownguide.org/cheat-sheet">
|
||||
Markdown
|
||||
</ExternalLink>
|
||||
<FormattedMessage
|
||||
defaultMessage="Supports {markdown}"
|
||||
values={{
|
||||
markdown: (
|
||||
<ExternalLink href="https://www.markdownguide.org/cheat-sheet">
|
||||
<FormattedMessage defaultMessage="Markdown" />
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="new-card-buttons">
|
||||
<button
|
||||
className="btn btn-primary add-button"
|
||||
onClick={() => onSave({ title, image, content, link })}
|
||||
>
|
||||
{cta || "Add Card"}
|
||||
<button className="btn btn-primary add-button" onClick={() => onSave({ title, image, content, link })}>
|
||||
<FormattedMessage defaultMessage={cta || "Add Card"} />
|
||||
</button>
|
||||
<button className="btn delete-button" onClick={onCancel}>
|
||||
{cancelCta || "Cancel"}
|
||||
<FormattedMessage defaultMessage={cancelCta || "Cancel"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -267,11 +256,12 @@ function EditCard({ card, cards }: EditCardProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const identifier = card.identifier;
|
||||
const tags = cards.map(toTag);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
async function editCard({ title, image, link, content }: CardType) {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const ev = await pub.generic((eb) => {
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(CARD).content(content).tag(["d", card.identifier]);
|
||||
if (title && title?.length > 0) {
|
||||
eb.tag(["title", title]);
|
||||
@ -293,8 +283,8 @@ function EditCard({ card, cards }: EditCardProps) {
|
||||
async function onCancel() {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const newTags = tags.filter((t) => !t[1].endsWith(`:${identifier}`));
|
||||
const userCardsEv = await pub.generic((eb) => {
|
||||
const newTags = tags.filter(t => !t[1].endsWith(`:${identifier}`));
|
||||
const userCardsEv = await pub.generic(eb => {
|
||||
eb.kind(USER_CARDS).content("");
|
||||
for (const tag of newTags) {
|
||||
eb.tag(tag);
|
||||
@ -312,15 +302,17 @@ function EditCard({ card, cards }: EditCardProps) {
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button className="btn btn-primary">Edit</button>
|
||||
<button className="btn btn-primary">
|
||||
<FormattedMessage defaultMessage="Edit" />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-overlay" />
|
||||
<Dialog.Content className="dialog-content">
|
||||
<CardDialog
|
||||
header="Edit card"
|
||||
cta="Save Card"
|
||||
cancelCta="Delete"
|
||||
header={formatMessage({ defaultMessage: "Edit card" })}
|
||||
cta={formatMessage({ defaultMessage: "Save card" })}
|
||||
cancelCta={formatMessage({ defaultMessage: "Delete" })}
|
||||
card={card}
|
||||
onSave={editCard}
|
||||
onCancel={onCancel}
|
||||
@ -343,7 +335,7 @@ function AddCard({ cards }: AddCardProps) {
|
||||
async function createCard({ title, image, link, content }: NewCard) {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const ev = await pub.generic((eb) => {
|
||||
const ev = await pub.generic(eb => {
|
||||
const d = String(Date.now());
|
||||
eb.kind(CARD).content(content).tag(["d", d]);
|
||||
if (title && title?.length > 0) {
|
||||
@ -357,7 +349,7 @@ function AddCard({ cards }: AddCardProps) {
|
||||
}
|
||||
return eb;
|
||||
});
|
||||
const userCardsEv = await pub.generic((eb) => {
|
||||
const userCardsEv = await pub.generic(eb => {
|
||||
eb.kind(USER_CARDS).content("");
|
||||
for (const tag of tags) {
|
||||
eb.tag(tag);
|
||||
@ -407,18 +399,13 @@ export function StreamCardEditor({ pubkey, tags }: StreamCardEditorProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="stream-cards">
|
||||
{cards.map((ev) => (
|
||||
{cards.map(ev => (
|
||||
<Card canEdit={isEditing} cards={cards} key={ev.id} ev={ev} />
|
||||
))}
|
||||
{isEditing && <AddCard cards={cards} />}
|
||||
</div>
|
||||
<div className="edit-container">
|
||||
<Toggle
|
||||
pressed={isEditing}
|
||||
onPressedChange={setIsEditing}
|
||||
label="Toggle edit mode"
|
||||
text="Edit cards"
|
||||
/>
|
||||
<Toggle pressed={isEditing} onPressedChange={setIsEditing} label="Toggle edit mode" text="Edit cards" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@ -432,7 +419,7 @@ export function ReadOnlyStreamCards({ host }: StreamCardsProps) {
|
||||
const cards = useCards(host);
|
||||
return (
|
||||
<div className="stream-cards">
|
||||
{cards.map((ev) => (
|
||||
{cards.map(ev => (
|
||||
<Card cards={cards} key={ev.id} ev={ev} />
|
||||
))}
|
||||
</div>
|
||||
|
@ -8,6 +8,7 @@ import AsyncButton from "./async-button";
|
||||
import { StreamState } from "../index";
|
||||
import { findTag } from "../utils";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
export interface StreamEditorProps {
|
||||
ev?: NostrEvent;
|
||||
@ -34,6 +35,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
const [contentWarning, setContentWarning] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const login = useLogin();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(findTag(ev, "title") ?? "");
|
||||
@ -42,7 +44,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
setStream(findTag(ev, "streaming") ?? "");
|
||||
setStatus(findTag(ev, "status") ?? StreamState.Live);
|
||||
setStart(findTag(ev, "starts"));
|
||||
setTags(ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? []);
|
||||
setTags(ev?.tags.filter(a => a[0] === "t").map(a => a[1]) ?? []);
|
||||
setContentWarning(findTag(ev, "content-warning") !== undefined);
|
||||
}, [ev?.id]);
|
||||
|
||||
@ -66,7 +68,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
async function publishStream() {
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
const evNew = await pub.generic((eb) => {
|
||||
const evNew = await pub.generic(eb => {
|
||||
const now = unixNow();
|
||||
const dTag = findTag(ev, "d") ?? now.toString();
|
||||
const starts = start ?? now.toString();
|
||||
@ -108,85 +110,81 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
|
||||
{(options?.canSetTitle ?? true) && (
|
||||
<div>
|
||||
<p>Title</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Title" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="What are we steaming today?"
|
||||
placeholder={formatMessage({ defaultMessage: "What are we steaming today?" })}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(options?.canSetSummary ?? true) && (
|
||||
<div>
|
||||
<p>Summary</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Summary" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="A short description of the content"
|
||||
placeholder={formatMessage({ defaultMessage: "A short description of the content" })}
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
onChange={e => setSummary(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(options?.canSetImage ?? true) && (
|
||||
<div>
|
||||
<p>Cover image</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Cover Image" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
/>
|
||||
<input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(options?.canSetStream ?? true) && (
|
||||
<div>
|
||||
<p>Stream Url</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Stream URL" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
value={stream}
|
||||
onChange={(e) => setStream(e.target.value)}
|
||||
/>
|
||||
<input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
|
||||
</div>
|
||||
<small>Stream type should be HLS</small>
|
||||
<small>
|
||||
<FormattedMessage defaultMessage="Stream type should be HLS" />
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{(options?.canSetStatus ?? true) && (
|
||||
<>
|
||||
<div>
|
||||
<p>Status</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Status" />
|
||||
</p>
|
||||
<div className="flex g12">
|
||||
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(
|
||||
(v) => (
|
||||
<span
|
||||
className={`pill${status === v ? " active" : ""}`}
|
||||
onClick={() => setStatus(v)}
|
||||
key={v}
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => (
|
||||
<span className={`pill${status === v ? " active" : ""}`} onClick={() => setStatus(v)} key={v}>
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{status === StreamState.Planned && (
|
||||
<div>
|
||||
<p>Start Time</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Start Time" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toDateTimeString(Number(start ?? "0"))}
|
||||
onChange={(e) =>
|
||||
setStart(fromDateTimeString(e.target.value).toString())
|
||||
}
|
||||
onChange={e => setStart(fromDateTimeString(e.target.value).toString())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -195,40 +193,30 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
)}
|
||||
{(options?.canSetTags ?? true) && (
|
||||
<div>
|
||||
<p>Tags</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Tags" />
|
||||
</p>
|
||||
<div className="paper">
|
||||
<TagsInput
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
placeHolder="Music,DJ,English"
|
||||
separators={["Enter", ","]}
|
||||
/>
|
||||
<TagsInput value={tags} onChange={setTags} placeHolder="Music,DJ,English" separators={["Enter", ","]} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(options?.canSetContentWarning ?? true) && (
|
||||
<div className="flex g12 content-warning">
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contentWarning}
|
||||
onChange={(e) => setContentWarning(e.target.checked)}
|
||||
/>
|
||||
<input type="checkbox" checked={contentWarning} onChange={e => setContentWarning(e.target.checked)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="warning">NSFW Content</div>
|
||||
Check here if this stream contains nudity or pornographic content.
|
||||
<div className="warning">
|
||||
<FormattedMessage defaultMessage="NSFW Content" />
|
||||
</div>
|
||||
<FormattedMessage defaultMessage="Check here if this stream contains nudity or pornographic content." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-primary wide"
|
||||
disabled={!isValid}
|
||||
onClick={publishStream}
|
||||
>
|
||||
{ev ? "Save" : "Start Stream"}
|
||||
<AsyncButton type="button" className="btn btn-primary wide" disabled={!isValid} onClick={publishStream}>
|
||||
<FormattedMessage defaultMessage={ev ? "Save" : "Start Stream"} />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
|
@ -11,9 +11,7 @@ export function StreamTimer({ ev }: { ev?: NostrEvent }) {
|
||||
const diff = unixNow() - starts;
|
||||
const hours = Number(diff / 60.0 / 60.0);
|
||||
const mins = Number((diff / 60) % 60);
|
||||
setTime(
|
||||
`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}`
|
||||
);
|
||||
setTime(`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import moment from "moment";
|
||||
|
||||
import { NostrEvent } from "@snort/system";
|
||||
@ -6,15 +7,7 @@ import { NostrEvent } from "@snort/system";
|
||||
import { StreamState } from "index";
|
||||
import { findTag, getTagValues } from "utils";
|
||||
|
||||
export function Tags({
|
||||
children,
|
||||
max,
|
||||
ev,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
max?: number;
|
||||
ev: NostrEvent;
|
||||
}) {
|
||||
export function Tags({ children, max, ev }: { children?: ReactNode; max?: number; ev: NostrEvent }) {
|
||||
const status = findTag(ev, "status");
|
||||
const start = findTag(ev, "starts");
|
||||
const hashtags = getTagValues(ev.tags, "t");
|
||||
@ -25,11 +18,11 @@ export function Tags({
|
||||
{children}
|
||||
{status === StreamState.Planned && (
|
||||
<span className="pill">
|
||||
{status === StreamState.Planned ? "Starts " : ""}
|
||||
{status === StreamState.Planned ? <FormattedMessage defaultMessage="Starts " /> : ""}
|
||||
{moment(Number(start) * 1000).fromNow()}
|
||||
</span>
|
||||
)}
|
||||
{tags.map((a) => (
|
||||
{tags.map(a => (
|
||||
<a href={`/t/${encodeURIComponent(a)}`} className="pill" key={a}>
|
||||
{a}
|
||||
</a>
|
||||
|
@ -1,10 +1,6 @@
|
||||
import { useMemo, type ReactNode, type FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
type NostrLink,
|
||||
parseNostrLink,
|
||||
validateNostrLink,
|
||||
} from "@snort/system";
|
||||
import { type NostrLink, parseNostrLink, validateNostrLink } from "@snort/system";
|
||||
|
||||
import { Event } from "element/Event";
|
||||
import { Mention } from "element/mention";
|
||||
@ -20,23 +16,17 @@ const EmojiRegex = /:([\w-]+):/g;
|
||||
|
||||
function extractLinks(fragments: Fragment[]) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return splitByUrl(f).map((a) => {
|
||||
return splitByUrl(f).map(a => {
|
||||
const validateLink = () => {
|
||||
const normalizedStr = a.toLowerCase();
|
||||
|
||||
if (
|
||||
normalizedStr.startsWith("web+nostr:") ||
|
||||
normalizedStr.startsWith("nostr:")
|
||||
) {
|
||||
if (normalizedStr.startsWith("web+nostr:") || normalizedStr.startsWith("nostr:")) {
|
||||
return validateNostrLink(normalizedStr);
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedStr.startsWith("http:") ||
|
||||
normalizedStr.startsWith("https:")
|
||||
);
|
||||
return normalizedStr.startsWith("http:") || normalizedStr.startsWith("https:");
|
||||
};
|
||||
|
||||
if (validateLink()) {
|
||||
@ -52,10 +42,10 @@ function extractLinks(fragments: Fragment[]) {
|
||||
|
||||
function extractEmoji(fragments: Fragment[], tags: string[][]) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(EmojiRegex).map((i) => {
|
||||
const t = tags.find((a) => a[0] === "emoji" && a[1] === i);
|
||||
return f.split(EmojiRegex).map(i => {
|
||||
const t = tags.find(a => a[0] === "emoji" && a[1] === i);
|
||||
if (t) {
|
||||
return <Emoji name={t[1]} url={t[2]} />;
|
||||
} else {
|
||||
@ -70,9 +60,9 @@ function extractEmoji(fragments: Fragment[], tags: string[][]) {
|
||||
|
||||
function extractNprofiles(fragments: Fragment[]) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(/(nostr:nprofile1[a-z0-9]+)/g).map((i) => {
|
||||
return f.split(/(nostr:nprofile1[a-z0-9]+)/g).map(i => {
|
||||
if (i.startsWith("nostr:nprofile1")) {
|
||||
try {
|
||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||
@ -92,9 +82,9 @@ function extractNprofiles(fragments: Fragment[]) {
|
||||
|
||||
function extractNpubs(fragments: Fragment[]) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(/(nostr:npub1[a-z0-9]+)/g).map((i) => {
|
||||
return f.split(/(nostr:npub1[a-z0-9]+)/g).map(i => {
|
||||
if (i.startsWith("nostr:npub1")) {
|
||||
try {
|
||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||
@ -114,9 +104,9 @@ function extractNpubs(fragments: Fragment[]) {
|
||||
|
||||
function extractNevents(fragments: Fragment[], Event: NostrComponent) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(/(nostr:nevent1[a-z0-9]+)/g).map((i) => {
|
||||
return f.split(/(nostr:nevent1[a-z0-9]+)/g).map(i => {
|
||||
if (i.startsWith("nostr:nevent1")) {
|
||||
try {
|
||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||
@ -136,9 +126,9 @@ function extractNevents(fragments: Fragment[], Event: NostrComponent) {
|
||||
|
||||
function extractNaddrs(fragments: Fragment[], Address: NostrComponent) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(/(nostr:naddr1[a-z0-9]+)/g).map((i) => {
|
||||
return f.split(/(nostr:naddr1[a-z0-9]+)/g).map(i => {
|
||||
if (i.startsWith("nostr:naddr1")) {
|
||||
try {
|
||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||
@ -159,9 +149,9 @@ function extractNaddrs(fragments: Fragment[], Address: NostrComponent) {
|
||||
|
||||
function extractNoteIds(fragments: Fragment[], Event: NostrComponent) {
|
||||
return fragments
|
||||
.map((f) => {
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(/(nostr:note1[a-z0-9]+)/g).map((i) => {
|
||||
return f.split(/(nostr:note1[a-z0-9]+)/g).map(i => {
|
||||
if (i.startsWith("nostr:note1")) {
|
||||
try {
|
||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||
@ -189,11 +179,7 @@ const components: NostrComponents = {
|
||||
Event,
|
||||
};
|
||||
|
||||
export function transformText(
|
||||
ps: Fragment[],
|
||||
tags: Array<string[]>,
|
||||
customComponents = components
|
||||
) {
|
||||
export function transformText(ps: Fragment[], tags: Array<string[]>, customComponents = components) {
|
||||
let fragments = extractEmoji(ps, tags);
|
||||
fragments = extractNprofiles(fragments);
|
||||
fragments = extractNevents(fragments, customComponents.Event);
|
||||
@ -214,11 +200,7 @@ interface TextProps {
|
||||
export function Text({ content, tags, customComponents }: TextProps) {
|
||||
// todo: RTL langugage support
|
||||
const element = useMemo(() => {
|
||||
return (
|
||||
<span className="text">
|
||||
{transformText([content], tags, customComponents)}
|
||||
</span>
|
||||
);
|
||||
return <span className="text">{transformText([content], tags, customComponents)}</span>;
|
||||
}, [content, tags]);
|
||||
|
||||
return <>{element}</>;
|
||||
|
@ -1,8 +1,6 @@
|
||||
import "./textarea.css";
|
||||
import type { KeyboardEvent, ChangeEvent } from "react";
|
||||
import ReactTextareaAutocomplete, {
|
||||
TriggerType,
|
||||
} from "@webscopeio/react-textarea-autocomplete";
|
||||
import ReactTextareaAutocomplete, { TriggerType } from "@webscopeio/react-textarea-autocomplete";
|
||||
import "@webscopeio/react-textarea-autocomplete/style.css";
|
||||
import uniqWith from "lodash/uniqWith";
|
||||
import isEqual from "lodash/isEqual";
|
||||
@ -59,7 +57,7 @@ export function Textarea({ emojis, ...props }: TextareaProps) {
|
||||
|
||||
const emojiDataProvider = (token: string) => {
|
||||
const results = emojis
|
||||
.map((t) => {
|
||||
.map(t => {
|
||||
return {
|
||||
name: t.at(1) || "",
|
||||
url: t.at(2) || "",
|
||||
@ -78,11 +76,8 @@ export function Textarea({ emojis, ...props }: TextareaProps) {
|
||||
"@": {
|
||||
afterWhitespace: true,
|
||||
dataProvider: userDataProvider,
|
||||
component: (props: { entity: MetadataCache }) => (
|
||||
<UserItem {...props.entity} />
|
||||
),
|
||||
output: (item: { pubkey: string }) =>
|
||||
`@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`,
|
||||
component: (props: { entity: MetadataCache }) => <UserItem {...props.entity} />,
|
||||
output: (item: { pubkey: string }) => `@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`,
|
||||
},
|
||||
} as TriggerType<string | object>;
|
||||
|
||||
|
@ -10,6 +10,7 @@ import { formatSats } from "number";
|
||||
import ZapStream from "../../public/zap-stream.svg";
|
||||
import { isContentWarningAccepted } from "./content-warning";
|
||||
import { Tags } from "element/tags";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function VideoTile({
|
||||
ev,
|
||||
@ -26,37 +27,22 @@ export function VideoTile({
|
||||
const image = findTag(ev, "image");
|
||||
const status = findTag(ev, "status");
|
||||
const viewers = findTag(ev, "current_participants");
|
||||
const contentWarning =
|
||||
findTag(ev, "content-warning") && !isContentWarningAccepted();
|
||||
const contentWarning = findTag(ev, "content-warning") && !isContentWarningAccepted();
|
||||
const host = getHost(ev);
|
||||
|
||||
const link = encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
id,
|
||||
undefined,
|
||||
ev.kind,
|
||||
ev.pubkey
|
||||
);
|
||||
const link = encodeTLV(NostrPrefix.Address, id, undefined, ev.kind, ev.pubkey);
|
||||
return (
|
||||
<div className="video-tile-container">
|
||||
<Link
|
||||
to={`/${link}`}
|
||||
className={`video-tile${contentWarning ? " nsfw" : ""}`}
|
||||
ref={ref}
|
||||
state={ev}
|
||||
>
|
||||
<Link to={`/${link}`} className={`video-tile${contentWarning ? " nsfw" : ""}`} ref={ref} state={ev}>
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(${
|
||||
inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : ""
|
||||
})`,
|
||||
}}
|
||||
></div>
|
||||
backgroundImage: `url(${inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : ""})`,
|
||||
}}></div>
|
||||
<span className="pill-box">
|
||||
{showStatus && <StatePill state={status as StreamState} />}
|
||||
{viewers && (
|
||||
<span className="pill viewers">
|
||||
{formatSats(Number(viewers))} viewers
|
||||
<FormattedMessage defaultMessage="{n} viewers" values={{ n: formatSats(Number(viewers)) }} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { NostrLink, EventKind } from "@snort/system";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { useLogin } from "hooks/login";
|
||||
import AsyncButton from "element/async-button";
|
||||
@ -10,20 +11,14 @@ import type { EmojiPack, Emoji } from "types";
|
||||
import { System } from "index";
|
||||
import { LIVE_STREAM_CHAT } from "const";
|
||||
|
||||
export function WriteMessage({
|
||||
link,
|
||||
emojiPacks,
|
||||
}: {
|
||||
link: NostrLink;
|
||||
emojiPacks: EmojiPack[];
|
||||
}) {
|
||||
export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks: EmojiPack[] }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const emojiRef = useRef(null);
|
||||
const [chat, setChat] = useState("");
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const login = useLogin();
|
||||
const emojis = emojiPacks.map((pack) => pack.emojis).flat();
|
||||
const names = emojis.map((t) => t.at(1));
|
||||
const emojis = emojiPacks.map(pack => pack.emojis).flat();
|
||||
const names = emojis.map(t => t.at(1));
|
||||
|
||||
const topOffset = ref.current?.getBoundingClientRect().top;
|
||||
const leftOffset = ref.current?.getBoundingClientRect().left;
|
||||
@ -39,10 +34,8 @@ export function WriteMessage({
|
||||
}
|
||||
}
|
||||
|
||||
const reply = await pub?.generic((eb) => {
|
||||
const emoji = [...emojiNames].map((name) =>
|
||||
emojis.find((e) => e.at(1) === name)
|
||||
);
|
||||
const reply = await pub?.generic(eb => {
|
||||
const emoji = [...emojiNames].map(name => emojis.find(e => e.at(1) === name));
|
||||
eb.kind(LIVE_STREAM_CHAT as EventKind)
|
||||
.content(chat)
|
||||
.tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"])
|
||||
@ -86,12 +79,7 @@ export function WriteMessage({
|
||||
return (
|
||||
<>
|
||||
<div className="paper" ref={ref}>
|
||||
<Textarea
|
||||
emojis={emojis}
|
||||
value={chat}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={(e) => setChat(e.target.value)}
|
||||
/>
|
||||
<Textarea emojis={emojis} value={chat} onKeyDown={onKeyDown} onChange={e => setChat(e.target.value)} />
|
||||
<div onClick={pickEmoji}>
|
||||
<Icon name="face" className="write-emoji-button" />
|
||||
</div>
|
||||
@ -107,7 +95,7 @@ export function WriteMessage({
|
||||
)}
|
||||
</div>
|
||||
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
|
||||
Send
|
||||
<FormattedMessage defaultMessage="Send" />
|
||||
</AsyncButton>
|
||||
</>
|
||||
);
|
||||
|
@ -5,8 +5,8 @@
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(outfit_400_latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
@ -15,9 +15,8 @@
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(outfit_400_latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
@ -26,8 +25,8 @@
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(outfit_500_latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
@ -36,9 +35,8 @@
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(outfit_500_latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
@ -47,8 +45,8 @@
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(outfit_600_latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
@ -57,9 +55,8 @@
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(outfit_600_latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
@ -68,8 +65,8 @@
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(outfit_700_latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
@ -78,7 +75,6 @@
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(outfit_700_latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
@ -1,11 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
TaggedNostrEvent,
|
||||
EventKind,
|
||||
NoteCollection,
|
||||
RequestBuilder,
|
||||
} from "@snort/system";
|
||||
import { TaggedNostrEvent, EventKind, NoteCollection, RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
import { findTag, toAddress, getTagValues } from "utils";
|
||||
@ -20,10 +15,7 @@ export function useBadges(
|
||||
const rb = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`);
|
||||
rb.withOptions({ leaveOpen });
|
||||
rb.withFilter().authors([pubkey]).kinds([EventKind.Badge]);
|
||||
rb.withFilter()
|
||||
.authors([pubkey])
|
||||
.kinds([EventKind.BadgeAward])
|
||||
.since(since);
|
||||
rb.withFilter().authors([pubkey]).kinds([EventKind.BadgeAward]).since(since);
|
||||
return rb;
|
||||
}, [pubkey, since]);
|
||||
|
||||
@ -31,15 +23,13 @@ export function useBadges(
|
||||
|
||||
const rawBadges = useMemo(() => {
|
||||
if (badgeEvents) {
|
||||
return badgeEvents
|
||||
.filter((e) => e.kind === EventKind.Badge)
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
return badgeEvents.filter(e => e.kind === EventKind.Badge).sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
return [];
|
||||
}, [badgeEvents]);
|
||||
const badgeAwards = useMemo(() => {
|
||||
if (badgeEvents) {
|
||||
return badgeEvents.filter((e) => e.kind === EventKind.BadgeAward);
|
||||
return badgeEvents.filter(e => e.kind === EventKind.BadgeAward);
|
||||
}
|
||||
return [];
|
||||
}, [badgeEvents]);
|
||||
@ -47,10 +37,7 @@ export function useBadges(
|
||||
const acceptedSub = useMemo(() => {
|
||||
if (rawBadges.length === 0) return null;
|
||||
const rb = new RequestBuilder(`accepted-badges:${pubkey.slice(0, 12)}`);
|
||||
rb.withFilter()
|
||||
.kinds([EventKind.ProfileBadges])
|
||||
.tag("d", ["profile_badges"])
|
||||
.tag("a", rawBadges.map(toAddress));
|
||||
rb.withFilter().kinds([EventKind.ProfileBadges]).tag("d", ["profile_badges"]).tag("a", rawBadges.map(toAddress));
|
||||
return rb;
|
||||
}, [rawBadges]);
|
||||
|
||||
@ -58,22 +45,16 @@ export function useBadges(
|
||||
const acceptedEvents = acceptedStream.data ?? [];
|
||||
|
||||
const badges = useMemo(() => {
|
||||
return rawBadges.map((e) => {
|
||||
return rawBadges.map(e => {
|
||||
const name = findTag(e, "d") ?? "";
|
||||
const address = toAddress(e);
|
||||
const awardEvents = badgeAwards.filter(
|
||||
(b) => findTag(b, "a") === address
|
||||
);
|
||||
const awardees = new Set(
|
||||
awardEvents.map((e) => getTagValues(e.tags, "p")).flat()
|
||||
);
|
||||
const awardEvents = badgeAwards.filter(b => findTag(b, "a") === address);
|
||||
const awardees = new Set(awardEvents.map(e => getTagValues(e.tags, "p")).flat());
|
||||
const accepted = new Set(
|
||||
acceptedEvents
|
||||
.filter((pb) => awardees.has(pb.pubkey))
|
||||
.filter((pb) =>
|
||||
pb.tags.find((t) => t.at(0) === "a" && t.at(1) === address)
|
||||
)
|
||||
.map((pb) => pb.pubkey)
|
||||
.filter(pb => awardees.has(pb.pubkey))
|
||||
.filter(pb => pb.tags.find(t => t.at(0) === "a" && t.at(1) === address))
|
||||
.map(pb => pb.pubkey)
|
||||
);
|
||||
const thumb = findTag(e, "thumb");
|
||||
const image = findTag(e, "image");
|
||||
|
@ -1,49 +1,34 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
TaggedNostrEvent,
|
||||
ReplaceableNoteStore,
|
||||
NoteCollection,
|
||||
RequestBuilder,
|
||||
} from "@snort/system";
|
||||
import { TaggedNostrEvent, ReplaceableNoteStore, NoteCollection, RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
import { USER_CARDS, CARD } from "const";
|
||||
import { findTag } from "utils";
|
||||
|
||||
export function useUserCards(
|
||||
pubkey: string,
|
||||
userCards: Array<string[]>,
|
||||
leaveOpen = false
|
||||
): TaggedNostrEvent[] {
|
||||
export function useUserCards(pubkey: string, userCards: Array<string[]>, leaveOpen = false): TaggedNostrEvent[] {
|
||||
const related = useMemo(() => {
|
||||
// filtering to only show CARD kinds for now, but in the future we could link and render anything
|
||||
if (userCards?.length > 0) {
|
||||
return userCards.filter(
|
||||
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`)
|
||||
);
|
||||
return userCards.filter(t => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`));
|
||||
}
|
||||
return [];
|
||||
}, [userCards]);
|
||||
|
||||
const subRelated = useMemo(() => {
|
||||
if (!pubkey) return null;
|
||||
const splitted = related.map((t) => t[1].split(":"));
|
||||
const splitted = related.map(t => t[1].split(":"));
|
||||
const authors = splitted
|
||||
.map((s) => s.at(1))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(1))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
const identifiers = splitted
|
||||
.map((s) => s.at(2))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(2))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
|
||||
const rb = new RequestBuilder(`cards:${pubkey}`);
|
||||
rb.withOptions({ leaveOpen })
|
||||
.withFilter()
|
||||
.kinds([CARD])
|
||||
.authors(authors)
|
||||
.tag("d", identifiers);
|
||||
rb.withOptions({ leaveOpen }).withFilter().kinds([CARD]).authors(authors).tag("d", identifiers);
|
||||
|
||||
return rb;
|
||||
}, [pubkey, related]);
|
||||
@ -52,27 +37,19 @@ export function useUserCards(
|
||||
|
||||
const cards = useMemo(() => {
|
||||
return related
|
||||
.map((t) => {
|
||||
.map(t => {
|
||||
const [k, pubkey, identifier] = t[1].split(":");
|
||||
const kind = Number(k);
|
||||
return (data ?? []).find(
|
||||
(e) =>
|
||||
e.kind === kind &&
|
||||
e.pubkey === pubkey &&
|
||||
findTag(e, "d") === identifier
|
||||
);
|
||||
return (data ?? []).find(e => e.kind === kind && e.pubkey === pubkey && findTag(e, "d") === identifier);
|
||||
})
|
||||
.filter((e) => e)
|
||||
.map((e) => e as TaggedNostrEvent);
|
||||
.filter(e => e)
|
||||
.map(e => e as TaggedNostrEvent);
|
||||
}, [related, data]);
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function useCards(
|
||||
pubkey: string,
|
||||
leaveOpen = false
|
||||
): TaggedNostrEvent[] {
|
||||
export function useCards(pubkey: string, leaveOpen = false): TaggedNostrEvent[] {
|
||||
const sub = useMemo(() => {
|
||||
const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`);
|
||||
b.withOptions({
|
||||
@ -89,31 +66,25 @@ export function useCards(
|
||||
const related = useMemo(() => {
|
||||
// filtering to only show CARD kinds for now, but in the future we could link and render anything
|
||||
if (userCards) {
|
||||
return userCards.tags.filter(
|
||||
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`)
|
||||
);
|
||||
return userCards.tags.filter(t => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`));
|
||||
}
|
||||
return [];
|
||||
}, [userCards]);
|
||||
|
||||
const subRelated = useMemo(() => {
|
||||
if (!pubkey) return null;
|
||||
const splitted = related.map((t) => t[1].split(":"));
|
||||
const splitted = related.map(t => t[1].split(":"));
|
||||
const authors = splitted
|
||||
.map((s) => s.at(1))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(1))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
const identifiers = splitted
|
||||
.map((s) => s.at(2))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(2))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
|
||||
const rb = new RequestBuilder(`cards:${pubkey}`);
|
||||
rb.withOptions({ leaveOpen })
|
||||
.withFilter()
|
||||
.kinds([CARD])
|
||||
.authors(authors)
|
||||
.tag("d", identifiers);
|
||||
rb.withOptions({ leaveOpen }).withFilter().kinds([CARD]).authors(authors).tag("d", identifiers);
|
||||
|
||||
return rb;
|
||||
}, [pubkey, related]);
|
||||
@ -123,18 +94,13 @@ export function useCards(
|
||||
|
||||
const cards = useMemo(() => {
|
||||
return related
|
||||
.map((t) => {
|
||||
.map(t => {
|
||||
const [k, pubkey, identifier] = t[1].split(":");
|
||||
const kind = Number(k);
|
||||
return cardEvents.find(
|
||||
(e) =>
|
||||
e.kind === kind &&
|
||||
e.pubkey === pubkey &&
|
||||
findTag(e, "d") === identifier
|
||||
);
|
||||
return cardEvents.find(e => e.kind === kind && e.pubkey === pubkey && findTag(e, "d") === identifier);
|
||||
})
|
||||
.filter((e) => e)
|
||||
.map((e) => e as TaggedNostrEvent);
|
||||
.filter(e => e)
|
||||
.map(e => e as TaggedNostrEvent);
|
||||
}, [related, cardEvents]);
|
||||
|
||||
return cards;
|
||||
|
@ -1,32 +1,17 @@
|
||||
import { unwrap } from "@snort/shared";
|
||||
import {
|
||||
NostrEvent,
|
||||
NostrLink,
|
||||
NostrPrefix,
|
||||
NoteCollection,
|
||||
RequestBuilder,
|
||||
TaggedNostrEvent,
|
||||
} from "@snort/system";
|
||||
import { NostrEvent, NostrLink, NostrPrefix, NoteCollection, RequestBuilder, TaggedNostrEvent } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { LIVE_STREAM } from "const";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function useCurrentStreamFeed(
|
||||
link: NostrLink,
|
||||
leaveOpen = false,
|
||||
evPreload?: NostrEvent
|
||||
) {
|
||||
const author =
|
||||
link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
|
||||
export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: NostrEvent) {
|
||||
const author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
|
||||
const sub = useMemo(() => {
|
||||
const b = new RequestBuilder(`current-event:${link.id}`);
|
||||
b.withOptions({
|
||||
leaveOpen,
|
||||
});
|
||||
if (
|
||||
link.type === NostrPrefix.PublicKey ||
|
||||
link.type === NostrPrefix.Profile
|
||||
) {
|
||||
if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) {
|
||||
b.withFilter().authors([link.id]).kinds([LIVE_STREAM]).limit(1);
|
||||
b.withFilter().tag("p", [link.id]).kinds([LIVE_STREAM]).limit(1);
|
||||
} else if (link.type === NostrPrefix.Address) {
|
||||
@ -49,12 +34,8 @@ export function useCurrentStreamFeed(
|
||||
|
||||
return useMemo(() => {
|
||||
const hosting = q.data?.filter(
|
||||
(a) =>
|
||||
a.pubkey === author ||
|
||||
a.tags.some((b) => b[0] === "p" && b[1] === author && b[3] === "host")
|
||||
a => a.pubkey === author || a.tags.some(b => b[0] === "p" && b[1] === author && b[3] === "host")
|
||||
);
|
||||
return [...(hosting ?? [])]
|
||||
.sort((a, b) => (b.created_at > a.created_at ? 1 : -1))
|
||||
.at(0);
|
||||
return [...(hosting ?? [])].sort((a, b) => (b.created_at > a.created_at ? 1 : -1)).at(0);
|
||||
}, [q.data]);
|
||||
}
|
||||
|
@ -1,12 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import uniqBy from "lodash.uniqby";
|
||||
|
||||
import {
|
||||
RequestBuilder,
|
||||
ReplaceableNoteStore,
|
||||
NoteCollection,
|
||||
NostrEvent,
|
||||
} from "@snort/system";
|
||||
import { RequestBuilder, ReplaceableNoteStore, NoteCollection, NostrEvent } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { findTag } from "utils";
|
||||
import { EMOJI_PACK, USER_EMOJIS } from "const";
|
||||
@ -23,8 +18,8 @@ export function toEmojiPack(ev: NostrEvent): EmojiPack {
|
||||
name: d,
|
||||
author: ev.pubkey,
|
||||
emojis: ev.tags
|
||||
.filter((t) => t.at(0) === "emoji")
|
||||
.map((t) => ["emoji", cleanShortcode(t.at(1)), t.at(2)]) as EmojiTag[],
|
||||
.filter(t => t.at(0) === "emoji")
|
||||
.map(t => ["emoji", cleanShortcode(t.at(1)), t.at(2)]) as EmojiTag[],
|
||||
};
|
||||
}
|
||||
|
||||
@ -35,24 +30,22 @@ export function packId(pack: EmojiPack): string {
|
||||
export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) {
|
||||
const related = useMemo(() => {
|
||||
if (userEmoji) {
|
||||
return userEmoji?.filter(
|
||||
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`)
|
||||
);
|
||||
return userEmoji?.filter(t => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`));
|
||||
}
|
||||
return [];
|
||||
}, [userEmoji]);
|
||||
|
||||
const subRelated = useMemo(() => {
|
||||
if (!pubkey) return null;
|
||||
const splitted = related.map((t) => t[1].split(":"));
|
||||
const splitted = related.map(t => t[1].split(":"));
|
||||
const authors = splitted
|
||||
.map((s) => s.at(1))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(1))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
const identifiers = splitted
|
||||
.map((s) => s.at(2))
|
||||
.filter((s) => s)
|
||||
.map((s) => s as string);
|
||||
.map(s => s.at(2))
|
||||
.filter(s => s)
|
||||
.map(s => s as string);
|
||||
|
||||
const rb = new RequestBuilder(`emoji-related:${pubkey}`);
|
||||
|
||||
|
@ -1,10 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
NostrPrefix,
|
||||
RequestBuilder,
|
||||
ReplaceableNoteStore,
|
||||
NostrLink,
|
||||
} from "@snort/system";
|
||||
import { NostrPrefix, RequestBuilder, ReplaceableNoteStore, NostrLink } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
export default function useEventFeed(link: NostrLink, leaveOpen = false) {
|
||||
@ -24,7 +19,7 @@ export default function useEventFeed(link: NostrLink, leaveOpen = false) {
|
||||
} else {
|
||||
const f = b.withFilter().ids([link.id]);
|
||||
if (link.relays) {
|
||||
link.relays.slice(0, 2).forEach((r) => f.relay(r));
|
||||
link.relays.slice(0, 2).forEach(r => f.relay(r));
|
||||
}
|
||||
if (link.author) {
|
||||
f.authors([link.author]);
|
||||
|
@ -1,11 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
NostrPrefix,
|
||||
ReplaceableNoteStore,
|
||||
RequestBuilder,
|
||||
type NostrLink,
|
||||
} from "@snort/system";
|
||||
import { NostrPrefix, ReplaceableNoteStore, RequestBuilder, type NostrLink } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
export function useAddress(kind: number, pubkey: string, identifier: string) {
|
||||
@ -34,7 +29,7 @@ export function useEvent(link: NostrLink) {
|
||||
} else {
|
||||
const f = b.withFilter().ids([link.id]);
|
||||
if (link.relays) {
|
||||
link.relays.slice(0, 2).forEach((r) => f.relay(r));
|
||||
link.relays.slice(0, 2).forEach(r => f.relay(r));
|
||||
}
|
||||
if (link.author) {
|
||||
f.authors([link.author]);
|
||||
|
@ -17,20 +17,13 @@ export function useZaps(goal: NostrEvent, leaveOpen = false) {
|
||||
const sub = useMemo(() => {
|
||||
const b = new RequestBuilder(`goal-zaps:${goal.id.slice(0, 12)}`);
|
||||
b.withOptions({ leaveOpen });
|
||||
b.withFilter()
|
||||
.kinds([EventKind.ZapReceipt])
|
||||
.tag("e", [goal.id])
|
||||
.since(goal.created_at);
|
||||
b.withFilter().kinds([EventKind.ZapReceipt]).tag("e", [goal.id]).since(goal.created_at);
|
||||
return b;
|
||||
}, [goal, leaveOpen]);
|
||||
|
||||
const { data } = useRequestBuilder(NoteCollection, sub);
|
||||
|
||||
return (
|
||||
data
|
||||
?.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
||||
.filter((z) => z && z.valid) ?? []
|
||||
);
|
||||
return data?.map(ev => parseZap(ev, System.ProfileLoader.Cache)).filter(z => z && z.valid) ?? [];
|
||||
}
|
||||
|
||||
export function useZapGoal(host: string, link?: NostrLink, leaveOpen = false) {
|
||||
|
@ -1,9 +1,4 @@
|
||||
import {
|
||||
NostrLink,
|
||||
RequestBuilder,
|
||||
EventKind,
|
||||
NoteCollection,
|
||||
} from "@snort/system";
|
||||
import { NostrLink, RequestBuilder, EventKind, NoteCollection } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { useMemo } from "react";
|
||||
@ -29,14 +24,14 @@ export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
|
||||
const feed = useRequestBuilder(NoteCollection, sub);
|
||||
|
||||
const messages = useMemo(() => {
|
||||
return (feed.data ?? []).filter((ev) => ev.kind === LIVE_STREAM_CHAT);
|
||||
return (feed.data ?? []).filter(ev => ev.kind === LIVE_STREAM_CHAT);
|
||||
}, [feed.data]);
|
||||
const zaps = useMemo(() => {
|
||||
return (feed.data ?? []).filter((ev) => ev.kind === EventKind.ZapReceipt);
|
||||
return (feed.data ?? []).filter(ev => ev.kind === EventKind.ZapReceipt);
|
||||
}, [feed.data]);
|
||||
|
||||
const etags = useMemo(() => {
|
||||
return messages.map((e) => e.id);
|
||||
return messages.map(e => e.id);
|
||||
}, [messages]);
|
||||
|
||||
const esub = useMemo(() => {
|
||||
@ -45,9 +40,7 @@ export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
|
||||
rb.withOptions({
|
||||
leaveOpen: true,
|
||||
});
|
||||
rb.withFilter()
|
||||
.kinds([EventKind.Reaction, EventKind.ZapReceipt])
|
||||
.tag("e", etags);
|
||||
rb.withFilter().kinds([EventKind.Reaction, EventKind.ZapReceipt]).tag("e", etags);
|
||||
return rb;
|
||||
}, [etags]);
|
||||
|
||||
|
@ -38,26 +38,18 @@ export function useStreamsFeed(tag?: string) {
|
||||
const feedSorted = useMemo(() => {
|
||||
if (feed.data) {
|
||||
if (__XXX) {
|
||||
return [...feed.data].filter(
|
||||
(a) => findTag(a, "content-warning") !== undefined
|
||||
);
|
||||
return [...feed.data].filter(a => findTag(a, "content-warning") !== undefined);
|
||||
} else {
|
||||
return [...feed.data].filter(
|
||||
(a) => findTag(a, "content-warning") === undefined
|
||||
);
|
||||
return [...feed.data].filter(a => findTag(a, "content-warning") === undefined);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}, [feed.data]);
|
||||
|
||||
const live = feedSorted
|
||||
.filter((a) => findTag(a, "status") === StreamState.Live)
|
||||
.sort(sortStarts);
|
||||
const planned = feedSorted
|
||||
.filter((a) => findTag(a, "status") === StreamState.Planned)
|
||||
.sort(sortStarts);
|
||||
const live = feedSorted.filter(a => findTag(a, "status") === StreamState.Live).sort(sortStarts);
|
||||
const planned = feedSorted.filter(a => findTag(a, "status") === StreamState.Planned).sort(sortStarts);
|
||||
const ended = feedSorted
|
||||
.filter((a) => {
|
||||
.filter(a => {
|
||||
const hasEnded = findTag(a, "status") === StreamState.Ended;
|
||||
const recording = findTag(a, "recording") ?? "";
|
||||
return hasEnded && recording?.length > 0;
|
||||
|
@ -11,7 +11,7 @@ import { Login } from "index";
|
||||
|
||||
export function useLogin() {
|
||||
const session = useSyncExternalStore(
|
||||
(c) => Login.hook(c),
|
||||
c => Login.hook(c),
|
||||
() => Login.snapshot()
|
||||
);
|
||||
if (!session) return;
|
||||
@ -26,7 +26,7 @@ export function useLogin() {
|
||||
export function useLoginEvents(pubkey?: string, leaveOpen = false) {
|
||||
const [userEmojis, setUserEmojis] = useState<Tags>([]);
|
||||
const session = useSyncExternalStore(
|
||||
(c) => Login.hook(c),
|
||||
c => Login.hook(c),
|
||||
() => Login.snapshot()
|
||||
);
|
||||
|
||||
|
@ -1,9 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function usePlaceholder(pubkey: string) {
|
||||
const url = useMemo(
|
||||
() => `https://robohash.v0l.io/${pubkey}.png?set=2`,
|
||||
[pubkey]
|
||||
);
|
||||
const url = useMemo(() => `https://robohash.v0l.io/${pubkey}.png?set=2`, [pubkey]);
|
||||
return url;
|
||||
}
|
||||
|
@ -1,11 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
RequestBuilder,
|
||||
NoteCollection,
|
||||
NostrLink,
|
||||
EventKind,
|
||||
parseZap,
|
||||
} from "@snort/system";
|
||||
import { RequestBuilder, NoteCollection, NostrLink, EventKind, parseZap } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { LIVE_STREAM } from "const";
|
||||
import { findTag } from "utils";
|
||||
@ -31,7 +25,7 @@ export function useProfile(link: NostrLink, leaveOpen = false) {
|
||||
|
||||
const addresses = useMemo(() => {
|
||||
if (streamsData) {
|
||||
return streamsData.map((e) => `${e.kind}:${e.pubkey}:${findTag(e, "d")}`);
|
||||
return streamsData.map(e => `${e.kind}:${e.pubkey}:${findTag(e, "d")}`);
|
||||
}
|
||||
return [];
|
||||
}, [streamsData]);
|
||||
@ -49,8 +43,8 @@ export function useProfile(link: NostrLink, leaveOpen = false) {
|
||||
|
||||
const { data: zapsData } = useRequestBuilder(NoteCollection, zapsSub);
|
||||
const zaps = (zapsData ?? [])
|
||||
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
||||
.filter((z) => z && z.valid && z.receiver === link.id);
|
||||
.map(ev => parseZap(ev, System.ProfileLoader.Cache))
|
||||
.filter(z => z && z.valid && z.receiver === link.id);
|
||||
|
||||
const sortedStreams = useMemo(() => {
|
||||
const sorted = [...streams];
|
||||
|
@ -3,7 +3,7 @@ import { useSyncExternalStore } from "react";
|
||||
|
||||
export function useStreamProvider() {
|
||||
return useSyncExternalStore(
|
||||
(c) => StreamProviderStore.hook(c),
|
||||
c => StreamProviderStore.hook(c),
|
||||
() => StreamProviderStore.snapshot()
|
||||
);
|
||||
}
|
||||
|
@ -2,19 +2,15 @@ import { useMemo } from "react";
|
||||
import { ParsedZap } from "@snort/system";
|
||||
|
||||
function totalZapped(pubkey: string, zaps: ParsedZap[]) {
|
||||
return zaps
|
||||
.filter((z) => (z.anonZap ? pubkey === "anon" : z.sender === pubkey))
|
||||
.reduce((acc, z) => acc + z.amount, 0);
|
||||
return zaps.filter(z => (z.anonZap ? pubkey === "anon" : z.sender === pubkey)).reduce((acc, z) => acc + z.amount, 0);
|
||||
}
|
||||
|
||||
export default function useTopZappers(zaps: ParsedZap[]) {
|
||||
const zappers = zaps
|
||||
.map((z) => (z.anonZap ? "anon" : z.sender))
|
||||
.map((p) => p as string);
|
||||
const zappers = zaps.map(z => (z.anonZap ? "anon" : z.sender)).map(p => p as string);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const pubkeys = [...new Set([...zappers])];
|
||||
const result = pubkeys.map((pubkey) => {
|
||||
const result = pubkeys.map(pubkey => {
|
||||
return { pubkey, total: totalZapped(pubkey, zaps) };
|
||||
});
|
||||
result.sort((a, b) => b.total - a.total);
|
||||
|
@ -19,11 +19,7 @@ body {
|
||||
--border: #171717;
|
||||
--gradient-purple: linear-gradient(135deg, #882bff 0%, #f83838 100%);
|
||||
--gradient-yellow: linear-gradient(270deg, #adff27 0%, #ffd027 100%);
|
||||
--gradient-orange: linear-gradient(
|
||||
270deg,
|
||||
#ff5b27 0%,
|
||||
rgba(255, 182, 39, 0.99) 100%
|
||||
);
|
||||
--gradient-orange: linear-gradient(270deg, #ff5b27 0%, rgba(255, 182, 39, 0.99) 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
@ -35,8 +31,7 @@ body {
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
a {
|
||||
@ -119,14 +114,12 @@ a {
|
||||
.btn-border {
|
||||
border: 1px solid transparent;
|
||||
color: inherit;
|
||||
background: linear-gradient(black, black) padding-box,
|
||||
linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
|
||||
background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.btn-border:hover {
|
||||
background: linear-gradient(black, black) padding-box,
|
||||
linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
|
||||
background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
|
@ -5,6 +5,7 @@ import "./fonts/outfit/outfit.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { NostrSystem } from "@snort/system";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||
|
||||
import { RootPage } from "pages/root";
|
||||
@ -17,8 +18,8 @@ import { LoginStore } from "login";
|
||||
import { StreamProvidersPage } from "pages/providers";
|
||||
import { defaultRelays } from "const";
|
||||
import { CatchAllRoutePage } from "pages/catch-all";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { register } from "serviceWorker";
|
||||
import { IntlProvider } from "intl";
|
||||
|
||||
export enum StreamState {
|
||||
Live = "live",
|
||||
@ -31,7 +32,7 @@ export const Login = new LoginStore();
|
||||
|
||||
register();
|
||||
|
||||
Object.entries(defaultRelays).forEach((params) => {
|
||||
Object.entries(defaultRelays).forEach(params => {
|
||||
const [relay, settings] = params;
|
||||
System.ConnectToRelay(relay, settings);
|
||||
});
|
||||
@ -75,13 +76,13 @@ const router = createBrowserRouter([
|
||||
element: <ChatPopout />,
|
||||
},
|
||||
]);
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLDivElement
|
||||
);
|
||||
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLDivElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<SnortContext.Provider value={System}>
|
||||
<RouterProvider router={router} />
|
||||
<IntlProvider>
|
||||
<RouterProvider router={router} />
|
||||
</IntlProvider>
|
||||
</SnortContext.Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
);
|
||||
|
40
src/intl.tsx
Normal file
40
src/intl.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { IntlProvider as ReactIntlProvider } from "react-intl";
|
||||
|
||||
import enMessages from "translations/en.json";
|
||||
|
||||
const DefaultLocale = "en-US";
|
||||
|
||||
const getMessages = (locale: string) => {
|
||||
const truncatedLocale = locale.toLowerCase().split(/[_-]+/)[0];
|
||||
|
||||
const matchLang = (lng: string) => {
|
||||
switch (lng) {
|
||||
case DefaultLocale:
|
||||
case "en":
|
||||
return enMessages;
|
||||
}
|
||||
};
|
||||
|
||||
return matchLang(locale) ?? matchLang(truncatedLocale) ?? enMessages;
|
||||
};
|
||||
|
||||
export const IntlProvider = ({ children }: { children: ReactNode }) => {
|
||||
const locale = getLocale();
|
||||
const [messages, setMessages] = useState<Record<string, string>>(enMessages);
|
||||
|
||||
useEffect(() => {
|
||||
const msg = getMessages(locale);
|
||||
setMessages(msg);
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<ReactIntlProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
</ReactIntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const getLocale = () => {
|
||||
return (navigator.languages && navigator.languages[0]) ?? navigator.language ?? DefaultLocale;
|
||||
};
|
248
src/lang.json
Normal file
248
src/lang.json
Normal file
@ -0,0 +1,248 @@
|
||||
{
|
||||
"+0zv6g": {
|
||||
"defaultMessage": "Image"
|
||||
},
|
||||
"/0TOL5": {
|
||||
"defaultMessage": "Amount"
|
||||
},
|
||||
"/GCoTA": {
|
||||
"defaultMessage": "Clear"
|
||||
},
|
||||
"04lmFi": {
|
||||
"defaultMessage": "Save Key"
|
||||
},
|
||||
"0GfNiL": {
|
||||
"defaultMessage": "Stream Zap Goals"
|
||||
},
|
||||
"1EYCdR": {
|
||||
"defaultMessage": "Tags"
|
||||
},
|
||||
"2CGh/0": {
|
||||
"defaultMessage": "live"
|
||||
},
|
||||
"3HwrQo": {
|
||||
"defaultMessage": "Zap!"
|
||||
},
|
||||
"3adEeb": {
|
||||
"defaultMessage": "{n} viewers"
|
||||
},
|
||||
"4l6vz1": {
|
||||
"defaultMessage": "Copy"
|
||||
},
|
||||
"4uI538": {
|
||||
"defaultMessage": "Resolutions"
|
||||
},
|
||||
"5JcXdV": {
|
||||
"defaultMessage": "Create Account"
|
||||
},
|
||||
"5QYdPU": {
|
||||
"defaultMessage": "Start Time"
|
||||
},
|
||||
"5kx+2v": {
|
||||
"defaultMessage": "Server Url"
|
||||
},
|
||||
"6Z2pvJ": {
|
||||
"defaultMessage": "Stream Providers"
|
||||
},
|
||||
"9WRlF4": {
|
||||
"defaultMessage": "Send"
|
||||
},
|
||||
"9a9+ww": {
|
||||
"defaultMessage": "Title"
|
||||
},
|
||||
"9anxhq": {
|
||||
"defaultMessage": "Starts"
|
||||
},
|
||||
"AIHaPH": {
|
||||
"defaultMessage": "{person} zapped {amount} sats"
|
||||
},
|
||||
"Atr2p4": {
|
||||
"defaultMessage": "NSFW Content"
|
||||
},
|
||||
"AyGauy": {
|
||||
"defaultMessage": "Login"
|
||||
},
|
||||
"BGxpTN": {
|
||||
"defaultMessage": "Stream Chat"
|
||||
},
|
||||
"C81/uG": {
|
||||
"defaultMessage": "Logout"
|
||||
},
|
||||
"ESyhzp": {
|
||||
"defaultMessage": "Your comment for {name}"
|
||||
},
|
||||
"Gq6x9o": {
|
||||
"defaultMessage": "Cover Image"
|
||||
},
|
||||
"H5+NAX": {
|
||||
"defaultMessage": "Balance"
|
||||
},
|
||||
"HAlOn1": {
|
||||
"defaultMessage": "Name"
|
||||
},
|
||||
"I1kjHI": {
|
||||
"defaultMessage": "Supports {markdown}"
|
||||
},
|
||||
"IJDKz3": {
|
||||
"defaultMessage": "Zap amount in {currency}"
|
||||
},
|
||||
"Jq3FDz": {
|
||||
"defaultMessage": "Content"
|
||||
},
|
||||
"K3r6DQ": {
|
||||
"defaultMessage": "Delete"
|
||||
},
|
||||
"K3uH1C": {
|
||||
"defaultMessage": "offline"
|
||||
},
|
||||
"K7AkdL": {
|
||||
"defaultMessage": "Show"
|
||||
},
|
||||
"KkIL3s": {
|
||||
"defaultMessage": "No, I am under 18"
|
||||
},
|
||||
"Ld5LAE": {
|
||||
"defaultMessage": "Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore!"
|
||||
},
|
||||
"LknBsU": {
|
||||
"defaultMessage": "Stream Key"
|
||||
},
|
||||
"My6HwN": {
|
||||
"defaultMessage": "Ok, it's safe"
|
||||
},
|
||||
"O2Cy6m": {
|
||||
"defaultMessage": "Yes, I am over 18"
|
||||
},
|
||||
"OKhRC6": {
|
||||
"defaultMessage": "Share"
|
||||
},
|
||||
"OWgHbg": {
|
||||
"defaultMessage": "Edit card"
|
||||
},
|
||||
"Q3au2v": {
|
||||
"defaultMessage": "About {estimate}"
|
||||
},
|
||||
"QRHNuF": {
|
||||
"defaultMessage": "What are we steaming today?"
|
||||
},
|
||||
"QRRCp0": {
|
||||
"defaultMessage": "Stream URL"
|
||||
},
|
||||
"QceMQZ": {
|
||||
"defaultMessage": "Goal: {amount}"
|
||||
},
|
||||
"RJOmzk": {
|
||||
"defaultMessage": "I have read and agree with {provider}''s {terms}."
|
||||
},
|
||||
"RXQdxR": {
|
||||
"defaultMessage": "Please login to write messages!"
|
||||
},
|
||||
"RrCui3": {
|
||||
"defaultMessage": "Summary"
|
||||
},
|
||||
"UfSot5": {
|
||||
"defaultMessage": "Past Streams"
|
||||
},
|
||||
"VA/Z1S": {
|
||||
"defaultMessage": "Hide"
|
||||
},
|
||||
"X2PZ7D": {
|
||||
"defaultMessage": "Create Goal"
|
||||
},
|
||||
"ZmqxZs": {
|
||||
"defaultMessage": "You can change this later"
|
||||
},
|
||||
"acrOoz": {
|
||||
"defaultMessage": "Continue"
|
||||
},
|
||||
"cvAsEh": {
|
||||
"defaultMessage": "Streamed on {date}"
|
||||
},
|
||||
"cyR7Kh": {
|
||||
"defaultMessage": "Back"
|
||||
},
|
||||
"dVD/AR": {
|
||||
"defaultMessage": "Top Zappers"
|
||||
},
|
||||
"ebmhes": {
|
||||
"defaultMessage": "Nostr Extension"
|
||||
},
|
||||
"fBI91o": {
|
||||
"defaultMessage": "Zap"
|
||||
},
|
||||
"hGQqkW": {
|
||||
"defaultMessage": "Schedule"
|
||||
},
|
||||
"itPgxd": {
|
||||
"defaultMessage": "Profile"
|
||||
},
|
||||
"jr4+vD": {
|
||||
"defaultMessage": "Markdown"
|
||||
},
|
||||
"jvo0vs": {
|
||||
"defaultMessage": "Save"
|
||||
},
|
||||
"lZpRMR": {
|
||||
"defaultMessage": "Check here if this stream contains nudity or pornographic content."
|
||||
},
|
||||
"ljmS5P": {
|
||||
"defaultMessage": "Endpoint"
|
||||
},
|
||||
"mtNGwh": {
|
||||
"defaultMessage": "A short description of the content"
|
||||
},
|
||||
"nBCvvJ": {
|
||||
"defaultMessage": "Topup"
|
||||
},
|
||||
"nOaArs": {
|
||||
"defaultMessage": "Setup Profile"
|
||||
},
|
||||
"oHPB8Q": {
|
||||
"defaultMessage": "Zap {name}"
|
||||
},
|
||||
"oZrFyI": {
|
||||
"defaultMessage": "Stream type should be HLS"
|
||||
},
|
||||
"pO/lPX": {
|
||||
"defaultMessage": "Scheduled for {date}"
|
||||
},
|
||||
"rWBFZA": {
|
||||
"defaultMessage": "Sexually explicit material ahead!"
|
||||
},
|
||||
"rbrahO": {
|
||||
"defaultMessage": "Close"
|
||||
},
|
||||
"rfC1Zq": {
|
||||
"defaultMessage": "Save card"
|
||||
},
|
||||
"s5ksS7": {
|
||||
"defaultMessage": "Image Link"
|
||||
},
|
||||
"s7V+5p": {
|
||||
"defaultMessage": "Confirm your age"
|
||||
},
|
||||
"thsiMl": {
|
||||
"defaultMessage": "terms and conditions"
|
||||
},
|
||||
"tzMNF3": {
|
||||
"defaultMessage": "Status"
|
||||
},
|
||||
"uYw2LD": {
|
||||
"defaultMessage": "Stream"
|
||||
},
|
||||
"vrTOHJ": {
|
||||
"defaultMessage": "{amount} sats"
|
||||
},
|
||||
"wCIL7o": {
|
||||
"defaultMessage": "Broadcast on Nostr"
|
||||
},
|
||||
"wEQDC6": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
"wOy57k": {
|
||||
"defaultMessage": "Add stream goal"
|
||||
},
|
||||
"wzWWzV": {
|
||||
"defaultMessage": "Top zappers"
|
||||
}
|
||||
}
|
@ -130,10 +130,7 @@ export function getPublisher(session: LoginSession) {
|
||||
return new EventPublisher(new Nip7Signer(), session.pubkey);
|
||||
}
|
||||
case LoginType.PrivateKey: {
|
||||
return new EventPublisher(
|
||||
new PrivateKeySigner(unwrap(session.privateKey)),
|
||||
session.pubkey
|
||||
);
|
||||
return new EventPublisher(new PrivateKeySigner(unwrap(session.privateKey)), session.pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,15 +11,7 @@ export function ChatPopout() {
|
||||
const link = parseNostrLink(unwrap(params.id));
|
||||
const ev = useCurrentStreamFeed(link, true);
|
||||
|
||||
const lnk = parseNostrLink(
|
||||
encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
findTag(ev, "d") ?? "",
|
||||
undefined,
|
||||
ev?.kind,
|
||||
ev?.pubkey
|
||||
)
|
||||
);
|
||||
const lnk = parseNostrLink(encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev?.kind, ev?.pubkey));
|
||||
const chat = Boolean(new URL(window.location.href).searchParams.get("chat"));
|
||||
return (
|
||||
<div className={`popout-chat${chat ? "" : " embed"}`}>
|
||||
|
@ -12,6 +12,7 @@ import { LoginSignup } from "element/login-signup";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import { hexToBech32 } from "@snort/shared";
|
||||
import { Login } from "index";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function LayoutPage() {
|
||||
const navigate = useNavigate();
|
||||
@ -40,17 +41,14 @@ export function LayoutPage() {
|
||||
</div>
|
||||
}
|
||||
align="end"
|
||||
gap={5}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => navigate(`/p/${hexToBech32("npub", login.pubkey)}`)}
|
||||
>
|
||||
gap={5}>
|
||||
<MenuItem onClick={() => navigate(`/p/${hexToBech32("npub", login.pubkey)}`)}>
|
||||
<Icon name="user" size={24} />
|
||||
Profile
|
||||
<FormattedMessage defaultMessage="Profile" />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => Login.logout()}>
|
||||
<Icon name="logout" size={24} />
|
||||
Logout
|
||||
<FormattedMessage defaultMessage="Logout" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
@ -63,12 +61,8 @@ export function LayoutPage() {
|
||||
return (
|
||||
<Dialog.Root open={showLogin} onOpenChange={setShowLogin}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-border"
|
||||
onClick={() => setShowLogin(true)}
|
||||
>
|
||||
Login
|
||||
<button type="button" className="btn btn-border" onClick={() => setShowLogin(true)}>
|
||||
<FormattedMessage defaultMessage="Login" />
|
||||
<Icon name="login" />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
@ -83,11 +77,7 @@ export function LayoutPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`page${
|
||||
location.pathname.startsWith("/naddr1") ? " stream" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`page${location.pathname.startsWith("/naddr1") ? " stream" : ""}`}>
|
||||
<Helmet>
|
||||
<title>Home - zap.stream</title>
|
||||
</Helmet>
|
||||
|
@ -162,12 +162,7 @@
|
||||
|
||||
.tabs-tab[data-state="active"] .tab-border {
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
94.73deg,
|
||||
#2bd9ff 0%,
|
||||
#8c8ded 47.4%,
|
||||
#f838d9 100%
|
||||
);
|
||||
background: linear-gradient(94.73deg, #2bd9ff 0%, #8c8ded 47.4%, #f838d9 100%);
|
||||
}
|
||||
|
||||
.tabs-content {
|
||||
|
@ -3,12 +3,7 @@ import { useMemo } from "react";
|
||||
import moment from "moment";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import {
|
||||
parseNostrLink,
|
||||
NostrPrefix,
|
||||
ParsedZap,
|
||||
encodeTLV,
|
||||
} from "@snort/system";
|
||||
import { parseNostrLink, NostrPrefix, ParsedZap, encodeTLV } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { Profile } from "element/profile";
|
||||
@ -24,6 +19,7 @@ import { Text } from "element/text";
|
||||
import { StreamState } from "index";
|
||||
import { findTag } from "utils";
|
||||
import { formatSats } from "number";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
function Zapper({ pubkey, total }: { pubkey: string; total: number }) {
|
||||
return (
|
||||
@ -41,7 +37,7 @@ function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
|
||||
const zappers = useTopZappers(zaps);
|
||||
return (
|
||||
<section className="profile-top-zappers">
|
||||
{zappers.map((z) => (
|
||||
{zappers.map(z => (
|
||||
<Zapper key={z.pubkey} pubkey={z.pubkey} total={z.total} />
|
||||
))}
|
||||
</section>
|
||||
@ -59,28 +55,20 @@ export function ProfilePage() {
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
const { streams, zaps } = useProfile(link, true);
|
||||
const liveEvent = useMemo(() => {
|
||||
return streams.find((ev) => findTag(ev, "status") === StreamState.Live);
|
||||
return streams.find(ev => findTag(ev, "status") === StreamState.Live);
|
||||
}, [streams]);
|
||||
const pastStreams = useMemo(() => {
|
||||
return streams.filter((ev) => findTag(ev, "status") === StreamState.Ended);
|
||||
return streams.filter(ev => findTag(ev, "status") === StreamState.Ended);
|
||||
}, [streams]);
|
||||
const futureStreams = useMemo(() => {
|
||||
return streams.filter(
|
||||
(ev) => findTag(ev, "status") === StreamState.Planned
|
||||
);
|
||||
return streams.filter(ev => findTag(ev, "status") === StreamState.Planned);
|
||||
}, [streams]);
|
||||
const isLive = Boolean(liveEvent);
|
||||
|
||||
function goToLive() {
|
||||
if (liveEvent) {
|
||||
const d = findTag(liveEvent, "d") || "";
|
||||
const naddr = encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
d,
|
||||
undefined,
|
||||
liveEvent.kind,
|
||||
liveEvent.pubkey
|
||||
);
|
||||
const naddr = encodeTLV(NostrPrefix.Address, d, undefined, liveEvent.kind, liveEvent.pubkey);
|
||||
navigate(`/${naddr}`);
|
||||
}
|
||||
}
|
||||
@ -88,52 +76,39 @@ export function ProfilePage() {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-container">
|
||||
<img
|
||||
className="banner"
|
||||
alt={profile?.name || link.id}
|
||||
src={profile?.banner || defaultBanner}
|
||||
/>
|
||||
<img className="banner" alt={profile?.name || link.id} src={profile?.banner || defaultBanner} />
|
||||
<div className="profile-content">
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
className="avatar"
|
||||
alt={profile.name || link.id}
|
||||
src={profile.picture}
|
||||
/>
|
||||
<img className="avatar" alt={profile.name || link.id} src={profile.picture} />
|
||||
) : (
|
||||
<img
|
||||
className="avatar"
|
||||
alt={profile?.name || link.id}
|
||||
src={placeholder}
|
||||
/>
|
||||
<img className="avatar" alt={profile?.name || link.id} src={placeholder} />
|
||||
)}
|
||||
<div className="status-indicator">
|
||||
{isLive ? (
|
||||
<div className="live-button pill live" onClick={goToLive}>
|
||||
<Icon name="signal" />
|
||||
<span>live</span>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="live" />
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="pill offline">offline</span>
|
||||
<span className="pill offline">
|
||||
<FormattedMessage defaultMessage="offline" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-actions">
|
||||
{zapTarget && (
|
||||
<SendZapsDialog
|
||||
aTag={
|
||||
liveEvent
|
||||
? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(
|
||||
liveEvent,
|
||||
"d"
|
||||
)}`
|
||||
: undefined
|
||||
}
|
||||
aTag={liveEvent ? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(liveEvent, "d")}` : undefined}
|
||||
lnurl={zapTarget}
|
||||
button={
|
||||
<button className="btn">
|
||||
<div className="zap-button">
|
||||
<Icon name="zap-filled" className="zap-button-icon" />
|
||||
<span>Zap</span>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Zap" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
@ -152,22 +127,17 @@ export function ProfilePage() {
|
||||
)}
|
||||
</div>
|
||||
<Tabs.Root className="tabs-root" defaultValue="top-zappers">
|
||||
<Tabs.List
|
||||
className="tabs-list"
|
||||
aria-label={`Information about ${
|
||||
profile ? profile.name : link.id
|
||||
}`}
|
||||
>
|
||||
<Tabs.List className="tabs-list" aria-label={`Information about ${profile ? profile.name : link.id}`}>
|
||||
<Tabs.Trigger className="tabs-tab" value="top-zappers">
|
||||
Top Zappers
|
||||
<FormattedMessage defaultMessage="Top Zappers" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger className="tabs-tab" value="past-streams">
|
||||
Past Streams
|
||||
<FormattedMessage defaultMessage="Past Streams" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger className="tabs-tab" value="schedule">
|
||||
Schedule
|
||||
<FormattedMessage defaultMessage="Schedule" />
|
||||
<div className="tab-border"></div>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
@ -176,14 +146,16 @@ export function ProfilePage() {
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="tabs-content" value="past-streams">
|
||||
<div className="stream-list">
|
||||
{pastStreams.map((ev) => (
|
||||
{pastStreams.map(ev => (
|
||||
<div key={ev.id} className="stream-item">
|
||||
<VideoTile ev={ev} showAuthor={false} showStatus={false} />
|
||||
<span className="timestamp">
|
||||
Streamed on{" "}
|
||||
{moment(Number(ev.created_at) * 1000).format(
|
||||
"MMM DD, YYYY"
|
||||
)}
|
||||
<FormattedMessage
|
||||
defaultMessage="Streamed on {date}"
|
||||
values={{
|
||||
date: moment(Number(ev.created_at) * 1000).format("MMM DD, YYYY"),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@ -191,14 +163,16 @@ export function ProfilePage() {
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="tabs-content" value="schedule">
|
||||
<div className="stream-list">
|
||||
{futureStreams.map((ev) => (
|
||||
{futureStreams.map(ev => (
|
||||
<div key={ev.id} className="stream-item">
|
||||
<VideoTile ev={ev} showAuthor={false} showStatus={false} />
|
||||
<span className="timestamp">
|
||||
Scheduled for{" "}
|
||||
{moment(Number(ev.created_at) * 1000).format(
|
||||
"MMM DD, YYYY h:mm:ss a"
|
||||
)}
|
||||
<FormattedMessage
|
||||
defaultMessage="Scheduled for {date}"
|
||||
values={{
|
||||
date: moment(Number(ev.created_at) * 1000).format("MMM DD, YYYY h:mm:ss a"),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
@ -48,16 +48,9 @@ export function StreamProvidersPage() {
|
||||
return (
|
||||
<div className="stream-providers-page">
|
||||
<h1>Providers</h1>
|
||||
<p>
|
||||
Stream providers streamline the process of streaming on Nostr, some
|
||||
event accept lightning payments!
|
||||
</p>
|
||||
<p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p>
|
||||
<div className="stream-providers-grid">
|
||||
{[
|
||||
StreamProviders.NostrType,
|
||||
StreamProviders.Owncast,
|
||||
StreamProviders.Cloudflare,
|
||||
].map((v) => providerLink(v))}
|
||||
{[StreamProviders.NostrType, StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -59,8 +59,7 @@ export function ConfigureNostrType() {
|
||||
onClick={() => {
|
||||
StreamProviderStore.add(new Nip103StreamProvider(url));
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
@ -74,12 +73,7 @@ export function ConfigureNostrType() {
|
||||
<div>
|
||||
<p>Nostr streaming provider URL</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
|
||||
|
@ -59,8 +59,7 @@ export function ConfigureOwncast() {
|
||||
onClick={() => {
|
||||
StreamProviderStore.add(new OwncastProvider(url, token));
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
@ -74,22 +73,13 @@ export function ConfigureOwncast() {
|
||||
<div>
|
||||
<p>Owncast instance url</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>API token</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
<input type="password" value={token} onChange={e => setToken(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
|
||||
|
@ -15,19 +15,17 @@ export function RootPage() {
|
||||
const tags = login?.follows.tags ?? [];
|
||||
const followsHost = useCallback(
|
||||
(ev: NostrEvent) => {
|
||||
return tags.find((t) => t.at(1) === getHost(ev));
|
||||
return tags.find(t => t.at(1) === getHost(ev));
|
||||
},
|
||||
[tags]
|
||||
);
|
||||
const hashtags = getTagValues(tags, "t");
|
||||
const following = live.filter(followsHost);
|
||||
const liveNow = live.filter((e) => !following.includes(e));
|
||||
const liveNow = live.filter(e => !following.includes(e));
|
||||
const hasFollowingLive = following.length > 0;
|
||||
|
||||
const plannedEvents = planned
|
||||
.filter((e) => !mutedHosts.has(getHost(e)))
|
||||
.filter(followsHost);
|
||||
const endedEvents = ended.filter((e) => !mutedHosts.has(getHost(e)));
|
||||
const plannedEvents = planned.filter(e => !mutedHosts.has(getHost(e))).filter(followsHost);
|
||||
const endedEvents = ended.filter(e => !mutedHosts.has(getHost(e)));
|
||||
|
||||
return (
|
||||
<div className="homepage">
|
||||
@ -35,7 +33,7 @@ export function RootPage() {
|
||||
<>
|
||||
<h2 className="divider line one-line">Following</h2>
|
||||
<div className="video-grid">
|
||||
{following.map((e) => (
|
||||
{following.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
@ -44,23 +42,23 @@ export function RootPage() {
|
||||
{!hasFollowingLive && (
|
||||
<div className="video-grid">
|
||||
{live
|
||||
.filter((e) => !mutedHosts.has(getHost(e)))
|
||||
.map((e) => (
|
||||
.filter(e => !mutedHosts.has(getHost(e)))
|
||||
.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hashtags.map((t) => (
|
||||
{hashtags.map(t => (
|
||||
<>
|
||||
<h2 className="divider line one-line">#{t}</h2>
|
||||
<div className="video-grid">
|
||||
{live
|
||||
.filter((e) => !mutedHosts.has(getHost(e)))
|
||||
.filter((e) => {
|
||||
.filter(e => !mutedHosts.has(getHost(e)))
|
||||
.filter(e => {
|
||||
const evTags = getTagValues(e.tags, "t");
|
||||
return evTags.includes(t);
|
||||
})
|
||||
.map((e) => (
|
||||
.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
@ -71,8 +69,8 @@ export function RootPage() {
|
||||
<h2 className="divider line one-line">Live</h2>
|
||||
<div className="video-grid">
|
||||
{liveNow
|
||||
.filter((e) => !mutedHosts.has(getHost(e)))
|
||||
.map((e) => (
|
||||
.filter(e => !mutedHosts.has(getHost(e)))
|
||||
.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
@ -82,7 +80,7 @@ export function RootPage() {
|
||||
<>
|
||||
<h2 className="divider line one-line">Planned</h2>
|
||||
<div className="video-grid">
|
||||
{plannedEvents.map((e) => (
|
||||
{plannedEvents.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
@ -92,7 +90,7 @@ export function RootPage() {
|
||||
<>
|
||||
<h2 className="divider line one-line">Ended</h2>
|
||||
<div className="video-grid">
|
||||
{endedEvents.map((e) => (
|
||||
{endedEvents.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
|
@ -2,9 +2,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: auto 450px;
|
||||
gap: var(--gap-m);
|
||||
height: calc(
|
||||
100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s)
|
||||
);
|
||||
height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s));
|
||||
}
|
||||
|
||||
.stream-page .video-content {
|
||||
@ -33,19 +31,14 @@
|
||||
padding: 24px 16px 8px 24px;
|
||||
border: 1px solid #171717;
|
||||
border-radius: 24px;
|
||||
height: calc(
|
||||
100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s) -
|
||||
24px - 8px
|
||||
);
|
||||
height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s) - 24px - 8px);
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.stream-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(
|
||||
100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s)
|
||||
);
|
||||
height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s));
|
||||
}
|
||||
|
||||
.stream-page .video-content {
|
||||
|
@ -1,22 +1,11 @@
|
||||
import "./stream-page.css";
|
||||
import {
|
||||
NostrLink,
|
||||
NostrPrefix,
|
||||
TaggedNostrEvent,
|
||||
tryParseNostrLink,
|
||||
} from "@snort/system";
|
||||
import { NostrLink, NostrPrefix, TaggedNostrEvent, tryParseNostrLink } from "@snort/system";
|
||||
import { fetchNip05Pubkey } from "@snort/shared";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
import { LiveVideoPlayer } from "element/live-video-player";
|
||||
import {
|
||||
createNostrLink,
|
||||
findTag,
|
||||
getEventFromLocationState,
|
||||
getHost,
|
||||
hexToBech32,
|
||||
} from "utils";
|
||||
import { createNostrLink, findTag, getEventFromLocationState, getHost, hexToBech32 } from "utils";
|
||||
import { Profile, getName } from "element/profile";
|
||||
import { LiveChat } from "element/live-chat";
|
||||
import AsyncButton from "element/async-button";
|
||||
@ -33,20 +22,11 @@ import { StreamCards } from "element/stream-cards";
|
||||
import { formatSats } from "number";
|
||||
import { StreamTimer } from "element/stream-time";
|
||||
import { ShareMenu } from "element/share-menu";
|
||||
import {
|
||||
ContentWarningOverlay,
|
||||
isContentWarningAccepted,
|
||||
} from "element/content-warning";
|
||||
import { ContentWarningOverlay, isContentWarningAccepted } from "element/content-warning";
|
||||
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function ProfileInfo({
|
||||
ev,
|
||||
goal,
|
||||
}: {
|
||||
ev?: NostrEvent;
|
||||
goal?: TaggedNostrEvent;
|
||||
}) {
|
||||
function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedNostrEvent }) {
|
||||
const login = useLogin();
|
||||
const navigate = useNavigate();
|
||||
const host = getHost(ev);
|
||||
@ -75,11 +55,7 @@ function ProfileInfo({
|
||||
<p>{findTag(ev, "summary")}</p>
|
||||
<div className="tags">
|
||||
<StatePill state={status as StreamState} />
|
||||
{viewers > 0 && (
|
||||
<span className="pill viewers">
|
||||
{formatSats(viewers)} viewers
|
||||
</span>
|
||||
)}
|
||||
{viewers > 0 && <span className="pill viewers">{formatSats(viewers)} viewers</span>}
|
||||
{status === StreamState.Live && (
|
||||
<span className="pill">
|
||||
<StreamTimer ev={ev} />
|
||||
@ -90,11 +66,7 @@ function ProfileInfo({
|
||||
{isMine && (
|
||||
<div className="actions">
|
||||
{ev && <NewStreamDialog text="Edit" ev={ev} btnClassName="btn" />}
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-warning"
|
||||
onClick={deleteStream}
|
||||
>
|
||||
<AsyncButton type="button" className="btn btn-warning" onClick={deleteStream}>
|
||||
Delete
|
||||
</AsyncButton>
|
||||
</div>
|
||||
@ -136,10 +108,8 @@ export function StreamPageHandler() {
|
||||
if (parsedLink) {
|
||||
setLink(parsedLink);
|
||||
} else {
|
||||
const [handle, domain] = (
|
||||
params.id.includes("@") ? params.id : `${params.id}@zap.stream`
|
||||
).split("@");
|
||||
fetchNip05Pubkey(handle, domain).then((d) => {
|
||||
const [handle, domain] = (params.id.includes("@") ? params.id : `${params.id}@zap.stream`).split("@");
|
||||
fetchNip05Pubkey(handle, domain).then(d => {
|
||||
if (d) {
|
||||
setLink({
|
||||
id: d,
|
||||
@ -157,13 +127,7 @@ export function StreamPageHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
export function StreamPage({
|
||||
link,
|
||||
evPreload,
|
||||
}: {
|
||||
evPreload?: NostrEvent;
|
||||
link: NostrLink;
|
||||
}) {
|
||||
export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link: NostrLink }) {
|
||||
const ev = useCurrentStreamFeed(link, true, evPreload);
|
||||
const host = getHost(ev);
|
||||
const goal = useZapGoal(host, createNostrLink(ev), true);
|
||||
@ -172,31 +136,21 @@ export function StreamPage({
|
||||
const summary = findTag(ev, "summary");
|
||||
const image = findTag(ev, "image");
|
||||
const status = findTag(ev, "status");
|
||||
const stream =
|
||||
status === StreamState.Live
|
||||
? findTag(ev, "streaming")
|
||||
: findTag(ev, "recording");
|
||||
const stream = status === StreamState.Live ? findTag(ev, "streaming") : findTag(ev, "recording");
|
||||
const contentWarning = findTag(ev, "content-warning");
|
||||
const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? [];
|
||||
const tags = ev?.tags.filter(a => a[0] === "t").map(a => a[1]) ?? [];
|
||||
|
||||
if (contentWarning && !isContentWarningAccepted()) {
|
||||
return <ContentWarningOverlay />;
|
||||
}
|
||||
|
||||
const descriptionContent = [
|
||||
title,
|
||||
(summary?.length ?? 0) > 0 ? summary : "Nostr live streaming",
|
||||
...tags,
|
||||
].join(", ");
|
||||
const descriptionContent = [title, (summary?.length ?? 0) > 0 ? summary : "Nostr live streaming", ...tags].join(", ");
|
||||
return (
|
||||
<div className="stream-page">
|
||||
<Helmet>
|
||||
<title>{`${title} - zap.stream`}</title>
|
||||
<meta name="description" content={descriptionContent} />
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`https://${window.location.host}/${link.encode()}`}
|
||||
/>
|
||||
<meta property="og:url" content={`https://${window.location.host}/${link.encode()}`} />
|
||||
<meta property="og:type" content="video" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={descriptionContent} />
|
||||
|
@ -16,7 +16,7 @@ export function TagPage() {
|
||||
<FollowTagButton tag={unwrap(tag)} />
|
||||
</div>
|
||||
<div className="video-grid">
|
||||
{live.map((e) => (
|
||||
{live.map(e => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
|
@ -80,8 +80,7 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
|
||||
super();
|
||||
const cache = window.localStorage.getItem("providers");
|
||||
if (cache) {
|
||||
const cached: Array<{ type: StreamProviders } & Record<string, unknown>> =
|
||||
JSON.parse(cache);
|
||||
const cached: Array<{ type: StreamProviders } & Record<string, unknown>> = JSON.parse(cache);
|
||||
for (const c of cached) {
|
||||
switch (c.type) {
|
||||
case StreamProviders.Manual: {
|
||||
@ -93,9 +92,7 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
|
||||
break;
|
||||
}
|
||||
case StreamProviders.Owncast: {
|
||||
this.#providers.push(
|
||||
new OwncastProvider(c.url as string, c.token as string)
|
||||
);
|
||||
this.#providers.push(new OwncastProvider(c.url as string, c.token as string));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -110,14 +107,12 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
|
||||
}
|
||||
|
||||
takeSnapshot() {
|
||||
const defaultProvider = new Nip103StreamProvider(
|
||||
"https://api.zap.stream/api/nostr/"
|
||||
);
|
||||
const defaultProvider = new Nip103StreamProvider("https://api.zap.stream/api/nostr/");
|
||||
return [defaultProvider, new ManualProvider(), ...this.#providers];
|
||||
}
|
||||
|
||||
#save() {
|
||||
const cfg = this.#providers.map((a) => a.createConfig());
|
||||
const cfg = this.#providers.map(a => a.createConfig());
|
||||
window.localStorage.setItem("providers", JSON.stringify(cfg));
|
||||
}
|
||||
}
|
||||
|
@ -52,11 +52,7 @@ export class OwncastProvider implements StreamProvider {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async #getJson<T>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
async #getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||
const rsp = await fetch(`${this.#url}${path}`, {
|
||||
method,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
|
@ -36,7 +36,7 @@ export class Nip103StreamProvider implements StreamProvider {
|
||||
balance: rsp.balance,
|
||||
tosAccepted: rsp.tos?.accepted,
|
||||
tosLink: rsp.tos?.link,
|
||||
endpoints: rsp.endpoints.map((a) => {
|
||||
endpoints: rsp.endpoints.map(a => {
|
||||
return {
|
||||
name: a.name,
|
||||
url: a.url,
|
||||
@ -60,7 +60,7 @@ export class Nip103StreamProvider implements StreamProvider {
|
||||
const title = findTag(ev, "title");
|
||||
const summary = findTag(ev, "summary");
|
||||
const image = findTag(ev, "image");
|
||||
const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]);
|
||||
const tags = ev?.tags.filter(a => a[0] === "t").map(a => a[1]);
|
||||
const contentWarning = findTag(ev, "content-warning");
|
||||
await this.#getJson("PATCH", "event", {
|
||||
title,
|
||||
@ -72,10 +72,7 @@ export class Nip103StreamProvider implements StreamProvider {
|
||||
}
|
||||
|
||||
async topup(amount: number): Promise<string> {
|
||||
const rsp = await this.#getJson<TopUpResponse>(
|
||||
"GET",
|
||||
`topup?amount=${amount}`
|
||||
);
|
||||
const rsp = await this.#getJson<TopUpResponse>("GET", `topup?amount=${amount}`);
|
||||
return rsp.pr;
|
||||
}
|
||||
|
||||
@ -85,22 +82,14 @@ export class Nip103StreamProvider implements StreamProvider {
|
||||
});
|
||||
}
|
||||
|
||||
async #getJson<T>(
|
||||
method: "GET" | "POST" | "PATCH",
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
async #getJson<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T> {
|
||||
const login = Login.snapshot();
|
||||
const pub = login && getPublisher(login);
|
||||
if (!pub) throw new Error("No signer");
|
||||
|
||||
const u = `${this.#url}${path}`;
|
||||
const token = await pub.generic((eb) => {
|
||||
return eb
|
||||
.kind(EventKind.HttpAuthentication)
|
||||
.content("")
|
||||
.tag(["u", u])
|
||||
.tag(["method", method]);
|
||||
const token = await pub.generic(eb => {
|
||||
return eb.kind(EventKind.HttpAuthentication).content("").tag(["u", u]).tag(["method", method]);
|
||||
});
|
||||
const rsp = await fetch(u, {
|
||||
method,
|
||||
|
@ -9,7 +9,7 @@ import { PrecacheEntry, precacheAndRoute } from "workbox-precaching";
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
clientsClaim();
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
self.addEventListener("message", event => {
|
||||
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||
self.skipWaiting();
|
||||
}
|
||||
|
5
src/translations/en.json
Normal file
5
src/translations/en.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"AyGauy": "Login",
|
||||
"C81/uG": "Logout",
|
||||
"itPgxd": "Profile"
|
||||
}
|
51
src/utils.ts
51
src/utils.ts
@ -1,10 +1,4 @@
|
||||
import {
|
||||
NostrEvent,
|
||||
NostrPrefix,
|
||||
TaggedNostrEvent,
|
||||
encodeTLV,
|
||||
parseNostrLink,
|
||||
} from "@snort/system";
|
||||
import { NostrEvent, NostrPrefix, TaggedNostrEvent, encodeTLV, parseNostrLink } from "@snort/system";
|
||||
import * as utils from "@noble/curves/abstract/utils";
|
||||
import { bech32 } from "@scure/base";
|
||||
import type { Tag, Tags } from "types";
|
||||
@ -39,7 +33,7 @@ export function toTag(e: NostrEvent): Tag {
|
||||
}
|
||||
|
||||
export function findTag(e: NostrEvent | undefined, tag: string) {
|
||||
const maybeTag = e?.tags.find((evTag) => {
|
||||
const maybeTag = e?.tags.find(evTag => {
|
||||
return evTag[0] === tag;
|
||||
});
|
||||
return maybeTag && maybeTag[1];
|
||||
@ -54,11 +48,7 @@ export function hexToBech32(hrp: string, hex?: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
hrp === NostrPrefix.Note ||
|
||||
hrp === NostrPrefix.PrivateKey ||
|
||||
hrp === NostrPrefix.PublicKey
|
||||
) {
|
||||
if (hrp === NostrPrefix.Note || hrp === NostrPrefix.PrivateKey || hrp === NostrPrefix.PublicKey) {
|
||||
const buf = utils.hexToBytes(hex);
|
||||
return bech32.encode(hrp, bech32.toWords(buf));
|
||||
} else {
|
||||
@ -80,19 +70,9 @@ export function splitByUrl(str: string) {
|
||||
export function eventLink(ev: NostrEvent | TaggedNostrEvent) {
|
||||
if (ev.kind && ev.kind >= 30000 && ev.kind <= 40000) {
|
||||
const d = findTag(ev, "d") ?? "";
|
||||
return encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
d,
|
||||
"relays" in ev ? ev.relays : undefined,
|
||||
ev.kind,
|
||||
ev.pubkey
|
||||
);
|
||||
return encodeTLV(NostrPrefix.Address, d, "relays" in ev ? ev.relays : undefined, ev.kind, ev.pubkey);
|
||||
} else {
|
||||
return encodeTLV(
|
||||
NostrPrefix.Event,
|
||||
ev.id,
|
||||
"relays" in ev ? ev.relays : undefined
|
||||
);
|
||||
return encodeTLV(NostrPrefix.Event, ev.id, "relays" in ev ? ev.relays : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,15 +82,11 @@ export function createNostrLink(ev?: NostrEvent) {
|
||||
}
|
||||
|
||||
export function getHost(ev?: NostrEvent) {
|
||||
return (
|
||||
ev?.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ??
|
||||
ev?.pubkey ??
|
||||
""
|
||||
);
|
||||
return ev?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev?.pubkey ?? "";
|
||||
}
|
||||
|
||||
export function openFile(): Promise<File | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise(resolve => {
|
||||
const elm = document.createElement("input");
|
||||
elm.type = "file";
|
||||
elm.onchange = (e: Event) => {
|
||||
@ -127,17 +103,14 @@ export function openFile(): Promise<File | undefined> {
|
||||
|
||||
export function getTagValues(tags: Tags, tag: string): Array<string> {
|
||||
return tags
|
||||
.filter((t) => t.at(0) === tag)
|
||||
.map((t) => t.at(1))
|
||||
.filter((t) => t)
|
||||
.map((t) => t as string);
|
||||
.filter(t => t.at(0) === tag)
|
||||
.map(t => t.at(1))
|
||||
.filter(t => t)
|
||||
.map(t => t as string);
|
||||
}
|
||||
|
||||
export function getEventFromLocationState(state: unknown | undefined | null) {
|
||||
return state &&
|
||||
typeof state === "object" &&
|
||||
"kind" in state &&
|
||||
state.kind === LIVE_STREAM
|
||||
return state && typeof state === "object" && "kind" in state && state.kind === LIVE_STREAM
|
||||
? (state as NostrEvent)
|
||||
: undefined;
|
||||
}
|
||||
|
@ -3,10 +3,7 @@ import { CandidateInfo, SDPInfo } from "semantic-sdp";
|
||||
import { TypedEventTarget, type StatusEvent, type LogEvent } from "./events";
|
||||
import { parserLinkHeader } from "./parser";
|
||||
|
||||
export const DEFAULT_ICE_SERVERS = [
|
||||
"stun:stun.cloudflare.com:3478",
|
||||
"stun:stun.l.google.com:19302",
|
||||
];
|
||||
export const DEFAULT_ICE_SERVERS = ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"];
|
||||
|
||||
export const TRICKLE_BATCH_INTERVAL = 50;
|
||||
|
||||
@ -49,9 +46,7 @@ export class WISH extends TypedEventTarget {
|
||||
if (iceServers) {
|
||||
this.iceServers = iceServers ? iceServers : DEFAULT_ICE_SERVERS;
|
||||
}
|
||||
this.logMessage(
|
||||
`Enabling webrtc-adapter for ${adapter.browserDetails.browser}@${adapter.browserDetails.version}`
|
||||
);
|
||||
this.logMessage(`Enabling webrtc-adapter for ${adapter.browserDetails.browser}@${adapter.browserDetails.version}`);
|
||||
this.newResolvers();
|
||||
}
|
||||
|
||||
@ -99,7 +94,7 @@ export class WISH extends TypedEventTarget {
|
||||
this.connectedResolver = resolve;
|
||||
this.connectedRejector = reject;
|
||||
});
|
||||
this.gatherPromise = new Promise((resolve) => {
|
||||
this.gatherPromise = new Promise(resolve => {
|
||||
this.gatherResolver = resolve;
|
||||
});
|
||||
}
|
||||
@ -108,36 +103,19 @@ export class WISH extends TypedEventTarget {
|
||||
if (!this.peerConnection) {
|
||||
return;
|
||||
}
|
||||
this.peerConnection.addEventListener(
|
||||
"connectionstatechange",
|
||||
this.onConnectionStateChange.bind(this)
|
||||
);
|
||||
this.peerConnection.addEventListener(
|
||||
"iceconnectionstatechange",
|
||||
this.onICEConnectionStateChange.bind(this)
|
||||
);
|
||||
this.peerConnection.addEventListener(
|
||||
"icegatheringstatechange",
|
||||
this.onGatheringStateChange.bind(this)
|
||||
);
|
||||
this.peerConnection.addEventListener(
|
||||
"icecandidate",
|
||||
this.onICECandidate.bind(this)
|
||||
);
|
||||
this.peerConnection.addEventListener("connectionstatechange", this.onConnectionStateChange.bind(this));
|
||||
this.peerConnection.addEventListener("iceconnectionstatechange", this.onICEConnectionStateChange.bind(this));
|
||||
this.peerConnection.addEventListener("icegatheringstatechange", this.onGatheringStateChange.bind(this));
|
||||
this.peerConnection.addEventListener("icecandidate", this.onICECandidate.bind(this));
|
||||
this.peerConnection.addEventListener("track", this.onTrack.bind(this));
|
||||
this.peerConnection.addEventListener(
|
||||
"signalingstatechange",
|
||||
this.onSignalingStateChange.bind(this)
|
||||
);
|
||||
this.peerConnection.addEventListener("signalingstatechange", this.onSignalingStateChange.bind(this));
|
||||
}
|
||||
|
||||
private onGatheringStateChange() {
|
||||
if (!this.peerConnection) {
|
||||
return;
|
||||
}
|
||||
this.logMessage(
|
||||
`ICE Gathering State changed: ${this.peerConnection.iceGatheringState}`
|
||||
);
|
||||
this.logMessage(`ICE Gathering State changed: ${this.peerConnection.iceGatheringState}`);
|
||||
switch (this.peerConnection.iceGatheringState) {
|
||||
case "complete":
|
||||
this.gatherResolver();
|
||||
@ -149,13 +127,8 @@ export class WISH extends TypedEventTarget {
|
||||
if (!this.peerConnection) {
|
||||
return;
|
||||
}
|
||||
this.logMessage(
|
||||
`Peer Connection State changed: ${this.peerConnection.connectionState}`
|
||||
);
|
||||
const transportHandler = (
|
||||
track: MediaStreamTrack,
|
||||
transport: RTCDtlsTransport
|
||||
) => {
|
||||
this.logMessage(`Peer Connection State changed: ${this.peerConnection.connectionState}`);
|
||||
const transportHandler = (track: MediaStreamTrack, transport: RTCDtlsTransport) => {
|
||||
const ice = transport.iceTransport;
|
||||
if (!ice) {
|
||||
return;
|
||||
@ -217,9 +190,7 @@ export class WISH extends TypedEventTarget {
|
||||
if (!candidate.candidate) {
|
||||
return;
|
||||
}
|
||||
this.logMessage(
|
||||
`Got ICE candidate: ${candidate.candidate.replace("candidate:", "")}`
|
||||
);
|
||||
this.logMessage(`Got ICE candidate: ${candidate.candidate.replace("candidate:", "")}`);
|
||||
if (!this.parsedOffer) {
|
||||
return;
|
||||
}
|
||||
@ -240,13 +211,8 @@ export class WISH extends TypedEventTarget {
|
||||
if (this.trickleBatchingJob) {
|
||||
clearInterval(this.trickleBatchingJob);
|
||||
}
|
||||
this.logMessage(
|
||||
`Starting batching job to trickle candidates every ${TRICKLE_BATCH_INTERVAL}ms`
|
||||
);
|
||||
this.trickleBatchingJob = setInterval(
|
||||
this.trickleBatch.bind(this),
|
||||
TRICKLE_BATCH_INTERVAL
|
||||
);
|
||||
this.logMessage(`Starting batching job to trickle candidates every ${TRICKLE_BATCH_INTERVAL}ms`);
|
||||
this.trickleBatchingJob = setInterval(this.trickleBatch.bind(this), TRICKLE_BATCH_INTERVAL);
|
||||
}
|
||||
|
||||
private stopTrickleBatching() {
|
||||
@ -281,8 +247,7 @@ export class WISH extends TypedEventTarget {
|
||||
type: candidate.type || "host",
|
||||
relAddr: candidate.relatedAddress || undefined,
|
||||
relPort:
|
||||
typeof candidate.relatedPort !== "undefined" &&
|
||||
candidate.relatedPort !== null
|
||||
typeof candidate.relatedPort !== "undefined" && candidate.relatedPort !== null
|
||||
? candidate.relatedPort.toString()
|
||||
: undefined,
|
||||
});
|
||||
@ -307,18 +272,14 @@ export class WISH extends TypedEventTarget {
|
||||
if (!this.peerConnection) {
|
||||
return;
|
||||
}
|
||||
this.logMessage(
|
||||
`Signaling State changed: ${this.peerConnection.signalingState}`
|
||||
);
|
||||
this.logMessage(`Signaling State changed: ${this.peerConnection.signalingState}`);
|
||||
}
|
||||
|
||||
private onICEConnectionStateChange() {
|
||||
if (!this.peerConnection) {
|
||||
return;
|
||||
}
|
||||
this.logMessage(
|
||||
`ICE Connection State changed: ${this.peerConnection.iceConnectionState}`
|
||||
);
|
||||
this.logMessage(`ICE Connection State changed: ${this.peerConnection.iceConnectionState}`);
|
||||
switch (this.peerConnection.iceConnectionState) {
|
||||
case "checking":
|
||||
this.iceStartTime = performance.now();
|
||||
@ -327,19 +288,11 @@ export class WISH extends TypedEventTarget {
|
||||
const connected = performance.now();
|
||||
if (this.connectStartTime) {
|
||||
const delta = connected - this.connectStartTime;
|
||||
this.logMessage(
|
||||
`Took ${(delta / 1000).toFixed(
|
||||
2
|
||||
)} seconds to establish PeerConnection (end-to-end)`
|
||||
);
|
||||
this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to establish PeerConnection (end-to-end)`);
|
||||
}
|
||||
if (this.iceStartTime) {
|
||||
const delta = connected - this.iceStartTime;
|
||||
this.logMessage(
|
||||
`Took ${(delta / 1000).toFixed(
|
||||
2
|
||||
)} seconds to establish PeerConnection (ICE)`
|
||||
);
|
||||
this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to establish PeerConnection (ICE)`);
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<StatusEvent>("status", {
|
||||
@ -421,19 +374,12 @@ export class WISH extends TypedEventTarget {
|
||||
}
|
||||
|
||||
private setVideoCodecPreference(transceiver: RTCRtpTransceiver) {
|
||||
if (
|
||||
typeof RTCRtpSender.getCapabilities === "undefined" ||
|
||||
typeof transceiver.setCodecPreferences === "undefined"
|
||||
) {
|
||||
if (typeof RTCRtpSender.getCapabilities === "undefined" || typeof transceiver.setCodecPreferences === "undefined") {
|
||||
return;
|
||||
}
|
||||
const capability = RTCRtpSender.getCapabilities("video");
|
||||
const codecs = capability ? capability.codecs : [];
|
||||
this.logMessage(
|
||||
`Available codecs for outbound video: ${codecs
|
||||
.map((c) => c.mimeType)
|
||||
.join(", ")}`
|
||||
);
|
||||
this.logMessage(`Available codecs for outbound video: ${codecs.map(c => c.mimeType).join(", ")}`);
|
||||
for (let i = 0; i < codecs.length; i++) {
|
||||
const codec = codecs[i];
|
||||
if (codec.mimeType === "video/VP9") {
|
||||
@ -486,10 +432,7 @@ export class WISH extends TypedEventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
private async doSignalingPOST(
|
||||
sdp: string,
|
||||
useLink?: boolean
|
||||
): Promise<string> {
|
||||
private async doSignalingPOST(sdp: string, useLink?: boolean): Promise<string> {
|
||||
if (!this.endpoint) {
|
||||
throw new Error("No WHIP/WHEP endpoint has been set");
|
||||
}
|
||||
@ -528,14 +471,10 @@ export class WISH extends TypedEventTarget {
|
||||
if (resp.headers.get("accept-post") || resp.headers.get("accept-patch")) {
|
||||
switch (this.mode) {
|
||||
case Mode.Publisher:
|
||||
this.logMessage(
|
||||
`WHIP version draft-ietf-wish-whip-05 (Accept-Post/Accept-Patch)`
|
||||
);
|
||||
this.logMessage(`WHIP version draft-ietf-wish-whip-05 (Accept-Post/Accept-Patch)`);
|
||||
break;
|
||||
case Mode.Player:
|
||||
this.logMessage(
|
||||
`WHEP version draft-murillo-whep-01 (Accept-Post/Accept-Patch)`
|
||||
);
|
||||
this.logMessage(`WHEP version draft-murillo-whep-01 (Accept-Post/Accept-Patch)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -560,9 +499,7 @@ export class WISH extends TypedEventTarget {
|
||||
|
||||
const signaled = performance.now();
|
||||
const delta = signaled - signalStartTime;
|
||||
this.logMessage(
|
||||
`Took ${(delta / 1000).toFixed(2)} seconds to exchange SDP`
|
||||
);
|
||||
this.logMessage(`Took ${(delta / 1000).toFixed(2)} seconds to exchange SDP`);
|
||||
|
||||
return body;
|
||||
}
|
||||
|
@ -83,10 +83,7 @@ const config = {
|
||||
options: {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
presets: [
|
||||
["@babel/preset-env"],
|
||||
["@babel/preset-react", { runtime: "automatic" }],
|
||||
],
|
||||
presets: [["@babel/preset-env"], ["@babel/preset-react", { runtime: "automatic" }]],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
102
yarn.lock
102
yarn.lock
@ -1540,6 +1540,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/fast-memoize@npm:2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "@formatjs/fast-memoize@npm:2.2.0"
|
||||
dependencies:
|
||||
tslib: ^2.4.0
|
||||
checksum: 8697fe72a7ece252d600a7d08105f2a2f758e2dd96f54ac0a4c508b1205a559fc08835635e1f8e5ca9dcc3ee61ce1fca4a0e7047b402f29fc96051e293a280ff
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/icu-messageformat-parser@npm:2.6.0":
|
||||
version: 2.6.0
|
||||
resolution: "@formatjs/icu-messageformat-parser@npm:2.6.0"
|
||||
@ -1561,6 +1570,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-displaynames@npm:6.5.0":
|
||||
version: 6.5.0
|
||||
resolution: "@formatjs/intl-displaynames@npm:6.5.0"
|
||||
dependencies:
|
||||
"@formatjs/ecma402-abstract": 1.17.0
|
||||
"@formatjs/intl-localematcher": 0.4.0
|
||||
tslib: ^2.4.0
|
||||
checksum: d071f8459796240575e9911052b7116a6e2e43687607b0a5d1ac8ceddbaa85324af78694226c37c8172c0f3e7d6b793f506c5758b6bc50b2110516902b532e12
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-listformat@npm:7.4.0":
|
||||
version: 7.4.0
|
||||
resolution: "@formatjs/intl-listformat@npm:7.4.0"
|
||||
dependencies:
|
||||
"@formatjs/ecma402-abstract": 1.17.0
|
||||
"@formatjs/intl-localematcher": 0.4.0
|
||||
tslib: ^2.4.0
|
||||
checksum: a2deed31cce57f249e470f54675286c36edc2f5ec1d63a2f36e6315a0154ca06404fa5c00aaefc2c52af57d3d471b17c217e885cc9565e5f54c36509af37fe12
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-localematcher@npm:0.4.0":
|
||||
version: 0.4.0
|
||||
resolution: "@formatjs/intl-localematcher@npm:0.4.0"
|
||||
@ -1570,6 +1601,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl@npm:2.9.0":
|
||||
version: 2.9.0
|
||||
resolution: "@formatjs/intl@npm:2.9.0"
|
||||
dependencies:
|
||||
"@formatjs/ecma402-abstract": 1.17.0
|
||||
"@formatjs/fast-memoize": 2.2.0
|
||||
"@formatjs/icu-messageformat-parser": 2.6.0
|
||||
"@formatjs/intl-displaynames": 6.5.0
|
||||
"@formatjs/intl-listformat": 7.4.0
|
||||
intl-messageformat: 10.5.0
|
||||
tslib: ^2.4.0
|
||||
peerDependencies:
|
||||
typescript: ^4.7 || 5
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
checksum: 8cd9c9a86ec79148d6e42ace85abbf39e0580a821a6a2d9edceeed3248d439a5f204d988ed714bef92933843a069ee910ed19675d77d60c0472e343dbe610bf4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/ts-transformer@npm:3.13.3, @formatjs/ts-transformer@npm:^3.13.3":
|
||||
version: 3.13.3
|
||||
resolution: "@formatjs/ts-transformer@npm:3.13.3"
|
||||
@ -2701,6 +2752,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/hoist-non-react-statics@npm:^3.3.1":
|
||||
version: 3.3.1
|
||||
resolution: "@types/hoist-non-react-statics@npm:3.3.1"
|
||||
dependencies:
|
||||
"@types/react": "*"
|
||||
hoist-non-react-statics: ^3.3.0
|
||||
checksum: 2c0778570d9a01d05afabc781b32163f28409bb98f7245c38d5eaf082416fdb73034003f5825eb5e21313044e8d2d9e1f3fe2831e345d3d1b1d20bcd12270719
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/html-minifier-terser@npm:^6.0.0":
|
||||
version: 6.1.0
|
||||
resolution: "@types/html-minifier-terser@npm:6.1.0"
|
||||
@ -2879,7 +2940,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:*, @types/react@npm:^18.2.21":
|
||||
"@types/react@npm:*, @types/react@npm:16 || 17 || 18, @types/react@npm:^18.2.21":
|
||||
version: 18.2.21
|
||||
resolution: "@types/react@npm:18.2.21"
|
||||
dependencies:
|
||||
@ -5989,7 +6050,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hoist-non-react-statics@npm:^3.3.2":
|
||||
"hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2":
|
||||
version: 3.3.2
|
||||
resolution: "hoist-non-react-statics@npm:3.3.2"
|
||||
dependencies:
|
||||
@ -6306,6 +6367,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"intl-messageformat@npm:10.5.0":
|
||||
version: 10.5.0
|
||||
resolution: "intl-messageformat@npm:10.5.0"
|
||||
dependencies:
|
||||
"@formatjs/ecma402-abstract": 1.17.0
|
||||
"@formatjs/fast-memoize": 2.2.0
|
||||
"@formatjs/icu-messageformat-parser": 2.6.0
|
||||
tslib: ^2.4.0
|
||||
checksum: 164c49028b8bf2685f57f8f018d9a2c1d827e94b1c300ebf9df50b6aef25adedb3bf511b3cf603364d67257634b57926935066b2f3715ccb6c2af683cc0815a6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"invariant@npm:^2.2.4":
|
||||
version: 2.2.4
|
||||
resolution: "invariant@npm:2.2.4"
|
||||
@ -8826,6 +8899,30 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-intl@npm:^6.4.4":
|
||||
version: 6.4.4
|
||||
resolution: "react-intl@npm:6.4.4"
|
||||
dependencies:
|
||||
"@formatjs/ecma402-abstract": 1.17.0
|
||||
"@formatjs/icu-messageformat-parser": 2.6.0
|
||||
"@formatjs/intl": 2.9.0
|
||||
"@formatjs/intl-displaynames": 6.5.0
|
||||
"@formatjs/intl-listformat": 7.4.0
|
||||
"@types/hoist-non-react-statics": ^3.3.1
|
||||
"@types/react": 16 || 17 || 18
|
||||
hoist-non-react-statics: ^3.3.2
|
||||
intl-messageformat: 10.5.0
|
||||
tslib: ^2.4.0
|
||||
peerDependencies:
|
||||
react: ^16.6.0 || 17 || 18
|
||||
typescript: ^4.7 || 5
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
checksum: c7003ff4927d6d6bf43edc04008bca48181bbfc1c669071746cbaa63b35fdd655c3172c653ac711eeb5053859aa0603048791f3ac8ca5726014a6d357682dfe1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-is@npm:^16.13.1, react-is@npm:^16.7.0":
|
||||
version: 16.13.1
|
||||
resolution: "react-is@npm:16.13.1"
|
||||
@ -9857,6 +9954,7 @@ __metadata:
|
||||
react-dom: ^18.2.0
|
||||
react-helmet: ^6.1.0
|
||||
react-intersection-observer: ^9.5.1
|
||||
react-intl: ^6.4.4
|
||||
react-markdown: ^8.0.7
|
||||
react-router-dom: ^6.13.0
|
||||
react-tag-input-component: ^2.0.2
|
||||
|
Loading…
x
Reference in New Issue
Block a user