add local relay cache option

cleanup event relay hint methods
This commit is contained in:
hzrd149 2023-12-02 12:00:58 -06:00
parent 9069932fbe
commit 199f208b11
29 changed files with 294 additions and 107 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add local relay cache option

View File

@ -29,10 +29,14 @@ export default class NostrMultiSubscription {
this.id = nanoid(); this.id = nanoid();
this.name = name; this.name = name;
} }
private handleEvent(event: IncomingEvent) { private handleEvent(incomingEvent: IncomingEvent) {
if (this.state === NostrMultiSubscription.OPEN && event.subId === this.id && !this.seenEvents.has(event.body.id)) { if (
this.onEvent.next(event.body); this.state === NostrMultiSubscription.OPEN &&
this.seenEvents.add(event.body.id); incomingEvent.subId === this.id &&
!this.seenEvents.has(incomingEvent.body.id)
) {
this.onEvent.next(incomingEvent.body);
this.seenEvents.add(incomingEvent.body.id);
} }
} }

View File

@ -1,9 +1,10 @@
import { nanoid } from "nanoid";
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import { NostrOutgoingMessage, NostrRequestFilter } from "../types/nostr-query"; import { NostrOutgoingMessage, NostrRequestFilter } from "../types/nostr-query";
import Relay, { IncomingEOSE } from "./relay"; import Relay, { IncomingEOSE } from "./relay";
import relayPoolService from "../services/relay-pool"; import relayPoolService from "../services/relay-pool";
import { Subject } from "./subject"; import { Subject } from "./subject";
import { nanoid } from "nanoid";
export default class NostrSubscription { export default class NostrSubscription {
static INIT = "initial"; static INIT = "initial";
@ -26,7 +27,9 @@ export default class NostrSubscription {
this.relay = relayPoolService.requestRelay(relayUrl); this.relay = relayPoolService.requestRelay(relayUrl);
this.onEvent.connectWithHandler(this.relay.onEvent, (event, next) => { this.onEvent.connectWithHandler(this.relay.onEvent, (event, next) => {
if (this.state === NostrSubscription.OPEN) next(event.body); if (this.state === NostrSubscription.OPEN) {
next(event.body);
}
}); });
this.onEOSE.connectWithHandler(this.relay.onEOSE, (eose, next) => { this.onEOSE.connectWithHandler(this.relay.onEOSE, (eose, next) => {
if (this.state === NostrSubscription.OPEN) next(eose); if (this.state === NostrSubscription.OPEN) next(eose);

View File

@ -9,7 +9,7 @@ import appSettings from "../../../services/settings/app-settings";
import EventVerificationIcon from "../../event-verification-icon"; import EventVerificationIcon from "../../event-verification-icon";
import { TrustProvider } from "../../../providers/trust"; import { TrustProvider } from "../../../providers/trust";
import Timestamp from "../../timestamp"; import Timestamp from "../../timestamp";
import { getNeventCodeWithRelays } from "../../../helpers/nip19"; import { getNeventForEventId } from "../../../helpers/nip19";
import { CompactNoteContent } from "../../compact-note-content"; import { CompactNoteContent } from "../../compact-note-content";
import HoverLinkOverlay from "../../hover-link-overlay"; import HoverLinkOverlay from "../../hover-link-overlay";
import { getReferences } from "../../../helpers/nostr/events"; import { getReferences } from "../../../helpers/nostr/events";
@ -26,7 +26,7 @@ export default function EmbeddedTorrentComment({
const { showSignatureVerification } = useSubject(appSettings); const { showSignatureVerification } = useSubject(appSettings);
const refs = getReferences(comment); const refs = getReferences(comment);
const torrent = useSingleEvent(refs.rootId, refs.rootRelay ? [refs.rootRelay] : []); const torrent = useSingleEvent(refs.rootId, refs.rootRelay ? [refs.rootRelay] : []);
const linkToTorrent = refs.rootId && `/torrents/${getNeventCodeWithRelays(refs.rootId)}`; const linkToTorrent = refs.rootId && `/torrents/${getNeventForEventId(refs.rootId)}`;
const handleClick = useCallback<MouseEventHandler>( const handleClick = useCallback<MouseEventHandler>(
(e) => { (e) => {

View File

@ -16,7 +16,7 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { getNeventCodeWithRelays } from "../../../helpers/nip19"; import { getSharableEventAddress } from "../../../helpers/nip19";
import UserAvatarLink from "../../user-avatar-link"; import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link"; import { UserLink } from "../../user-link";
import { NostrEvent } from "../../../types/nostr-event"; import { NostrEvent } from "../../../types/nostr-event";
@ -29,7 +29,7 @@ import HoverLinkOverlay from "../../hover-link-overlay";
export default function EmbeddedTorrent({ torrent, ...props }: Omit<CardProps, "children"> & { torrent: NostrEvent }) { export default function EmbeddedTorrent({ torrent, ...props }: Omit<CardProps, "children"> & { torrent: NostrEvent }) {
const navigate = useNavigateInDrawer(); const navigate = useNavigateInDrawer();
const link = `/torrents/${getNeventCodeWithRelays(torrent.id)}`; const link = `/torrents/${getSharableEventAddress(torrent)}`;
const handleClick = useCallback<MouseEventHandler>( const handleClick = useCallback<MouseEventHandler>(
(e) => { (e) => {

View File

@ -29,6 +29,7 @@ import accountService from "../../services/account";
import PayStep from "./pay-step"; import PayStep from "./pay-step";
import { getInvoiceFromCallbackUrl } from "../../helpers/lnurl"; import { getInvoiceFromCallbackUrl } from "../../helpers/lnurl";
import { UserLink } from "../user-link"; import { UserLink } from "../user-link";
import relayHintService from "../../services/event-relay-hint";
export type PayRequest = { invoice?: string; pubkey: string; error?: any }; export type PayRequest = { invoice?: string; pubkey: string; error?: any };
@ -69,7 +70,7 @@ async function getPayRequestForPubkey(
.map((r) => r.url) ?? [], .map((r) => r.url) ?? [],
) )
.slice(0, 4); .slice(0, 4);
const eventRelays = event ? relayScoreboardService.getRankedRelays(getEventRelays(event.id).value).slice(0, 4) : []; const eventRelays = event ? relayHintService.getEventRelayHints(event, 4) : [];
const outbox = relayScoreboardService.getRankedRelays(clientRelaysService.getWriteUrls()).slice(0, 4); const outbox = relayScoreboardService.getRankedRelays(clientRelaysService.getWriteUrls()).slice(0, 4);
const additional = relayScoreboardService.getRankedRelays(additionalRelays); const additional = relayScoreboardService.getRankedRelays(additionalRelays);

View File

@ -3,14 +3,14 @@ import { Link, LinkProps } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { truncatedId } from "../helpers/nostr/events"; import { truncatedId } from "../helpers/nostr/events";
import { getNeventCodeWithRelays } from "../helpers/nip19"; import { getNeventForEventId } from "../helpers/nip19";
export type NoteLinkProps = LinkProps & { export type NoteLinkProps = LinkProps & {
noteId: string; noteId: string;
}; };
export const NoteLink = ({ children, noteId, color = "blue.500", ...props }: NoteLinkProps) => { export const NoteLink = ({ children, noteId, color = "blue.500", ...props }: NoteLinkProps) => {
const nevent = useMemo(() => getNeventCodeWithRelays(noteId), [noteId]); const nevent = useMemo(() => getNeventForEventId(noteId), [noteId]);
return ( return (
<Link as={RouterLink} to={`/n/${nevent}`} color={color} {...props}> <Link as={RouterLink} to={`/n/${nevent}`} color={color} {...props}>

View File

@ -13,28 +13,25 @@ import {
useDisclosure, useDisclosure,
useToast, useToast,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import dayjs from "dayjs";
import type { AddressPointer } from "nostr-tools/lib/types/nip19";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event"; import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { EmbedEvent } from "../../embed-event"; import { EmbedEvent } from "../../embed-event";
import { getEventRelays } from "../../../services/event-relays";
import relayScoreboardService from "../../../services/relay-scoreboard";
import { Kind } from "nostr-tools";
import dayjs from "dayjs";
import NostrPublishAction from "../../../classes/nostr-publish-action"; import NostrPublishAction from "../../../classes/nostr-publish-action";
import clientRelaysService from "../../../services/client-relays"; import clientRelaysService from "../../../services/client-relays";
import { useSigningContext } from "../../../providers/signing-provider"; import { useSigningContext } from "../../../providers/signing-provider";
import { ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon } from "../../icons"; import { ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon } from "../../icons";
import useUserCommunitiesList from "../../../hooks/use-user-communities-list"; import useUserCommunitiesList from "../../../hooks/use-user-communities-list";
import useCurrentAccount from "../../../hooks/use-current-account"; import useCurrentAccount from "../../../hooks/use-current-account";
import { AddressPointer } from "nostr-tools/lib/types/nip19";
import { createCoordinate } from "../../../services/replaceable-event-requester"; import { createCoordinate } from "../../../services/replaceable-event-requester";
import relayHintService from "../../../services/event-relay-hint";
function buildRepost(event: NostrEvent): DraftNostrEvent { function buildRepost(event: NostrEvent): DraftNostrEvent {
const relays = getEventRelays(event.id).value; const hint = relayHintService.getEventRelayHint(event);
const topRelay = relayScoreboardService.getRankedRelays(relays)[0] ?? "";
const tags: NostrEvent["tags"] = []; const tags: NostrEvent["tags"] = [];
tags.push(["e", event.id, topRelay]); tags.push(["e", event.id, hint ?? ""]);
return { return {
kind: Kind.Repost, kind: Kind.Repost,
@ -65,6 +62,7 @@ export default function RepostModal({
draftRepost.tags.push([ draftRepost.tags.push([
"a", "a",
createCoordinate(communityPointer.kind, communityPointer.pubkey, communityPointer.identifier), createCoordinate(communityPointer.kind, communityPointer.pubkey, communityPointer.identifier),
relayHintService.getAddressPointerRelayHint(communityPointer) ?? "",
]); ]);
} }
const signed = await requestSignature(draftRepost); const signed = await requestSignature(draftRepost);

View File

@ -1,10 +1,9 @@
import { bech32 } from "bech32"; import { bech32 } from "bech32";
import { getPublicKey, nip19 } from "nostr-tools"; import { getPublicKey, nip19 } from "nostr-tools";
import { getEventRelays } from "../services/event-relays";
import relayScoreboardService from "../services/relay-scoreboard";
import { NostrEvent, Tag, isATag, isDTag, isETag, isPTag } from "../types/nostr-event"; import { NostrEvent, Tag, isATag, isDTag, isETag, isPTag } from "../types/nostr-event";
import { getEventUID, isReplaceable } from "./nostr/events"; import { isReplaceable } from "./nostr/events";
import { DecodeResult } from "nostr-tools/lib/types/nip19"; import { DecodeResult } from "nostr-tools/lib/types/nip19";
import relayHintService from "../services/event-relay-hint";
export function isHexKey(key?: string) { export function isHexKey(key?: string) {
if (key?.toLowerCase()?.match(/^[0-9a-f]{64}$/)) return true; if (key?.toLowerCase()?.match(/^[0-9a-f]{64}$/)) return true;
@ -47,7 +46,7 @@ export function safeDecode(str: string) {
} catch (e) {} } catch (e) {}
} }
export function getPubkey(result?: nip19.DecodeResult) { export function getPubkeyFromDecodeResult(result?: nip19.DecodeResult) {
if (!result) return; if (!result) return;
switch (result.type) { switch (result.type) {
case "naddr": case "naddr":
@ -68,26 +67,21 @@ export function normalizeToHex(hex: string) {
} }
export function getSharableEventAddress(event: NostrEvent) { export function getSharableEventAddress(event: NostrEvent) {
const relays = getEventRelays(getEventUID(event)).value; const relays = relayHintService.getEventRelayHints(event, 2);
const ranked = relayScoreboardService.getRankedRelays(relays);
const maxTwo = ranked.slice(0, 2);
if (isReplaceable(event.kind)) { if (isReplaceable(event.kind)) {
const d = event.tags.find(isDTag)?.[1]; const d = event.tags.find(isDTag)?.[1];
if (!d) return null; if (!d) return null;
return nip19.naddrEncode({ kind: event.kind, identifier: d, pubkey: event.pubkey, relays: maxTwo }); return nip19.naddrEncode({ kind: event.kind, identifier: d, pubkey: event.pubkey, relays });
} else { } else {
if (maxTwo.length == 2) { return nip19.neventEncode({ id: event.id, kind: event.kind, relays, author: event.pubkey });
return nip19.neventEncode({ id: event.id, kind: event.kind, relays: maxTwo });
} else return nip19.neventEncode({ id: event.id, kind: event.kind, relays: maxTwo, author: event.pubkey });
} }
} }
export function getNeventCodeWithRelays(eventId: string) { /** @deprecated use getSharableEventAddress unless required */
const relays = getEventRelays(eventId).value; export function getNeventForEventId(eventId: string, maxRelays = 2) {
const ranked = relayScoreboardService.getRankedRelays(relays); const relays = relayHintService.getEventPointerRelayHints(eventId).slice(0, maxRelays);
const maxTwo = ranked.slice(0, 2); return nip19.neventEncode({ id: eventId, relays });
return nip19.neventEncode({ id: eventId, relays: maxTwo });
} }
export function encodePointer(pointer: DecodeResult) { export function encodePointer(pointer: DecodeResult) {

View File

@ -193,30 +193,6 @@ export function parseCoordinate(a: string, requireD = false): CustomEventPointer
}; };
} }
export function draftAddCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string, relay?: string) {
if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("event already in list");
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: [...list.tags, relay ? ["a", coordinate, relay] : ["a", coordinate]],
};
return draft;
}
export function draftRemoveCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string) {
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: list.tags.filter((t) => !(t[0] === "a" && t[1] === coordinate)),
};
return draft;
}
export function parseHardcodedNoteContent(event: NostrEvent) { export function parseHardcodedNoteContent(event: NostrEvent) {
const json = safeJson(event.content, null); const json = safeJson(event.content, null);
if (!json) return null; if (!json) return null;

View File

@ -1,5 +1,6 @@
import stringify from "json-stringify-deterministic"; import stringify from "json-stringify-deterministic";
import { NostrQuery, NostrRequestFilter, RelayQueryMap } from "../../types/nostr-query"; import { NostrQuery, NostrRequestFilter, RelayQueryMap } from "../../types/nostr-query";
import localCacheRelayService, { LOCAL_CACHE_RELAY } from "../../services/local-cache-relay";
export function addQueryToFilter(filter: NostrRequestFilter, query: NostrQuery) { export function addQueryToFilter(filter: NostrRequestFilter, query: NostrQuery) {
if (Array.isArray(filter)) { if (Array.isArray(filter)) {
@ -20,6 +21,13 @@ export function mapQueryMap(queryMap: RelayQueryMap, fn: (filter: NostrRequestFi
export function createSimpleQueryMap(relays: string[], filter: NostrRequestFilter) { export function createSimpleQueryMap(relays: string[], filter: NostrRequestFilter) {
const map: RelayQueryMap = {}; const map: RelayQueryMap = {};
// if the local cache relay is enabled, also ask it
if (localCacheRelayService.enabled) {
map[LOCAL_CACHE_RELAY] = filter;
}
for (const relay of relays) map[relay] = filter; for (const relay of relays) map[relay] = filter;
return map; return map;
} }

View File

@ -149,7 +149,8 @@ export function listAddCoordinate(
coordinate: string, coordinate: string,
relay?: string, relay?: string,
): DraftNostrEvent { ): DraftNostrEvent {
if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("coordinate already in list"); if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("Event already in list");
return { return {
created_at: dayjs().unix(), created_at: dayjs().unix(),
kind: list.kind, kind: list.kind,

View File

@ -1,12 +1,11 @@
import { DraftNostrEvent, NostrEvent, Tag } from "../../types/nostr-event"; import { DraftNostrEvent, NostrEvent, Tag } from "../../types/nostr-event";
import { getMatchEmoji, getMatchHashtag } from "../regexp"; import { getMatchEmoji, getMatchHashtag } from "../regexp";
import { getReferences } from "./events"; import { getReferences } from "./events";
import { getEventRelays } from "../../services/event-relays"; import { getPubkeyFromDecodeResult, safeDecode } from "../nip19";
import relayScoreboardService from "../../services/relay-scoreboard";
import { getPubkey, safeDecode } from "../nip19";
import { Emoji } from "../../providers/emoji-provider"; import { Emoji } from "../../providers/emoji-provider";
import { EventSplit } from "./zaps"; import { EventSplit } from "./zaps";
import { unique } from "../array"; import { unique } from "../array";
import relayHintService from "../../services/event-relay-hint";
function addTag(tags: Tag[], tag: Tag, overwrite = false) { function addTag(tags: Tag[], tag: Tag, overwrite = false) {
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) { if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) {
@ -21,10 +20,9 @@ function addTag(tags: Tag[], tag: Tag, overwrite = false) {
return [...tags, tag]; return [...tags, tag];
} }
function AddEtag(tags: Tag[], eventId: string, type?: string, overwrite = false) { function AddEtag(tags: Tag[], eventId: string, type?: string, overwrite = false) {
const relays = getEventRelays(eventId).value ?? []; const hint = relayHintService.getEventPointerRelayHint(eventId) ?? "";
const top = relayScoreboardService.getRankedRelays(relays)[0] ?? "";
const tag = type ? ["e", eventId, top, type] : ["e", eventId, top]; const tag = type ? ["e", eventId, hint, type] : ["e", eventId, hint];
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1] && t[3] === tag[3])) { if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1] && t[3] === tag[3])) {
if (overwrite) { if (overwrite) {
@ -73,7 +71,7 @@ export function getContentMentions(content: string) {
Array.from(matched) Array.from(matched)
.map((m) => { .map((m) => {
const parsed = safeDecode(m[1]); const parsed = safeDecode(m[1]);
return parsed && getPubkey(parsed); return parsed && getPubkeyFromDecodeResult(parsed);
}) })
.filter(Boolean) as string[], .filter(Boolean) as string[],
); );

View File

@ -1,10 +0,0 @@
import { useMemo } from "react";
import { RelayConfig } from "../classes/relay";
import relayScoreboardService from "../services/relay-scoreboard";
export default function useRankedRelayConfigs(relays: RelayConfig[]) {
return useMemo(() => {
const rankedUrls = relayScoreboardService.getRankedRelays(relays.map((r) => r.url));
return rankedUrls.map((u) => relays.find((r) => r.url === u) as RelayConfig);
}, [relays.join("|")]);
}

View File

@ -1,7 +1,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import relayScoreboardService from "../services/relay-scoreboard";
import { RelayMode } from "../classes/relay";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { RelayMode } from "../classes/relay";
import relayScoreboardService from "../services/relay-scoreboard";
import { useUserRelays } from "./use-user-relays"; import { useUserRelays } from "./use-user-relays";
export function useSharableProfileId(pubkey: string, relayCount = 2) { export function useSharableProfileId(pubkey: string, relayCount = 2) {

View File

@ -2,8 +2,7 @@ import "./polyfill";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./app"; import { App } from "./app";
import { GlobalProviders } from "./providers"; import { GlobalProviders } from "./providers";
import "./services/local-cache-relay";
import "./services/serial-port";
// setup dayjs // setup dayjs
import dayjs from "dayjs"; import dayjs from "dayjs";

View File

@ -12,6 +12,7 @@ import { logger } from "../helpers/debug";
import db from "./db"; import db from "./db";
import createDefer, { Deferred } from "../classes/deferred"; import createDefer, { Deferred } from "../classes/deferred";
import { getChannelPointer } from "../helpers/nostr/channel"; import { getChannelPointer } from "../helpers/nostr/channel";
import localCacheRelayService, { LOCAL_CACHE_RELAY } from "./local-cache-relay";
type Pubkey = string; type Pubkey = string;
type Relay = string; type Relay = string;
@ -25,6 +26,8 @@ export type RequestOptions = {
// keepAlive?: boolean; // keepAlive?: boolean;
}; };
const RELAY_REQUEST_BATCH_TIME = 1000;
/** This class is ued to batch requests to a single relay */ /** This class is ued to batch requests to a single relay */
class ChannelMetadataRelayLoader { class ChannelMetadataRelayLoader {
private subscription: NostrSubscription; private subscription: NostrSubscription;
@ -78,7 +81,7 @@ class ChannelMetadataRelayLoader {
return subject; return subject;
} }
updateThrottle = _throttle(this.update, 1000); updateThrottle = _throttle(this.update, RELAY_REQUEST_BATCH_TIME);
update() { update() {
let needsUpdate = false; let needsUpdate = false;
for (const channelId of this.requestNext) { for (const channelId of this.requestNext) {
@ -119,6 +122,9 @@ class ChannelMetadataRelayLoader {
} }
} }
const READ_CACHE_BATCH_TIME = 250;
const WRITE_CACHE_BATCH_TIME = 250;
/** This is a clone of ReplaceableEventLoaderService to support channel metadata */ /** This is a clone of ReplaceableEventLoaderService to support channel metadata */
class ChannelMetadataService { class ChannelMetadataService {
private metadata = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>()); private metadata = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
@ -147,7 +153,7 @@ class ChannelMetadataService {
} }
private readFromCachePromises = new Map<string, Deferred<boolean>>(); private readFromCachePromises = new Map<string, Deferred<boolean>>();
private readFromCacheThrottle = _throttle(this.readFromCache, 1000); private readFromCacheThrottle = _throttle(this.readFromCache, READ_CACHE_BATCH_TIME);
private async readFromCache() { private async readFromCache() {
if (this.readFromCachePromises.size === 0) return; if (this.readFromCachePromises.size === 0) return;
@ -187,7 +193,7 @@ class ChannelMetadataService {
} }
private writeCacheQueue = new Map<string, NostrEvent>(); private writeCacheQueue = new Map<string, NostrEvent>();
private writeToCacheThrottle = _throttle(this.writeToCache, 1000); private writeToCacheThrottle = _throttle(this.writeToCache, WRITE_CACHE_BATCH_TIME);
private async writeToCache() { private async writeToCache() {
if (this.writeCacheQueue.size === 0) return; if (this.writeCacheQueue.size === 0) return;
@ -224,7 +230,11 @@ class ChannelMetadataService {
private requestChannelMetadataFromRelays(relays: string[], channelId: string) { private requestChannelMetadataFromRelays(relays: string[], channelId: string) {
const sub = this.metadata.get(channelId); const sub = this.metadata.get(channelId);
for (const relay of relays) { const relayUrls = Array.from(relays);
if (localCacheRelayService.enabled) {
relayUrls.unshift(LOCAL_CACHE_RELAY);
}
for (const relay of relayUrls) {
const request = this.loaders.get(relay).requestMetadata(channelId); const request = this.loaders.get(relay).requestMetadata(channelId);
sub.connectWithHandler(request, (event, next, current) => { sub.connectWithHandler(request, (event, next, current) => {

View File

@ -8,6 +8,7 @@ import relayScoreboardService from "./relay-scoreboard";
import { logger } from "../helpers/debug"; import { logger } from "../helpers/debug";
import { matchFilter, matchFilters } from "nostr-tools"; import { matchFilter, matchFilters } from "nostr-tools";
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import localCacheRelayService, { LOCAL_CACHE_RELAY } from "./local-cache-relay";
function hashFilter(filter: NostrRequestFilter) { function hashFilter(filter: NostrRequestFilter) {
// const encoder = new TextEncoder(); // const encoder = new TextEncoder();
@ -41,7 +42,10 @@ class EventExistsService {
if (!this.filters.has(key)) this.filters.set(key, filter); if (!this.filters.has(key)) this.filters.set(key, filter);
if (sub.value !== true) { if (sub.value !== true) {
for (const url of relays) { const relayUrls = Array.from(relays);
if (localCacheRelayService.enabled) relayUrls.unshift(LOCAL_CACHE_RELAY);
for (const url of relayUrls) {
if (!asked.has(url) && !pending.has(url)) { if (!asked.has(url) && !pending.has(url)) {
pending.add(url); pending.add(url);
} }

View File

@ -0,0 +1,49 @@
import { NostrEvent } from "../types/nostr-event";
import { getEventRelays } from "./event-relays";
import relayScoreboardService from "./relay-scoreboard";
import type { AddressPointer, EventPointer } from "nostr-tools/lib/types/nip19";
import { createCoordinate } from "./replaceable-event-requester";
function pickBestRelays(relays: string[]) {
// ignore local relays
relays = relays.filter((url) => !url.includes("://localhost") && !url.includes("://192.168"));
return relayScoreboardService.getRankedRelays(relays);
}
function getAddressPointerRelayHint(pointer: AddressPointer): string | undefined {
let relays = getEventRelays(createCoordinate(pointer.kind, pointer.pubkey, pointer.identifier)).value;
return pickBestRelays(relays)[0];
}
function getEventPointerRelayHints(pointerOrId: string | EventPointer): string[] {
let relays =
typeof pointerOrId === "string" ? getEventRelays(pointerOrId).value : getEventRelays(pointerOrId.id).value;
return pickBestRelays(relays);
}
function getEventPointerRelayHint(pointerOrId: string | EventPointer): string | undefined {
return getEventPointerRelayHints(pointerOrId)[0];
}
function getEventRelayHint(event: NostrEvent): string | undefined {
return getEventRelayHints(event, 1)[0];
}
function getEventRelayHints(event: NostrEvent, count = 2): string[] {
// NOTE: in the future try to use the events authors relays
let relays = getEventRelays(event.id).value;
return pickBestRelays(relays).slice(0, count);
}
const relayHintService = {
getEventRelayHints,
getEventRelayHint,
getEventPointerRelayHint,
getEventPointerRelayHints,
getAddressPointerRelayHint,
pickBestRelays,
};
export default relayHintService;

View File

@ -0,0 +1,62 @@
import { logger } from "../helpers/debug";
import { NostrEvent } from "../types/nostr-event";
import relayPoolService from "./relay-pool";
import _throttle from "lodash.throttle";
const enabled = !!localStorage.getItem("enable-cache-relay");
export const LOCAL_CACHE_RELAY = "ws://localhost:7000";
const wroteEvents = new Set<string>();
const writeQueue: NostrEvent[] = [];
const BATCH_WRITE = 100;
const log = logger.extend(`LocalCacheRelay`);
async function flush() {
for (let i = 0; i < BATCH_WRITE; i++) {
const e = writeQueue.pop();
if (!e) continue;
relayPoolService.requestRelay(LOCAL_CACHE_RELAY).send(["EVENT", e]);
}
}
function report() {
if (writeQueue.length) {
log(`${writeQueue.length} events in write queue`);
}
}
function addToQueue(e: NostrEvent) {
if (!enabled) return;
if (!wroteEvents.has(e.id)) {
wroteEvents.add(e.id);
writeQueue.push(e);
}
}
if (enabled) {
log("Enabled");
relayPoolService.onRelayCreated.subscribe((relay) => {
if (relay.url !== LOCAL_CACHE_RELAY) {
relay.onEvent.subscribe((incomingEvent) => addToQueue(incomingEvent.body));
}
});
}
const localCacheRelayService = {
enabled,
addToQueue,
};
setInterval(() => {
if (enabled) flush();
}, 1000);
setInterval(() => {
if (enabled) report();
}, 1000 * 10);
if (import.meta.env.DEV) {
//@ts-ignore
window.localCacheRelayService = localCacheRelayService;
}
export default localCacheRelayService;

View File

@ -12,6 +12,7 @@ import db from "./db";
import { nameOrPubkey } from "./user-metadata"; import { nameOrPubkey } from "./user-metadata";
import { getEventCoordinate } from "../helpers/nostr/events"; import { getEventCoordinate } from "../helpers/nostr/events";
import createDefer, { Deferred } from "../classes/deferred"; import createDefer, { Deferred } from "../classes/deferred";
import localCacheRelayService, { LOCAL_CACHE_RELAY } from "./local-cache-relay";
type Pubkey = string; type Pubkey = string;
type Relay = string; type Relay = string;
@ -32,6 +33,8 @@ export function createCoordinate(kind: number, pubkey: string, d?: string) {
return `${kind}:${pubkey}${d ? ":" + d : ""}`; return `${kind}:${pubkey}${d ? ":" + d : ""}`;
} }
const RELAY_REQUEST_BATCH_TIME = 1000;
/** This class is ued to batch requests to a single relay */ /** This class is ued to batch requests to a single relay */
class ReplaceableEventRelayLoader { class ReplaceableEventRelayLoader {
private subscription: NostrSubscription; private subscription: NostrSubscription;
@ -85,7 +88,7 @@ class ReplaceableEventRelayLoader {
return event; return event;
} }
updateThrottle = _throttle(this.update, 1000); updateThrottle = _throttle(this.update, RELAY_REQUEST_BATCH_TIME);
update() { update() {
let needsUpdate = false; let needsUpdate = false;
for (const cord of this.requestNext) { for (const cord of this.requestNext) {
@ -144,6 +147,9 @@ class ReplaceableEventRelayLoader {
} }
} }
const READ_CACHE_BATCH_TIME = 250;
const WRITE_CACHE_BATCH_TIME = 250;
class ReplaceableEventLoaderService { class ReplaceableEventLoaderService {
private events = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>()); private events = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
@ -170,7 +176,7 @@ class ReplaceableEventLoaderService {
} }
private readFromCachePromises = new Map<string, Deferred<boolean>>(); private readFromCachePromises = new Map<string, Deferred<boolean>>();
private readFromCacheThrottle = _throttle(this.readFromCache, 1000); private readFromCacheThrottle = _throttle(this.readFromCache, READ_CACHE_BATCH_TIME);
private async readFromCache() { private async readFromCache() {
if (this.readFromCachePromises.size === 0) return; if (this.readFromCachePromises.size === 0) return;
@ -210,7 +216,7 @@ class ReplaceableEventLoaderService {
} }
private writeCacheQueue = new Map<string, NostrEvent>(); private writeCacheQueue = new Map<string, NostrEvent>();
private writeToCacheThrottle = _throttle(this.writeToCache, 1000); private writeToCacheThrottle = _throttle(this.writeToCache, WRITE_CACHE_BATCH_TIME);
private async writeToCache() { private async writeToCache() {
if (this.writeCacheQueue.size === 0) return; if (this.writeCacheQueue.size === 0) return;
@ -248,7 +254,10 @@ class ReplaceableEventLoaderService {
const cord = createCoordinate(kind, pubkey, d); const cord = createCoordinate(kind, pubkey, d);
const sub = this.events.get(cord); const sub = this.events.get(cord);
for (const relay of relays) { const relayUrls = Array.from(relays);
if (localCacheRelayService.enabled) relayUrls.unshift(LOCAL_CACHE_RELAY);
for (const relay of relayUrls) {
const request = this.loaders.get(relay).requestEvent(kind, pubkey, d); const request = this.loaders.get(relay).requestEvent(kind, pubkey, d);
sub.connectWithHandler(request, (event, next, current) => { sub.connectWithHandler(request, (event, next, current) => {

View File

@ -5,6 +5,9 @@ import Subject from "../classes/subject";
import SuperMap from "../classes/super-map"; import SuperMap from "../classes/super-map";
import { safeRelayUrls } from "../helpers/url"; import { safeRelayUrls } from "../helpers/url";
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import localCacheRelayService, { LOCAL_CACHE_RELAY } from "./local-cache-relay";
const RELAY_REQUEST_BATCH_TIME = 1000;
class SingleEventService { class SingleEventService {
private cache = new SuperMap<string, Subject<NostrEvent>>(() => new Subject()); private cache = new SuperMap<string, Subject<NostrEvent>>(() => new Subject());
@ -14,7 +17,9 @@ class SingleEventService {
const subject = this.cache.get(id); const subject = this.cache.get(id);
if (subject.value) return subject; if (subject.value) return subject;
this.pending.set(id, this.pending.get(id)?.concat(safeRelayUrls(relays)) ?? safeRelayUrls(relays)); const newUrls = safeRelayUrls(relays);
if (localCacheRelayService.enabled) newUrls.push(LOCAL_CACHE_RELAY);
this.pending.set(id, this.pending.get(id)?.concat(newUrls) ?? newUrls);
this.batchRequestsThrottle(); this.batchRequestsThrottle();
return subject; return subject;
@ -24,7 +29,7 @@ class SingleEventService {
this.cache.get(event.id).next(event); this.cache.get(event.id).next(event);
} }
private batchRequestsThrottle = _throttle(this.batchRequests, 1000 * 2); private batchRequestsThrottle = _throttle(this.batchRequests, RELAY_REQUEST_BATCH_TIME);
batchRequests() { batchRequests() {
if (this.pending.size === 0) return; if (this.pending.size === 0) return;

View File

@ -8,6 +8,8 @@ import SuperMap from "../classes/super-map";
import Subject from "../classes/subject"; import Subject from "../classes/subject";
import replaceableEventLoaderService, { RequestOptions } from "./replaceable-event-requester"; import replaceableEventLoaderService, { RequestOptions } from "./replaceable-event-requester";
const WRITE_USER_SEARCH_BATCH_TIME = 500;
class UserMetadataService { class UserMetadataService {
private parsedSubjects = new SuperMap<string, Subject<Kind0ParsedContent>>((pubkey) => { private parsedSubjects = new SuperMap<string, Subject<Kind0ParsedContent>>((pubkey) => {
const sub = new Subject<Kind0ParsedContent>(); const sub = new Subject<Kind0ParsedContent>();
@ -34,7 +36,7 @@ class UserMetadataService {
} }
private writeSearchQueue = new Set<string>(); private writeSearchQueue = new Set<string>();
private writeSearchDataThrottle = _throttle(this.writeSearchData.bind(this)); private writeSearchDataThrottle = _throttle(this.writeSearchData.bind(this), WRITE_USER_SEARCH_BATCH_TIME);
private async writeSearchData() { private async writeSearchData() {
if (this.writeSearchQueue.size === 0) return; if (this.writeSearchQueue.size === 0) return;

View File

@ -3,7 +3,7 @@ import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
import useReplaceableEvent from "../../hooks/use-replaceable-event"; import useReplaceableEvent from "../../hooks/use-replaceable-event";
import { Spinner } from "@chakra-ui/react"; import { Spinner } from "@chakra-ui/react";
import CommunityHomePage from "./community-home"; import CommunityHomePage from "./community-home";
import { getPubkey, isHexKey, safeDecode } from "../../helpers/nip19"; import { getPubkeyFromDecodeResult, isHexKey, safeDecode } from "../../helpers/nip19";
function useCommunityPointer() { function useCommunityPointer() {
const { community, pubkey } = useParams(); const { community, pubkey } = useParams();
@ -12,7 +12,7 @@ function useCommunityPointer() {
if (decoded) { if (decoded) {
if (decoded.type === "naddr" && decoded.data.kind === COMMUNITY_DEFINITION_KIND) return decoded.data; if (decoded.type === "naddr" && decoded.data.kind === COMMUNITY_DEFINITION_KIND) return decoded.data;
} else if (community && pubkey) { } else if (community && pubkey) {
const hexPubkey = isHexKey(pubkey) ? pubkey : getPubkey(safeDecode(pubkey)); const hexPubkey = isHexKey(pubkey) ? pubkey : getPubkeyFromDecodeResult(safeDecode(pubkey));
if (!hexPubkey) return; if (!hexPubkey) return;
return { kind: COMMUNITY_DEFINITION_KIND, pubkey: hexPubkey, identifier: community }; return { kind: COMMUNITY_DEFINITION_KIND, pubkey: hexPubkey, identifier: community };

View File

@ -4,13 +4,14 @@ import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event"; import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { StarEmptyIcon, StarFullIcon } from "../../../components/icons"; import { StarEmptyIcon, StarFullIcon } from "../../../components/icons";
import { draftAddCoordinate, draftRemoveCoordinate, getEventCoordinate } from "../../../helpers/nostr/events"; import { getEventCoordinate } from "../../../helpers/nostr/events";
import { useSigningContext } from "../../../providers/signing-provider"; import { useSigningContext } from "../../../providers/signing-provider";
import NostrPublishAction from "../../../classes/nostr-publish-action"; import NostrPublishAction from "../../../classes/nostr-publish-action";
import clientRelaysService from "../../../services/client-relays"; import clientRelaysService from "../../../services/client-relays";
import replaceableEventLoaderService from "../../../services/replaceable-event-requester"; import replaceableEventLoaderService from "../../../services/replaceable-event-requester";
import { USER_EMOJI_LIST_KIND } from "../../../helpers/nostr/emoji-packs"; import { USER_EMOJI_LIST_KIND } from "../../../helpers/nostr/emoji-packs";
import useFavoriteEmojiPacks from "../../../hooks/use-favorite-emoji-packs"; import useFavoriteEmojiPacks from "../../../hooks/use-favorite-emoji-packs";
import { listAddCoordinate, listRemoveCoordinate } from "../../../helpers/nostr/lists";
export default function EmojiPackFavoriteButton({ export default function EmojiPackFavoriteButton({
pack, pack,
@ -33,7 +34,7 @@ export default function EmojiPackFavoriteButton({
try { try {
setLoading(true); setLoading(true);
const draft = isFavorite ? draftRemoveCoordinate(prev, coordinate) : draftAddCoordinate(prev, coordinate); const draft = isFavorite ? listRemoveCoordinate(prev, coordinate) : listAddCoordinate(prev, coordinate);
const signed = await requestSignature(draft); const signed = await requestSignature(draft);
const pub = new NostrPublishAction( const pub = new NostrPublishAction(
isFavorite ? "Unfavorite Emoji pack" : "Favorite emoji pack", isFavorite ? "Unfavorite Emoji pack" : "Favorite emoji pack",

View File

@ -4,13 +4,13 @@ import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event"; import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { StarEmptyIcon, StarFullIcon } from "../../../components/icons"; import { StarEmptyIcon, StarFullIcon } from "../../../components/icons";
import { draftAddCoordinate, draftRemoveCoordinate, getEventCoordinate } from "../../../helpers/nostr/events"; import { getEventCoordinate } from "../../../helpers/nostr/events";
import { useSigningContext } from "../../../providers/signing-provider"; import { useSigningContext } from "../../../providers/signing-provider";
import NostrPublishAction from "../../../classes/nostr-publish-action"; import NostrPublishAction from "../../../classes/nostr-publish-action";
import clientRelaysService from "../../../services/client-relays"; import clientRelaysService from "../../../services/client-relays";
import replaceableEventLoaderService from "../../../services/replaceable-event-requester"; import replaceableEventLoaderService from "../../../services/replaceable-event-requester";
import useFavoriteLists, { FAVORITE_LISTS_IDENTIFIER } from "../../../hooks/use-favorite-lists"; import useFavoriteLists, { FAVORITE_LISTS_IDENTIFIER } from "../../../hooks/use-favorite-lists";
import { NOTE_LIST_KIND, isSpecialListKind } from "../../../helpers/nostr/lists"; import { NOTE_LIST_KIND, isSpecialListKind, listAddCoordinate, listRemoveCoordinate } from "../../../helpers/nostr/lists";
export default function ListFavoriteButton({ export default function ListFavoriteButton({
list, list,
@ -38,7 +38,7 @@ export default function ListFavoriteButton({
try { try {
setLoading(true); setLoading(true);
const draft = isFavorite ? draftRemoveCoordinate(prev, coordinate) : draftAddCoordinate(prev, coordinate); const draft = isFavorite ? listRemoveCoordinate(prev, coordinate) : listAddCoordinate(prev, coordinate);
const signed = await requestSignature(draft); const signed = await requestSignature(draft);
const pub = new NostrPublishAction("Favorite list", clientRelaysService.getWriteUrls(), signed); const pub = new NostrPublishAction("Favorite list", clientRelaysService.getWriteUrls(), signed);
replaceableEventLoaderService.handleEvent(signed); replaceableEventLoaderService.handleEvent(signed);

View File

@ -36,7 +36,7 @@ import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon"
import NoteProxyLink from "../../../components/note/components/note-proxy-link"; import NoteProxyLink from "../../../components/note/components/note-proxy-link";
import { NoteDetailsButton } from "../../../components/note/components/note-details-button"; import { NoteDetailsButton } from "../../../components/note/components/note-details-button";
import EventInteractionDetailsModal from "../../../components/event-interactions-modal"; import EventInteractionDetailsModal from "../../../components/event-interactions-modal";
import { getNeventCodeWithRelays } from "../../../helpers/nip19"; import { getSharableEventAddress } from "../../../helpers/nip19";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import useAppSettings from "../../../hooks/use-app-settings"; import useAppSettings from "../../../hooks/use-app-settings";
import useThreadColorLevelProps from "../../../hooks/use-thread-color-level-props"; import useThreadColorLevelProps from "../../../hooks/use-thread-color-level-props";
@ -80,7 +80,7 @@ export const ThreadPost = memo(({ post, initShowReplies, focusId, level = -1 }:
<UserAvatarLink pubkey={post.event.pubkey} size="sm" /> <UserAvatarLink pubkey={post.event.pubkey} size="sm" />
<UserLink pubkey={post.event.pubkey} fontWeight="bold" isTruncated /> <UserLink pubkey={post.event.pubkey} fontWeight="bold" isTruncated />
<UserDnsIdentityIcon pubkey={post.event.pubkey} onlyIcon /> <UserDnsIdentityIcon pubkey={post.event.pubkey} onlyIcon />
<Link as={RouterLink} whiteSpace="nowrap" color="current" to={`/n/${getNeventCodeWithRelays(post.event.id)}`}> <Link as={RouterLink} whiteSpace="nowrap" color="current" to={`/n/${getSharableEventAddress(post.event)}`}>
<Timestamp timestamp={post.event.created_at} /> <Timestamp timestamp={post.event.created_at} />
</Link> </Link>
{replies.length > 0 ? ( {replies.length > 0 ? (

View File

@ -13,13 +13,28 @@ import {
Input, Input,
Link, Link,
FormErrorMessage, FormErrorMessage,
Code,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
useDisclosure,
Text,
Heading,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { safeUrl } from "../../helpers/parse"; import { safeUrl } from "../../helpers/parse";
import { AppSettings } from "../../services/settings/migrations"; import { AppSettings } from "../../services/settings/migrations";
import { PerformanceIcon } from "../../components/icons"; import { PerformanceIcon } from "../../components/icons";
import { useLocalStorage } from "react-use";
import { LOCAL_CACHE_RELAY } from "../../services/local-cache-relay";
export default function PerformanceSettings() { export default function PerformanceSettings() {
const { register, formState } = useFormContext<AppSettings>(); const { register, formState } = useFormContext<AppSettings>();
const [localCacheRelay, setLocalCacheRelay] = useLocalStorage<boolean>("enable-cache-relay");
const cacheDetails = useDisclosure();
return ( return (
<AccordionItem> <AccordionItem>
@ -95,6 +110,58 @@ export default function PerformanceSettings() {
</Flex> </Flex>
<FormHelperText>Enabled: show signature verification on notes</FormHelperText> <FormHelperText>Enabled: show signature verification on notes</FormHelperText>
</FormControl> </FormControl>
<FormControl>
<Flex alignItems="center">
<FormLabel htmlFor="localCacheRelay" mb="0">
Local Cache Relay
</FormLabel>
<Switch
id="localCacheRelay"
isChecked={localCacheRelay}
onChange={(e) => setLocalCacheRelay(e.target.checked)}
/>
<Button onClick={cacheDetails.onOpen} variant="link" ml="4">
Details
</Button>
</Flex>
<FormHelperText>Enabled: Use a local relay as a caching service</FormHelperText>
<Modal isOpen={cacheDetails.isOpen} onClose={cacheDetails.onClose} size="4xl">
<ModalOverlay />
<ModalContent>
<ModalHeader p="4">Local cache relay</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" pb="4" pt="0">
<Text>
When this option is enabled noStrudel will mirror every event it sees to the relay. It will also try
to load as much data from the relay first before reaching out to other relays.
</Text>
<Text>
For security reasons noStrudel will only use <Code>ws://localhost:7000</Code> as the cache relay.
</Text>
<Heading size="md" mt="2">
Linux setup instructions
</Heading>
<Text>
You can run a local relay using{" "}
<Link href="https://www.docker.com/get-started/" isExternal>
docker
</Link>{" "}
and{" "}
<Link href="https://hub.docker.com/r/scsibug/nostr-rs-relay" isExternal>
nostr-rs-relay
</Link>
</Text>
<Text mt="2">1. Create a folder for the data</Text>
<Code>mkdir ~/.nostr-relay/data -p -m 777</Code>
<Text mt="2">2. Start the relay</Text>
<Code>
docker run --rm -it -p 7000:8080 -v ~/.nostr-relay/data:/usr/src/app/db scsibug/nostr-rs-relay
</Code>
</ModalBody>
</ModalContent>
</Modal>
</FormControl>
</Flex> </Flex>
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>

View File

@ -7,7 +7,7 @@ import { NostrEvent } from "../../../types/nostr-event";
import Timestamp from "../../../components/timestamp"; import Timestamp from "../../../components/timestamp";
import { UserLink } from "../../../components/user-link"; import { UserLink } from "../../../components/user-link";
import Magnet from "../../../components/icons/magnet"; import Magnet from "../../../components/icons/magnet";
import { getNeventCodeWithRelays } from "../../../helpers/nip19"; import { getNeventForEventId } from "../../../helpers/nip19";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { getEventUID } from "../../../helpers/nostr/events"; import { getEventUID } from "../../../helpers/nostr/events";
import { formatBytes } from "../../../helpers/number"; import { formatBytes } from "../../../helpers/number";
@ -58,7 +58,7 @@ function TorrentTableRow({ torrent }: { torrent: NostrEvent }) {
))} ))}
</Td> </Td>
<Td> <Td>
<Link as={RouterLink} to={`/torrents/${getNeventCodeWithRelays(torrent.id)}`} isTruncated maxW="lg"> <Link as={RouterLink} to={`/torrents/${getNeventForEventId(torrent.id)}`} isTruncated maxW="lg">
{getTorrentTitle(torrent)} {getTorrentTitle(torrent)}
</Link> </Link>
</Td> </Td>