Compare commits

..

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

432 changed files with 9129 additions and 21875 deletions

View File

@ -8,6 +8,42 @@ env:
DOCKER_CLI_EXPERIMENTAL: enabled
TAG_FMT: "^refs/tags/(((.?[0-9]+){3,4}))$"
jobs:
tauri_release:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
platform: [macos-latest, ubuntu-20.04, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev
- name: Rust setup
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> target"
- name: Sync node version and setup cache
uses: actions/setup-node@v3
with:
node-version: "16"
cache: "yarn"
- name: Install frontend dependencies
run: yarn install
- name: Build the app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
app:
runs-on: ubuntu-latest
permissions:
@ -56,8 +92,7 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "33.0.0"
- name: Sign APK
uses: r0adkll/sign-android-release@v1
with:
@ -66,8 +101,6 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "33.0.0"
- name: Rename files
run: |-
mkdir -p snort_android/app/release

3
.gitignore vendored
View File

@ -12,5 +12,4 @@ dist/
*.log
.DS_Store
.pnp*
docs/
.wrangler/
docs/

View File

@ -38,14 +38,13 @@ Snort supports the following NIP's:
- [x] NIP-50: Search
- [x] NIP-51: Lists
- [x] NIP-53: Live Events
- [x] NIP-55: Android signer application
- [x] NIP-57: Zaps
- [x] NIP-58: Badges
- [x] NIP-59: Gift Wrap
- [x] NIP-65: Relay List Metadata
- [x] NIP-75: Zap Goals
- [x] NIP-78: App specific data
- [x] NIP-89: App handlers
- [ ] NIP-89: App handlers
- [x] NIP-94: File Metadata
- [x] NIP-96: HTTP File Storage Integration (Draft)
- [x] NIP-98: HTTP Auth

View File

@ -1,30 +0,0 @@
# Reactions
## Problem
When presented with a feed of notes, either a timeline (social) or a live chat log (live stream chat)
how do you fetch the reactions to such notes and maintain realtime updates.
## Current solution
When a list of reactions is requested we use the expensive `buildDiff` operation to compute a
list of new (added) filters and send them to relays.
Usually if `leaveOpen` is specified (as it should be for realtime updates) this new trace will be sent
as a separate subscription causing exhasution.
Another side effect of this this approach is that over time (especially in live chat) the number of filters that get passed to `buildDiff` increases and so the computation time takes longer and causes jank (https://git.v0l.io/Kieran/zap.stream/issues/126).
There is also the question of updating the "root" query, since this is not updated, each independant query trace receives its own set of updates which is a problem of its own.
## Proposed solution (Live chat)
The ideal solution is to update only the "root" query as new filters are detected along with appending the current timestamp as the `since` value.
In this way only 1 subscription is maintained, the "root" query trace.
Each time a new set of filters is created from `buildDiff` we push the same `REQ` again with the new filters which **should** result in no new results from the relays as we expect there to be none `since` the current time is the time of the latest message.
## Proposed solution (Timeline)
TBD

View File

@ -37,7 +37,7 @@ export const onRequest: PagesFunction<Env> = async context => {
return new Response(body, {
headers: {
...Object.fromEntries(rsp.headers.entries()),
"cache-control": "no-cache",
"cache-control": "public, max-age=60",
},
});
}

View File

@ -1,8 +0,0 @@
maintainers:
- npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
relays:
- wss://relay.snort.social/
- wss://pyramid.fiatjaf.com/
- wss://nos.lol/
- ws://skzzn6cimfdv5e2phjc4yr5v7ikbxtn5f7dkwn5c7v47tduzlbosqmqd.onion/

View File

@ -1,11 +0,0 @@
id: "social.snort.app"
name: "Snort"
description: ""
icon: "https://snort.social/nostrich_256.png"
images:
- "https://snort.social/nostrich_512.png"
repository: "https://github.com/v0l/snort"
license: "MIT"
tags:
- "social"
- "twitter"

View File

@ -21,12 +21,12 @@
"packageManager": "yarn@4.1.1",
"dependencies": {
"@cloudflare/workers-types": "^4.20230307.0",
"@tauri-apps/cli": "^1.2.3",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"typescript": "^5.2.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0-rc.14",
"typedoc": "^0.25.7"
}
}

View File

@ -4,6 +4,12 @@ module.exports = {
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "formatjs", "react-refresh", "simple-import-sort"],
rules: {
"formatjs/enforce-id": [
"error",
{
idInterpolationPattern: "[sha512:contenthash:base64:6]",
},
],
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"react-refresh/only-export-components": "error",

View File

@ -25,5 +25,4 @@ yarn-error.log*
.idea
dist/
dev-dist/
.wrangler/
dev-dist/

View File

@ -1,11 +0,0 @@
{
"plugins": [
[
"formatjs",
{
"idInterpolationPattern": "[sha512:contenthash:base64:6]",
"ast": true
}
]
]
}

View File

@ -12,10 +12,11 @@
"defaultZapPoolFee": 1,
"features": {
"analytics": true,
"subscriptions": false,
"deck": false,
"zapPool": false,
"communityLeaders": false,
"subscriptions": true,
"deck": true,
"zapPool": true,
"notificationGraph": true,
"communityLeaders": true,
"nostrAddress": true,
"pushNotifications": true
},
@ -35,32 +36,23 @@
"list": "naddr1qq4xc6tnw3ez6vp58y6rywpjxckngdtyxukngwr9vckkze33vcknzcnrxcenje35xqmn2cczyp3lucccm3v9s087z6qslpkap8schltk427zfgqgrn3g2menq5zw6qcyqqq82vqprpmhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv7rajfl"
},
"noteCreatorToast": false,
"hideFromNavbar": [],
"hideFromNavbar": ["/graph"],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",
"profileLinkPrefix": "nprofile",
"defaultRelays": {
"wss://relay.snort.social/": {
"read": true,
"write": true
},
"wss://nostr.wine/": {
"read": true,
"write": false
},
"wss://relay.damus.io/": {
"read": true,
"write": true
},
"wss://nos.lol/": {
"read": true,
"write": true
}
"wss://relay.snort.social/": { "read": true, "write": true },
"wss://nostr.wine/": { "read": true, "write": false },
"wss://relay.damus.io/": { "read": true, "write": true },
"wss://nos.lol/": { "read": true, "write": true }
},
"alby": {
"clientId": "pohiJjPhQR",
"clientSecret": "GAl1YKLA3FveK1gLBYok"
},
"chatChannels": []
"chatChannels": [
{ "type": "telegram", "value": "https://t.me/irismessenger" },
{ "type": "nip28", "value": "23286a4602ada10cc10200553bff62a110e8dc0eacddf73277395a89ddf26a09" }
]
}

View File

@ -15,6 +15,7 @@
"subscriptions": true,
"deck": true,
"zapPool": true,
"notificationGraph": false,
"communityLeaders": true
},
"defaultPreferences": {

View File

@ -1,49 +0,0 @@
{
"appName": "めく",
"appNameCapitalized": "めく",
"appTitle": "めく",
"hostname": "meku.app",
"nip05Domain": "meku.app",
"icon": "/nostr.jpg",
"navLogo": null,
"publicDir": "public/nostr",
"httpCache": "",
"animalNamePlaceholders": false,
"defaultZapPoolFee": 0,
"features": {
"analytics": true,
"subscriptions": false,
"deck": false,
"zapPool": false,
"communityLeaders": false,
"nostrAddress": false,
"pushNotifications": true
},
"signUp": {
"quickStart": false,
"defaultFollows": []
},
"defaultPreferences": {
"hideMutedNotes": false,
"defaultRootTab": "following",
"language": "ja"
},
"media": {
"bypassImgProxyError": false,
"preferLargeMedia": true
},
"communityLeaders": null,
"noteCreatorToast": false,
"hideFromNavbar": [],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",
"profileLinkPrefix": "nprofile",
"defaultRelays": {
"wss://relay.nostr.wirednet.jp/": { "read": true, "write": true },
"wss://yabu.me/": { "read": true, "write": true },
"wss://nos.lol/": { "read": true, "write": true }
},
"alby": null,
"chatChannels": null
}

View File

@ -15,6 +15,7 @@
"subscriptions": false,
"deck": false,
"zapPool": false,
"notificationGraph": true,
"communityLeaders": false,
"nostrAddress": false,
"pushNotifications": false
@ -33,7 +34,7 @@
},
"communityLeaders": null,
"noteCreatorToast": true,
"hideFromNavbar": [],
"hideFromNavbar": ["/graph"],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",

View File

@ -1,49 +0,0 @@
{
"appName": "Soloco",
"appNameCapitalized": "Soloco",
"appTitle": "Soloco",
"hostname": "soloco.nl",
"nip05Domain": "soloco.nl",
"icon": "/nostrich_512.png",
"favicon": "public/favicon.ico",
"appleTouchIconUrl": "/nostrich_512.png",
"navLogo": null,
"publicDir": "public/snort",
"httpCache": "",
"animalNamePlaceholders": false,
"defaultZapPoolFee": 0,
"features": {
"analytics": false,
"subscriptions": false,
"deck": false,
"zapPool": false,
"communityLeaders": false,
"nostrAddress": false,
"pushNotifications": true
},
"signUp": {
"quickStart": false,
"defaultFollows": []
},
"defaultPreferences": {
"hideMutedNotes": false,
"defaultRootTab": "following",
"language": "nl"
},
"media": {
"bypassImgProxyError": false,
"preferLargeMedia": true
},
"communityLeaders": null,
"noteCreatorToast": true,
"hideFromNavbar": [],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",
"profileLinkPrefix": "nprofile",
"defaultRelays": {
"wss://soloco.nl/": { "read": true, "write": false }
},
"alby": null,
"chatChannels": null
}

View File

@ -57,6 +57,7 @@ declare const CONFIG: {
subscriptions: boolean;
deck: boolean;
zapPool: boolean;
notificationGraph: boolean;
communityLeaders: boolean;
nostrAddress: boolean;
pushNotifications: boolean;

View File

@ -4,13 +4,11 @@
"dependencies": {
"@cashu/cashu-ts": "^1.0.0-rc.3",
"@here/maps-api-for-javascript": "^1.50.0",
"@livekit/components-react": "^2.5.4",
"@livekit/protocol": "^1.22.0",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"@scure/base": "^1.1.6",
"@scure/bip32": "^1.5.0",
"@scure/bip39": "^1.4.0",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.3.3",
"@scure/base": "^1.1.1",
"@scure/bip32": "^1.3.0",
"@scure/bip39": "^1.1.1",
"@snort/shared": "workspace:*",
"@snort/system": "workspace:*",
"@snort/system-react": "workspace:*",
@ -18,7 +16,7 @@
"@snort/system-web": "workspace:*",
"@snort/wallet": "workspace:*",
"@snort/worker-relay": "workspace:*",
"@szhsin/react-menu": "^3.5.3",
"@szhsin/react-menu": "^3.3.1",
"@uidotdev/usehooks": "^2.4.1",
"@void-cat/api": "^1.0.12",
"classnames": "^2.3.2",
@ -31,7 +29,6 @@
"highlight.js": "^11.8.0",
"latlon-geohash": "^2.0.0",
"light-bolt11-decoder": "^2.1.0",
"livekit-client": "^2.5.2",
"lottie-react": "^2.4.0",
"marked": "^9.1.0",
"marked-footnote": "^1.0.0",
@ -47,7 +44,6 @@
"react-textarea-autosize": "^8.4.0",
"recharts": "^2.8.0",
"three": "^0.157.0",
"tslib": "^2.7.0",
"typescript-lru-cache": "^2.0.0",
"use-long-press": "^3.2.0",
"use-sync-external-store": "^1.2.0",
@ -67,9 +63,7 @@
"test:watch": "vitest watch",
"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",
"eslint": "eslint .",
"deploy:meku": "NODE_CONFIG_ENV=meku yarn build && npx wrangler pages deploy --project-name meku build/",
"deploy:notestr": "NODE_CONFIG_ENV=nostr yarn build && npx wrangler pages deploy --project-name nostr-generic build/"
"eslint": "eslint ."
},
"eslintConfig": {
"extends": [
@ -105,13 +99,11 @@
"@types/webtorrent": "^0.109.3",
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"@vitejs/plugin-basic-ssl": "^1.2.0",
"@vitejs/plugin-react": "^4.2.0",
"@webbtc/webln-types": "^3.0.0",
"@webbtc/webln-types": "^2.1.0",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"@welldone-software/why-did-you-render": "^8.0.1",
"autoprefixer": "^10.4.16",
"babel-plugin-formatjs": "^10.5.14",
"config": "^3.3.9",
"eslint": "^8.48.0",
"eslint-config-react-app": "^7.0.1",

View File

@ -1,4 +1,2 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;
/service-worker.js
Cache-Control: max-age=604800, must-revalidate;
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;

View File

@ -1,4 +1,2 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;
/service-worker.js
Cache-Control: max-age=604800, must-revalidate;
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;

View File

@ -27,8 +27,4 @@ export class ChatCache extends FeedCache<NostrEvent> {
takeSnapshot(): Array<NostrEvent> {
return [...this.cache.values()];
}
async search() {
return <Array<NostrEvent>>[];
}
}

View File

@ -1,13 +1,14 @@
import { CachedTable, CacheEvents } from "@snort/shared";
import { CacheRelay, NostrEvent } from "@snort/system";
import { NostrEvent } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import { EventEmitter } from "eventemitter3";
export class EventCacheWorker extends EventEmitter<CacheEvents> implements CachedTable<NostrEvent> {
#relay: CacheRelay;
#relay: WorkerRelayInterface;
#keys = new Set<string>();
#cache = new Map<string, NostrEvent>();
constructor(relay: CacheRelay) {
constructor(relay: WorkerRelayInterface) {
super();
this.#relay = relay;
}
@ -23,17 +24,6 @@ export class EventCacheWorker extends EventEmitter<CacheEvents> implements Cache
this.#keys = new Set<string>(ids as unknown as Array<string>);
}
async search(q: string) {
const results = await this.#relay.query([
"REQ",
"events-search",
{
search: q,
},
]);
return results;
}
keysOnTable(): string[] {
return [...this.#keys];
}

View File

@ -1,9 +1,10 @@
import { EventKind, EventPublisher, TaggedNostrEvent } from "@snort/system";
import { EventKind, EventPublisher, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { db, UnwrappedGift } from "@/Db";
import { findTag, unwrap } from "@/Utils";
import { LoginSession, LoginSessionType } from "@/Utils/Login";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
import { RefreshFeedCache } from "./RefreshFeedCache";
export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
constructor() {
@ -14,8 +15,11 @@ export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
return of.id;
}
buildSub(): void {
// not used
buildSub(session: LoginSession, rb: RequestBuilder): void {
const pubkey = session.publicKey;
if (pubkey && session.type === LoginSessionType.PrivateKey) {
rb.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubkey]).since(this.newest());
}
}
takeSnapshot(): Array<UnwrappedGift> {
@ -53,8 +57,4 @@ export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
}
await this.bulkSet(unwrapped);
}
search(): Promise<TWithCreated<UnwrappedGift>[]> {
throw new Error("Method not implemented.");
}
}

View File

@ -1,15 +1,16 @@
import { CachedTable, CacheEvents, removeUndefined, unixNowMs, unwrap } from "@snort/shared";
import { CachedMetadata, CacheRelay, mapEventToProfile, NostrEvent } from "@snort/system";
import { CachedMetadata, mapEventToProfile, NostrEvent } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import debug from "debug";
import { EventEmitter } from "eventemitter3";
export class ProfileCacheRelayWorker extends EventEmitter<CacheEvents> implements CachedTable<CachedMetadata> {
#relay: CacheRelay;
#relay: WorkerRelayInterface;
#keys = new Set<string>();
#cache = new Map<string, CachedMetadata>();
#log = debug("ProfileCacheRelayWorker");
constructor(relay: CacheRelay) {
constructor(relay: WorkerRelayInterface) {
super();
this.#relay = relay;
}
@ -28,18 +29,6 @@ export class ProfileCacheRelayWorker extends EventEmitter<CacheEvents> implement
this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString());
}
async search(q: string) {
const profiles = await this.#relay.query([
"REQ",
"profiles-search",
{
kinds: [0],
search: q,
},
]);
return removeUndefined(profiles.map(mapEventToProfile));
}
keysOnTable(): string[] {
return [...this.#keys];
}

View File

@ -1,15 +1,16 @@
import { CachedTable, CacheEvents, removeUndefined, unixNowMs, unwrap } from "@snort/shared";
import { CacheRelay, EventKind, NostrEvent, UsersFollows } from "@snort/system";
import { EventKind, NostrEvent, UsersFollows } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import debug from "debug";
import { EventEmitter } from "eventemitter3";
export class UserFollowsWorker extends EventEmitter<CacheEvents> implements CachedTable<UsersFollows> {
#relay: CacheRelay;
#relay: WorkerRelayInterface;
#keys = new Set<string>();
#cache = new Map<string, UsersFollows>();
#log = debug("UserFollowsWorker");
constructor(relay: CacheRelay) {
constructor(relay: WorkerRelayInterface) {
super();
this.#relay = relay;
}
@ -28,18 +29,6 @@ export class UserFollowsWorker extends EventEmitter<CacheEvents> implements Cach
this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString());
}
async search(q: string) {
const results = await this.#relay.query([
"REQ",
"contacts-search",
{
kinds: [3],
search: q,
},
]);
return removeUndefined(results.map(mapEventToUserFollows));
}
keysOnTable(): string[] {
return [...this.#keys];
}

View File

@ -1,4 +1,4 @@
import { CacheRelay, Connection, ConnectionCacheRelay, RelayMetricCache, UserRelaysCache } from "@snort/system";
import { RelayMetricCache, UserRelaysCache } from "@snort/system";
import { SnortSystemDb } from "@snort/system-web";
import { WorkerRelayInterface } from "@snort/worker-relay";
import WorkerVite from "@snort/worker-relay/src/worker?worker";
@ -8,52 +8,12 @@ import { GiftWrapCache } from "./GiftWrapCache";
import { ProfileCacheRelayWorker } from "./ProfileWorkerCache";
import { UserFollowsWorker } from "./UserFollowsWorker";
const cacheRelay = localStorage.getItem("cache-relay");
const workerRelay = new WorkerRelayInterface(
export const Relay = new WorkerRelayInterface(
import.meta.env.DEV ? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url) : new WorkerVite(),
);
export const Relay: CacheRelay = cacheRelay
? new ConnectionCacheRelay(new Connection(cacheRelay, { read: true, write: true }))
: workerRelay;
async function tryUseCacheRelay(url: string) {
try {
const conn = new Connection(url, { read: true, write: true });
await conn.connect(true);
localStorage.setItem("cache-relay", url);
return conn;
} catch (e) {
console.warn(e);
}
}
export async function tryUseLocalRelay() {
let conn = await tryUseCacheRelay("ws://localhost:4869");
if (!conn) {
conn = await tryUseCacheRelay("ws://umbrel:4848");
}
return conn;
}
export async function initRelayWorker() {
try {
if (Relay instanceof ConnectionCacheRelay) {
await Relay.connection.connect(true);
return;
}
} catch (e) {
localStorage.removeItem("cache-relay");
console.error(e);
if (cacheRelay) {
window.location.reload();
}
}
try {
await workerRelay.debug("*");
await workerRelay.init({
await Relay.init({
databasePath: "relay.db",
insertBatchSize: 100,
});

View File

@ -12,12 +12,7 @@ interface IconButtonProps {
const IconButton = ({ onClick, icon, children, className }: IconButtonProps) => {
return (
<button
className={classNames(
"flex items-center justify-center aspect-square w-10 h-10 !p-0 !m-0 bg-gray-dark text-white",
className,
)}
onClick={onClick}>
<button className={classNames("icon", className)} type="button" onClick={onClick}>
<Icon {...icon} />
{children}
</button>

View File

@ -4,7 +4,7 @@ import { ReactNode, useState } from "react";
import Icon from "@/Components/Icons/Icon";
interface CollapsedProps {
text?: ReactNode;
text?: string;
children: ReactNode;
collapsed: boolean;
setCollapsed(b: boolean): void;
@ -33,11 +33,10 @@ interface CollapsedSectionProps {
title: ReactNode;
children: ReactNode;
className?: string;
startClosed?: boolean;
}
export const CollapsedSection = ({ title, children, className, startClosed }: CollapsedSectionProps) => {
const [collapsed, setCollapsed] = useState(startClosed ?? true);
export const CollapsedSection = ({ title, children, className }: CollapsedSectionProps) => {
const [collapsed, setCollapsed] = useState(true);
const icon = (
<div className={classNames("collapse-icon", { flip: !collapsed })}>
<Icon name="arrowFront" />

View File

@ -19,7 +19,7 @@ export function LeaderBadge() {
}}>
<AwardIcon size={16} />
<div className="text-xs font-medium text-[#AC88FF]">
<FormattedMessage defaultMessage="Community Leader" />
<FormattedMessage defaultMessage="Community Leader" id="7YkSA2" />
</div>
</div>
{showModal && (
@ -28,7 +28,7 @@ export function LeaderBadge() {
<CloseButton className="absolute right-2 top-2" onClick={() => setShowModal(false)} />
<AwardIcon size={80} />
<div className="text-3xl font-semibold">
<FormattedMessage defaultMessage="Community Leader" />
<FormattedMessage defaultMessage="Community Leader" id="7YkSA2" />
</div>
<p className="text-secondary">
<FormattedMessage
@ -38,7 +38,7 @@ export function LeaderBadge() {
</p>
<Link to="/settings/invite">
<button className="primary">
<FormattedMessage defaultMessage="Become a leader" />
<FormattedMessage defaultMessage="Become a leader" id="M6C/px" />
</button>
</Link>
</div>

View File

@ -22,12 +22,7 @@ export default function Copy({ text, maxSize = 32, className, showText, mask }:
: displayText;
return (
<div
className={classNames("copy flex pointer g8 items-center", className)}
onClick={e => {
e.stopPropagation();
copy(text);
}}>
<div className={classNames("copy flex pointer g8 items-center", className)} onClick={() => copy(text)}>
{(showText ?? true) && <span className="copy-body">{trimmed}</span>}
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
{copied ? <Icon name="check" size={14} /> : <Icon name="copy-solid" size={14} />}

View File

@ -3,15 +3,21 @@ const AppleMusicEmbed = ({ link }: { link: string }) => {
const isSongLink = /\?i=\d+$/.test(convertedUrl);
return (
<iframe
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
frameBorder="0"
height={isSongLink ? 175 : 450}
style={{ width: "100%", maxWidth: 660, overflow: "hidden", background: "transparent" }}
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={convertedUrl}
loading="lazy"
/>
<>
<iframe
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
frameBorder="0"
// eslint-disable-next-line react/no-unknown-property
credentialless=""
height={isSongLink ? 175 : 450}
style={{ width: "100%", maxWidth: 660, overflow: "hidden", background: "transparent" }}
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={convertedUrl}
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -74,7 +74,7 @@ export default function CashuNuts({ token }: { token: string }) {
<Icon name="copy" />
</AsyncButton>
<AsyncButton onClick={() => redeemToken(token)}>
<FormattedMessage defaultMessage="Redeem" />
<FormattedMessage defaultMessage="Redeem" id="XrSk2j" description="Button: Redeem Cashu token" />
</AsyncButton>
</div>
</div>

View File

@ -1,4 +1,3 @@
import { Bech32Regex } from "@snort/shared";
import { ReactNode } from "react";
import AppleMusicEmbed from "@/Components/Embed/AppleMusicEmbed";
@ -11,11 +10,11 @@ import SpotifyEmbed from "@/Components/Embed/SpotifyEmbed";
import TidalEmbed from "@/Components/Embed/TidalEmbed";
import TwitchEmbed from "@/Components/Embed/TwitchEmbed";
import WavlakeEmbed from "@/Components/Embed/WavlakeEmbed";
import YoutubeEmbed from "@/Components/Embed/YoutubeEmbed";
import { magnetURIDecode } from "@/Utils";
import {
AppleMusicRegex,
MixCloudRegex,
NostrNestsRegex,
SoundCloudRegex,
SpotifyRegex,
TidalRegex,
@ -35,23 +34,57 @@ export default function HyperText({ link, depth, showLinkPreview, children }: Hy
const a = link;
try {
const url = new URL(a);
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
const tidalId = TidalRegex.test(a) && RegExp.$1;
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
const isSpotifyLink = SpotifyRegex.test(a);
const isTwitchLink = TwitchRegex.test(a);
const isAppleMusicLink = AppleMusicRegex.test(a);
const isNostrNestsLink = NostrNestsRegex.test(a);
const isWavlakeLink = WavlakeRegex.test(a);
let m = null;
if (a.match(YoutubeUrlRegex)) {
return <YoutubeEmbed link={a} />;
} else if (a.match(TidalRegex)) {
if (youtubeId) {
return (
<>
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
className="-mx-4 md:mx-0 w-max my-2"
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
key={youtubeId}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
</a>
</>
);
} else if (tidalId) {
return <TidalEmbed link={a} />;
} else if (a.match(SoundCloudRegex)) {
} else if (soundcloundId) {
return <SoundCloudEmbed link={a} />;
} else if (a.match(MixCloudRegex)) {
} else if (mixcloudId) {
return <MixCloudEmbed link={a} />;
} else if (a.match(SpotifyRegex)) {
} else if (isSpotifyLink) {
return <SpotifyEmbed link={a} />;
} else if (a.match(TwitchRegex)) {
} else if (isTwitchLink) {
return <TwitchEmbed link={a} />;
} else if (a.match(AppleMusicRegex)) {
} else if (isAppleMusicLink) {
return <AppleMusicEmbed link={a} />;
} else if (a.match(WavlakeRegex)) {
} else if (isNostrNestsLink) {
return (
<>
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{children ?? a}
</a>
{/*<NostrNestsEmbed link={a} />,*/}
</>
);
} else if (isWavlakeLink) {
return <WavlakeEmbed link={a} />;
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
return <NostrLink link={a} depth={depth} />;
@ -60,8 +93,6 @@ export default function HyperText({ link, depth, showLinkPreview, children }: Hy
if (parsed) {
return <MagnetLink magnet={parsed} />;
}
} else if ((m = a.match(Bech32Regex)) != null) {
return <NostrLink link={`nostr:${m[1]}`} depth={depth} />;
} else if (showLinkPreview ?? true) {
return <LinkPreview url={a} />;
}

View File

@ -77,7 +77,7 @@ export default function Invoice(props: InvoiceProps) {
{description && <p>{description}</p>}
{isPaid ? (
<div className="paid">
<FormattedMessage defaultMessage="Paid" />
<FormattedMessage defaultMessage="Paid" id="u/vOPu" />
</div>
) : (
<button disabled={isExpired} type="button" onClick={payInvoice}>

View File

@ -10,7 +10,7 @@ const MagnetLink = ({ magnet }: MagnetLinkProps) => {
return (
<div className="note-invoice">
<h4>
<FormattedMessage defaultMessage="Magnet Link" />
<FormattedMessage defaultMessage="Magnet Link" id="Gcn9NQ" />
</h4>
<a href={magnet.raw} rel="noreferrer">
{magnet.dn ?? magnet.infoHash}

View File

@ -2,21 +2,24 @@ import usePreferences from "@/Hooks/usePreferences";
import { MixCloudRegex } from "@/Utils/Const";
const MixCloudEmbed = ({ link }: { link: string }) => {
const match = link.match(MixCloudRegex);
if (!match) return;
const feedPath = match[1] + "%2F" + match[2];
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
const theme = usePreferences(s => s.theme);
const lightParams = theme === "light" ? "light=1" : "light=0";
return (
<iframe
title="SoundCloud player"
width="100%"
height="120"
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
loading="lazy"
/>
<>
<br />
<iframe
title="SoundCloud player"
width="100%"
height="120"
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
/>
<a href={link} target="_blank" rel="noreferrer">
{link}
</a>
</>
);
};

View File

@ -65,6 +65,7 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
return (
<FollowListBase
pubkeys={ids}
showAbout={true}
className={className}
title={findTag(ev, "title") ?? findTag(ev, "d")}
actions={
@ -80,11 +81,6 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
</AsyncButton>
</>
}
profilePreviewProps={{
options: {
about: true,
},
}}
/>
);
}

View File

@ -1,12 +1,19 @@
const SoundCloudEmbed = ({ link }: { link: string }) => {
return (
<iframe
width="100%"
height="166"
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${link}`}
loading="lazy"
/>
<>
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
width="100%"
height="166"
scrolling="no"
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${link}`}
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -2,15 +2,22 @@ const SpotifyEmbed = ({ link }: { link: string }) => {
const convertedUrl = link.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2");
return (
<iframe
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
height="352"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
<>
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
height="352"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -53,17 +53,17 @@ const TidalEmbed = ({ link }: { link: string }) => {
</a>
);
}
const iframe = (
// eslint-disable-next-line react/no-unknown-property
<iframe src={source} style={extraStyles} width="100%" title="TIDAL Embed" frameBorder={0} credentialless="" />
);
return (
<iframe
src={source}
style={extraStyles}
width="100%"
allow="encrypted-media *; clipboard-write *; clipboard-read *"
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
title="TIDAL Embed"
frameBorder={0}
loading="lazy"
/>
<>
{iframe}
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -3,12 +3,12 @@ const TwitchEmbed = ({ link }: { link: string }) => {
const args = `?channel=${channel}&parent=${window.location.hostname}&muted=true`;
return (
<iframe
src={`https://player.twitch.tv/${args}`}
className="aspect-video w-full"
allowFullScreen={true}
loading="lazy"
/>
<>
<iframe src={`https://player.twitch.tv/${args}`} className="w-max" allowFullScreen={true} />
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -2,7 +2,21 @@ const WavlakeEmbed = ({ link }: { link: string }) => {
const convertedUrl = link.replace(/(?:player\.|www\.)?wavlake\.com/, "embed.wavlake.com");
return (
<iframe style={{ borderRadius: 12 }} src={convertedUrl} width="100%" height="380" frameBorder="0" loading="lazy" />
<>
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
height="380"
frameBorder="0"
loading="lazy"
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -1,17 +0,0 @@
import { YoutubeUrlRegex } from "@/Utils/Const";
export default function YoutubeEmbed({ link }: { link: string }) {
const m = link.match(YoutubeUrlRegex);
if (!m) return;
return (
<iframe
className="-mx-4 md:mx-0 w-max my-2"
src={`https://www.youtube.com/embed/${m[1]}${m[3] ? `?list=${m[3].slice(6)}` : ""}`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
);
}

View File

@ -32,7 +32,7 @@ export default function ZapstrEmbed({ ev }: { ev: NostrEvent }) {
</div>
<Link to={`https://zapstr.live/?track=${link}`} target="_blank">
<button>
<FormattedMessage defaultMessage="Open on Zapstr" />
<FormattedMessage defaultMessage="Open on Zapstr" id="Lu5/Bj" />
</button>
</Link>
</>

View File

@ -0,0 +1,101 @@
.note-creator-modal .modal-body > div {
display: flex;
flex-direction: column;
gap: 16px;
}
.note-creator-modal .note.card {
padding: 0;
border: none;
min-height: unset;
}
.note-creator-modal .note.card.note-quote {
border: 1px solid var(--gray);
padding: 8px 12px;
}
.note-creator-modal h4 {
font-size: 11px;
font-weight: 600;
letter-spacing: 1.21px;
text-transform: uppercase;
color: var(--gray-light);
margin: 0;
}
.note-creator-relay {
background-color: var(--gray-dark);
border-radius: 12px;
}
.note-creator textarea {
border: none;
outline: none;
resize: none;
padding: 0;
border-radius: 0;
margin: 8px 12px;
min-height: 100px;
width: stretch;
width: -webkit-fill-available;
width: -moz-available;
max-height: 210px;
}
.note-creator textarea::placeholder {
color: var(--font-secondary-color);
font-size: var(--font-size);
line-height: 24px;
}
.note-creator.poll textarea {
min-height: 120px;
}
.note-creator .error {
position: absolute;
left: 16px;
bottom: 12px;
color: var(--error);
margin-right: 12px;
font-size: 16px;
}
.note-creator-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
cursor: pointer;
}
.note-creator-icon.pfp .avatar {
width: 32px;
height: 32px;
}
.note-creator-modal .rti--container {
background-color: unset !important;
box-shadow: unset !important;
border: 2px solid var(--border-color) !important;
border-radius: 12px !important;
padding: 4px 8px !important;
}
.note-creator-modal .rti--tag {
color: black !important;
padding: 4px 10px !important;
border-radius: 12px !important;
display: unset !important;
}
.note-creator-modal .rti--input {
width: 100% !important;
border: unset !important;
}
.note-creator-modal .rti--tag button {
padding: 0 0 0 var(--rti-s);
}

View File

@ -1,9 +1,8 @@
/* eslint-disable max-lines */
import "./NoteCreator.css";
import { fetchNip05Pubkey, unixNow } from "@snort/shared";
import { EventBuilder, EventKind, NostrLink, NostrPrefix, TaggedNostrEvent, tryParseNostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { ZapTarget } from "@snort/wallet";
import { Menu, MenuItem } from "@szhsin/react-menu";
import classNames from "classnames";
import { ClipboardEventHandler, DragEvent, useEffect } from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -11,26 +10,25 @@ import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import { AsyncIcon } from "@/Components/Button/AsyncIcon";
import CloseButton from "@/Components/Button/CloseButton";
import IconButton from "@/Components/Button/IconButton";
import { sendEventToRelays } from "@/Components/Event/Create/util";
import Note from "@/Components/Event/EventComponent";
import Flyout from "@/Components/flyout";
import Icon from "@/Components/Icons/Icon";
import { ToggleSwitch } from "@/Components/Icons/Toggle";
import Modal from "@/Components/Modal/Modal";
import Textarea from "@/Components/Textarea/Textarea";
import { Toastore } from "@/Components/Toaster/Toaster";
import { MediaServerFileList } from "@/Components/Upload/file-picker";
import Avatar from "@/Components/User/Avatar";
import ProfileImage from "@/Components/User/ProfileImage";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import useRelays from "@/Hooks/useRelays";
import { useNoteCreator } from "@/State/NoteCreator";
import { openFile, trackEvent } from "@/Utils";
import useFileUpload, { addExtensionToNip94Url, nip94TagsToIMeta, readNip94Tags } from "@/Utils/Upload";
import useFileUpload from "@/Utils/Upload";
import { GetPowWorker } from "@/Utils/wasm";
import { ZapTarget } from "@/Utils/Zapper";
import FileUploadProgress from "../FileUpload";
import { OkResponseRow } from "./OkResponseRow";
const previewNoteOptions = {
@ -61,7 +59,6 @@ export function NoteCreator() {
const { formatMessage } = useIntl();
const uploader = useFileUpload();
const publicKey = useLogin(s => s.publicKey);
const profile = useUserProfile(publicKey);
const pow = usePreferences(s => s.pow);
const relays = useRelays();
const { system, publisher: pub } = useEventPublisher();
@ -148,18 +145,6 @@ export function NoteCreator() {
extraTags ??= [];
extraTags.push(...note.hashTags.map(a => ["t", a.toLowerCase()]));
}
for (const ex of note.otherEvents ?? []) {
const meta = readNip94Tags(ex.tags);
if (!meta.url) continue;
if (!note.note.endsWith("\n")) {
note.note += "\n";
}
note.note += addExtensionToNip94Url(meta);
extraTags ??= [];
extraTags.push(nip94TagsToIMeta(meta));
}
// add quote repost
if (note.quote) {
if (!note.note.endsWith("\n")) {
@ -226,16 +211,19 @@ export function NoteCreator() {
}
trackEvent("PostNote", props);
sendEventToRelays(system, ev, note.selectedCustomRelays, r => {
if (CONFIG.noteCreatorToast) {
r.forEach(rr => {
Toastore.push({
element: c => <OkResponseRow rsp={rr} close={c} />,
expire: unixNow() + (rr.ok ? 5 : 55555),
const events = (note.otherEvents ?? []).concat(ev);
events.map(a =>
sendEventToRelays(system, a, note.selectedCustomRelays, r => {
if (CONFIG.noteCreatorToast) {
r.forEach(rr => {
Toastore.push({
element: c => <OkResponseRow rsp={rr} close={c} />,
expire: unixNow() + (rr.ok ? 5 : 55555),
});
});
});
}
});
}
}),
);
note.update(n => n.reset());
localStorage.removeItem("msgDraft");
}
@ -260,17 +248,29 @@ export function NoteCreator() {
async function uploadFile(file: File) {
try {
if (file && uploader) {
if (file) {
const rx = await uploader.upload(file, file.name);
note.update(v => {
if (rx.header) {
v.otherEvents ??= [];
v.otherEvents.push(rx.header);
const link = `nostr:${new NostrLink(NostrPrefix.Event, rx.header.id, rx.header.kind).encode(
CONFIG.eventLinkPrefix,
)}`;
v.note = `${v.note ? `${v.note}\n` : ""}${link}`;
v.otherEvents = [...(v.otherEvents ?? []), rx.header];
} else if (rx.url) {
v.note = `${v.note ? `${v.note}\n` : ""}${rx.url}`;
if (rx.metadata) {
v.extraTags ??= [];
const imeta = nip94TagsToIMeta(rx.metadata);
const imeta = ["imeta", `url ${rx.url}`];
if (rx.metadata.blurhash) {
imeta.push(`blurhash ${rx.metadata.blurhash}`);
}
if (rx.metadata.width && rx.metadata.height) {
imeta.push(`dim ${rx.metadata.width}x${rx.metadata.height}`);
}
if (rx.metadata.hash) {
imeta.push(`x ${rx.metadata.hash}`);
}
v.extraTags.push(imeta);
}
} else if (rx?.error) {
@ -330,12 +330,12 @@ export function NoteCreator() {
return (
<>
<h4>
<FormattedMessage defaultMessage="Poll Options" />
<FormattedMessage defaultMessage="Poll Options" id="vhlWFg" />
</h4>
{note.pollOptions?.map((a, i) => (
<div className="form-group w-max" key={`po-${i}`}>
<div>
<FormattedMessage defaultMessage="Option: {n}" values={{ n: i + 1 }} />
<FormattedMessage defaultMessage="Option: {n}" id="mfe8RW" values={{ n: i + 1 }} />
</div>
<div>
<input type="text" value={a} onChange={e => changePollOption(i, e.target.value)} />
@ -369,12 +369,12 @@ export function NoteCreator() {
function renderRelayCustomisation() {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col g8">
{Object.entries(relays)
.filter(el => el[1].write)
.map(a => a[0])
.map((r, i, a) => (
<div className="p flex items-center justify-between bg-gray br" key={r}>
<div className="p flex justify-between note-creator-relay" key={r}>
<div>{r}</div>
<div>
<input
@ -422,24 +422,24 @@ export function NoteCreator() {
<>
<div>
<h4>
<FormattedMessage defaultMessage="Custom Relays" />
<FormattedMessage defaultMessage="Custom Relays" id="EcZF24" />
</h4>
<p>
<FormattedMessage defaultMessage="Send note to a subset of your write relays" />
<FormattedMessage defaultMessage="Send note to a subset of your write relays" id="th5lxp" />
</p>
{renderRelayCustomisation()}
</div>
<div className="flex flex-col g8">
<h4>
<FormattedMessage defaultMessage="Zap Splits" />
<FormattedMessage defaultMessage="Zap Splits" id="5CB6zB" />
</h4>
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." />
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." id="LwYmVi" />
<div className="flex flex-col g8">
{[...(note.zapSplits ?? [])].map((v: ZapTarget, i, arr) => (
<div className="flex items-center g8" key={`${v.name}-${v.value}`}>
<div className="flex flex-col flex-4 g4">
<h4>
<FormattedMessage defaultMessage="Recipient" />
<FormattedMessage defaultMessage="Recipient" id="8Rkoyb" />
</h4>
<input
type="text"
@ -454,7 +454,7 @@ export function NoteCreator() {
</div>
<div className="flex flex-col flex-1 g4">
<h4>
<FormattedMessage defaultMessage="Weight" />
<FormattedMessage defaultMessage="Weight" id="zCb8fX" />
</h4>
<input
type="number"
@ -470,7 +470,7 @@ export function NoteCreator() {
}
/>
</div>
<div className="flex flex-col g4">
<div className="flex flex-col s g4">
<div>&nbsp;</div>
<Icon
name="close"
@ -484,18 +484,24 @@ export function NoteCreator() {
onClick={() =>
note.update(v => (v.zapSplits = [...(v.zapSplits ?? []), { type: "pubkey", value: "", weight: 1 }]))
}>
<FormattedMessage defaultMessage="Add" />
<FormattedMessage defaultMessage="Add" id="2/2yg+" />
</button>
</div>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this, you may still receive some zaps as if zap splits was not configured" />
<FormattedMessage
defaultMessage="Not all clients support this, you may still receive some zaps as if zap splits was not configured"
id="6bgpn+"
/>
</span>
</div>
<div className="flex flex-col g8">
<h4>
<FormattedMessage defaultMessage="Sensitive Content" />
<FormattedMessage defaultMessage="Sensitive Content" id="bQdA2k" />
</h4>
<FormattedMessage defaultMessage="Users must accept the content warning to show the content of your note." />
<FormattedMessage
defaultMessage="Users must accept the content warning to show the content of your note."
id="UUPFlt"
/>
<input
className="w-max"
type="text"
@ -509,7 +515,7 @@ export function NoteCreator() {
})}
/>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
<FormattedMessage defaultMessage="Not all clients support this yet" id="gXgY3+" />
</span>
</div>
</>
@ -519,46 +525,32 @@ export function NoteCreator() {
function noteCreatorFooter() {
return (
<div className="flex justify-between">
<div className="flex items-center gap-4 text-gray-light cursor-pointer">
<Avatar pubkey={publicKey ?? ""} user={profile} size={28} showTitle={true} />
<Menu
menuButton={
<AsyncIcon iconName="attachment" iconSize={24} className="hover:text-gray-superlight transition" />
}
menuClassName="ctx-menu no-icons">
<div className="close-menu-container">
{/* This menu item serves as a "close menu" button;
it allows the user to click anywhere nearby the menu to close it. */}
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={() => note.update(s => (s.filePicker = "compact"))}>
<FormattedMessage defaultMessage="From Server" />
</MenuItem>
<MenuItem onClick={() => attachFile()}>
<FormattedMessage defaultMessage="From File" />
</MenuItem>
</Menu>
<div className="flex items-center g8">
<ProfileImage
pubkey={publicKey ?? ""}
className="note-creator-icon"
link=""
showUsername={false}
showFollowDistance={false}
showProfileCard={false}
/>
{note.pollOptions === undefined && !note.replyTo && (
<AsyncIcon
iconName="bar-chart"
iconName="list"
iconSize={24}
onClick={() => note.update(v => (v.pollOptions = ["A", "B"]))}
className={classNames("hover:text-gray-superlight transition", {
"text-white": note.pollOptions !== undefined,
})}
className={classNames("note-creator-icon", { active: note.pollOptions !== undefined })}
/>
)}
<AsyncIcon iconName="image-plus" iconSize={24} onClick={attachFile} className="note-creator-icon" />
<AsyncIcon
iconName="settings-outline"
iconName="settings-04"
iconSize={24}
onClick={() => note.update(v => (v.advanced = !v.advanced))}
className={classNames("hover:text-gray-superlight transition", { "text-white": note.advanced })}
className={classNames("note-creator-icon", { active: note.advanced })}
/>
<span className="sm:inline hidden">
<FormattedMessage defaultMessage="Preview" />
<FormattedMessage defaultMessage="Preview" id="TJo5E6" />
</span>
<ToggleSwitch
onClick={() => loadPreview()}
@ -566,9 +558,18 @@ export function NoteCreator() {
className={classNames({ active: Boolean(note.preview) })}
/>
</div>
<AsyncButton onClick={onSubmit} className="primary">
{note.replyTo ? <FormattedMessage defaultMessage="Reply" /> : <FormattedMessage defaultMessage="Send" />}
</AsyncButton>
<div className="flex g8">
<button className="secondary" onClick={cancel}>
<FormattedMessage defaultMessage="Cancel" id="47FYwb" />
</button>
<AsyncButton onClick={onSubmit} className="primary">
{note.replyTo ? (
<FormattedMessage defaultMessage="Reply" id="9HU8vw" />
) : (
<FormattedMessage defaultMessage="Send" id="9WRlF4" />
)}
</AsyncButton>
</div>
</div>
);
}
@ -616,7 +617,7 @@ export function NoteCreator() {
{note.replyTo && (
<>
<h4>
<FormattedMessage defaultMessage="Reply To" />
<FormattedMessage defaultMessage="Reply To" id="8ED/4u" />
</h4>
<div className="max-h-64 overflow-y-auto">
<Note className="hover:bg-transparent" data={note.replyTo} options={replyToNoteOptions} />
@ -627,7 +628,7 @@ export function NoteCreator() {
{note.quote && (
<>
<h4>
<FormattedMessage defaultMessage="Quote Repost" />
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
</h4>
<div className="max-h-64 overflow-y-auto">
<Note className="hover:bg-transparent" data={note.quote} options={quoteNoteOptions} />
@ -637,22 +638,13 @@ export function NoteCreator() {
)}
{note.preview && getPreviewNote()}
{!note.preview && (
<div className="flex flex-col gap-4">
<div className="font-medium flex justify-between items-center">
<FormattedMessage defaultMessage="Compose a note" />
<AsyncIcon
iconName="x"
className="bg-gray rounded-full items-center justify-center flex p-1 cursor-pointer"
onClick={cancel}
/>
</div>
<div onPaste={handlePaste} className={classNames({ poll: Boolean(note.pollOptions) })}>
<>
<div onPaste={handlePaste} className={classNames("note-creator", { poll: Boolean(note.pollOptions) })}>
<Textarea
className="!border-none !resize-none !p-0 !rounded-none !text-sm"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
autoFocus={true}
autoFocus
onChange={c => onChange(c)}
value={note.note}
onFocus={() => note.update(v => (v.active = true))}
@ -664,74 +656,12 @@ export function NoteCreator() {
/>
{renderPollOptions()}
</div>
</div>
)}
{(note.otherEvents?.length ?? 0) > 0 && !note.preview && (
<div className="flex gap-2 flex-wrap">
{note.otherEvents
?.map(a => ({
event: a,
tags: readNip94Tags(a.tags),
}))
.filter(a => a.tags.url)
.map(a => (
<div key={a.tags.url} className="relative">
<img
className="object-cover w-[80px] h-[80px] !mt-0 rounded-lg"
src={addExtensionToNip94Url(a.tags)}
/>
<Icon
name="x"
className="absolute -top-[0.25rem] -right-[0.25rem] bg-gray rounded-full cursor-pointer"
onClick={() =>
note.update(
n => (n.otherEvents = n.otherEvents?.filter(b => readNip94Tags(b.tags).url !== a.tags.url)),
)
}
/>
</div>
))}
</div>
</>
)}
{uploader.progress.length > 0 && <FileUploadProgress progress={uploader.progress} />}
{noteCreatorFooter()}
{note.error && <span className="error">{note.error}</span>}
{note.advanced && noteCreatorAdvanced()}
<Flyout
show={note.filePicker !== "hidden"}
width={note.filePicker !== "compact" ? "70vw" : undefined}
onClose={() => note.update(v => (v.filePicker = "hidden"))}
side="right"
title={
<div className="text-xl font-medium">
<FormattedMessage defaultMessage="Attach Media" />
</div>
}
actions={
<>
<IconButton
className="max-lg:!hidden"
icon={{
name: "expand",
}}
onClick={() => note.update(n => (n.filePicker = n.filePicker === "wide" ? "compact" : "wide"))}
/>
</>
}>
<div className="overflow-y-auto h-[calc(100%-2rem)]">
{note.filePicker !== "hidden" && (
<MediaServerFileList
onPicked={files => {
note.update(n => {
n.otherEvents ??= [];
n.otherEvents?.push(...files);
n.filePicker = "hidden";
});
}}
cols={note.filePicker === "compact" ? 2 : 6}
/>
)}
</div>
</Flyout>
</>
);
}
@ -744,7 +674,11 @@ export function NoteCreator() {
if (!note.show) return null;
return (
<Modal id="note-creator" bodyClassName="modal-body gap-4" onClose={reset}>
<Modal
id="note-creator"
bodyClassName="modal-body flex flex-col gap-4"
className="note-creator-modal"
onClose={reset}>
{noteCreatorForm()}
</Modal>
);

View File

@ -15,12 +15,10 @@ export const NoteCreatorButton = ({
className,
alwaysShow,
showText,
withModal,
}: {
className?: string;
alwaysShow?: boolean;
showText?: boolean;
withModal: boolean;
}) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const location = useLocation();
@ -76,12 +74,12 @@ export const NoteCreatorButton = ({
<Icon name="plus" size={16} />
{showText && (
<span className="ml-2 hidden xl:inline">
<FormattedMessage defaultMessage="New Note" />
<FormattedMessage defaultMessage="New Note" id="2mcwT8" />
</span>
)}
</button>
)}
{withModal && <NoteCreator key="global-note-creator" />}
<NoteCreator key="global-note-creator" />
</>
);
};

View File

@ -13,10 +13,10 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => {
) : (
<div className="bb p flex items-center justify-between">
<div className="text-sm text-secondary">
<FormattedMessage defaultMessage="This note has been muted" />
<FormattedMessage defaultMessage="This note has been muted" id="qfmMQh" />
</div>
<button className="btn btn-sm btn-neutral" onClick={() => setShow(true)}>
<FormattedMessage defaultMessage="Show" />
<FormattedMessage defaultMessage="Show" id="K7AkdL" />
</button>
</div>
);

View File

@ -14,7 +14,7 @@ interface ShowMoreProps {
const LoadMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
return (
<button type="button" className={className} onClick={onClick}>
{text || <FormattedMessage defaultMessage="Load more" />}
{text || <FormattedMessage defaultMessage="Load more" id="00LcfG" />}
</button>
);
};

View File

@ -1,8 +1,9 @@
import "./LongFormText.css";
import { TaggedNostrEvent } from "@snort/system";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import classNames from "classnames";
import { CSSProperties, useCallback, useRef, useState } from "react";
import React, { CSSProperties, useCallback, useRef, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import Text from "@/Components/Text/Text";
@ -31,6 +32,8 @@ export function LongFormText(props: LongFormTextProps) {
const [reading, setReading] = useState(false);
const [showMore, setShowMore] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const related = useReactions("note:reactions", [NostrLink.fromEvent(props.ev)], undefined, false);
const { reactions, reposts, zaps } = useEventReactions(NostrLink.fromEvent(props.ev), related);
function previewText() {
return (
@ -97,7 +100,11 @@ export function LongFormText(props: LongFormTextProps) {
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? <FormattedMessage defaultMessage="Show less" /> : <FormattedMessage defaultMessage="Show more" />}
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
</a>
);
@ -107,7 +114,7 @@ export function LongFormText(props: LongFormTextProps) {
function fullText() {
return (
<>
<NoteFooter ev={props.ev} />
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
<hr />
<div className="flex g8">
<div>
@ -122,12 +129,12 @@ export function LongFormText(props: LongFormTextProps) {
<div></div>
{!reading && (
<div className="pointer" onClick={() => readArticle()}>
<FormattedMessage defaultMessage="Listen to this article" />
<FormattedMessage defaultMessage="Listen to this article" id="nihgfo" />
</div>
)}
{reading && (
<div className="pointer" onClick={() => stopReading()}>
<FormattedMessage defaultMessage="Stop listening" />
<FormattedMessage defaultMessage="Stop listening" id="U1aPPi" />
</div>
)}
</div>
@ -136,7 +143,7 @@ export function LongFormText(props: LongFormTextProps) {
<Markdown content={content} tags={props.ev.tags} ref={ref} />
{shouldTruncate && !showMore && <ToggleShowMore />}
<hr />
<NoteFooter ev={props.ev} />
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
</>
);
}

View File

@ -26,7 +26,10 @@ export function NostrFileElement({ ev }: { ev: NostrEvent }) {
if (u && m) {
return (
<Reveal message={<FormattedMessage defaultMessage="Click to load content from {link}" values={{ link: u }} />}>
<Reveal
message={
<FormattedMessage defaultMessage="Click to load content from {link}" id="lsNFM1" values={{ link: u }} />
}>
<MediaElement
mime={m}
url={u}
@ -41,7 +44,7 @@ export function NostrFileElement({ ev }: { ev: NostrEvent }) {
} else {
return (
<b className="error">
<FormattedMessage defaultMessage="Unknown file header: {name}" values={{ name: ev.content }} />
<FormattedMessage defaultMessage="Unknown file header: {name}" id="PamNxw" values={{ name: ev.content }} />
</b>
);
}

View File

@ -1,7 +1,6 @@
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import classNames from "classnames";
import { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
@ -15,12 +14,14 @@ import { TranslationInfo } from "@/Components/Event/Note/TranslationInfo";
import { NoteTranslation } from "@/Components/Event/Note/types";
import Username from "@/Components/User/Username";
import useModeration from "@/Hooks/useModeration";
import { findTag } from "@/Utils";
import { chainKey } from "@/Utils/Thread/ChainKey";
import { NoteProps, NotePropsOptions } from "../EventComponent";
import messages from "../../messages";
import Text from "../../Text/Text";
import { NoteProps } from "../EventComponent";
import HiddenNote from "../HiddenNote";
import Poll from "../Poll";
import NoteAppHandler from "./NoteAppHandler";
import NoteFooter from "./NoteFooter/NoteFooter";
const defaultOptions = {
@ -39,10 +40,10 @@ export function Note(props: NoteProps) {
const { data: ev, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className ?? "");
const { isEventMuted } = useModeration();
const { ref, inView } = useInView({ triggerOnce: true });
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const { ref: setSeenAtRef, inView: setSeenAtInView } = useInView({ rootMargin: "0px", threshold: 1 });
const [showTranslation, setShowTranslation] = useState(true);
const [translated, setTranslated] = useState<NoteTranslation | null>(translationCache.get(ev.id));
const [translated, setTranslated] = useState<NoteTranslation>(translationCache.get(ev.id));
const cachedSetTranslated = useCallback(
(translation: NoteTranslation) => {
translationCache.set(ev.id, translation);
@ -55,9 +56,7 @@ export function Note(props: NoteProps) {
let timeout: ReturnType<typeof setTimeout>;
if (setSeenAtInView) {
timeout = setTimeout(() => {
if (Relay instanceof WorkerRelayInterface) {
Relay.setEventMetadata(ev.id, { seen_at: Math.round(Date.now() / 1000) });
}
Relay.setEventMetadata(ev.id, { seen_at: Math.round(Date.now() / 1000) });
}, 1000);
}
return () => clearTimeout(timeout);
@ -100,7 +99,7 @@ export function Note(props: NoteProps) {
<div
className={classNames(baseClassName, {
active: highlight,
"hover:bg-nearly-bg-background cursor-pointer": !opt?.isRoot,
"hover:bg-nearly-bg-color cursor-pointer": !opt?.isRoot,
})}
onClick={e => goToEvent(e, ev)}
ref={ref}>
@ -111,10 +110,10 @@ export function Note(props: NoteProps) {
return !ignoreModeration && isEventMuted(ev) ? <HiddenNote>{noteElement}</HiddenNote> : noteElement;
}
function useGoToEvent(props: NoteProps, options: NotePropsOptions) {
function useGoToEvent(props, options) {
const navigate = useNavigate();
return useCallback(
(e: React.MouseEvent, eTarget: TaggedNostrEvent) => {
(e, eTarget) => {
if (options?.canClick === false) {
return;
}
@ -133,20 +132,11 @@ function useGoToEvent(props: NoteProps, options: NotePropsOptions) {
}
e.stopPropagation();
// prevent navigation if selecting text
const cellText = document.getSelection();
if (cellText?.type === "Range") {
return;
}
// custom onclick handler
if (props.onClick) {
props.onClick(eTarget);
return;
}
// link to event
const link = NostrLink.fromEvent(eTarget);
if (e.metaKey) {
window.open(`/${link.encode(CONFIG.eventLinkPrefix)}`, "_blank");
@ -170,7 +160,7 @@ function Reaction({ ev }: { ev: TaggedNostrEvent }) {
<div className="text-gray-medium font-bold">
<Username pubkey={ev.pubkey} onLinkVisit={() => {}} />
<span> </span>
<FormattedMessage defaultMessage="liked" />
<FormattedMessage defaultMessage="liked" id="TvKqBp" />
</div>
<NoteQuote link={link} />
</div>
@ -178,9 +168,23 @@ function Reaction({ ev }: { ev: TaggedNostrEvent }) {
}
function handleNonTextNote(ev: TaggedNostrEvent) {
if (ev.kind === EventKind.Reaction) {
const alt = findTag(ev, "alt");
if (alt) {
return (
<div className="note-quote">
<Text id={ev.id} content={alt} tags={[]} creator={ev.pubkey} />
</div>
);
} else if (ev.kind === EventKind.Reaction) {
return <Reaction ev={ev} />;
} else {
return <NoteAppHandler ev={ev} />;
return (
<>
<h4>
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.kind }} />
</h4>
<pre>{JSON.stringify(ev, undefined, " ")}</pre>
</>
);
}
}

View File

@ -1,63 +0,0 @@
import { mapEventToProfile, NostrLink, TaggedNostrEvent } from "@snort/system";
import { FormattedMessage } from "react-intl";
import Icon from "@/Components/Icons/Icon";
import NostrIcon from "@/Components/Icons/Nostrich";
import KindName from "@/Components/kind-name";
import Avatar from "@/Components/User/Avatar";
import DisplayName from "@/Components/User/DisplayName";
import useAppHandler from "@/Hooks/useAppHandler";
export default function NoteAppHandler({ ev }: { ev: TaggedNostrEvent }) {
const handlers = useAppHandler(ev.kind);
const link = NostrLink.fromEvent(ev);
const profiles = handlers.apps
.filter(a => a.tags.find(b => b[0] === "web" && b[2] === "nevent"))
.map(a => ({ profile: mapEventToProfile(a), event: a }))
.filter(a => a.profile)
.slice(0, 5);
return (
<div className="card flex flex-col gap-3">
<small>
<FormattedMessage
defaultMessage="Sorry, we dont understand this event kind ({name}), please try one of the following apps instead!"
values={{
name: <KindName kind={ev.kind} />,
}}
/>
</small>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => {
window.open(`nostr:${link.encode()}`, "_blank");
}}>
<div className="flex items-center gap-2">
<NostrIcon width={40} />
<FormattedMessage defaultMessage="Native App" />
</div>
<Icon name="link" />
</div>
{profiles.map(a => (
<div
className="flex justify-between items-center cursor-pointer"
key={a.event.id}
onClick={() => {
const webHandler = a.event.tags.find(a => a[0] === "web" && a[2] === "nevent")?.[1];
if (webHandler) {
window.open(webHandler.replace("<bech32>", link.encode()), "_blank");
}
}}>
<div className="flex items-center gap-2">
<Avatar size={40} pubkey={a.event.pubkey} user={a.profile} />
<div>
<DisplayName pubkey={a.event.pubkey} user={a.profile} />
</div>
</div>
<Icon name="link" />
</div>
))}
</div>
);
}

View File

@ -148,7 +148,7 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
)}
<MenuItem onClick={handleReBroadcastButtonClick}>
<Icon name="relay" />
<FormattedMessage defaultMessage="Broadcast Event" />
<FormattedMessage defaultMessage="Broadcast Event" id="Gxcr08" />
</MenuItem>
<MenuItem onClick={() => translate()}>
<Icon name="translate" />

View File

@ -1,7 +1,6 @@
import { barrierQueue } from "@snort/shared";
import { NostrLink, ParsedZap, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { Zapper, ZapTarget } from "@snort/wallet";
import React, { useEffect, useState } from "react";
import { useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
@ -14,6 +13,7 @@ import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { getDisplayName } from "@/Utils";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
import { ZapPoolController } from "@/Utils/ZapPoolController";
import { useWallet } from "@/Wallet";
@ -140,7 +140,13 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
<ZapsSummary zaps={zaps} onClick={onClickZappers ?? (() => {})} />
</div>
{showZapModal && (
<ZapModal targets={getZapTarget()} onClose={() => setShowZapModal(false)} show={true} allocatePool={true} />
<ZapModal
targets={getZapTarget()}
onClose={() => setShowZapModal(false)}
note={ev.id}
show={true}
allocatePool={true}
/>
)}
</>
)}

View File

@ -9,7 +9,6 @@ import { ReplyButton } from "@/Components/Event/Note/NoteFooter/ReplyButton";
import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton";
import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import usePreferences from "@/Hooks/usePreferences";
export interface NoteFooterProps {
@ -20,14 +19,11 @@ export interface NoteFooterProps {
export default function NoteFooter(props: NoteFooterProps) {
const { ev } = props;
const link = useMemo(() => NostrLink.fromEvent(ev), [ev.id]);
const ids = useMemo(() => [link], [link]);
const [showReactions, setShowReactions] = useState(false);
const { isMuted } = useModeration();
const related = useReactions("reactions", link);
const { replies, reactions, zaps, reposts } = useEventReactions(
link,
related.filter(a => !isMuted(a.pubkey)),
);
const related = useReactions("reactions", ids, undefined, false);
const { replies, reactions, zaps, reposts } = useEventReactions(link, related);
const { positive } = reactions;
const readonly = useLogin(s => s.readonly);

View File

@ -58,7 +58,7 @@ export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: T
</div>
<MenuItem onClick={repost} disabled={hasReposted()}>
<Icon name="repeat" />
<FormattedMessage defaultMessage="Repost" />
<FormattedMessage defaultMessage="Repost" id="JeoS4y" />
</MenuItem>
<MenuItem
onClick={() =>
@ -69,7 +69,7 @@ export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: T
})
}>
<Icon name="edit" />
<FormattedMessage defaultMessage="Quote Repost" />
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
</MenuItem>
</Menu>
);

View File

@ -1,15 +1,21 @@
import classNames from "classnames";
import { FormattedMessage } from "react-intl";
import "../EventComponent.css";
import ProfileImage from "@/Components/User/ProfileImage";
interface NoteGhostProps {
className?: string;
link: string;
children: React.ReactNode;
}
export default function NoteGhost(props: NoteGhostProps) {
const className = `note card ${props.className ?? ""}`;
return (
<div className={classNames("p bb", props.className)}>
<FormattedMessage defaultMessage="Loading note: {id}" values={{ id: props.link }} />
<div className={className}>
<div className="header">
<ProfileImage pubkey="" />
</div>
<div className="body">{props.children}</div>
<div className="footer"></div>
</div>
);
}

View File

@ -1,13 +1,8 @@
import { dedupe, sanitizeRelayUrl } from "@snort/shared";
import { NostrLink, NostrPrefix } from "@snort/system";
import { NostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import Copy from "@/Components/Copy/Copy";
import Note from "@/Components/Event/EventComponent";
import Spinner from "@/Components/Icons/Spinner";
import PageSpinner from "@/Components/PageSpinner";
const options = {
showFooter: false,
@ -15,52 +10,11 @@ const options = {
};
export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: number }) {
const [tryLink, setLink] = useState<NostrLink>(link);
const [tryRelay, setTryRelay] = useState("");
const { formatMessage } = useIntl();
const ev = useEventFeed(tryLink);
const ev = useEventFeed(link);
if (!ev)
return (
<div className="note-quote flex flex-col gap-2">
<Spinner />
<div>
<FormattedMessage
defaultMessage="Looking for: {noteId}"
values={{
noteId: <Copy text={tryLink.encode()} />,
}}
/>
</div>
<div className="flex gap-2">
<input
type="text"
value={tryRelay}
onChange={e => setTryRelay(e.target.value)}
placeholder={formatMessage({ defaultMessage: "Try another relay" })}
/>
<AsyncButton
onClick={() => {
const u = sanitizeRelayUrl(tryRelay);
if (u) {
const relays = tryLink.relays ?? [];
relays.push(u);
setLink(
new NostrLink(
tryLink.type !== NostrPrefix.Address ? NostrPrefix.Event : NostrPrefix.Address,
tryLink.id,
tryLink.kind,
tryLink.author,
dedupe(relays),
tryLink.marker,
),
);
setTryRelay("");
}
}}>
<FormattedMessage defaultMessage="Add" />
</AsyncButton>
</div>
<div className="note-quote flex items-center justify-center h-[110px]">
<PageSpinner />
</div>
);
return <Note data={ev} className="note-quote" depth={(depth ?? 0) + 1} options={options} />;

View File

@ -27,7 +27,11 @@ export const NoteText = memo(function InnerContent(
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? <FormattedMessage defaultMessage="Show less" /> : <FormattedMessage defaultMessage="Show more" />}
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
</a>
);
@ -76,10 +80,10 @@ export const NoteText = memo(function InnerContent(
/>
</>
)}
. <FormattedMessage defaultMessage="Click here to load anyway" />.{" "}
. <FormattedMessage defaultMessage="Click here to load anyway" id="IoQq+a" />.{" "}
<Link to="/settings/moderation">
<i>
<FormattedMessage defaultMessage="Settings" />
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
</i>
</Link>
</>

View File

@ -4,20 +4,19 @@ import { FormattedMessage } from "react-intl";
export interface NoteTimeProps {
from: number;
fallback?: string;
className?: string;
}
const secondsInAMinute = 60;
const secondsInAnHour = secondsInAMinute * 60;
const secondsInADay = secondsInAnHour * 24;
const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback, className }) => {
const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
const calcTime = useCallback((fromTime: number) => {
const currentTime = new Date();
const timeDifference = Math.floor((currentTime.getTime() - fromTime) / 1000);
if (timeDifference < secondsInAMinute) {
return <FormattedMessage defaultMessage="now" />;
return <FormattedMessage defaultMessage="now" id="kaaf1E" />;
} else if (timeDifference < secondsInAnHour) {
return `${Math.floor(timeDifference / secondsInAMinute)}m`;
} else if (timeDifference < secondsInADay) {
@ -53,7 +52,7 @@ const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback, className }) => {
const isoDate = useMemo(() => new Date(from).toISOString(), [from]);
return (
<time dateTime={isoDate} title={absoluteTime} className={className}>
<time dateTime={isoDate} title={absoluteTime}>
{time || fallback}
</time>
);

View File

@ -1,14 +1,16 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import "./ReactionsModal.css";
import { NostrLink, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import { Fragment, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { FormattedMessage, MessageDescriptor, useIntl } from "react-intl";
import CloseButton from "@/Components/Button/CloseButton";
import Icon from "@/Components/Icons/Icon";
import Modal from "@/Components/Modal/Modal";
import TabSelectors, { Tab } from "@/Components/TabSelectors/TabSelectors";
import ProfileImage from "@/Components/User/ProfileImage";
import ZapAmount from "@/Components/zap-amount";
import useWoT from "@/Hooks/useWoT";
import { formatShort } from "@/Utils/Number";
import messages from "../../messages";
@ -23,11 +25,14 @@ const ReactionsModal = ({ onClose, event, initialTab = 0 }: ReactionsModalProps)
const link = NostrLink.fromEvent(event);
const related = useReactions("reactions", link, undefined, false);
const related = useReactions("note:reactions", [link], undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related);
const { positive, negative } = reactions;
const { sortEvents } = useWoT();
const sortEvents = (events: Array<TaggedNostrEvent>) =>
events.sort(
(a, b) => socialGraphInstance.getFollowDistance(a.pubkey) - socialGraphInstance.getFollowDistance(b.pubkey),
);
const likes = useMemo(() => sortEvents([...positive]), [positive]);
const dislikes = useMemo(() => sortEvents([...negative]), [negative]);
@ -55,42 +60,48 @@ const ReactionsModal = ({ onClose, event, initialTab = 0 }: ReactionsModalProps)
const [tab, setTab] = useState(tabs[initialTab]);
const renderReactionItem = (ev: TaggedNostrEvent, icon: string, iconClass?: string, size?: number) => (
<Fragment key={ev.id}>
<div className="mx-auto">
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Icon name={icon} size={size} className={iconClass} />
</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</Fragment>
</div>
);
return (
<Modal id="reactions" onClose={onClose}>
<div className="text-lg font-semibold mb-2">
<FormattedMessage defaultMessage="Reactions ({n})" values={{ n: total }} />
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
<CloseButton onClick={onClose} className="absolute right-4 top-3" />
<div className="reactions-header">
<h2>
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
</h2>
</div>
<TabSelectors tabs={tabs} tab={tab} setTab={setTab} />
<div className="h-[30vh] overflow-y-auto">
<div className="grid grid-cols-[100px_auto] gap-y-2 items-center py-2" key={tab.value}>
{tab.value === 0 && likes.map(ev => renderReactionItem(ev, "heart-solid", "text-heart"))}
{tab.value === 1 &&
zaps.map(
z =>
z.sender && (
<Fragment key={z.id}>
<ZapAmount n={z.amount} />
<ProfileImage
showProfileCard={true}
pubkey={z.anonZap ? "" : z.sender}
subHeader={<div title={z.content}>{z.content}</div>}
link={z.anonZap ? "" : undefined}
overrideUsername={z.anonZap ? formatMessage({ defaultMessage: "Anonymous" }) : undefined}
/>
</Fragment>
),
)}
{tab.value === 2 && sortedReposts.map(ev => renderReactionItem(ev, "repost", "text-repost", 16))}
{tab.value === 3 && dislikes.map(ev => renderReactionItem(ev, "dislike"))}
</div>
<div className="reactions-body" key={tab.value}>
{tab.value === 0 && likes.map(ev => renderReactionItem(ev, "heart-solid", "text-heart"))}
{tab.value === 1 &&
zaps.map(
z =>
z.sender && (
<div key={z.id} className="reactions-item">
<div className="zap-reaction-icon">
<Icon name="zap-solid" size={20} className="text-zap" />
<span className="zap-amount">{formatShort(z.amount)}</span>
</div>
<ProfileImage
showProfileCard={true}
pubkey={z.anonZap ? "" : z.sender}
subHeader={<div title={z.content}>{z.content}</div>}
link={z.anonZap ? "" : undefined}
overrideUsername={
z.anonZap ? formatMessage({ defaultMessage: "Anonymous", id: "LXxsbk" }) : undefined
}
/>
</div>
),
)}
{tab.value === 2 && sortedReposts.map(ev => renderReactionItem(ev, "repost", "text-repost", 16))}
{tab.value === 3 && dislikes.map(ev => renderReactionItem(ev, "dislike"))}
</div>
</Modal>
);

View File

@ -12,8 +12,6 @@ import Icon from "@/Components/Icons/Icon";
import useModeration from "@/Hooks/useModeration";
import { eventLink, getDisplayName, hexToBech32 } from "@/Utils";
import NoteFooter from "./Note/NoteFooter/NoteFooter";
export interface NoteReactionProps {
data: TaggedNostrEvent;
root?: TaggedNostrEvent;
@ -87,13 +85,13 @@ export default function NoteReaction(props: NoteReactionProps) {
<Icon name="repeat" size={18} />
<FormattedMessage
defaultMessage="{name} reposted"
id="+xliwN"
values={{
name: getDisplayName(profile, ev.pubkey),
}}
/>
</div>
{root ? <Note data={root} options={opt} depth={props.depth} /> : null}
<NoteFooter ev={ev} />
{!root && refEvent ? (
<p>
<Link to={eventLink(refEvent[1] ?? "", refEvent[2])}>

View File

@ -135,9 +135,9 @@ export default function Poll(props: PollProps) {
values={{
type:
tallyBy === "zaps" ? (
<FormattedMessage defaultMessage="zap" />
<FormattedMessage defaultMessage="zap" id="5BVs2e" />
) : (
<FormattedMessage defaultMessage="user" />
<FormattedMessage defaultMessage="user" id="sUNhQE" />
),
}}
/>

View File

@ -42,7 +42,7 @@
top: 48px;
border-left: 1px solid var(--border-color);
height: 100%;
z-index: -1;
z-index: 1;
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:before {
@ -52,7 +52,7 @@
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
z-index: -1;
z-index: 1;
}
.subthread-container.subthread-last .line-container:before {
@ -62,7 +62,7 @@
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
z-index: -1;
z-index: 1;
}
.divider {

View File

@ -57,6 +57,8 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
waitUntilInView={false}
/>
);
} else {
return <NoteGhost className={className}>Loading thread root.. ({thread.data?.length} notes loaded)</NoteGhost>;
}
}
@ -73,18 +75,16 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
function renderCurrent() {
if (thread.current) {
const note = thread.data.find(n => n.id === thread.current);
if (note) {
return (
return (
note && (
<Note
data={note}
options={{ showReactionsLink: true, showMediaSpotlight: true }}
threadChains={thread.chains}
onClick={navigateThread}
/>
);
} else {
return <NoteGhost link={thread.current} />;
}
)
);
}
}
@ -100,6 +100,7 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
const parentText = formatMessage({
defaultMessage: "Parent",
id: "ADmfQT",
description: "Link to parent note in thread",
});
@ -133,15 +134,10 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
{thread.root && renderRoot(thread.root)}
{thread.root && renderChain(chainKey(thread.root))}
{!thread.root && renderCurrent()}
{thread.mutedData.length > 0 && (
<div className="p br b mx-2 my-3 bg-gray-ultradark text-gray-light font-medium cursor-pointer">
<FormattedMessage
defaultMessage="{n} notes have been muted"
values={{
n: thread.mutedData.length,
}}
/>
</div>
{!thread.root && !thread.current && (
<NoteGhost>
<FormattedMessage defaultMessage="Looking up thread..." id="JA+tz3" />
</NoteGhost>
)}
</div>
</>

View File

@ -1,12 +1,12 @@
import "./ZapButton.css";
import { HexKey, NostrLink } from "@snort/system";
import { HexKey } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { ZapTarget } from "@snort/wallet";
import { useState } from "react";
import Icon from "@/Components/Icons/Icon";
import ZapModal from "@/Components/ZapModal/ZapModal";
import { ZapTarget } from "@/Utils/Zapper";
const ZapButton = ({
pubkey,
@ -17,7 +17,7 @@ const ZapButton = ({
pubkey: HexKey;
lnurl?: string;
children?: React.ReactNode;
event?: NostrLink;
event?: string;
}) => {
const profile = useUserProfile(pubkey);
const [zap, setZap] = useState(false);
@ -37,11 +37,12 @@ const ZapButton = ({
value: service,
weight: 1,
name: profile?.display_name || profile?.name,
zap: { pubkey: pubkey, event },
zap: { pubkey: pubkey },
} as ZapTarget,
]}
show={zap}
onClose={() => setZap(false)}
note={event}
/>
</>
);

View File

@ -1,7 +1,6 @@
import "./ZapGoal.css";
import { NostrEvent, NostrLink } from "@snort/system";
import { Zapper } from "@snort/wallet";
import { useState } from "react";
import { FormattedNumber } from "react-intl";
@ -11,6 +10,7 @@ import ZapModal from "@/Components/ZapModal/ZapModal";
import useZapsFeed from "@/Feed/ZapsFeed";
import { findTag } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import { Zapper } from "@/Utils/Zapper";
export function ZapGoal({ ev }: { ev: NostrEvent }) {
const [zap, setZap] = useState(false);

View File

@ -26,9 +26,13 @@ export const ZapsSummary = ({ zaps, onClick }: ZapsSummaryProps) => {
};
return (
<div className="flex items-center cursor-pointer" onClick={myOnClick}>
<AvatarGroup ids={sortedZappers} onClick={() => {}} />
{zaps.length > 3 && <div className="hidden md:inline-flex">+{zaps.length - 3}</div>}
<div className="zaps-summary" onClick={myOnClick}>
<div className={`top-zap`}>
<div className="summary">
<AvatarGroup ids={sortedZappers} onClick={() => {}} />
{zaps.length > 3 && <div className="hidden md:flex -ml-2">+{zaps.length - 3}</div>}
</div>
</div>
</div>
);
};

View File

@ -13,7 +13,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="user-v2" />
<FormattedMessage defaultMessage="For you" />
<FormattedMessage defaultMessage="For you" id="xEjBS7" />
</>
),
},
@ -24,7 +24,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="user-v2" />
<FormattedMessage defaultMessage="Following" />
<FormattedMessage defaultMessage="Following" id="cPIKU2" />
</>
),
},
@ -35,7 +35,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="fire" />
<FormattedMessage defaultMessage="Trending Notes" />
<FormattedMessage defaultMessage="Trending Notes" id="Ix8l+B" />
</>
),
},
@ -46,7 +46,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="message-chat-circle" />
<FormattedMessage defaultMessage="Conversations" />
<FormattedMessage defaultMessage="Conversations" id="1udzha" />
</>
),
},
@ -57,7 +57,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="user-v2" />
<FormattedMessage defaultMessage="Followed by friends" />
<FormattedMessage defaultMessage="Followed by friends" id="voxBKC" />
</>
),
},
@ -68,7 +68,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="thumbs-up" />
<FormattedMessage defaultMessage="Suggested Follows" />
<FormattedMessage defaultMessage="Suggested Follows" id="C8HhVE" />
</>
),
},
@ -79,7 +79,18 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="hash" />
<FormattedMessage defaultMessage="Trending Hashtags" />
<FormattedMessage defaultMessage="Trending Hashtags" id="XXm7jJ" />
</>
),
},
{
tab: "global",
path: `${base}/global`,
show: true,
element: (
<>
<Icon name="globe" />
<FormattedMessage defaultMessage="Global" id="EWyQH5" />
</>
),
},
@ -90,7 +101,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="hash" />
<FormattedMessage defaultMessage="Topics" />
<FormattedMessage defaultMessage="Topics" id="kc79d3" />
</>
),
},

View File

@ -1,7 +1,6 @@
import "./Timeline.css";
import { unixNow } from "@snort/shared";
import { TaggedNostrEvent } from "@snort/system";
import { socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { useCallback, useMemo, useState } from "react";
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
@ -9,7 +8,6 @@ import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "@/Feed/TimelineFeed";
import useHistoryState from "@/Hooks/useHistoryState";
import useLogin from "@/Hooks/useLogin";
import useWoT from "@/Hooks/useWoT";
import { dedupeByPubkey } from "@/Utils";
export interface TimelineProps {
@ -30,7 +28,7 @@ export interface TimelineProps {
*/
const Timeline = (props: TimelineProps) => {
const login = useLogin();
const [openedAt] = useHistoryState(unixNow(), "openedAt");
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
const feedOptions = useMemo(
() => ({
method: props.method,
@ -42,7 +40,6 @@ const Timeline = (props: TimelineProps) => {
const feed: TimelineFeed = useTimelineFeed(props.subject, feedOptions);
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const wot = useWoT();
const filterPosts = useCallback(
(nts: readonly TaggedNostrEvent[]) => {
@ -50,7 +47,7 @@ const Timeline = (props: TimelineProps) => {
if (props.followDistance === undefined) {
return true;
}
const followDistance = wot.followDistance(a.pubkey);
const followDistance = socialGraphInstance.getFollowDistance(a.pubkey);
return followDistance === props.followDistance;
};
return nts

View File

@ -1,48 +0,0 @@
import { NostrEvent, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { ReactNode, useMemo } from "react";
import { WindowChunk } from "@/Hooks/useTimelineChunks";
import { DisplayAs } from "./DisplayAsSelector";
import { TimelineRenderer } from "./TimelineRenderer";
export interface TimelineChunkProps {
id: string;
chunk: WindowChunk;
builder: (rb: RequestBuilder) => void;
noteFilter?: (ev: NostrEvent) => boolean;
noteRenderer?: (ev: NostrEvent) => ReactNode;
noteOnClick?: (ev: NostrEvent) => void;
displayAs?: DisplayAs;
}
/**
* Simple chunk of a timeline using absoliute time ranges
*/
export default function TimelineChunk(props: TimelineChunkProps) {
const sub = useMemo(() => {
const rb = new RequestBuilder(`timeline-chunk:${props.id}:${props.chunk.since}-${props.chunk.until}`);
props.builder(rb);
for (const f of rb.filterBuilders) {
f.since(props.chunk.since).until(props.chunk.until);
}
return rb;
}, [props.id, props.chunk.until, props.builder]);
const feed = useRequestBuilder(sub);
return (
<TimelineRenderer
frags={{
events: feed.filter(a => props.noteFilter?.(a) ?? true),
refTime: props.chunk.until,
}}
noteOnClick={props.noteOnClick}
noteRenderer={props.noteRenderer}
displayAs={props.displayAs}
latest={[]}
showLatest={() => {}}
/>
);
}

View File

@ -1,18 +1,16 @@
import "./Timeline.css";
import { unixNow } from "@snort/shared";
import { EventKind, NostrEvent, RequestBuilder } from "@snort/system";
import { ReactNode, useCallback, useState } from "react";
import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system";
import { ReactNode, useCallback, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
import useFollowsControls from "@/Hooks/useFollowControls";
import useHistoryState from "@/Hooks/useHistoryState";
import useLogin from "@/Hooks/useLogin";
import useTimelineChunks from "@/Hooks/useTimelineChunks";
import { Hour } from "@/Utils/Const";
import { AutoLoadMore } from "../Event/LoadMore";
import TimelineChunk from "./TimelineChunk";
import { dedupeByPubkey } from "@/Utils";
export interface TimelineFollowsProps {
postsOnly: boolean;
@ -21,10 +19,11 @@ export interface TimelineFollowsProps {
noteRenderer?: (ev: NostrEvent) => ReactNode;
noteOnClick?: (ev: NostrEvent) => void;
displayAs?: DisplayAs;
showDisplayAsSelector?: boolean;
}
/**
* A list of notes by your follows
* A list of notes by "subject"
*/
const TimelineFollows = (props: TimelineFollowsProps) => {
const login = useLogin(s => ({
@ -34,44 +33,81 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
}));
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const [openedAt] = useHistoryState(unixNow(), "openedAt");
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
const { isFollowing, followList } = useFollowsControls();
const { chunks, showMore } = useTimelineChunks({
now: openedAt,
firstChunkSize: Hour * 2,
});
const subject = useMemo(
() =>
({
type: "pubkey",
items: followList,
discriminator: login.publicKey?.slice(0, 12),
extra: rb => {
if (login.tags.length > 0) {
rb.withFilter().kinds([EventKind.TextNote, EventKind.Repost]).tags(login.tags);
}
},
}) as TimelineSubject,
[login.publicKey, followList, login.tags],
);
const feed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);
const builder = useCallback(
(rb: RequestBuilder) => {
rb.withFilter().authors(followList).kinds([EventKind.TextNote, EventKind.Repost, EventKind.Polls]);
// TODO allow reposts:
const postsOnly = useCallback(
(a: NostrEvent) => (props.postsOnly ? !a.tags.some(b => b[0] === "e" || b[0] === "a") : true),
[props.postsOnly],
);
const filterPosts = useCallback(
(nts: Array<TaggedNostrEvent>) => {
const a = nts.filter(a => a.kind !== EventKind.LiveEvent);
return a
?.filter(postsOnly)
.filter(a => props.noteFilter?.(a) ?? true)
.filter(a => isFollowing(a.pubkey) || a.tags.filter(a => a[0] === "t").length < 5);
},
[followList],
[postsOnly, props.noteFilter, isFollowing],
);
const filterEvents = useCallback(
(a: NostrEvent) =>
(props.noteFilter?.(a) ?? true) &&
(props.postsOnly ? !a.tags.some(b => b[0] === "e" || b[0] === "a") : true) &&
(isFollowing(a.pubkey) || a.tags.filter(a => a[0] === "t").length < 5),
[props.noteFilter, props.postsOnly, followList],
);
const mainFeed = useMemo(() => {
return filterPosts(feed.main ?? []);
}, [feed.main, filterPosts]);
const latestFeed = useMemo(() => {
return filterPosts(feed.latest ?? []);
}, [feed.latest]);
const latestAuthors = useMemo(() => {
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
}, [latestFeed]);
function onShowLatest(scrollToTop = false) {
feed.showLatest();
if (scrollToTop) {
window.scrollTo(0, 0);
}
}
return (
<>
<DisplayAsSelector activeSelection={displayAs} onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)} />
{chunks.map(c => (
<TimelineChunk
key={c.until}
id="follows"
chunk={c}
builder={builder}
noteFilter={filterEvents}
noteOnClick={props.noteOnClick}
noteRenderer={props.noteRenderer}
displayAs={displayAs}
/>
))}
<AutoLoadMore onClick={() => showMore()} />
<DisplayAsSelector
show={props.showDisplayAsSelector}
activeSelection={displayAs}
onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)}
/>
<TimelineRenderer
frags={[{ events: mainFeed, refTime: 0 }]}
latest={latestAuthors}
showLatest={t => onShowLatest(t)}
noteOnClick={props.noteOnClick}
noteRenderer={props.noteRenderer}
noteContext={e => {
if (typeof e.context === "string") {
return <Link to={`/t/${e.context}`}>{`#${e.context}`}</Link>;
}
}}
displayAs={displayAs}
loadMore={() => feed.loadMore()}
/>
</>
);
};

View File

@ -14,7 +14,7 @@ import ProfileImage from "@/Components/User/ProfileImage";
import getEventMedia from "@/Utils/getEventMedia";
export interface TimelineRendererProps {
frags: Array<TimelineFragment> | TimelineFragment;
frags: Array<TimelineFragment>;
/**
* List of pubkeys who have posted recently
*/
@ -29,10 +29,10 @@ export interface TimelineRendererProps {
}
// filter frags[0].events that have media
function Grid({ frags }: { frags: Array<TimelineFragment> | TimelineFragment }) {
function Grid({ frags }: { frags: Array<TimelineFragment> }) {
const [modalEventIndex, setModalEventIndex] = useState<number | undefined>(undefined);
const allEvents = useMemo(() => {
return (Array.isArray(frags) ? frags : [frags]).flatMap(frag => frag.events);
return frags.flatMap(frag => frag.events);
}, [frags]);
const mediaEvents = useMemo(() => {
return allEvents.filter(event => getEventMedia(event).length > 0);
@ -99,8 +99,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
}, [inView, props.latest]);
const renderNotes = () => {
const frags = Array.isArray(props.frags) ? props.frags : [props.frags];
return frags.map((frag, index) => (
return props.frags.map((frag, index) => (
<ErrorBoundary key={frag.events[0]?.id + index}>
<TimelineFragment
frag={frag}
@ -114,7 +113,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
};
return (
<div ref={containerRef}>
<div ref={containerRef} className="pb-[10vh]">
{props.latest.length > 0 && (
<>
<div className="card latest-notes" onClick={() => props.showLatest(false)} ref={ref}>

View File

@ -27,14 +27,5 @@ export default function UsersFeed({ keyword, sortPopular = true }: { keyword: st
if (!usersFeed) return <PageSpinner />;
return (
<FollowListBase
pubkeys={usersFeed}
profilePreviewProps={{
options: {
about: true,
},
}}
/>
);
return <FollowListBase pubkeys={usersFeed} showAbout={true} />;
}

View File

@ -9,8 +9,8 @@
<symbol id="arrowUp" viewBox="0 0 12 12" fill="none">
<path d="M5.99992 10.6673V1.33398M5.99992 1.33398L1.33325 6.00065M5.99992 1.33398L10.6666 6.00065" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="attachment" viewBox="0 0 24 25" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1667 3.5C8.69391 3.5 7.5 4.69391 7.5 6.16667V17C7.5 19.4853 9.51472 21.5 12 21.5C14.4853 21.5 16.5 19.4853 16.5 17V5.75581C16.5 5.20353 16.9477 4.75581 17.5 4.75581C18.0523 4.75581 18.5 5.20353 18.5 5.75581V17C18.5 20.5899 15.5899 23.5 12 23.5C8.41015 23.5 5.5 20.5899 5.5 17V6.16667C5.5 3.58934 7.58934 1.5 10.1667 1.5C12.744 1.5 14.8333 3.58934 14.8333 6.16667V16.9457C14.8333 18.5105 13.5648 19.7791 12 19.7791C10.4352 19.7791 9.16667 18.5105 9.16667 16.9457V7.15116C9.16667 6.59888 9.61438 6.15116 10.1667 6.15116C10.719 6.15116 11.1667 6.59888 11.1667 7.15116V16.9457C11.1667 17.406 11.5398 17.7791 12 17.7791C12.4602 17.7791 12.8333 17.406 12.8333 16.9457V6.16667C12.8333 4.69391 11.6394 3.5 10.1667 3.5Z" fill="currentColor"/>
<symbol id="attachment" viewBox="0 0 21 22" fill="none">
<path d="M19.1525 9.89945L10.1369 18.9151C8.08662 20.9653 4.7625 20.9653 2.71225 18.9151C0.661997 16.8648 0.661998 13.5407 2.71225 11.4904L11.7279 2.47483C13.0947 1.108 15.3108 1.108 16.6776 2.47483C18.0444 3.84167 18.0444 6.05775 16.6776 7.42458L8.01555 16.0866C7.33213 16.7701 6.22409 16.7701 5.54068 16.0866C4.85726 15.4032 4.85726 14.2952 5.54068 13.6118L13.1421 6.01037" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="badge" viewBox="0 0 16 15" fill="none">
<path d="M6.00004 7.50065L7.33337 8.83398L10.3334 5.83398M11.9342 2.83299C12.0714 3.16501 12.3349 3.42892 12.6667 3.5667L13.8302 4.04864C14.1622 4.18617 14.426 4.44998 14.5636 4.78202C14.7011 5.11407 14.7011 5.48715 14.5636 5.81919L14.082 6.98185C13.9444 7.31404 13.9442 7.6875 14.0824 8.01953L14.5632 9.18185C14.6313 9.34631 14.6664 9.52259 14.6665 9.70062C14.6665 9.87865 14.6315 10.0549 14.5633 10.2194C14.4952 10.3839 14.3953 10.5333 14.2694 10.6592C14.1435 10.7851 13.9941 10.8849 13.8296 10.953L12.6669 11.4346C12.3349 11.5718 12.071 11.8354 11.9333 12.1672L11.4513 13.3307C11.3138 13.6627 11.05 13.9265 10.718 14.0641C10.3859 14.2016 10.0129 14.2016 9.68085 14.0641L8.51823 13.5825C8.18619 13.4453 7.81326 13.4455 7.48143 13.5832L6.31797 14.0645C5.98612 14.2017 5.61338 14.2016 5.28162 14.0642C4.94986 13.9267 4.68621 13.6632 4.54858 13.3316L4.06652 12.1677C3.92924 11.8357 3.66574 11.5718 3.33394 11.434L2.17048 10.9521C1.8386 10.8146 1.57488 10.5509 1.4373 10.2191C1.29971 9.88724 1.29953 9.51434 1.43678 9.18235L1.91835 8.01968C2.05554 7.68763 2.05526 7.31469 1.91757 6.98284L1.43669 5.81851C1.36851 5.65405 1.3334 5.47777 1.33337 5.29974C1.33335 5.12171 1.3684 4.94542 1.43652 4.78094C1.50465 4.61646 1.60452 4.46702 1.73042 4.34115C1.85632 4.21529 2.00579 4.11546 2.17028 4.04739L3.33291 3.5658C3.66462 3.42863 3.92836 3.16545 4.06624 2.83402L4.54816 1.67052C4.68569 1.33848 4.94949 1.07467 5.28152 0.937137C5.61355 0.7996 5.98662 0.7996 6.31865 0.937137L7.48127 1.41873C7.81331 1.55593 8.18624 1.55565 8.51808 1.41795L9.68202 0.937884C10.014 0.800424 10.387 0.800452 10.719 0.937962C11.0509 1.07547 11.3147 1.3392 11.4522 1.67116L11.9343 2.835L11.9342 2.83299Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
@ -402,15 +402,18 @@
<path d="M7.39516 18.3711L7.97961 19.6856C8.15335 20.0768 8.43689 20.4093 8.79583 20.6426C9.15478 20.8759 9.57372 21.0001 10.0018 21C10.4299 21.0001 10.8489 20.8759 11.2078 20.6426C11.5668 20.4093 11.8503 20.0768 12.0241 19.6856L12.6085 18.3711C12.8165 17.9047 13.1665 17.5159 13.6085 17.26C14.0533 17.0034 14.5678 16.8941 15.0785 16.9478L16.5085 17.1C16.9342 17.145 17.3638 17.0656 17.7452 16.8713C18.1266 16.6771 18.4435 16.3763 18.6574 16.0056C18.8716 15.635 18.9736 15.2103 18.9511 14.7829C18.9286 14.3555 18.7826 13.9438 18.5307 13.5978L17.6841 12.4344C17.3826 12.0171 17.2215 11.5148 17.2241 11C17.224 10.4866 17.3866 9.98635 17.6885 9.57111L18.5352 8.40778C18.787 8.06175 18.9331 7.65007 18.9556 7.22267C18.978 6.79528 18.876 6.37054 18.6618 6C18.4479 5.62923 18.1311 5.32849 17.7496 5.13423C17.3682 4.93997 16.9386 4.86053 16.5129 4.90556L15.0829 5.05778C14.5723 5.11141 14.0577 5.00212 13.6129 4.74556C13.1701 4.48825 12.82 4.09736 12.6129 3.62889L12.0241 2.31444C11.8503 1.92317 11.5668 1.59072 11.2078 1.3574C10.8489 1.12408 10.4299 0.99993 10.0018 1C9.57372 0.99993 9.15478 1.12408 8.79583 1.3574C8.43689 1.59072 8.15335 1.92317 7.97961 2.31444L7.39516 3.62889C7.18809 4.09736 6.83804 4.48825 6.39516 4.74556C5.95038 5.00212 5.43583 5.11141 4.92516 5.05778L3.49072 4.90556C3.06505 4.86053 2.63546 4.93997 2.25403 5.13423C1.87261 5.32849 1.55574 5.62923 1.34183 6C1.12765 6.37054 1.02561 6.79528 1.0481 7.22267C1.07058 7.65007 1.21662 8.06175 1.4685 8.40778L2.31516 9.57111C2.61711 9.98635 2.7797 10.4866 2.77961 11C2.7797 11.5134 2.61711 12.0137 2.31516 12.4289L1.4685 13.5922C1.21662 13.9382 1.07058 14.3499 1.0481 14.7773C1.02561 15.2047 1.12765 15.6295 1.34183 16C1.55595 16.3706 1.87286 16.6712 2.25423 16.8654C2.6356 17.0596 3.06508 17.1392 3.49072 17.0944L4.92072 16.9422C5.43139 16.8886 5.94594 16.9979 6.39072 17.2544C6.83525 17.511 7.18693 17.902 7.39516 18.3711Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 14C11.6569 14 13 12.6569 13 11C13 9.34315 11.6569 8 10 8C8.34319 8 7.00004 9.34315 7.00004 11C7.00004 12.6569 8.34319 14 10 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="home-solid" viewBox="0 0 18 18" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.43553 0.113589C9.15028 0.0363557 8.84962 0.0363557 8.56438 0.113589C8.23324 0.203247 7.95445 0.422125 7.73194 0.596817L7.66975 0.645498L1.95307 5.09173C1.63921 5.3353 1.3627 5.54988 1.15665 5.82847C0.975827 6.07295 0.841122 6.34837 0.759154 6.64121C0.665754 6.97489 0.666132 7.3249 0.666561 7.72218L0.666619 13.8654C0.666604 14.3047 0.666592 14.6837 0.692095 14.9958C0.719012 15.3253 0.778442 15.6529 0.939104 15.9683C1.17879 16.4387 1.56124 16.8211 2.03164 17.0608C2.34696 17.2215 2.67464 17.2809 3.0041 17.3078C3.31624 17.3333 3.6952 17.3333 4.13448 17.3333H13.8654C14.3047 17.3333 14.6837 17.3333 14.9958 17.3078C15.3253 17.2809 15.6529 17.2215 15.9683 17.0608C16.4387 16.8211 16.8211 16.4387 17.0608 15.9683C17.2215 15.6529 17.2809 15.3253 17.3078 14.9958C17.3333 14.6837 17.3333 14.3047 17.3333 13.8654L17.3333 7.72219C17.3338 7.3249 17.3342 6.97489 17.2408 6.64121C17.1588 6.34837 17.0241 6.07295 16.8433 5.82847C16.6372 5.54988 16.3607 5.33529 16.0468 5.09172L10.3302 0.645498L10.268 0.59682C10.0455 0.422128 9.76667 0.203248 9.43553 0.113589ZM6.57867 10.4589C6.46395 10.0132 6.00963 9.74487 5.56392 9.85959C5.11821 9.97431 4.84989 10.4286 4.9646 10.8743C5.4271 12.6713 7.05731 14 8.99995 14C10.9426 14 12.5728 12.6713 13.0353 10.8743C13.15 10.4286 12.8817 9.97431 12.436 9.85959C11.9903 9.74487 11.536 10.0132 11.4212 10.4589C11.1437 11.5374 10.1637 12.3333 8.99995 12.3333C7.8362 12.3333 6.85624 11.5374 6.57867 10.4589Z" fill="currentColor"/>
</symbol>
<symbol id="home-outline" viewBox="0 0 18 19" fill="none">
<path d="M5.77168 11.6668C6.14172 13.1045 7.4468 14.1668 9 14.1668C10.5532 14.1668 11.8583 13.1045 12.2283 11.6668M8.18141 2.30345L2.52949 6.69939C2.15168 6.99324 1.96278 7.14017 1.82669 7.32417C1.70614 7.48716 1.61633 7.67077 1.56169 7.866C1.5 8.08639 1.5 8.3257 1.5 8.80433V14.8334C1.5 15.7669 1.5 16.2336 1.68166 16.5901C1.84144 16.9037 2.09641 17.1587 2.41002 17.3185C2.76654 17.5001 3.23325 17.5001 4.16667 17.5001H13.8333C14.7668 17.5001 15.2335 17.5001 15.59 17.3185C15.9036 17.1587 16.1586 16.9037 16.3183 16.5901C16.5 16.2336 16.5 15.7669 16.5 14.8334V8.80433C16.5 8.3257 16.5 8.08639 16.4383 7.866C16.3837 7.67077 16.2939 7.48716 16.1733 7.32417C16.0372 7.14017 15.8483 6.99324 15.4705 6.69939L9.81859 2.30345C9.52582 2.07574 9.37943 1.96189 9.21779 1.91812C9.07516 1.87951 8.92484 1.87951 8.78221 1.91812C8.62057 1.96189 8.47418 2.07574 8.18141 2.30345Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="sign-in" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 3C6.44772 3 6 3.44772 6 4C6 4.55228 6.44772 5 7 5H18C18.5523 5 19 5.44772 19 6V18C19 18.5523 18.5523 19 18 19H7C6.44772 19 6 19.4477 6 20C6 20.5523 6.44772 21 7 21H18C19.6569 21 21 19.6569 21 18V6C21 4.34315 19.6569 3 18 3H7ZM12.7071 7.29289C12.3166 6.90237 11.6834 6.90237 11.2929 7.29289C10.9024 7.68342 10.9024 8.31658 11.2929 8.70711L13.5858 11H4C3.44772 11 3 11.4477 3 12C3 12.5523 3.44772 13 4 13H13.5858L11.2929 15.2929C10.9024 15.6834 10.9024 16.3166 11.2929 16.7071C11.6834 17.0976 12.3166 17.0976 12.7071 16.7071L16.7071 12.7071C17.0976 12.3166 17.0976 11.6834 16.7071 11.2929L12.7071 7.29289Z" fill="currentColor"/>
</symbol>
<symbol id="deck-solid" viewBox="0 0 20 20" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.3011 1.66602H14.8653C15.3046 1.666 15.6836 1.66599 15.9957 1.69149C16.3251 1.71841 16.6528 1.77784 16.9681 1.9385C17.4386 2.17818 17.821 2.56064 18.0607 3.03104C18.2213 3.34636 18.2808 3.67404 18.3077 4.00349C18.3332 4.31564 18.3332 4.69462 18.3332 5.13392V14.8648C18.3332 15.3041 18.3332 15.6831 18.3077 15.9952C18.2808 16.3247 18.2213 16.6523 18.0607 16.9677C17.821 17.4381 17.4386 17.8205 16.9681 18.0602C16.6528 18.2209 16.3251 18.2803 15.9957 18.3072C15.6836 18.3327 15.3046 18.3327 14.8653 18.3327H14.301C13.8618 18.3327 13.4828 18.3327 13.1706 18.3072C12.8412 18.2803 12.5135 18.2209 12.1982 18.0602C11.7278 17.8205 11.3453 17.4381 11.1057 16.9677C10.945 16.6523 10.8856 16.3247 10.8586 15.9952C10.8331 15.6831 10.8332 15.3041 10.8332 14.8648V5.1339C10.8332 4.69461 10.8331 4.31564 10.8586 4.00349C10.8856 3.67404 10.945 3.34636 11.1057 3.03104C11.3453 2.56064 11.7278 2.17818 12.1982 1.9385C12.5135 1.77784 12.8412 1.71841 13.1706 1.69149C13.4828 1.66599 13.8618 1.666 14.3011 1.66602Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.13439 1.66602H5.69863C6.13792 1.666 6.51689 1.66599 6.82903 1.69149C7.15848 1.71841 7.48617 1.77784 7.80148 1.9385C8.27189 2.17818 8.65434 2.56064 8.89402 3.03104C9.05468 3.34636 9.11411 3.67404 9.14103 4.00349C9.16653 4.31564 9.16652 4.69462 9.16651 5.13392V14.8648C9.16652 15.3041 9.16653 15.6831 9.14103 15.9952C9.11411 16.3247 9.05468 16.6523 8.89402 16.9677C8.65434 17.4381 8.27189 17.8205 7.80148 18.0602C7.48617 18.2209 7.15848 18.2803 6.82903 18.3072C6.51689 18.3327 6.13793 18.3327 5.69864 18.3327H5.13437C4.69508 18.3327 4.31612 18.3327 4.00398 18.3072C3.67453 18.2803 3.34685 18.2209 3.03153 18.0602C2.56112 17.8205 2.17867 17.4381 1.93899 16.9677C1.77833 16.6523 1.7189 16.3247 1.69198 15.9952C1.66648 15.6831 1.66649 15.3041 1.6665 14.8648V5.1339C1.66649 4.69461 1.66648 4.31564 1.69198 4.00349C1.7189 3.67404 1.77833 3.34636 1.93899 3.03104C2.17867 2.56064 2.56112 2.17818 3.03153 1.9385C3.34685 1.77784 3.67453 1.71841 4.00398 1.69149C4.31613 1.66599 4.69509 1.666 5.13439 1.66602Z" fill="currentColor"/>
@ -419,24 +422,28 @@
<path d="M4.66667 1.5H4.16667C3.23325 1.5 2.76654 1.5 2.41002 1.68166C2.09641 1.84144 1.84144 2.09641 1.68166 2.41002C1.5 2.76654 1.5 3.23325 1.5 4.16667V13.8333C1.5 14.7668 1.5 15.2335 1.68166 15.59C1.84144 15.9036 2.09641 16.1586 2.41002 16.3183C2.76654 16.5 3.23325 16.5 4.16667 16.5H4.66667C5.60009 16.5 6.0668 16.5 6.42332 16.3183C6.73692 16.1586 6.99189 15.9036 7.15168 15.59C7.33333 15.2335 7.33333 14.7668 7.33333 13.8333V4.16667C7.33333 3.23325 7.33333 2.76654 7.15168 2.41002C6.99189 2.09641 6.73692 1.84144 6.42332 1.68166C6.0668 1.5 5.60009 1.5 4.66667 1.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.8333 1.5H13.3333C12.3999 1.5 11.9332 1.5 11.5767 1.68166C11.2631 1.84144 11.0081 2.09641 10.8483 2.41002C10.6667 2.76654 10.6667 3.23325 10.6667 4.16667V13.8333C10.6667 14.7668 10.6667 15.2335 10.8483 15.59C11.0081 15.9036 11.2631 16.1586 11.5767 16.3183C11.9332 16.5 12.3999 16.5 13.3333 16.5H13.8333C14.7668 16.5 15.2335 16.5 15.59 16.3183C15.9036 16.1586 16.1586 15.9036 16.3183 15.59C16.5 15.2335 16.5 14.7668 16.5 13.8333V4.16667C16.5 3.23325 16.5 2.76654 16.3183 2.41002C16.1586 2.09641 15.9036 1.84144 15.59 1.68166C15.2335 1.5 14.7668 1.5 13.8333 1.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="graph-outline" viewBox="0 0 20 22" fill="none">
<path d="M6.59 12.51L13.42 16.49M13.41 5.51L6.59 9.49M19 4C19 5.65685 17.6569 7 16 7C14.3431 7 13 5.65685 13 4C13 2.34315 14.3431 1 16 1C17.6569 1 19 2.34315 19 4ZM7 11C7 12.6569 5.65685 14 4 14C2.34315 14 1 12.6569 1 11C1 9.34315 2.34315 8 4 8C5.65685 8 7 9.34315 7 11ZM19 18C19 19.6569 17.6569 21 16 21C14.3431 21 13 19.6569 13 18C13 16.3431 14.3431 15 16 15C17.6569 15 19 16.3431 19 18Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="graph-solid" viewBox="0 0 24 24" fill="none">
<path d="M14 5C14 2.79086 15.7909 1 18 1C20.2091 1 22 2.79086 22 5C22 7.20914 20.2091 9 18 9C16.8885 9 15.883 8.54668 15.1581 7.81485L9.85034 10.9123C9.94784 11.2581 10 11.623 10 12C10 12.3768 9.9479 12.7415 9.8505 13.0871L15.1613 16.1819C15.886 15.452 16.8902 15 18 15C20.2091 15 22 16.7909 22 19C22 21.2091 20.2091 23 18 23C15.7909 23 14 21.2091 14 19C14 18.6214 14.0526 18.255 14.1509 17.9079L8.84235 14.8144C8.11742 15.5465 7.11167 16 6 16C3.79086 16 2 14.2091 2 12C2 9.79086 3.79086 8 6 8C7.11146 8 8.11703 8.45332 8.84193 9.18514L14.1497 6.08767C14.0522 5.74185 14 5.37701 14 5Z" fill="currentColor"/>
</symbol>
<symbol id="media" viewBox="0 0 18 18" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.80659 0.666994C3.87988 0.667127 4.95525 0.667127 6.02642 0.666994C6.23674 0.666969 6.44157 0.666945 6.61503 0.681118C6.80553 0.696682 7.03031 0.733404 7.25649 0.848652C7.5701 1.00844 7.82506 1.26341 7.98485 1.57701C8.1001 1.8032 8.13682 2.02798 8.15239 2.21847C8.16656 2.39194 8.16654 2.59677 8.16651 2.8071V6.0269C8.16654 6.23722 8.16656 6.44205 8.15239 6.61552C8.13682 6.80602 8.1001 7.03079 7.98485 7.25698C7.82506 7.57058 7.5701 7.82555 7.25649 7.98534C7.03031 8.10059 6.80553 8.13731 6.61503 8.15288C6.44156 8.16705 6.23673 8.16702 6.02641 8.167H2.80661C2.59628 8.16702 2.39145 8.16705 2.21798 8.15288C2.02749 8.13731 1.80271 8.10059 1.57652 7.98534C1.26292 7.82555 1.00795 7.57058 0.848164 7.25698C0.732916 7.03079 0.696193 6.80602 0.680629 6.61552C0.666457 6.44206 0.666481 6.23723 0.666506 6.02691C0.666507 6.01806 0.666508 6.0092 0.666508 6.00033V2.83366C0.666508 2.82479 0.666507 2.81593 0.666506 2.80708C0.666481 2.59676 0.666457 2.39194 0.680629 2.21847C0.696193 2.02798 0.732916 1.8032 0.848164 1.57701C1.00795 1.26341 1.26292 1.00844 1.57652 0.848652C1.80271 0.733404 2.02749 0.696682 2.21798 0.681118C2.39145 0.666945 2.59627 0.666969 2.80659 0.666994Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.80659 9.83366C3.87988 9.83379 4.95525 9.83379 6.02642 9.83366C6.23674 9.83364 6.44157 9.83361 6.61503 9.84778C6.80553 9.86335 7.03031 9.90007 7.25649 10.0153C7.5701 10.1751 7.82506 10.4301 7.98485 10.7437C8.1001 10.9699 8.13682 11.1946 8.15239 11.3851C8.16656 11.5586 8.16654 11.7634 8.16651 11.9738V15.1936C8.16654 15.4039 8.16656 15.6087 8.15239 15.7822C8.13682 15.9727 8.1001 16.1975 7.98485 16.4236C7.82506 16.7373 7.5701 16.9922 7.25649 17.152C7.03031 17.2673 6.80553 17.304 6.61503 17.3195C6.44156 17.3337 6.23673 17.3337 6.02641 17.3337H2.80661C2.59628 17.3337 2.39145 17.3337 2.21798 17.3195C2.02749 17.304 1.80271 17.2673 1.57652 17.152C1.26292 16.9922 1.00795 16.7373 0.848164 16.4236C0.732916 16.1975 0.696193 15.9727 0.680629 15.7822C0.666457 15.6087 0.666481 15.4039 0.666506 15.1936C0.666507 15.1847 0.666508 15.1759 0.666508 15.167V12.0003C0.666508 11.9915 0.666507 11.9826 0.666506 11.9737C0.666481 11.7634 0.666457 11.5586 0.680629 11.3851C0.696193 11.1946 0.732916 10.9699 0.848164 10.7437C1.00795 10.4301 1.26292 10.1751 1.57652 10.0153C1.80271 9.90007 2.02749 9.86335 2.21798 9.84778C2.39145 9.83361 2.59627 9.83364 2.80659 9.83366Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9733 0.666994C13.0465 0.667127 14.1219 0.667127 15.1931 0.666994C15.4034 0.666969 15.6082 0.666945 15.7817 0.681118C15.9722 0.696682 16.197 0.733404 16.4232 0.848652C16.7368 1.00844 16.9917 1.26341 17.1515 1.57701C17.2668 1.8032 17.3035 2.02798 17.3191 2.21847C17.3332 2.39194 17.3332 2.59677 17.3332 2.8071V6.0269C17.3332 6.23722 17.3332 6.44205 17.3191 6.61552C17.3035 6.80602 17.2668 7.03079 17.1515 7.25698C16.9917 7.57058 16.7368 7.82555 16.4232 7.98534C16.197 8.10059 15.9722 8.13731 15.7817 8.15288C15.6082 8.16705 15.4034 8.16702 15.1931 8.167H11.9733C11.763 8.16702 11.5581 8.16705 11.3847 8.15288C11.1942 8.13731 10.9694 8.10059 10.7432 7.98534C10.4296 7.82555 10.1746 7.57058 10.0148 7.25698C9.89958 7.03079 9.86286 6.80602 9.8473 6.61552C9.83312 6.44206 9.83315 6.23723 9.83317 6.02691C9.83317 6.01806 9.83317 6.0092 9.83317 6.00033V2.83366C9.83317 2.82479 9.83317 2.81593 9.83317 2.80708C9.83315 2.59676 9.83312 2.39194 9.8473 2.21847C9.86286 2.02798 9.89958 1.8032 10.0148 1.57701C10.1746 1.26341 10.4296 1.00844 10.7432 0.848652C10.9694 0.733404 11.1942 0.696682 11.3847 0.681118C11.5581 0.666945 11.7629 0.666969 11.9733 0.666994Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9733 9.83366C13.0465 9.83379 14.1219 9.83379 15.1931 9.83366C15.4034 9.83364 15.6082 9.83361 15.7817 9.84778C15.9722 9.86335 16.197 9.90007 16.4232 10.0153C16.7368 10.1751 16.9917 10.4301 17.1515 10.7437C17.2668 10.9699 17.3035 11.1946 17.3191 11.3851C17.3332 11.5586 17.3332 11.7634 17.3332 11.9738V15.1936C17.3332 15.4039 17.3332 15.6087 17.3191 15.7822C17.3035 15.9727 17.2668 16.1975 17.1515 16.4236C16.9917 16.7373 16.7368 16.9922 16.4232 17.152C16.197 17.2673 15.9722 17.304 15.7817 17.3195C15.6082 17.3337 15.4034 17.3337 15.1931 17.3337H11.9733C11.763 17.3337 11.5581 17.3337 11.3847 17.3195C11.1942 17.304 10.9694 17.2673 10.7432 17.152C10.4296 16.9922 10.1746 16.7373 10.0148 16.4236C9.89958 16.1975 9.86286 15.9727 9.8473 15.7822C9.83312 15.6087 9.83315 15.4039 9.83317 15.1936C9.83317 15.1847 9.83317 15.1759 9.83317 15.167V12.0003C9.83317 11.9915 9.83317 11.9826 9.83317 11.9737C9.83315 11.7634 9.83312 11.5586 9.8473 11.3851C9.86286 11.1946 9.89958 10.9699 10.0148 10.7437C10.1746 10.4301 10.4296 10.1751 10.7432 10.0153C10.9694 9.90007 11.1942 9.86335 11.3847 9.84778C11.5581 9.83361 11.7629 9.83364 11.9733 9.83366Z" fill="currentColor"/>
</symbol>
<symbol id="info-solid" viewBox="0 0 22 22" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 0C4.92487 0 0 4.92487 0 11C0 17.0751 4.92487 22 11 22C17.0751 22 22 17.0751 22 11C22 4.92487 17.0751 0 11 0ZM11 6C10.4477 6 10 6.44772 10 7C10 7.55228 10.4477 8 11 8H11.01C11.5623 8 12.01 7.55228 12.01 7C12.01 6.44772 11.5623 6 11.01 6H11ZM12 11C12 10.4477 11.5523 10 11 10C10.4477 10 10 10.4477 10 11V15C10 15.5523 10.4477 16 11 16C11.5523 16 12 15.5523 12 15V11Z" fill="currentColor"/>
</symbol>
<symbol id="info-outline" viewBox="0 0 22 22" fill="none">
<path d="M11 15V11M11 7H11.01M21 11C21 16.5228 16.5228 21 11 21C5.47715 21 1 16.5228 1 11C1 5.47715 5.47715 1 11 1C16.5228 1 21 5.47715 21 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="play-square-outline" viewBox="0 0 20 20" fill="none">
<path d="M7.5 6.96533C7.5 6.48805 7.5 6.24941 7.59974 6.11618C7.68666 6.00007 7.81971 5.92744 7.96438 5.9171C8.13038 5.90525 8.33112 6.03429 8.73261 6.29239L13.4532 9.32706C13.8016 9.55102 13.9758 9.663 14.0359 9.80539C14.0885 9.9298 14.0885 10.0702 14.0359 10.1946C13.9758 10.337 13.8016 10.449 13.4532 10.6729L8.73261 13.7076C8.33112 13.9657 8.13038 14.0948 7.96438 14.0829C7.81971 14.0726 7.68666 13.9999 7.59974 13.8838C7.5 13.7506 7.5 13.512 7.5 13.0347V6.96533Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1 5.8C1 4.11984 1 3.27976 1.32698 2.63803C1.6146 2.07354 2.07354 1.6146 2.63803 1.32698C3.27976 1 4.11984 1 5.8 1H14.2C15.8802 1 16.7202 1 17.362 1.32698C17.9265 1.6146 18.3854 2.07354 18.673 2.63803C19 3.27976 19 4.11984 19 5.8V14.2C19 15.8802 19 16.7202 18.673 17.362C18.3854 17.9265 17.9265 18.3854 17.362 18.673C16.7202 19 15.8802 19 14.2 19H5.8C4.11984 19 3.27976 19 2.63803 18.673C2.07354 18.3854 1.6146 17.9265 1.32698 17.362C1 16.7202 1 15.8802 1 14.2V5.8Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@ -453,16 +460,6 @@
<symbol id="sats" viewBox="0 0 24 25" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21 12.5C21 13.6819 20.7672 14.8522 20.3149 15.9442C19.8626 17.0361 19.1997 18.0282 18.364 18.864C17.5282 19.6997 16.5361 20.3626 15.4442 20.8149C14.3522 21.2672 13.1819 21.5 12 21.5C10.8181 21.5 9.64778 21.2672 8.55585 20.8149C7.46392 20.3626 6.47177 19.6997 5.63604 18.864C4.80031 18.0282 4.13738 17.0361 3.68508 15.9442C3.23279 14.8522 3 13.6819 3 12.5C3 10.1131 3.94821 7.82387 5.63604 6.13604C7.32387 4.44821 9.61305 3.5 12 3.5C14.3869 3.5 16.6761 4.44821 18.364 6.13604C20.0518 7.82387 21 10.1131 21 12.5ZM8.693 9.242L16.33 11.305L16.667 9.843L9.029 7.78L8.693 9.242ZM14.219 6.192L13.813 7.966L12.365 7.574L12.772 5.8L14.219 6.192ZM11.227 19.2L11.635 17.426L10.187 17.035L9.779 18.809L11.227 19.2ZM15.648 14.266L8.011 12.2L8.347 10.738L15.984 12.804L15.648 14.266ZM7.332 15.156L14.97 17.22L15.306 15.758L7.668 13.694L7.332 15.156Z" fill="currentColor"/>
</symbol>
<symbol id="face-smile" viewBox="0 0 24 25" fill="none">
<path d="M8 14.5C8 14.5 9.5 16.5 12 16.5C14.5 16.5 16 14.5 16 14.5M15 9.5H15.01M9 9.5H9.01M22 12.5C22 18.0228 17.5228 22.5 12 22.5C6.47715 22.5 2 18.0228 2 12.5C2 6.97715 6.47715 2.5 12 2.5C17.5228 2.5 22 6.97715 22 12.5ZM15.5 9.5C15.5 9.77614 15.2761 10 15 10C14.7239 10 14.5 9.77614 14.5 9.5C14.5 9.22386 14.7239 9 15 9C15.2761 9 15.5 9.22386 15.5 9.5ZM9.5 9.5C9.5 9.77614 9.27614 10 9 10C8.72386 10 8.5 9.77614 8.5 9.5C8.5 9.22386 8.72386 9 9 9C9.27614 9 9.5 9.22386 9.5 9.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="bar-chart" viewBox="0 0 24 25" fill="none" >
<path d="M9 7.5H4.6C4.03995 7.5 3.75992 7.5 3.54601 7.60899C3.35785 7.70487 3.20487 7.85785 3.10899 8.04601C3 8.25992 3 8.53995 3 9.1V19.9C3 20.4601 3 20.7401 3.10899 20.954C3.20487 21.1422 3.35785 21.2951 3.54601 21.391C3.75992 21.5 4.03995 21.5 4.6 21.5H9M9 21.5H15M9 21.5L9 5.1C9 4.53995 9 4.25992 9.10899 4.04601C9.20487 3.85785 9.35785 3.70487 9.54601 3.60899C9.75992 3.5 10.0399 3.5 10.6 3.5L13.4 3.5C13.9601 3.5 14.2401 3.5 14.454 3.60899C14.6422 3.70487 14.7951 3.85785 14.891 4.04601C15 4.25992 15 4.53995 15 5.1V21.5M15 11.5H19.4C19.9601 11.5 20.2401 11.5 20.454 11.609C20.6422 11.7049 20.7951 11.8578 20.891 12.046C21 12.2599 21 12.5399 21 13.1V19.9C21 20.4601 21 20.7401 20.891 20.954C20.7951 21.1422 20.6422 21.2951 20.454 21.391C20.2401 21.5 19.9601 21.5 19.4 21.5H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="expand" viewBox="0 0 24 24" fill="none">
<path d="M14 3C14 2.44772 14.4477 2 15 2H21C21.5523 2 22 2.44772 22 3V9C22 9.55229 21.5523 10 21 10C20.4477 10 20 9.55229 20 9V5.41421L14.7071 10.7071C14.3166 11.0976 13.6834 11.0976 13.2929 10.7071C12.9024 10.3166 12.9024 9.68342 13.2929 9.29289L18.5858 4H15C14.4477 4 14 3.55228 14 3Z" fill="currentColor" />
<path d="M5.41421 20L10.7071 14.7071C11.0976 14.3166 11.0976 13.6834 10.7071 13.2929C10.3166 12.9024 9.68342 12.9024 9.29289 13.2929L4 18.5858L4 15C4 14.4477 3.55229 14 3 14C2.44772 14 2 14.4477 2 15V21C2 21.5523 2.44772 22 3 22H9C9.55228 22 10 21.5523 10 21C10 20.4477 9.55228 20 9 20H5.41421Z" fill="currentColor" />
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

@ -3,15 +3,6 @@ export const DefaultLocale = "en-US";
export const getLocale = () => {
return (navigator.languages && navigator.languages[0]) ?? navigator.language ?? DefaultLocale;
};
export const getCurrency = () => {
const locale = navigator.language || navigator.languages[0];
const formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
currencyDisplay: "code",
});
return formatter.formatToParts(1.2345).find(a => a.type === "currency")?.value ?? "USD";
};
export const AllLanguageCodes = [
"en",
"ja",

View File

@ -42,7 +42,7 @@ const InviteModal = () => {
</p>
<Link to="/login/sign-up">
<button className="primary">
<FormattedMessage defaultMessage="Sign Up" />
<FormattedMessage defaultMessage="Sign Up" id="39AHJm" />
</button>
</Link>
</div>

View File

@ -11,10 +11,10 @@ export default function AccountName({ name = "", link = true }: AccountNameProps
return (
<>
<div>
<FormattedMessage defaultMessage="Username" />: <b>{name}</b>
<FormattedMessage defaultMessage="Username" id="JCIgkj" />: <b>{name}</b>
</div>
<div>
<FormattedMessage defaultMessage="Short link" />:{" "}
<FormattedMessage defaultMessage="Short link" id="rx1i0i" />:{" "}
{link ? (
<a
href={`https://iris.to/${name}`}
@ -29,7 +29,7 @@ export default function AccountName({ name = "", link = true }: AccountNameProps
)}
</div>
<div>
<FormattedMessage defaultMessage="Nostr address (nip05)" />: <b>{name}@iris.to</b>
<FormattedMessage defaultMessage="Nostr address (nip05)" id="BjNwZW" />: <b>{name}@iris.to</b>
</div>
</>
);

View File

@ -67,12 +67,12 @@ export default function ActiveAccount({ name = "", setAsPrimary = () => {} }: Ac
return (
<div>
<div className="negative">
<FormattedMessage defaultMessage="You have an active iris.to account" />:
<FormattedMessage defaultMessage="You have an active iris.to account" id="UrKTqQ" />:
<AccountName name={name} />
</div>
<p>
<button type="button" onClick={onClick}>
<FormattedMessage defaultMessage="Set as primary Nostr address (nip05)" />
<FormattedMessage defaultMessage="Set as primary Nostr address (nip05)" id="MiMipu" />
</button>
</p>
</div>

View File

@ -72,7 +72,7 @@ class IrisAccount extends Component<Props> {
view = (
<div>
<p>
<FormattedMessage defaultMessage="Register an Iris username" /> (iris.to/username)
<FormattedMessage defaultMessage="Register an Iris username" id="kEZUR8" /> (iris.to/username)
</p>
<form onSubmit={e => this.showChallenge(e)}>
<div className="flex g8">
@ -84,14 +84,14 @@ class IrisAccount extends Component<Props> {
onInput={e => this.onNewUserNameChange(e)}
/>
<button type="submit">
<FormattedMessage defaultMessage="Register" />
<FormattedMessage defaultMessage="Register" id="deEeEI" />
</button>
</div>
<div>
{this.state.newUserNameValid ? (
<>
<span className="success">
<FormattedMessage defaultMessage="Username is available" />
<FormattedMessage defaultMessage="Username is available" id="EcfIwB" />
</span>
<AccountName name={this.state.newUserName} link={false} />
</>
@ -107,7 +107,7 @@ class IrisAccount extends Component<Props> {
return (
<>
<h3>
<FormattedMessage defaultMessage="Iris.to account" />
<FormattedMessage defaultMessage="Iris.to account" id="Mzizei" />
</h3>
{view}
<p>

View File

@ -25,12 +25,12 @@ export default function ReservedAccount({
<AccountName name={name} link={false} />
<p>
<button className="btn btn-sm btn-primary" onClick={() => enableReserved()}>
<FormattedMessage defaultMessage="Yes please" />
<FormattedMessage defaultMessage="Yes please" id="VcwrfF" />
</button>
</p>
<p>
<button className="btn btn-sm btn-neutral" onClick={() => declineReserved()}>
<FormattedMessage defaultMessage="No thanks" />
<FormattedMessage defaultMessage="No thanks" id="c+JYNI" />
</button>
</p>
</div>

View File

@ -1,36 +1,18 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { lazy, Suspense, useState } from "react";
import { NostrEvent, NostrLink } from "@snort/system";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import Icon from "@/Components/Icons/Icon";
import { findTag } from "@/Utils";
import { extractStreamInfo } from "@/Utils/stream";
import NoteAppHandler from "../Event/Note/NoteAppHandler";
import ProfileImage from "../User/ProfileImage";
const LiveKitRoom = lazy(() => import("./livekit"));
export function LiveEvent({ ev }: { ev: TaggedNostrEvent }) {
const service = ev.tags.find(a => a[0] === "streaming")?.at(1);
function inner() {
if (service?.endsWith(".m3u8")) {
return <LiveStreamEvent ev={ev} />;
} else if (service?.startsWith("wss+livekit://")) {
return (
<Suspense>
<LiveKitRoom ev={ev} canJoin={true} />
</Suspense>
);
}
return <NoteAppHandler ev={ev} />;
}
return inner();
}
function LiveStreamEvent({ ev }: { ev: TaggedNostrEvent }) {
const { title, status, starts, host } = extractStreamInfo(ev);
export function LiveEvent({ ev }: { ev: NostrEvent }) {
const title = findTag(ev, "title");
const status = findTag(ev, "status");
const starts = Number(findTag(ev, "starts"));
const host = ev.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;
const [play, setPlay] = useState(false);
function statusLine() {
@ -40,7 +22,7 @@ function LiveStreamEvent({ ev }: { ev: TaggedNostrEvent }) {
<div className="flex g4 items-center">
<Icon name="signal-01" />
<b className="uppercase">
<FormattedMessage defaultMessage="Live" />
<FormattedMessage defaultMessage="Live" id="Dn82AL" />
</b>
</div>
);
@ -48,7 +30,7 @@ function LiveStreamEvent({ ev }: { ev: TaggedNostrEvent }) {
case "ended": {
return (
<b className="uppercase">
<FormattedMessage defaultMessage="Ended" />
<FormattedMessage defaultMessage="Ended" id="TP/cMX" />
</b>
);
}
@ -56,7 +38,7 @@ function LiveStreamEvent({ ev }: { ev: TaggedNostrEvent }) {
return (
<b className="uppercase">
{new Intl.DateTimeFormat(undefined, { dateStyle: "full", timeStyle: "short" }).format(
new Date(Number(starts) * 1000),
new Date(starts * 1000),
)}
</b>
);
@ -70,7 +52,7 @@ function LiveStreamEvent({ ev }: { ev: TaggedNostrEvent }) {
case "live": {
return (
<button className="nowrap" onClick={() => setPlay(true)}>
<FormattedMessage defaultMessage="Watch Stream" />
<FormattedMessage defaultMessage="Watch Stream" id="furjvW" />
</button>
);
}
@ -79,7 +61,7 @@ function LiveStreamEvent({ ev }: { ev: TaggedNostrEvent }) {
return (
<Link to={link} target="_blank">
<button className="nowrap">
<FormattedMessage defaultMessage="Watch Replay" />
<FormattedMessage defaultMessage="Watch Replay" id="6/hB3S" />
</button>
</Link>
);
@ -91,6 +73,8 @@ function LiveStreamEvent({ ev }: { ev: TaggedNostrEvent }) {
const link = `https://zap.stream/embed/${NostrLink.fromEvent(ev).encode()}`;
return (
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
src={link}
width="100%"
style={{
@ -102,7 +86,7 @@ function LiveStreamEvent({ ev }: { ev: TaggedNostrEvent }) {
return (
<div className="sm:flex g12 br p24 bg-primary items-center">
<div>
<ProfileImage pubkey={host!} showUsername={false} size={56} />
<ProfileImage pubkey={host} showUsername={false} size={56} />
</div>
<div className="flex flex-col g8 grow">
<div className="font-semibold text-3xl">{title}</div>

View File

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

View File

@ -1,67 +1,64 @@
import { NostrEvent, NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import classNames from "classnames";
import { CSSProperties } from "react";
import { FormattedMessage } from "react-intl";
import "./LiveStreams.css";
import { unixNow } from "@snort/shared";
import { EventKind, NostrEvent, NostrLink, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { CSSProperties, useMemo } from "react";
import { Link } from "react-router-dom";
import Icon from "@/Components/Icons/Icon";
import useFollowsControls from "@/Hooks/useFollowControls";
import useImgProxy from "@/Hooks/useImgProxy";
import useLiveStreams from "@/Hooks/useLiveStreams";
import { findTag } from "@/Utils";
import Avatar from "../User/Avatar";
export function LiveStreams() {
const streams = useLiveStreams();
const { followList } = useFollowsControls();
const sub = useMemo(() => {
const since = unixNow() - 60 * 60 * 24;
const rb = new RequestBuilder("follows:streams");
rb.withFilter().kinds([EventKind.LiveEvent]).authors(followList).since(since);
rb.withFilter().kinds([EventKind.LiveEvent]).tag("p", followList).since(since);
return rb;
}, [followList]);
const streams = useRequestBuilder(sub);
if (streams.length === 0) return null;
return (
<div className="flex mx-2 gap-4 overflow-x-auto sm-hide-scrollbar">
<div className="stream-list">
{streams.map(v => (
<LiveStreamEvent ev={v} key={`${v.kind}:${v.pubkey}:${findTag(v, "d")}`} className="h-[80px]" />
<LiveStreamEvent ev={v} key={`${v.kind}:${v.pubkey}:${findTag(v, "d")}`} />
))}
</div>
);
}
export function LiveStreamEvent({ ev, className }: { ev: NostrEvent; className?: string }) {
function LiveStreamEvent({ ev }: { ev: NostrEvent }) {
const { proxy } = useImgProxy();
const title = findTag(ev, "title");
const image = findTag(ev, "image");
const status = findTag(ev, "status");
const viewers = findTag(ev, "current_participants");
const host = ev.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;
const hostProfile = useUserProfile(host);
const link = NostrLink.fromEvent(ev).encode();
const link = NostrLink.fromEvent(ev).encode(CONFIG.eventLinkPrefix);
const imageProxy = proxy(image ?? "");
return (
<Link className={classNames("flex gap-2", className)} to={`https://zap.stream/${link}`} target="_blank">
<div className="relative aspect-video">
<div
className="absolute h-full w-full bg-center bg-cover bg-gray-ultradark rounded-lg"
style={
{
backgroundImage: `url(${imageProxy})`,
} as CSSProperties
}></div>
<div className="absolute left-0 top-0 w-full overflow-hidden">
<div
className="whitespace-nowrap px-1 text-ellipsis overflow-hidden text-xs font-medium bg-background opacity-70 text-center"
title={title}>
{title}
<Link className="stream-event" to={`https://zap.stream/${link}`} target="_blank">
<div
style={
{
"--img": `url(${imageProxy})`,
} as CSSProperties
}></div>
<div className="flex flex-col details">
<div className="flex g2">
<span className="live">{status}</span>
<div className="reaction-pill">
<Icon name="zap" size={24} />
<div className="reaction-pill-number">0</div>
</div>
</div>
<div className="absolute bottom-1 left-1 bg-heart rounded-md px-2 uppercase font-bold">{status}</div>
<div className="absolute right-1 bottom-1">
<Avatar pubkey={host} user={hostProfile} size={25} className="outline outline-2 outline-highlight" />
</div>
{viewers && (
<div className="absolute left-1 bottom-7 rounded-md px-2 py-1 text-xs bg-gray font-medium">
<FormattedMessage defaultMessage="{n} viewers" values={{ n: viewers }} />
</div>
)}
<div>{title}</div>
</div>
</Link>
);

View File

@ -1,131 +0,0 @@
import { LiveKitRoom as LiveKitRoomContext, RoomAudioRenderer, useParticipants } from "@livekit/components-react";
import { dedupe, unixNow } from "@snort/shared";
import { EventKind, NostrLink, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder, useUserProfile } from "@snort/system-react";
import { LocalParticipant, RemoteParticipant } from "livekit-client";
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { extractStreamInfo } from "@/Utils/stream";
import AsyncButton from "../Button/AsyncButton";
import { ProxyImg } from "../ProxyImg";
import Avatar from "../User/Avatar";
import { AvatarGroup } from "../User/AvatarGroup";
import DisplayName from "../User/DisplayName";
export default function LiveKitRoom({ ev, canJoin }: { ev: TaggedNostrEvent; canJoin?: boolean }) {
const { stream, service, id } = extractStreamInfo(ev);
const { publisher } = useEventPublisher();
const [join, setJoin] = useState(false);
const [token, setToken] = useState<string>();
async function getToken() {
if (!service || !publisher) return;
const url = `${service}/api/v1/nests/${id}`;
const auth = await publisher.generic(eb => {
eb.kind(EventKind.HttpAuthentication);
eb.tag(["url", url]);
eb.tag(["u", url]);
eb.tag(["method", "GET"]);
return eb;
});
const rsp = await fetch(url, {
headers: {
authorization: `Nostr ${window.btoa(JSON.stringify(auth))}`,
},
});
const text = await rsp.text();
if (rsp.ok) {
return JSON.parse(text) as { token: string };
}
}
useEffect(() => {
if (join && !token) {
getToken()
.then(t => setToken(t?.token))
.catch(console.error);
}
}, [join]);
if (!join) {
return (
<div className="p flex flex-col gap-2">
<RoomHeader ev={ev} />
{(canJoin ?? false) && (
<AsyncButton onClick={() => setJoin(true)}>
<FormattedMessage defaultMessage="Join Room" />
</AsyncButton>
)}
</div>
);
}
return (
<LiveKitRoomContext token={token} serverUrl={stream?.replace("wss+livekit://", "wss://")} connect={true}>
<RoomAudioRenderer volume={1} />
<ParticipantList ev={ev} />
</LiveKitRoomContext>
);
}
function RoomHeader({ ev }: { ev: TaggedNostrEvent }) {
const { image, title } = extractStreamInfo(ev);
return (
<div className="relative rounded-xl h-[140px] w-full overflow-hidden">
{image ? <ProxyImg src={image} className="w-full" /> : <div className="absolute bg-gray-dark w-full h-full" />}
<div className="absolute left-4 top-4 w-full flex justify-between pr-4">
<div className="text-2xl">{title}</div>
<div>
<NostrParticipants ev={ev} />
</div>
</div>
</div>
);
}
function ParticipantList({ ev }: { ev: TaggedNostrEvent }) {
const participants = useParticipants();
return (
<div className="p">
<RoomHeader ev={ev} />
<h3>
<FormattedMessage defaultMessage="Participants" />
</h3>
<div className="grid grid-cols-4">
{participants.map(a => (
<LiveKitUser p={a} key={a.identity} />
))}
</div>
</div>
);
}
function NostrParticipants({ ev }: { ev: TaggedNostrEvent }) {
const link = NostrLink.fromEvent(ev);
const sub = useMemo(() => {
const sub = new RequestBuilder(`livekit-participants:${link.tagKey}`);
sub
.withFilter()
.replyToLink([link])
.kinds([10_312 as EventKind])
.since(unixNow() - 600);
return sub;
}, [link.tagKey]);
const presense = useRequestBuilder(sub);
return <AvatarGroup ids={dedupe(presense.map(a => a.pubkey))} size={32} />;
}
function LiveKitUser({ p }: { p: RemoteParticipant | LocalParticipant }) {
const pubkey = p.identity.startsWith("guest-") ? "anon" : p.identity;
const profile = useUserProfile(pubkey);
return (
<div className="flex flex-col gap-2 items-center text-center">
<Avatar pubkey={pubkey} className={p.isSpeaking ? "outline" : ""} user={profile} size={48} />
<DisplayName pubkey={pubkey} user={pubkey === "anon" ? { name: "Anon" } : profile} />
</div>
);
}

View File

@ -283,7 +283,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
: startBuy(handle, domain)
}>
{props.forSubscription ? (
<FormattedMessage defaultMessage="Claim Now" />
<FormattedMessage defaultMessage="Claim Now" id="FdhSU2" />
) : (
<FormattedMessage {...messages.BuyNow} />
)}

View File

@ -10,11 +10,11 @@ export function Offline({ onRetry, className }: { onRetry?: () => void | Promise
<div className={classNames("flex items-center g8", className)}>
<Icon name="wifi-off" className="error" />
<div className="error">
<FormattedMessage defaultMessage="Offline" />
<FormattedMessage defaultMessage="Offline" id="7UOvbT" />
</div>
{onRetry && (
<AsyncButton onClick={onRetry}>
<FormattedMessage defaultMessage="Retry" />
<FormattedMessage defaultMessage="Retry" id="62nsdy" />
</AsyncButton>
)}
</div>

View File

@ -70,7 +70,7 @@ export function PinPrompt({
}}>
<div className="flex flex-col g12">
<h2>
<FormattedMessage defaultMessage="Enter Pin" />
<FormattedMessage defaultMessage="Enter Pin" id="KtsyO0" />
</h2>
{subTitle ? <div>{subTitle}</div> : null}
<input
@ -84,10 +84,10 @@ export function PinPrompt({
{error && <b className="error">{error}</b>}
<div className="flex g8">
<button type="button" onClick={() => onCancel()}>
<FormattedMessage defaultMessage="Cancel" />
<FormattedMessage defaultMessage="Cancel" id="47FYwb" />
</button>
<AsyncButton ref={submitButtonRef} onClick={() => submitPin()} type="submit">
<FormattedMessage defaultMessage="Submit" />
<FormattedMessage defaultMessage="Submit" id="wSZR47" />
</AsyncButton>
</div>
</div>
@ -168,7 +168,7 @@ export function LoginUnlock() {
<PinPrompt
subTitle={
<p>
<FormattedMessage defaultMessage="Enter pin to unlock your private key" />
<FormattedMessage defaultMessage="Enter pin to unlock your private key" id="e7VmYP" />
</p>
}
onResult={unlockSession}

View File

@ -1,75 +1,65 @@
import { OkResponse, TaggedNostrEvent } from "@snort/system";
import { TaggedNostrEvent } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { useContext, useState } from "react";
import { FormattedMessage } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import Modal from "@/Components/Modal/Modal";
import useRelays from "@/Hooks/useRelays";
import AsyncButton from "./Button/AsyncButton";
import messages from "./messages";
export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: TaggedNostrEvent }) {
const [selected, setSelected] = useState<Array<string>>();
const [replies, setReplies] = useState<Array<OkResponse>>([]);
const [sending, setSending] = useState(false);
const system = useContext(SnortContext);
const relays = useRelays();
async function sendReBroadcast() {
setSending(true);
setReplies([]);
try {
if (selected) {
await Promise.all(selected.map(r => system.WriteOnceToRelay(r, ev).then(o => setReplies(v => [...v, o]))));
} else {
const rsp = await system.BroadcastEvent(ev);
setReplies(rsp);
}
} catch (e) {
console.error(e);
} finally {
setSending(false);
if (selected) {
await Promise.all(selected.map(r => system.WriteOnceToRelay(r, ev)));
} else {
system.BroadcastEvent(ev);
}
}
function renderRelayCustomisation() {
return (
<div className="flex flex-col g8">
{Object.keys(relays)
.filter(el => relays[el].write)
.map((r, i, a) => (
<div key={r} className="card flex justify-between">
<div>{r}</div>
<div>
<input
type="checkbox"
checked={!selected || selected.includes(r)}
onChange={e =>
setSelected(
e.target.checked && selected && selected.length === a.length - 1
? undefined
: a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))),
)
}
/>
</div>
</div>
))}
</div>
);
}
return (
<>
<Modal id="broadcaster" onClose={onClose}>
<div className="flex flex-col gap-4">
<div className="text-xl font-medium">
<FormattedMessage defaultMessage="Broadcast Event" />
</div>
{Object.keys(relays)
.filter(el => relays[el].write)
.map((r, i, a) => (
<div key={r} className="flex justify-between">
<div className="flex flex-col gap-1">
<div>{r}</div>
<small>{replies.findLast(a => a.relay === r)?.message}</small>
</div>
<div>
<input
type="checkbox"
disabled={sending}
checked={!selected || selected.includes(r)}
onChange={e =>
setSelected(
e.target.checked && selected && selected.length === a.length - 1
? undefined
: a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))),
)
}
/>
</div>
</div>
))}
<div className="flex gap-2">
<button className="secondary" onClick={onClose}>
<FormattedMessage defaultMessage="Cancel" />
</button>
<AsyncButton onClick={sendReBroadcast} disabled={sending}>
<FormattedMessage defaultMessage="Send" />
</AsyncButton>
</div>
<Modal id="broadcaster" className="note-creator-modal" onClose={onClose}>
{renderRelayCustomisation()}
<div className="flex g8">
<button className="secondary" onClick={onClose}>
<FormattedMessage {...messages.Cancel} />
</button>
<AsyncButton onClick={sendReBroadcast}>
<FormattedMessage {...messages.ReBroadcast} />
</AsyncButton>
</div>
</Modal>
</>

View File

@ -0,0 +1,11 @@
.relay {
border-radius: 5px;
display: grid;
grid-template-columns: min-content auto;
overflow: hidden;
font-size: var(--font-size-small);
}
.relay > div {
padding: 5px;
}

View File

@ -1,48 +1,85 @@
import { Link } from "react-router-dom";
import "./Relay.css";
import { RelaySettings } from "@snort/system";
import classNames from "classnames";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { AsyncIcon } from "@/Components/Button/AsyncIcon";
import useRelayState from "@/Feed/RelayState";
import useLogin from "@/Hooks/useLogin";
import { getRelayName } from "@/Utils";
import Icon from "../Icons/Icon";
import RelayPermissions from "./permissions";
import RelayStatusLabel from "./status-label";
import RelayUptime from "./uptime";
import { RelayFavicon } from "./RelaysMetadata";
export interface RelayProps {
addr: string;
}
export default function Relay(props: RelayProps) {
const navigate = useNavigate();
const state = useLogin(s => s.state);
const name = useMemo(() => getRelayName(props.addr), [props.addr]);
const connection = useRelayState(props.addr);
const { state } = useLogin(s => ({ v: s.state.version, state: s.state }));
if (!connection) return;
const name = connection.info?.name ?? getRelayName(props.addr);
const relaySettings = state.relays?.find(a => a.url === props.addr)?.settings;
if (!relaySettings || !connection) return;
async function configure(o: RelaySettings) {
await state.updateRelay(props.addr, o);
}
return (
<tr>
<td className="text-ellipsis" title={props.addr}>
<Link to={`/settings/relays/${encodeURIComponent(props.addr)}`}>
{name.length > 20 ? <>{name.slice(0, 20)}...</> : name}
</Link>
</td>
<td>
<RelayStatusLabel conn={connection} />
</td>
<td>
<RelayPermissions conn={connection} />
</td>
<td className="text-center">
<RelayUptime url={props.addr} />
</td>
<td>
<Icon
name="trash"
className="text-gray-light cursor-pointer"
onClick={() => {
state.removeRelay(props.addr, true);
}}
/>
</td>
</tr>
<>
<div className="relay bg-dark">
<div className={classNames("flex items-center", connection.isOpen ? "bg-success" : "bg-error")}>
<RelayFavicon url={props.addr} />
</div>
<div className="flex flex-col g8">
<div>
<b>{name}</b>
</div>
{!connection?.Ephemeral && (
<div className="flex g8">
<AsyncIcon
iconName="write"
iconSize={16}
className={classNames("button-icon-sm transparent", { active: relaySettings.write })}
onClick={() =>
configure({
write: !relaySettings.write,
read: relaySettings.read,
})
}
/>
<AsyncIcon
iconName="read"
iconSize={16}
className={classNames("button-icon-sm transparent", { active: relaySettings.read })}
onClick={() =>
configure({
write: relaySettings.write,
read: !relaySettings.read,
})
}
/>
<AsyncIcon
iconName="trash"
iconSize={16}
className="button-icon-sm transparent trash-icon"
onClick={() => state.removeRelay(props.addr)}
/>
<AsyncIcon
iconName="gear"
iconSize={16}
className="button-icon-sm transparent"
onClick={() => navigate(connection?.Id ?? "")}
/>
</div>
)}
</div>
</div>
</>
);
}

View File

@ -0,0 +1,9 @@
.favicon {
width: 21px;
height: 21px;
max-width: unset;
}
.relay-active {
color: var(--highlight);
}

View File

@ -1,10 +1,12 @@
import "./RelaysMetadata.css";
import { FullRelaySettings } from "@snort/system";
import { useState } from "react";
import Nostrich from "@/assets/img/nostrich.webp";
import Icon from "@/Components/Icons/Icon";
export const RelayFavicon = ({ url, size }: { url: string; size?: number }) => {
export const RelayFavicon = ({ url }: { url: string }) => {
const cleanUrl = url
.replace(/^wss:\/\//, "https://")
.replace(/^ws:\/\//, "http://")
@ -12,12 +14,10 @@ export const RelayFavicon = ({ url, size }: { url: string; size?: number }) => {
const [faviconUrl, setFaviconUrl] = useState(`${cleanUrl}/favicon.ico`);
return (
<img
className="rounded-full object-cover"
className="circle favicon"
src={faviconUrl}
onError={() => setFaviconUrl(Nostrich)}
alt={`favicon for ${url}`}
width={size ?? 20}
height={size ?? 20}
/>
);
};
@ -35,8 +35,8 @@ const RelaysMetadata = ({ relays }: RelaysMetadataProps) => {
<RelayFavicon url={url} />
<code className="grow f-ellipsis">{url}</code>
<div className="flex g8">
<Icon name="read" className={settings.read ? "text-highlight" : "disabled"} />
<Icon name="write" className={settings.write ? "text-highlight" : "disabled"} />
<Icon name="read" className={settings.read ? "relay-active" : "disabled"} />
<Icon name="write" className={settings.write ? "relay-active" : "disabled"} />
</div>
</div>
);

View File

@ -1,17 +0,0 @@
import { RelayInfo } from "@snort/system";
import classNames from "classnames";
import { FormattedMessage } from "react-intl";
export default function RelayPaymentLabel({ info }: { info: RelayInfo }) {
const isPaid = info?.limitation?.payment_required ?? false;
return (
<div
className={classNames("rounded-full px-2 py-1 font-medium", {
"bg-[var(--pro)] text-black": isPaid,
"bg-[var(--free)]": !isPaid,
})}>
{isPaid && <FormattedMessage defaultMessage="Paid" />}
{!isPaid && <FormattedMessage defaultMessage="Free" />}
</div>
);
}

View File

@ -1,33 +0,0 @@
import { ConnectionType } from "@snort/system/dist/connection-pool";
import { FormattedMessage } from "react-intl";
import useLogin from "@/Hooks/useLogin";
export default function RelayPermissions({ conn }: { conn: ConnectionType }) {
const { state } = useLogin(s => ({ v: s.state.version, state: s.state }));
return (
<div className="flex gap-2 cursor-pointer select-none">
<div
className={conn.settings.read ? "" : "text-gray"}
onClick={async () =>
await state.updateRelay(conn.address, {
read: !conn.settings.read,
write: conn.settings.write,
})
}>
<FormattedMessage defaultMessage="Read" />
</div>
<div
className={conn.settings.write ? "" : "text-gray"}
onClick={async () =>
await state.updateRelay(conn.address, {
read: conn.settings.read,
write: !conn.settings.write,
})
}>
<FormattedMessage defaultMessage="Write" />
</div>
</div>
);
}

View File

@ -1,9 +0,0 @@
import { Link } from "react-router-dom";
export default function RelaySoftware({ software }: { software: string }) {
if (software.includes("git")) {
const u = new URL(software);
return <Link to={software}>{u.pathname.split("/").at(-1)?.replace(".git", "")}</Link>;
}
return software;
}

View File

@ -1,16 +0,0 @@
import { ConnectionType } from "@snort/system/dist/connection-pool";
import classNames from "classnames";
import { FormattedMessage } from "react-intl";
export default function RelayStatusLabel({ conn }: { conn: ConnectionType }) {
return (
<div className="flex gap-1 items-center">
<div
className={classNames("rounded-full w-4 h-4", {
"bg-success": conn.isOpen,
"bg-error": !conn.isOpen,
})}></div>
{conn.isOpen ? <FormattedMessage defaultMessage="Connected" /> : <FormattedMessage defaultMessage="Offline" />}
</div>
);
}

View File

@ -1,21 +0,0 @@
import classNames from "classnames";
import { FormattedMessage } from "react-intl";
export default function UptimeLabel({ avgPing }: { avgPing: number }) {
const idealPing = 500;
const badPing = idealPing * 2;
return (
<div
className={classNames("font-semibold", {
"text-error": isNaN(avgPing) || avgPing > badPing,
"text-warning": avgPing > idealPing && avgPing < badPing,
"text-success": avgPing < idealPing,
})}
title={`${avgPing.toFixed(0)} ms`}>
{isNaN(avgPing) && <FormattedMessage defaultMessage="Dead" />}
{avgPing > badPing && <FormattedMessage defaultMessage="Poor" />}
{avgPing > idealPing && avgPing < badPing && <FormattedMessage defaultMessage="Good" />}
{avgPing < idealPing && <FormattedMessage defaultMessage="Great" />}
</div>
);
}

View File

@ -1,43 +0,0 @@
import { sanitizeRelayUrl, unixNow } from "@snort/shared";
import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
import { findTag } from "@/Utils";
import { Day, MonitorRelays } from "@/Utils/Const";
import UptimeLabel from "./uptime-label";
export default function RelayUptime({ url }: { url: string }) {
const sub = useMemo(() => {
const u = sanitizeRelayUrl(url);
const rb = new RequestBuilder(`uptime`);
if (u) {
rb.withFilter()
.kinds([30_166 as EventKind])
.tag("d", [u])
.since(unixNow() - Day)
.relay(MonitorRelays);
}
return rb;
}, [url]);
const data = useRequestBuilder(sub);
const myData = data.filter(a => findTag(a, "d") === url);
const ping = myData.reduce(
(acc, v) => {
const read = findTag(v, "rtt-read");
if (read) {
acc.n += 1;
acc.total += Number(read);
}
return acc;
},
{
n: 0,
total: 0,
},
);
const avgPing = ping.total / ping.n;
return <UptimeLabel avgPing={avgPing} />;
}

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