Merge remote-tracking branch 'kieran/main'
continuous-integration/drone/pr Build is failing Details

This commit is contained in:
Martti Malmi 2023-11-26 15:52:50 +02:00
commit c2bd6ae856
69 changed files with 761 additions and 437 deletions

View File

@ -75,7 +75,7 @@ jobs:
run: |-
git clone --depth 1 --branch ${{ github.ref_name }} https://git.v0l.io/Kieran/snort_android.git
mkdir -p snort_android/app/src/main/assets/
cp packages/app/build/* snort_android/app/src/main/assets/
cp -r packages/app/build/* snort_android/app/src/main/assets/
- name: Build AAB
working-directory: snort_android

View File

@ -4,4 +4,5 @@ build/
.github/
transifex.yml
dist/
src-tauri/
src-tauri/
target/

View File

@ -1,3 +1,55 @@
# v0.1.23
## Added
- DeepL translate api (Automatic for PRO subscribers)
- Add nostr:nprofile1qqsydl97xpj74udw0qg5vkfyujyjxd3l706jd0t0w0turp93d0vvungfgfewr to contributors
- Proxy LN address type enabled on Nostr Address settings pages
- Infinite scrol on notifications page
- Default 0.5% ZapPool rate for Snort donation address
- Collect relay metrics in `@snort/system` for better relay selection algo in Outbox Model (NIP-65)
- New sign up / login flow!
- Topics / Mute words on sign up for easier onboarding
- Drag & Drop for uploads on note creator - nostr:nprofile1qqs8tchhwf5smv3r2g0vkswz58c837uu456x59m3dh380gtrhqzydeqz4wlka
- Mixin topics (hashtags) into timeline feed
- Language specific trending posts
- Show following info for hashtags
- Sync preferences to network (`NIP-78` support)
- Trending hashtags page
- Note creator hashtag input
- Top trending hashtags on note creator
- Social Graph - nostr:nprofile1qqsy2ga7trfetvd3j65m3jptqw9k39wtq2mg85xz2w542p5dhg06e5qpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uh8am0r
- New users relay list based off "close" relays
- `NIP-96` support for nostr native image/file uploaders
- Write replies/reactions to `p` tagged users read relays (Outbox model)
- Sync joined public chats (`NIP-28`) using `PublicChatList` kind `10_005`
## Changed
- Read/Write relays only on kind `10_002` (NIP-65)
- Removed `nostr.watch` code for adding new users to random relays
- Render kind `10_002` on profile relays tab
- `@snort/system` using eventemitter3 for triggering events
- Use latest `NIP-51` spec (Bookmarks/Interests/`NIP-28` PublicChatList)
- `nreq` support (Demo)
- Write profile/relays to blasters
- `@snort/system` automated outbox model (automatic fetching of relay metadata)
## Fixes
- Upgrade ephermal connection to non-ephemeral
- Remove relay tag from zaps (Some zap services dont support it)
- Fix zap parsing for goals
- Remove extra chars from quoted events to fix loading (`'s` etc)
- CSS Fixes for profile card on light theme
- Zap counting on replacable events
- `NIP-28` chats loading
- Overflowing modal UI
- Live stream widget layout with long titles
- Notifications marker has returned from its long slumber
---
# v0.1.22
## Fixes

View File

@ -1,6 +1,6 @@
{
"name": "@snort/app",
"version": "0.1.22",
"version": "0.1.23",
"dependencies": {
"@cashu/cashu-ts": "^0.6.1",
"@lightninglabs/lnc-web": "^0.2.3-alpha",
@ -35,7 +35,6 @@
"react-router-dom": "^6.5.0",
"react-tag-input-component": "^2.0.2",
"react-textarea-autosize": "^8.4.0",
"react-twitter-embed": "^4.0.4",
"recharts": "^2.8.0",
"three": "^0.157.0",
"use-long-press": "^3.2.0",

View File

@ -93,11 +93,6 @@ export const InvoiceRegex = /(lnbc\w+)/i;
export const YoutubeUrlRegex =
/(?:https?:\/\/)?(?:www|m\.)?(?:youtu\.be\/|youtube\.com\/(?:live\/|shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
/**
* Tweet Regex
*/
export const TweetUrlRegex = /https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/;
/**
* Hashtag regex
*/

View File

@ -51,7 +51,7 @@ export default function Note(props: NoteProps) {
if (ev.kind === EventKind.ZapstrTrack) {
return <ZapstrEmbed ev={ev} />;
}
if (ev.kind === EventKind.CategorizedPeople || ev.kind === EventKind.ContactList) {
if (ev.kind === EventKind.FollowSet || ev.kind === EventKind.ContactList) {
return <PubkeyList ev={ev} className={className} />;
}
if (ev.kind === EventKind.LiveEvent) {

View File

@ -1,8 +1,5 @@
// import { TwitterTweetEmbed } from "react-twitter-embed";
import {
YoutubeUrlRegex,
//TweetUrlRegex,
TidalRegex,
SoundCloudRegex,
MixCloudRegex,
@ -37,7 +34,6 @@ export default function HyperText({ link, depth, showLinkPreview, children }: Hy
try {
const url = new URL(a);
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
//const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
const tidalId = TidalRegex.test(a) && RegExp.$1;
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
@ -46,13 +42,8 @@ export default function HyperText({ link, depth, showLinkPreview, children }: Hy
const isAppleMusicLink = AppleMusicRegex.test(a);
const isNostrNestsLink = NostrNestsRegex.test(a);
const isWavlakeLink = WavlakeRegex.test(a);
/*if (tweetId) { // tmp disabled, react-twitter-embed causes "require is not defined" error
return (
<div className="tweet" key={tweetId}>
<TwitterTweetEmbed tweetId={tweetId} />
</div>
);
} else*/ if (youtubeId) {
if (youtubeId) {
return (
<iframe
className="w-max"

View File

@ -16,7 +16,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
switch (status) {
case "live": {
return (
<div className="flex g4">
<div className="flex g4 items-center">
<Icon name="signal-01" />
<b className="uppercase">
<FormattedMessage defaultMessage="Live" id="Dn82AL" />
@ -49,7 +49,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
case "live": {
return (
<Link to={link} target="_blank">
<button type="button">
<button className="nowrap">
<FormattedMessage defaultMessage="Join Stream" id="GQPtfk" />
</button>
</Link>
@ -59,7 +59,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
if (findTag(ev, "recording")) {
return (
<Link to={link} target="_blank">
<button type="button">
<button className="nowrap">
<FormattedMessage defaultMessage="Watch Replay" id="6/hB3S" />
</button>
</Link>
@ -70,13 +70,13 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
}
return (
<div className="flex justify-between br p24 bg-primary">
<div className="flex g12">
<div className="sm:flex g12 br p24 bg-primary items-center">
<div>
<ProfileImage pubkey={host} showUsername={false} size={56} />
<div>
<h2>{title}</h2>
{statusLine()}
</div>
</div>
<div className="flex flex-col g8 grow">
<div className="font-semibold text-3xl">{title}</div>
<div>{statusLine()}</div>
</div>
<div>{cta()}</div>
</div>

View File

@ -20,6 +20,7 @@
margin-top: auto;
margin-bottom: auto;
--border-color: var(--gray);
max-width: 100%;
}
@media (min-width: 600px) {
@ -28,6 +29,11 @@
width: 600px;
}
}
@media (max-width: 600px) {
.modal-body {
min-width: 100%;
}
}
.modal-body button.secondary:hover {
background-color: var(--gray);

View File

@ -16,7 +16,7 @@ enum Provider {
}
export default function SuggestedProfiles() {
const login = useLogin();
const login = useLogin(s => ({ publicKey: s.publicKey, follows: s.follows.item }));
const [userList, setUserList] = useState<HexKey[]>();
const [provider, setProvider] = useState(Provider.NostrBand);
const [error, setError] = useState<Error>();
@ -37,7 +37,7 @@ export default function SuggestedProfiles() {
}
case Provider.SemisolDev: {
const api = new SemisolDevApi();
const users = await api.sugguestedFollows(login.publicKey, login.follows.item);
const users = await api.sugguestedFollows(login.publicKey, login.follows);
const keys = users.recommendations.sort(a => a[1]).map(a => a[0]);
setUserList(keys);
break;
@ -52,7 +52,7 @@ export default function SuggestedProfiles() {
useEffect(() => {
loadSuggestedProfiles();
}, [login, provider]);
}, [login.publicKey, login.follows, provider]);
return (
<>

View File

@ -1,13 +1,12 @@
import "./ProfileImage.css";
import React, { ReactNode, useCallback, useRef, useState } from "react";
import { HexKey, socialGraphInstance, UserMetadata } from "@snort/system";
import { HexKey, UserMetadata } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import classNames from "classnames";
import Avatar from "@/Element/User/Avatar";
import Nip05 from "@/Element/User/Nip05";
import Icon from "@/Icons/Icon";
import DisplayName from "./DisplayName";
import { ProfileLink } from "./ProfileLink";
import { ProfileCard } from "./ProfileCard";

View File

@ -1,13 +1,12 @@
import { useEffect, useMemo } from "react";
import { TaggedNostrEvent, EventKind, RequestBuilder, NoteCollection, NostrLink } from "@snort/system";
import { TaggedNostrEvent, EventKind, RequestBuilder, NoteCollection, NostrLink, parseRelayTags } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { bech32ToHex, debounce, findTag, getNewest, getNewestEventTagsByKey, unwrap } from "@/SnortUtils";
import { makeNotification, sendNotification } from "@/Notifications";
import { bech32ToHex, debounce, getNewest, getNewestEventTagsByKey, unwrap } from "@/SnortUtils";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useModeration from "@/Hooks/useModeration";
import useLogin from "@/Hooks/useLogin";
import {
LoginStore,
SnortAppData,
addSubscription,
setAppData,
@ -21,18 +20,18 @@ import {
} from "@/Login";
import { SnortPubKey } from "@/Const";
import { SubscriptionEvent } from "@/Subscription";
import useRelaysFeedFollows from "./RelaysFeedFollows";
import { FollowLists, FollowsFeed, GiftsCache, Notifications, UserRelays } from "@/Cache";
import { Nip28Chats, Nip4Chats } from "@/chat";
import { useRefreshFeedCache } from "@/Hooks/useRefreshFeedcache";
import { usePrevious } from "@uidotdev/usehooks";
import { Nip28ChatSystem } from "@/chat/nip28";
/**
* Managed loading data for the current logged in user
*/
export default function useLoginFeed() {
const login = useLogin();
const { publicKey: pubKey, readNotifications, follows } = login;
const { isMuted } = useModeration();
const { publicKey: pubKey, follows } = login;
const { publisher, system } = useEventPublisher();
useRefreshFeedCache(Notifications, true);
@ -44,15 +43,19 @@ export default function useLoginFeed() {
system.checkSigs = login.appData.item.preferences.checkSigs;
}, [login]);
const previous = usePrevious(login.appData.item);
// write appdata after 10s of no changes
useEffect(() => {
if (!previous || JSON.stringify(previous) === JSON.stringify(login.appData.item)) {
return;
}
return debounce(10_000, async () => {
if (publisher && login.appData.item) {
const ev = await publisher.appData(login.appData.item, "snort");
await system.BroadcastEvent(ev);
}
});
}, [login.appData.timestamp]);
}, [previous]);
const subLogin = useMemo(() => {
if (!login || !pubKey) return null;
@ -63,8 +66,16 @@ export default function useLoginFeed() {
});
b.withFilter()
.authors([pubKey])
.kinds([EventKind.ContactList, EventKind.Relays, EventKind.MuteList, EventKind.PinList]);
b.withFilter().authors([pubKey]).kinds([EventKind.CategorizedBookmarks]).tag("d", ["follow", "bookmark"]);
.kinds([
EventKind.ContactList,
EventKind.Relays,
EventKind.MuteList,
EventKind.PinList,
EventKind.BookmarksList,
EventKind.InterestsList,
EventKind.PublicChatsList,
]);
b.withFilter().authors([pubKey]).kinds([]);
if (CONFIG.features.subscriptions && !login.readonly) {
b.withFilter().authors([pubKey]).kinds([EventKind.AppData]).tag("d", ["snort"]);
b.withFilter()
@ -101,17 +112,7 @@ export default function useLoginFeed() {
const relays = getNewest(loginFeed.data.filter(a => a.kind === EventKind.Relays));
if (relays) {
const parsedRelays = relays.tags
.filter(a => a[0] === "r")
.map(a => {
return [
a[1],
{
read: a[2] === "read" || a[2] === undefined,
write: a[2] === "write" || a[2] === undefined,
},
];
});
const parsedRelays = parseRelayTags(relays.tags.filter(a => a[0] === "r")).map(a => [a.url, a.settings]);
setRelays(login, Object.fromEntries(parsedRelays), relays.created_at * 1000);
}
@ -145,21 +146,6 @@ export default function useLoginFeed() {
}
}, [loginFeed, publisher]);
// send out notifications
useEffect(() => {
if (loginFeed.data) {
const replies = loginFeed.data.filter(
a => a.kind === EventKind.TextNote && !isMuted(a.pubkey) && a.created_at > readNotifications,
);
replies.forEach(async nx => {
const n = await makeNotification(nx);
if (n) {
sendNotification(login, n);
}
});
}
}, [loginFeed, readNotifications]);
async function handleMutedFeed(mutedFeed: TaggedNostrEvent[]) {
const latest = getNewest(mutedFeed);
if (!latest) return;
@ -204,6 +190,16 @@ export default function useLoginFeed() {
}
}
function handlePublicChatsListFeed(bookmarkFeed: TaggedNostrEvent[]) {
const newest = getNewestEventTagsByKey(bookmarkFeed, "e");
if (newest) {
LoginStore.updateSession({
...login,
extraChats: newest.keys.map(Nip28ChatSystem.chatId),
});
}
}
useEffect(() => {
if (loginFeed.data) {
const mutedFeed = loginFeed.data.filter(a => a.kind === EventKind.MuteList);
@ -212,25 +208,19 @@ export default function useLoginFeed() {
const pinnedFeed = loginFeed.data.filter(a => a.kind === EventKind.PinList);
handlePinnedFeed(pinnedFeed);
const tagsFeed = loginFeed.data.filter(
a => a.kind === EventKind.CategorizedBookmarks && findTag(a, "d") === "follow",
);
const tagsFeed = loginFeed.data.filter(a => a.kind === EventKind.InterestsList);
handleTagFeed(tagsFeed);
const bookmarkFeed = loginFeed.data.filter(
a => a.kind === EventKind.CategorizedBookmarks && findTag(a, "d") === "bookmark",
);
const bookmarkFeed = loginFeed.data.filter(a => a.kind === EventKind.BookmarksList);
handleBookmarkFeed(bookmarkFeed);
const publicChatsFeed = loginFeed.data.filter(a => a.kind === EventKind.PublicChatsList);
handlePublicChatsListFeed(publicChatsFeed);
}
}, [loginFeed]);
useEffect(() => {
UserRelays.buffer(follows.item).catch(console.error);
system.ProfileLoader.TrackMetadata(follows.item); // always track follows profiles
system.ProfileLoader.TrackKeys(follows.item); // always track follows profiles
}, [follows.item]);
const fRelays = useRelaysFeedFollows(follows.item);
useEffect(() => {
UserRelays.bulkSet(fRelays).catch(console.error);
}, [fRelays]);
}

View File

@ -1,7 +1,6 @@
import { useMemo } from "react";
import { HexKey, EventKind, RequestBuilder, ReplaceableNoteStore } from "@snort/system";
import { HexKey, EventKind, RequestBuilder, ReplaceableNoteStore, parseRelayTags } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { parseRelayTag } from "./RelaysFeedFollows";
export default function useRelaysFeed(pubkey?: HexKey) {
const sub = useMemo(() => {
@ -12,5 +11,5 @@ export default function useRelaysFeed(pubkey?: HexKey) {
}, [pubkey]);
const relays = useRequestBuilder(ReplaceableNoteStore, sub);
return relays.data?.tags.filter(a => a[0] === "r").map(parseRelayTag) ?? [];
return parseRelayTags(relays.data?.tags.filter(a => a[0] === "r") ?? []);
}

View File

@ -1,49 +0,0 @@
import { useMemo } from "react";
import { HexKey, FullRelaySettings, TaggedNostrEvent, EventKind, NoteCollection, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import debug from "debug";
import { sanitizeRelayUrl } from "@/SnortUtils";
import { UserRelays } from "@/Cache";
interface RelayList {
pubkey: string;
created_at: number;
relays: FullRelaySettings[];
}
export default function useRelaysFeedFollows(pubkeys: HexKey[]): Array<RelayList> {
const sub = useMemo(() => {
const b = new RequestBuilder(`relays:follows`);
const since = UserRelays.newest();
debug("LoginFeed")("Loading relay lists since %s", new Date(since * 1000).toISOString());
b.withFilter().authors(pubkeys).kinds([EventKind.Relays]).since(since);
return b;
}, [pubkeys]);
function mapFromRelays(notes: Array<TaggedNostrEvent>): Array<RelayList> {
return notes.map(ev => {
return {
pubkey: ev.pubkey,
created_at: ev.created_at,
relays: ev.tags.map(parseRelayTag).filter(a => a.url !== undefined),
};
});
}
const relays = useRequestBuilder(NoteCollection, sub);
const notesRelays = relays.data?.filter(a => a.kind === EventKind.Relays) ?? [];
return useMemo(() => {
return mapFromRelays(notesRelays);
}, [relays]);
}
export function parseRelayTag(tag: Array<string>) {
return {
url: sanitizeRelayUrl(tag[1]),
settings: {
read: tag[2] === "read" || tag[2] === undefined,
write: tag[2] === "write" || tag[2] === undefined,
},
} as FullRelaySettings;
}

View File

@ -27,7 +27,7 @@ export function useLinkListEvents(id: string, fn: (rb: RequestBuilder) => void)
}
export function usePinList(pubkey: string | undefined) {
return useLinkListEvents(`pins:${pubkey?.slice(0, 12)}`, rb => {
return useLinkListEvents(`list:pins:${pubkey?.slice(0, 12)}`, rb => {
if (pubkey) {
rb.withFilter().kinds([EventKind.PinList]).authors([pubkey]);
}
@ -35,17 +35,25 @@ export function usePinList(pubkey: string | undefined) {
}
export function useMuteList(pubkey: string | undefined) {
return useLinkList(`pins:${pubkey?.slice(0, 12)}`, rb => {
return useLinkList(`list:mute:${pubkey?.slice(0, 12)}`, rb => {
if (pubkey) {
rb.withFilter().kinds([EventKind.MuteList]).authors([pubkey]);
}
});
}
export default function useCategorizedBookmarks(pubkey: string | undefined, list: string) {
return useLinkListEvents(`categorized-bookmarks:${list}:${pubkey?.slice(0, 12)}`, rb => {
export function useBookmarkList(pubkey: string | undefined) {
return useLinkListEvents(`list:bookmark:${pubkey?.slice(0, 12)}`, rb => {
if (pubkey) {
rb.withFilter().kinds([EventKind.CategorizedBookmarks]).authors([pubkey]).tag("d", [list]);
rb.withFilter().kinds([EventKind.BookmarksList]).authors([pubkey]);
}
});
}
export function useInterestsList(pubkey: string | undefined) {
return useLinkList(`list:interest:${pubkey?.slice(0, 12)}`, rb => {
if (pubkey) {
rb.withFilter().kinds([EventKind.InterestsList]).authors([pubkey]);
}
});
}

View File

@ -95,11 +95,6 @@ export interface LoginSession {
*/
blocked: Newest<Array<HexKey>>;
/**
* Latest notification
*/
latestNotification: number;
/**
* Timestamp of last read notification
*/

View File

@ -1,7 +1,7 @@
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { EventKind, NostrHashtagLink, NoteCollection, RequestBuilder } from "@snort/system";
import { EventKind, NoteCollection, RequestBuilder } from "@snort/system";
import { dedupe } from "@snort/shared";
import { useRequestBuilder } from "@snort/system-react";
@ -44,10 +44,11 @@ export function HashTagHeader({ tag, events, className }: { tag: string; events?
async function followTags(ts: string[]) {
if (publisher) {
const ev = await publisher.bookmarks(
ts.map(a => new NostrHashtagLink(a)),
"follow",
);
const ev = await publisher.generic(eb => {
eb.kind(EventKind.InterestsList);
ts.forEach(a => eb.tag(["t", a]));
return eb;
});
setTags(login, ts, ev.created_at * 1000);
await system.BroadcastEvent(ev);
}
@ -55,7 +56,7 @@ export function HashTagHeader({ tag, events, className }: { tag: string; events?
const sub = useMemo(() => {
const rb = new RequestBuilder(`hashtag-counts:${tag}`);
rb.withFilter().kinds([EventKind.CategorizedBookmarks]).tag("d", ["follow"]).tag("t", [tag.toLowerCase()]);
rb.withFilter().kinds([EventKind.InterestsList]).tag("t", [tag.toLowerCase()]);
return rb;
}, [tag]);
const followsTag = useRequestBuilder(NoteCollection, sub);

View File

@ -1,18 +1,19 @@
import { Link, useNavigate } from "react-router-dom";
import { useUserProfile } from "@snort/system-react";
import { useMemo } from "react";
import { useMemo, useSyncExternalStore } from "react";
import { base64 } from "@scure/base";
import { unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl";
import SearchBox from "../../Element/SearchBox";
import { ProfileLink } from "../../Element/User/ProfileLink";
import Avatar from "../../Element/User/Avatar";
import Icon from "../../Icons/Icon";
import useKeyboardShortcut from "../../Hooks/useKeyboardShortcut";
import { isFormElement } from "../../SnortUtils";
import useLogin from "../../Hooks/useLogin";
import useEventPublisher from "../../Hooks/useEventPublisher";
import SnortApi from "../../External/SnortApi";
import SearchBox from "@/Element/SearchBox";
import { ProfileLink } from "@/Element/User/ProfileLink";
import Avatar from "@/Element/User/Avatar";
import Icon from "@/Icons/Icon";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import { isFormElement } from "@/SnortUtils";
import useLogin from "@/Hooks/useLogin";
import useEventPublisher from "@/Hooks/useEventPublisher";
import SnortApi from "@/External/SnortApi";
import { Notifications } from "@/Cache";
const AccountHeader = () => {
const navigate = useNavigate();
@ -25,19 +26,13 @@ const AccountHeader = () => {
}
});
const { publicKey, latestNotification, readNotifications, readonly } = useLogin(s => ({
const { publicKey, readonly } = useLogin(s => ({
publicKey: s.publicKey,
latestNotification: s.latestNotification,
readNotifications: s.readNotifications,
readonly: s.readonly,
}));
const profile = useUserProfile(publicKey);
const { publisher } = useEventPublisher();
const hasNotifications = useMemo(
() => latestNotification > readNotifications,
[latestNotification, readNotifications],
);
const unreadDms = useMemo(() => (publicKey ? 0 : 0), [publicKey]);
async function goToNotifications() {
@ -92,7 +87,7 @@ const AccountHeader = () => {
)}
<Link className="btn" to="/notifications" onClick={goToNotifications}>
<Icon name="bell-02" size={24} />
{hasNotifications && <span className="has-unread"></span>}
<HasNotificationsMarker />
</Link>
<ProfileLink pubkey={publicKey} user={profile}>
<Avatar pubkey={publicKey} user={profile} />
@ -101,4 +96,24 @@ const AccountHeader = () => {
);
};
function HasNotificationsMarker() {
const readNotifications = useLogin(s => s.readNotifications);
const notifications = useSyncExternalStore(
c => Notifications.hook(c, "*"),
() => Notifications.snapshot(),
);
const latestNotification = useMemo(
() => notifications.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0),
[notifications],
);
const hasNotifications = useMemo(
() => latestNotification * 1000 > readNotifications,
[notifications, readNotifications],
);
if (hasNotifications) {
return <span className="has-unread"></span>;
}
}
export default AccountHeader;

View File

@ -48,9 +48,9 @@ header {
border-radius: 100%;
width: 9px;
height: 9px;
position: absolute;
top: 8px;
right: 8px;
position: relative;
top: -4px;
right: 7px;
}
@media (max-width: 520px) {

View File

@ -3,7 +3,7 @@ import "./MessagesPage.css";
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate, useParams } from "react-router-dom";
import { NostrLink, TLVEntryType, UserMetadata, decodeTLV } from "@snort/system";
import { EventKind, NostrLink, TLVEntryType, UserMetadata, decodeTLV } from "@snort/system";
import { useEventFeed, useUserProfile, useUserSearch } from "@snort/system-react";
import UnreadCount from "@/Element/UnreadCount";
@ -25,6 +25,7 @@ import { LoginSession, LoginStore } from "@/Login";
import { Nip28ChatSystem } from "@/chat/nip28";
import { ChatParticipantProfile } from "@/Element/Chat/ChatParticipant";
import classNames from "classnames";
import useEventPublisher from "@/Hooks/useEventPublisher";
const TwoCol = 768;
const ThreeCol = 1500;
@ -182,6 +183,7 @@ function NewChatWindow() {
const navigate = useNavigate();
const search = useUserSearch();
const login = useLogin();
const { system, publisher } = useEventPublisher();
useEffect(() => {
setNewChat([]);
@ -270,12 +272,25 @@ function NewChatWindow() {
{results.length === 1 && (
<Nip28ChatProfile
id={results[0]}
onClick={id => {
onClick={async id => {
setShow(false);
const chats = appendDedupe(login.extraChats, [Nip28ChatSystem.chatId(id)]);
LoginStore.updateSession({
...login,
extraChats: appendDedupe(login.extraChats, [Nip28ChatSystem.chatId(id)]),
extraChats: chats,
} as LoginSession);
const evList = await publisher?.generic(eb => {
eb.kind(EventKind.PublicChatsList);
chats.forEach(c => {
if (c.startsWith("chat281")) {
eb.tag(["e", decodeTLV(c)[0].value as string]);
}
});
return eb;
});
if (evList) {
await system.BroadcastEvent(evList);
}
navigate(createChatLink(ChatType.PublicGroupChat, id));
}}
/>

View File

@ -13,7 +13,7 @@ import Bookmarks from "@/Element/User/Bookmarks";
import Icon from "@/Icons/Icon";
import { Tab } from "@/Element/Tabs";
import { default as ZapElement } from "@/Element/Event/Zap";
import useCategorizedBookmarks from "@/Hooks/useLists";
import { useBookmarkList } from "@/Hooks/useLists";
import messages from "../messages";
@ -60,7 +60,7 @@ export function RelaysTab({ id }: { id: HexKey }) {
}
export function BookMarksTab({ id }: { id: HexKey }) {
const bookmarks = useCategorizedBookmarks(id, "bookmark");
const bookmarks = useBookmarkList(id);
const reactions = useReactions(`bookmark:reactions:{id}`, bookmarks.map(NostrLink.fromEvent));
return <Bookmarks pubkey={id} bookmarks={bookmarks} related={reactions.data ?? []} />;
}

View File

@ -5,7 +5,7 @@ import AsyncButton from "@/Element/AsyncButton";
import classNames from "classnames";
import { appendDedupe } from "@/SnortUtils";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { NostrHashtagLink } from "@snort/system";
import { EventKind } from "@snort/system";
export const FixedTopics = {
life: {
@ -283,11 +283,14 @@ export function Topics() {
const tags = Object.entries(FixedTopics)
.filter(([k]) => topics.includes(k))
.map(([, v]) => v.tags)
.flat()
.map(a => new NostrHashtagLink(a));
.flat();
if (tags.length > 0) {
const ev = await publisher?.bookmarks(tags, "follow");
const ev = await publisher?.generic(eb => {
eb.kind(EventKind.InterestsList);
tags.forEach(a => eb.tag(["t", a]));
return eb;
});
if (ev) {
await system.BroadcastEvent(ev);
}

View File

@ -0,0 +1,53 @@
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { BaseUITask } from "@/Tasks";
import { MetadataCache } from "@snort/system";
import { LoginSession } from "@/Login";
import Icon from "@/Icons/Icon";
export class BackupKeyTask extends BaseUITask {
id = "backup-key";
noBaseStyle = true;
check(_: MetadataCache, session: LoginSession): boolean {
return !this.state.muted && session.type == "private_key";
}
render() {
return (
<div className="p card">
<div className="flex g12 bg-superdark p24 br">
<div>
<div className="p12 bg-dark circle">
<Icon name="key" size={21} />
</div>
</div>
<div className="flex flex-col g8">
<div className="font-semibold text-xl">
<FormattedMessage defaultMessage="Be sure to back up your keys!" id="1UWegE" />
</div>
<small>
<FormattedMessage
defaultMessage="No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute."
id="YR2I9M"
values={{
app: CONFIG.appNameCapitalized,
}}
/>
</small>
<div className="flex g8">
<Link to="/settings/keys">
<button>
<FormattedMessage defaultMessage="Back up now" id="rMgF34" />
</button>
</Link>
<button className="secondary" onClick={() => this.mute()}>
<FormattedMessage defaultMessage="Already backed up" id="j9xbzF" />
</button>
</div>
</div>
</div>
</div>
);
}
}

View File

@ -1,5 +1,5 @@
import "./TaskList.css";
import { useState } from "react";
import { useSyncExternalStore } from "react";
import { useUserProfile } from "@snort/system-react";
import useLogin from "@/Hooks/useLogin";
@ -9,38 +9,65 @@ import { DonateTask } from "./DonateTask";
import { Nip5Task } from "./Nip5Task";
import { RenewSubTask } from "./RenewSubscription";
import { NoticeZapPoolDefault } from "./NoticeZapPool";
import { BackupKeyTask } from "./BackupKey";
import { ExternalStore } from "@snort/shared";
const AllTasks: Array<UITask> = [new Nip5Task(), new DonateTask(), new NoticeZapPoolDefault()];
if (CONFIG.features.subscriptions) {
AllTasks.push(new RenewSubTask());
class TaskStore extends ExternalStore<Array<UITask>> {
#tasks: Array<UITask>;
constructor() {
super();
const AllTasks: Array<UITask> = [new BackupKeyTask(), new Nip5Task(), new DonateTask(), new NoticeZapPoolDefault()];
if (CONFIG.features.subscriptions) {
AllTasks.push(new RenewSubTask());
}
AllTasks.forEach(a =>
a.load(() => {
this.notifyChange();
}),
);
this.#tasks = AllTasks;
}
takeSnapshot(): UITask[] {
return [...this.#tasks];
}
}
AllTasks.forEach(a => a.load());
const AllTasks = new TaskStore();
export const TaskList = () => {
const session = useLogin();
const user = useUserProfile(session.publicKey);
const [, setTick] = useState<number>(0);
const tasks = useSyncExternalStore(
c => AllTasks.hook(c),
() => AllTasks.snapshot(),
);
function muteTask(t: UITask) {
t.mute();
setTick(x => (x += 1));
}
return (
<div className="task-list">
{AllTasks.filter(a => (user ? a.check(user, session) : false)).map(a => {
return (
<div key={a.id} className="card">
<div className="header">
<Icon name="lightbulb" />
<div className="close" onClick={() => muteTask(a)}>
<Icon name="close" size={14} />
{tasks
.filter(a => (user ? a.check(user, session) : false))
.map(a => {
if (a.noBaseStyle) {
return a.render();
} else {
return (
<div key={a.id} className="card">
<div className="header">
<Icon name="lightbulb" />
<div className="close" onClick={() => muteTask(a)}>
<Icon name="close" size={14} />
</div>
</div>
{a.render()}
</div>
</div>
{a.render()}
</div>
);
})}
);
}
})}
</div>
);
};

View File

@ -3,12 +3,13 @@ import { LoginSession } from "@/Login";
export interface UITask {
id: string;
noBaseStyle: boolean;
/**
* Run checks to determine if this Task should be triggered for this user
*/
check(user: MetadataCache, session: LoginSession): boolean;
mute(): void;
load(): void;
load(cb: () => void): void;
render(): JSX.Element;
}
@ -19,9 +20,11 @@ export interface UITaskState {
}
export abstract class BaseUITask implements UITask {
#cb?: () => void;
protected state: UITaskState;
abstract id: string;
noBaseStyle = false;
abstract check(user: MetadataCache, session: LoginSession): boolean;
abstract render(): JSX.Element;
@ -34,7 +37,8 @@ export abstract class BaseUITask implements UITask {
this.#save();
}
load() {
load(cb: () => void) {
this.#cb = cb;
const state = window.localStorage.getItem(`task:${this.id}`);
if (state) {
this.state = JSON.parse(state);
@ -43,5 +47,6 @@ export abstract class BaseUITask implements UITask {
#save() {
window.localStorage.setItem(`task:${this.id}`, JSON.stringify(this.state));
this.#cb?.();
}
}

View File

@ -204,6 +204,6 @@ export function useChatSystem() {
return [...nip4, ...nip28].filter(a => {
const authors = a.participants.filter(a => a.type === "pubkey").map(a => a.id);
return !authors.every(a => isBlocked(a));
return authors.length === 0 || !authors.every(a => isBlocked(a));
});
}

View File

@ -1,5 +1,5 @@
import debug from "debug";
import { ExternalStore, FeedCache, unixNow, unwrap } from "@snort/shared";
import { ExternalStore, FeedCache, unwrap } from "@snort/shared";
import {
EventKind,
NostrEvent,
@ -16,7 +16,6 @@ import {
import { LoginSession } from "@/Login";
import { findTag } from "@/SnortUtils";
import { Chat, ChatParticipant, ChatSystem, ChatType, lastReadInChat } from "@/chat";
import { Day } from "@/Const";
export class Nip28ChatSystem extends ExternalStore<Array<Chat>> implements ChatSystem {
#cache: FeedCache<NostrEvent>;
@ -50,7 +49,7 @@ export class Nip28ChatSystem extends ExternalStore<Array<Chat>> implements ChatS
const lastMessage = messages[id]?.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0) ?? 0;
rb.withFilter()
.tag("e", [id])
.since(lastMessage === 0 ? unixNow() - 2 * Day : lastMessage)
.since(lastMessage === 0 ? undefined : lastMessage)
.kinds(this.ChannelKinds);
}
@ -67,9 +66,10 @@ export class Nip28ChatSystem extends ExternalStore<Array<Chat>> implements ChatS
listChats(): Chat[] {
const chats = this.#chatChannels();
return Object.entries(chats).map(([k, v]) => {
const ret = Object.entries(chats).map(([k, v]) => {
return Nip28ChatSystem.createChatObj(Nip28ChatSystem.chatId(k), v);
});
return ret;
}
static chatId(id: string) {

View File

@ -710,24 +710,6 @@ div.form-col {
color: var(--gray-light);
}
.tweet {
display: flex;
align-items: center;
justify-content: center;
}
.tweet div {
width: 100%;
}
.tweet div .twitter-tweet {
margin: 0 auto;
}
.tweet div .twitter-tweet > iframe {
max-height: unset;
}
@media (max-width: 720px) {
div.form {
grid-auto-flow: dense;

View File

@ -90,6 +90,9 @@
"1R43+L": {
"defaultMessage": "Enter Nostr Wallet Connect config"
},
"1UWegE": {
"defaultMessage": "Be sure to back up your keys!"
},
"1c4YST": {
"defaultMessage": "Connected to: {node} 🎉"
},
@ -913,6 +916,9 @@
"YDURw6": {
"defaultMessage": "Service URL"
},
"YR2I9M": {
"defaultMessage": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute."
},
"YXA3AH": {
"defaultMessage": "Enable reactions"
},
@ -1170,6 +1176,9 @@
"izWS4J": {
"defaultMessage": "Unfollow"
},
"j9xbzF": {
"defaultMessage": "Already backed up"
},
"jA3OE/": {
"defaultMessage": "{n,plural,=1{{n} sat} other{{n} sats}}"
},
@ -1358,6 +1367,9 @@
"r5srDR": {
"defaultMessage": "Enter wallet password"
},
"rMgF34": {
"defaultMessage": "Back up now"
},
"rT14Ow": {
"defaultMessage": "Add Relays"
},

View File

@ -17,6 +17,21 @@ self.addEventListener("message", event => {
self.skipWaiting();
}
});
self.addEventListener("install", event => {
// delete all cache on install
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
console.debug("Deleting cache: ", cacheName);
return caches.delete(cacheName);
}),
);
}),
);
// always skip waiting
self.skipWaiting();
});
const enum PushType {
Mention = 1,

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "هل أنت متأكد من حذف هذا المنشور من المنشورات المرجعية؟",
"1R43+L": "أدخل تكوين اتصال محفظة Nostr",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "متصل بـ: {node}🎉",
"1nYUGC": "المتابَعون {n}",
"1o2BgB": "التحقق من التوقيعات",
@ -298,6 +299,7 @@
"Xopqkl": "مبلغ زابي الافتراضي الخاص بك هو {number} جلسة، على سبيل المثال يتم حساب القيم من هذا.",
"XrSk2j": "Redeem",
"YDURw6": "رابط الخدمة",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "تمكين التفاعل",
"Z4BMCZ": "أدخل عبارة الاقتران",
"ZKORll": "نشط الآن",
@ -381,6 +383,7 @@
"ieGrWo": "متابعة",
"itPgxd": "الملف التعريفي",
"izWS4J": "الغاء المتابعة",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} ساتوشي}other{{n} ساتوشي}}",
"jAmfGl": "إنتهت صلاحية اشتراكك {site_name}",
"jHa/ko": "تنظيف موجز الويب الخاص بك",
@ -442,6 +445,7 @@
"qz9fty": "دبوس غير صحيح",
"r3C4x/": "برنامج",
"r5srDR": "أدخل كلمة مرور المحفظة",
"rMgF34": "Back up now",
"rT14Ow": "إضافة موصّلات",
"rbrahO": "أغلق",
"rfuMjE": "(افتراضي)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "Bu qeydi əlfəcinlərdən silmək istədiyinizə əminsiniz?",
"1R43+L": "Nostr Wallet Connect konfiqurasiyasını daxil edin",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Qoşuldu: {node} 🎉",
"1nYUGC": "{n} İzləyir",
"1o2BgB": "Check Signatures",
@ -298,6 +299,7 @@
"Xopqkl": "Your default zap amount is {number} sats, example values are calculated from this.",
"XrSk2j": "Redeem",
"YDURw6": "Service URL",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Enable reactions",
"Z4BMCZ": "Enter pairing phrase",
"ZKORll": "Activate Now",
@ -381,6 +383,7 @@
"ieGrWo": "Follow",
"itPgxd": "Profile",
"izWS4J": "Unfollow",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} sat} other{{n} sats}}",
"jAmfGl": "Your {site_name} subscription is expired",
"jHa/ko": "Clean up your feed",
@ -442,6 +445,7 @@
"qz9fty": "Incorrect pin",
"r3C4x/": "Software",
"r5srDR": "Enter wallet password",
"rMgF34": "Back up now",
"rT14Ow": "Add Relays",
"rbrahO": "Close",
"rfuMjE": "(Default)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} Benutzer",
"1Mo59U": "Bist du sicher, dass du diese Note aus deinen Lesezeichen entfernen möchtest?",
"1R43+L": "Nostr Wallet Connect Konfiguration eingeben",
"1UWegE": "Stelle sicher, dass du deine Schlüssel absicherst!",
"1c4YST": "Verbunden mit: {node}🎉",
"1nYUGC": "{n} Folgen",
"1o2BgB": "Signaturen prüfen",
@ -298,6 +299,7 @@
"Xopqkl": "Dein standardmäßiger Zap-Betrag ist {number} sats, Beispielwerte werden daraus berechnet.",
"XrSk2j": "Einlösen",
"YDURw6": "URL des Dienstes",
"YR2I9M": "Keine Schlüssel, kein {app}. Es gibt keine Möglichkeit, sie zurückzusetzen, wenn du keine Sicherungskopie gemacht hast. Es dauert nur eine Minute.",
"YXA3AH": "Reaktionen aktivieren",
"Z4BMCZ": "Verbindungs-Passphrase eingeben",
"ZKORll": "Jetzt aktivieren",
@ -381,6 +383,7 @@
"ieGrWo": "Folgen",
"itPgxd": "Profil",
"izWS4J": "Entfolgen",
"j9xbzF": "Bereits gesichert",
"jA3OE/": "{n,plural,one {}=1{{n} sat} other{{n} sats}}",
"jAmfGl": "Dein {site_name} Abonnement ist abgelaufen",
"jHa/ko": "Räume deinen Feed auf",
@ -442,6 +445,7 @@
"qz9fty": "Falsche PIN",
"r3C4x/": "Software",
"r5srDR": "Wallet Passwort eingeben",
"rMgF34": "Jetzt sichern",
"rT14Ow": "Relais hinzufügen",
"rbrahO": "Schließen",
"rfuMjE": "(Standard)",

View File

@ -29,6 +29,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "Are you sure you want to remove this note from bookmarks?",
"1R43+L": "Enter Nostr Wallet Connect config",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Connected to: {node} 🎉",
"1nYUGC": "{n} Following",
"1o2BgB": "Check Signatures",
@ -300,6 +301,7 @@
"Xopqkl": "Your default zap amount is {number} sats, example values are calculated from this.",
"XrSk2j": "Redeem",
"YDURw6": "Service URL",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Enable reactions",
"Z4BMCZ": "Enter pairing phrase",
"ZKORll": "Activate Now",
@ -385,6 +387,7 @@
"ieGrWo": "Follow",
"itPgxd": "Profile",
"izWS4J": "Unfollow",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} sat} other{{n} sats}}",
"jAmfGl": "Your {site_name} subscription is expired",
"jHa/ko": "Clean up your feed",
@ -447,6 +450,7 @@
"qz9fty": "Incorrect pin",
"r3C4x/": "Software",
"r5srDR": "Enter wallet password",
"rMgF34": "Back up now",
"rT14Ow": "Add Relays",
"rbrahO": "Close",
"rfuMjE": "(Default)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "¿Estás seguro de que quieres eliminar esta nota de tus favoritos?",
"1R43+L": "Introduzca la configuración de Nostr Wallet Connect",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Conectado a: {node}🎉",
"1nYUGC": "{n} Siguiendo",
"1o2BgB": "Firmas de cheques",
@ -298,6 +299,7 @@
"Xopqkl": "Tu cantidad de zap por defecto es {number} sats, los valores de ejemplo se calculan a partir de esto.",
"XrSk2j": "Canjear",
"YDURw6": "URL del Servicio",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Activar reacciones",
"Z4BMCZ": "Introduce la frase de emparejamiento",
"ZKORll": "Activar Ahora",
@ -381,6 +383,7 @@
"ieGrWo": "Seguir",
"itPgxd": "Perfil",
"izWS4J": "No seguir",
"j9xbzF": "Already backed up",
"jA3OE/": "{n} {n, plural, =1 {sat} other {sats}}",
"jAmfGl": "Su suscripción a {site_name} ha caducado",
"jHa/ko": "Limpie su alimentación",
@ -442,6 +445,7 @@
"qz9fty": "Clavija incorrecta",
"r3C4x/": "Software",
"r5srDR": "Introducir contraseña de cartera",
"rMgF34": "Back up now",
"rT14Ow": "Añadir Relays",
"rbrahO": "Cerrar",
"rfuMjE": "(Por defecto)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "مطمئنید می خواهید این یادداشت را از نشانک ها خارج کنید؟",
"1R43+L": "پیکربندی اتصال ناستر به کیف پول",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "متصل به:{node}🎉",
"1nYUGC": "{n} دنبال شونده",
"1o2BgB": "بررسی امضا",
@ -298,6 +299,7 @@
"Xopqkl": "مبلغ پیش فرض زپ شما {number} ساتوشی است، مقادیر نمونه از روی این محاسبه شده اند.",
"XrSk2j": "بازخرید",
"YDURw6": "خدمات URL",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "فعال سازی واکنش ها",
"Z4BMCZ": "عبارت جفت را وارد کنید",
"ZKORll": "اکنون فعال کن",
@ -381,6 +383,7 @@
"ieGrWo": "دنبال کردن",
"itPgxd": "نمایه",
"izWS4J": "لغو دنبال کردن",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,one {}=1{{n} نوت جدید} other{{n} نوت جدید}}",
"jAmfGl": "عضویت شما در {site_name} منقضی شده است",
"jHa/ko": "پاک سازی خبرنامه",
@ -442,6 +445,7 @@
"qz9fty": "پین نادرست",
"r3C4x/": "نرم افزار",
"r5srDR": "رمز کیف‌پول را وارد کنید",
"rMgF34": "Back up now",
"rT14Ow": "افزودن رله",
"rbrahO": "بستن",
"rfuMjE": "(پیش‌فرض)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "Haluatko varmasti poistaa tämän viestin kirjanmerkeistä?",
"1R43+L": "Anna Nostr Wallet Connect asetukset",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Yhdistetty: {node} 🎉",
"1nYUGC": "{n} Seuraa",
"1o2BgB": "Tarkista allekirjoitukset",
@ -298,6 +299,7 @@
"Xopqkl": "Oletus zap-määräsi on {number} satsia, esimerkit on laskettu tämän mukaan.",
"XrSk2j": "Lunasta",
"YDURw6": "Palvelun URL",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Ota reaktiot käyttöön",
"Z4BMCZ": "Anna pariliitoslause",
"ZKORll": "Aktivoi nyt",
@ -381,6 +383,7 @@
"ieGrWo": "Seuraa",
"itPgxd": "Profiili",
"izWS4J": "Lopeta seuraaminen",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} sat} other{{n} satia}}",
"jAmfGl": "{site_name} tilauksesi on päättynyt",
"jHa/ko": "Siivoa rehusi",
@ -442,6 +445,7 @@
"qz9fty": "Väärä tappi",
"r3C4x/": "Ohjelmisto",
"r5srDR": "Anna lompakon salasana",
"rMgF34": "Back up now",
"rT14Ow": "Lisää välittäjiä",
"rbrahO": "Sulje",
"rfuMjE": "(Oletus)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "Êtes-vous sûr de vouloir supprimer cette note de vos favoris ?",
"1R43+L": "Accéder à la configuration de Nostr Wallet Connect",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Connecté à : {node} 🎉",
"1nYUGC": "{n} Abonnements",
"1o2BgB": "Signatures de chèques",
@ -298,6 +299,7 @@
"Xopqkl": "Votre montant de zap par défaut est {number} sats, les valeurs d'exemple sont calculées à partir de ceci.",
"XrSk2j": "Retirer",
"YDURw6": "URL de service",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Activer les réactions",
"Z4BMCZ": "Entrez la phrase d'appairage",
"ZKORll": "Activer Maintenant",
@ -381,6 +383,7 @@
"ieGrWo": "Suivre",
"itPgxd": "Profil",
"izWS4J": "Ne plus suivre",
"j9xbzF": "Already backed up",
"jA3OE/": "{n} {n, plural, =1 {sat} other {sats}}",
"jAmfGl": "Votre abonnement à {site_name} a expiré",
"jHa/ko": "Nettoyez votre flux",
@ -442,6 +445,7 @@
"qz9fty": "Broche incorrecte",
"r3C4x/": "Logiciel",
"r5srDR": "Entrez le mot de passe du portefeuille",
"rMgF34": "Back up now",
"rT14Ow": "Ajouter Relais",
"rbrahO": "Fermer",
"rfuMjE": "(Défaut)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "Jeste li sigurni da želite ukloniti ovu bilješku iz trake s oznakama?",
"1R43+L": "Enter Nostr Wallet Connect config",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Povezano s: {node} 🎉",
"1nYUGC": "{n} Pratitelja",
"1o2BgB": "Check Signatures",
@ -298,6 +299,7 @@
"Xopqkl": "Your default zap amount is {number} sats, example values are calculated from this.",
"XrSk2j": "Redeem",
"YDURw6": "URL usluge",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Omogući reakcije",
"Z4BMCZ": "Unesite frazu za uparivanje",
"ZKORll": "Aktivirajte Odmah",
@ -381,6 +383,7 @@
"ieGrWo": "Prati",
"itPgxd": "Profil",
"izWS4J": "Prestani pratiti",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} sat} other{{n} sats}}",
"jAmfGl": "Your {site_name} subscription is expired",
"jHa/ko": "Clean up your feed",
@ -442,6 +445,7 @@
"qz9fty": "Incorrect pin",
"r3C4x/": "Software",
"r5srDR": "Unesite lozinku novčanika",
"rMgF34": "Back up now",
"rT14Ow": "Dodaj relej",
"rbrahO": "Close",
"rfuMjE": "(Zadano)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} felhasználó",
"1Mo59U": "Biztos hogy a kedvencekből ezt a bejegyzést el akarod távolítani?",
"1R43+L": "Írd be a Nostr Wallet Connect konfigurációt",
"1UWegE": "A kulcsaidról, mindenképpen készíts biztonsági másolatot!",
"1c4YST": "Kapcsolódás a: {node} 🎉",
"1nYUGC": "{n} Követek",
"1o2BgB": "Aláírások ellenörzése",
@ -298,6 +299,7 @@
"Xopqkl": "Az alapértelmezett zap összeg {number} sats, a példaértékek kiszámítása ebből történik.",
"XrSk2j": "Beváltás",
"YDURw6": "Szervíz cím",
"YR2I9M": "Nincsenek kulcsok, nincs {app}, Nincs mód a visszaállításra, ha nem készítesz biztonsági mentést. Csak egy percet vesz igénybe.",
"YXA3AH": "Reakciók engedélyezése",
"Z4BMCZ": "Párosító kifejezés megadása",
"ZKORll": "Aktiválás",
@ -381,6 +383,7 @@
"ieGrWo": "Követem",
"itPgxd": "Profil",
"izWS4J": "Követés visszavonása",
"j9xbzF": "Már kész a biztonsági mentés",
"jA3OE/": "{n,plural,one {}=1{{n} sat} other{{n} sats}}",
"jAmfGl": "{site_name}-előfizetése lejárt",
"jHa/ko": "Tisztítsa meg a takarmányt",
@ -442,6 +445,7 @@
"qz9fty": "Helytelen Pin-kód",
"r3C4x/": "Szoftver",
"r5srDR": "Add meg a pénztárcád jelszavát",
"rMgF34": "Biztonsági mentés",
"rT14Ow": "Csomópont hozzáadása",
"rbrahO": "Bezárás",
"rfuMjE": "(Alapértelmezett)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "Apa Anda yakin Anda ingin memindahkan catatan ini dari penanda buku?",
"1R43+L": "Masukkan konfigurasi Nostr Wallet Connect",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Terhubung ke: {node} 🎉",
"1nYUGC": "{n} Mengikuti",
"1o2BgB": "Periksa Tanda Tangan",
@ -298,6 +299,7 @@
"Xopqkl": "Jumlah zap default Anda adalah {number} sats, nilai contoh dihitung dari ini.",
"XrSk2j": "Tebus",
"YDURw6": "URL layanan",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Aktifkan reaksi",
"Z4BMCZ": "Masukkan frasa pasangan",
"ZKORll": "Aktifkan Sekarang",
@ -381,6 +383,7 @@
"ieGrWo": "Ikuti",
"itPgxd": "Profil",
"izWS4J": "Berhenti mengikuti",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} sat} other{{n} sat}}",
"jAmfGl": "Langganan {site_name} Anda telah kedaluwarsa",
"jHa/ko": "Bersihkan feed Anda",
@ -442,6 +445,7 @@
"qz9fty": "Pin salah",
"r3C4x/": "Perangkat lunak",
"r5srDR": "Masukkan kata sandi dompet",
"rMgF34": "Back up now",
"rT14Ow": "Tambahkan Relay",
"rbrahO": "Tutup",
"rfuMjE": "(Bawaan)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "Vuoi davvero rimuovere questa nota dai preferiti?",
"1R43+L": "Inserire la configurazione di Nostr Wallet Connect",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Connessione a: {node}🎉",
"1nYUGC": "{n} seguiti",
"1o2BgB": "Firma degli assegni",
@ -298,6 +299,7 @@
"Xopqkl": "La quantità di zap predefinita è {number} sats, i valori di esempio sono calcolati da questo valore.",
"XrSk2j": "Riscatto",
"YDURw6": "URL del servizio",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Abilita reazioni",
"Z4BMCZ": "Inserisci la frase di accoppiamento",
"ZKORll": "Attiva adesso",
@ -381,6 +383,7 @@
"ieGrWo": "Segui",
"itPgxd": "Profilo",
"izWS4J": "Smetti di seguire",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} sat} other{{n} sats}}",
"jAmfGl": "L'abbonamento a {site_name} è scaduto",
"jHa/ko": "Pulite il vostro feed",
@ -442,6 +445,7 @@
"qz9fty": "Pin non corretto",
"r3C4x/": "Software",
"r5srDR": "Inserire la password del portafogli",
"rMgF34": "Back up now",
"rT14Ow": "Aggiungi Relays",
"rbrahO": "Chiudere",
"rfuMjE": "(Predefinito)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "本当にこの投稿をブックマークから削除しますか?",
"1R43+L": "Nostr Wallet Connectの設定値を入力",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "{node}に接続しました🎉",
"1nYUGC": "{n} フォロー",
"1o2BgB": "小切手署名",
@ -298,6 +299,7 @@
"Xopqkl": "デフォルトのザップ量は{number} satsで、例示された値はここから算出されます。",
"XrSk2j": "交換",
"YDURw6": "サービスURL",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "リアクションを有効にする",
"Z4BMCZ": "ペアリングフレーズを入力",
"ZKORll": "今すぐ有効化",
@ -381,6 +383,7 @@
"ieGrWo": "フォロー",
"itPgxd": "プロフィール",
"izWS4J": "フォロー解除",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} sat} other{{n} sats}}",
"jAmfGl": "{site_name} 、有効期限が切れています。",
"jHa/ko": "フィードをきれいにする",
@ -442,6 +445,7 @@
"qz9fty": "ピンが正しくない",
"r3C4x/": "ソフトウェア",
"r5srDR": "ウォレットのパスワードを入力",
"rMgF34": "Back up now",
"rT14Ow": "リレーを追加する",
"rbrahO": "閉じる",
"rfuMjE": "(デフォルト)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "Weet u zeker dat u deze notitie uit bladwijzers wilt verwijderen?",
"1R43+L": "Voer Nostr Wallet Connect configuratie in",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Verbonden met: {node}🎉",
"1nYUGC": "{n} Volgend",
"1o2BgB": "Handtekeningen controleren",
@ -298,6 +299,7 @@
"Xopqkl": "Uw standaard zap bedrag is {number} sats, voorbeeldwaarden worden hiermee berekend.",
"XrSk2j": "Inwisselen",
"YDURw6": "Service-URL",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Reacties inschakelen",
"Z4BMCZ": "Voer koppelingszin in",
"ZKORll": "Activeer Nu",
@ -381,6 +383,7 @@
"ieGrWo": "Volgen",
"itPgxd": "Profiel",
"izWS4J": "Ontvolgen",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,one {}=1{{n} sat} other{{n} sats}}",
"jAmfGl": "Uw {site_name} abonnement is verlopen",
"jHa/ko": "Ruim je feed op",
@ -442,6 +445,7 @@
"qz9fty": "Verkeerde pin",
"r3C4x/": "Software",
"r5srDR": "Voer wallet wachtwoord in",
"rMgF34": "Back up now",
"rT14Ow": "Relays toevoegen",
"rbrahO": "Sluit",
"rfuMjE": "(Standaard)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "Tem certeza que deseja remover esta nota dos favoritos?",
"1R43+L": "Insira a configuração da Nostr Wallet Connect",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Conectado em: {node} 🎉",
"1nYUGC": "{n} Seguindo",
"1o2BgB": "Assinaturas de cheques",
@ -298,6 +299,7 @@
"Xopqkl": "Sua quantidade padrão de zap é de {number} sats, valores de exemplo são calculados a partir disso.",
"XrSk2j": "Resgatar",
"YDURw6": "URL do serviço",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Habilitar reações",
"Z4BMCZ": "Inserir frase de pareamento",
"ZKORll": "Ativar agora",
@ -381,6 +383,7 @@
"ieGrWo": "Seguir",
"itPgxd": "Perfil",
"izWS4J": "Deixar de seguir",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} sat} other{{n} sats}}",
"jAmfGl": "Sua assinatura do {site_name} expirou",
"jHa/ko": "Limpe seu feed",
@ -442,6 +445,7 @@
"qz9fty": "Pino incorreto",
"r3C4x/": "Software",
"r5srDR": "Digite a senha da carteira",
"rMgF34": "Back up now",
"rT14Ow": "Adicionar Relés",
"rbrahO": "Fechar",
"rfuMjE": "(Padrão)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "Вы уверены, что хотите удалить эту заметку из закладок?",
"1R43+L": "Введите конфигурацию Nostr Wallet Connect",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Подключен к: {node} 🎉",
"1nYUGC": "{n} Подписчиков",
"1o2BgB": "Контрольные подписи",
@ -298,6 +299,7 @@
"Xopqkl": "По умолчанию величина zap равна {number} sats, примерные значения рассчитываются исходя из этого.",
"XrSk2j": "Получить",
"YDURw6": "URL-адрес сервиса",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Включить реакции",
"Z4BMCZ": "Введи фразу для сопряжения",
"ZKORll": "Активировать",
@ -381,6 +383,7 @@
"ieGrWo": "Подписаться",
"itPgxd": "Профиль",
"izWS4J": "Отписаться",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} sat} other{{n} sats}}",
"jAmfGl": "Срок действия вашей подписки {site_name} истек",
"jHa/ko": "Очистка корма",
@ -442,6 +445,7 @@
"qz9fty": "Неправильный вывод",
"r3C4x/": "Программное обеспечение",
"r5srDR": "Введите пароль кошелька",
"rMgF34": "Back up now",
"rT14Ow": "Добавить реле",
"rbrahO": "Закрыть",
"rfuMjE": "(по умолчанию)",

View File

@ -21,15 +21,16 @@
"08zn6O": "Exportera nycklar",
"0Azlrb": "Hantera",
"0BUTMv": "Sök...",
"0HFX0T": "Use Exact Location",
"0HFX0T": "Använd exakt plats",
"0jOEtS": "Ogiltig LNURL",
"0mch2Y": "namnet har otillåtna tecken",
"0siT4z": "Politik",
"0uoY11": "Visa status",
"0yO7wF": "{n} secs",
"1H4Keq": "{n} users",
"1H4Keq": "{n} användare",
"1Mo59U": "Är du säker på att du vill ta bort den här anteckningen från bokmärken?",
"1R43+L": "Skriv in Nostr Wallet Connect konfiguration",
"1UWegE": "Se till att säkerhetskopiera dina nycklar!",
"1c4YST": "Ansluten till: {node}🎉",
"1nYUGC": "{n} Följer",
"1o2BgB": "Kontrollera signaturer",
@ -100,7 +101,7 @@
"9wO4wJ": "Lightning-faktura",
"ABAQyo": "Chattar",
"ADmfQT": "Förälder",
"AIgmDy": "Add up to 4 hashtags",
"AIgmDy": "Lägg till upp till 4 hashtaggar",
"AN0Z7Q": "Tystade ord",
"ASRK0S": "Denna författare har tystats",
"Ai8VHU": "Obegränsat antal anteckningar på Snort-relä",
@ -124,7 +125,7 @@
"CmZ9ls": "{n} tystad",
"CsCUYo": "{n} sats",
"Cu/K85": "Översatt från {lang}",
"CzHZoc": "Social Graph",
"CzHZoc": "Social graf",
"D+KzKd": "Zappa automatiskt varje anteckning när den är laddad",
"D3idYv": "Inställningar",
"DBiVK1": "Cache",
@ -276,7 +277,7 @@
"Ub+AGc": "Logga in",
"Up5U7K": "Blockera",
"UrKTqQ": "Du har ett aktivt iris.to konto",
"VL900k": "Recommended Relays",
"VL900k": "Rekommenderade reläer",
"VN0+Fz": "Saldo: {amount} sats",
"VOjC1i": "Välj vilken uppladdningstjänst du vill ladda upp bilagor till",
"VR5eHw": "Publik nyckel (npub/nprofile)",
@ -293,11 +294,12 @@
"X7xU8J": "nsec, npub, nip-05, hex, mnemonic",
"XECMfW": "Skicka användningsstatistik",
"XICsE8": "Filvärdar",
"XXm7jJ": "Trending Hashtags",
"XXm7jJ": "Trendande hashtaggar",
"XgWvGA": "Reaktioner",
"Xopqkl": "Ditt förvalda zap-belopp är {number} sats, exempelvärden beräknas utifrån detta.",
"XrSk2j": "Lös in",
"YDURw6": "Service URL",
"YR2I9M": "Inga nycklar, ingen {app}, Det finns inget sätt att återställa den om du inte säkerhetskopierar. Det tar bara en minut.",
"YXA3AH": "Aktivera reaktioner",
"Z4BMCZ": "Ange parningsfras",
"ZKORll": "Aktivera nu",
@ -331,9 +333,9 @@
"d+6YsV": "Listor för att stänga av ljudet:",
"d6CyG5": "Historik",
"d7d0/x": "LN Adress",
"d8gpCh": "Try to use less than 5 hashtags to stay on topic 🙏",
"d8gpCh": "Försök att använda färre än 5 hashtags för att hålla dig till ämnet 🙏",
"dOQCL8": "Visnings namn",
"ddd3JX": "Popular Hashtags",
"ddd3JX": "Populära Hashtaggar",
"deEeEI": "Registrering",
"dmsiLv": "En standard Zap Pool split av {n} har konfigurerats för {site} utvecklare, du kan inaktivera den när som helst i {link}",
"e61Jf3": "Kommer snart",
@ -344,7 +346,7 @@
"eJj8HD": "Bli Verifierad",
"eSzf2G": "En enda zap med {nIn} sats kommer att fördela {nOut} sats till zappoolen.",
"eXT2QQ": "Gruppchatt",
"egib+2": "{n,plural,=1{& {n} other} other{& {n} others}}",
"egib+2": "{n,plural,=1{& {n} andra} other{& {n} andras}}",
"fBI91o": "Zap",
"fBlba3": "Tack för att du använder {site}, vänligen överväg att donera om du kan.",
"fOksnD": "Kan inte rösta eftersom LNURL-tjänsten inte stöder zaps",
@ -381,6 +383,7 @@
"ieGrWo": "Följ",
"itPgxd": "Profil",
"izWS4J": "Sluta följ",
"j9xbzF": "Redan säkerhetskopierad",
"jA3OE/": "{n,plural,=1{{n} sat} other{{n} sats}}",
"jAmfGl": "Ditt {site_name} abonnemang har löpt ut",
"jHa/ko": "Städa upp i ditt flöde",
@ -442,6 +445,7 @@
"qz9fty": "Felaktig pin",
"r3C4x/": "Mjukvara",
"r5srDR": "Ange lösenord för plånboken",
"rMgF34": "Säkerhetskopiera nu",
"rT14Ow": "Lägg till reläer",
"rbrahO": "Stäng",
"rfuMjE": "(Standard)",
@ -464,7 +468,7 @@
"uSV4Ti": "Dela vidare måste bekräftas manuellt",
"uc0din": "Skicka sats-delningar till",
"ugyJnE": "Skicka anteckningar och andra saker",
"un1nGw": "{n} notes",
"un1nGw": "{n} anteckningar",
"usAvMr": "Redigera profil",
"v8lolG": "Starta chatt",
"vB3oQ/": "Måste vara en kontaktlista eller en pubkey lista",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "Je, una uhakika unataka kuondoa dokezo hili kutoka kwa vialamisho?",
"1R43+L": "Ingiza usanidi wa Nostr Wallet Connect",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "Imeunganishwa kwa: {node} 🎉",
"1nYUGC": "{n} Unafuata",
"1o2BgB": "Check Signatures",
@ -298,6 +299,7 @@
"Xopqkl": "Kiasi chako chaguomsingi cha zap ni {number} sats, thamani za mfano zinakokotolewa kutoka hii.",
"XrSk2j": "Komboa",
"YDURw6": "URL ya huduma",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "Washa maitikio",
"Z4BMCZ": "Weka maneno ya kuoanisha",
"ZKORll": "Washa Sasa",
@ -381,6 +383,7 @@
"ieGrWo": "Fuata",
"itPgxd": "Wasifu",
"izWS4J": "Acha kufuata",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,one {}=1{{n} sat} other{{n} sats}}",
"jAmfGl": "Your {site_name} subscription is expired",
"jHa/ko": "Clean up your feed",
@ -442,6 +445,7 @@
"qz9fty": "Incorrect pin",
"r3C4x/": "Programu",
"r5srDR": "Ingiza nenosiri la pochi",
"rMgF34": "Back up now",
"rT14Ow": "Ongeza Relay",
"rbrahO": "Close",
"rfuMjE": "(Chaguo-msingi)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "இந்தக் குறிப்பைப் புக்மார்க்குகளிலிருந்து அகற்ற நிச்சயமாக விரும்புகிறீர்களா?",
"1R43+L": "நாஸ்டர் பணப்பை இணைப்புக் கட்டமைப்பை உள்ளிடவும்",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "{node} உடன் இணைக்கப் பட்டது 🎉",
"1nYUGC": "{n} பின்தொடரப் படுவோர்",
"1o2BgB": "கையெழுத்துக்களை சரிபார்",
@ -298,6 +299,7 @@
"Xopqkl": "Your default zap amount is {number} sats, example values are calculated from this.",
"XrSk2j": "Redeem",
"YDURw6": "சேவை URL",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "எதிர்வினைகளை அனுமதி",
"Z4BMCZ": "இணைத்தல் சொற்றொடரை உள்ளிடவும்",
"ZKORll": "இப்போது செயல்படுத்துக",
@ -381,6 +383,7 @@
"ieGrWo": "பின்தொடர்",
"itPgxd": "சுயவிவரம்",
"izWS4J": "பின்தொடர்வதை நிறுத்துக",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} ஸாட்} other{{n} ஸாட்கள்}}",
"jAmfGl": "Your {site_name} subscription is expired",
"jHa/ko": "Clean up your feed",
@ -442,6 +445,7 @@
"qz9fty": "Incorrect pin",
"r3C4x/": "மென்பொருள்",
"r5srDR": "பணப்பையின் கடவுச்சொல்லை உள்ளிடவும்",
"rMgF34": "Back up now",
"rT14Ow": "ரிலேகளைச் சேர்க்கவும்",
"rbrahO": "Close",
"rfuMjE": "(இயல்புநிலை)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "คุณแน่ใจหรือว่าต้องการลบโน้ตนี้ออกจากบุ๊คมาร์ค?",
"1R43+L": "ใส่ Nostr Wallet Connect config",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "เชื่อมต่อกับ: {node} 🎉",
"1nYUGC": "กำลังติดตาม {n}",
"1o2BgB": "Check Signatures",
@ -298,6 +299,7 @@
"Xopqkl": "จํานวน zap เริ่มต้นของคุณคือ {number} sats ค่าตัวอย่างคํานวณจากสิ่งนี้",
"XrSk2j": "รับคืน",
"YDURw6": "Service URL",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "เปิดใช้งาน reactions",
"Z4BMCZ": "ใส่ pairing phrase",
"ZKORll": "เปิดใช้งานทันที",
@ -381,6 +383,7 @@
"ieGrWo": "ติดตาม",
"itPgxd": "โปรไฟล์",
"izWS4J": "เลิกติดตาม",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} sat} other{{n} sats}}",
"jAmfGl": "Your {site_name} subscription is expired",
"jHa/ko": "Clean up your feed",
@ -442,6 +445,7 @@
"qz9fty": "Incorrect pin",
"r3C4x/": "ซอฟต์แวร์",
"r5srDR": "โปรดใส่รหัส wallet",
"rMgF34": "Back up now",
"rT14Ow": "เพิ่มรีเลย์",
"rbrahO": "ปิด",
"rfuMjE": "(ค่าเริ่มต้น)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "是否确定要从收藏中移除此条笔记?",
"1R43+L": "输入 Nostr Wallet Connect 配置",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "已连接到:{node}🎉",
"1nYUGC": "{n} 个关注",
"1o2BgB": "检查签名",
@ -298,6 +299,7 @@
"Xopqkl": "你的默认打闪金额是 {number} 聪,示例值是以此计算的。",
"XrSk2j": "兑现",
"YDURw6": "服务网址",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "启用回应",
"Z4BMCZ": "输入配对词句",
"ZKORll": "立即激活",
@ -381,6 +383,7 @@
"ieGrWo": "关注",
"itPgxd": "个人档案",
"izWS4J": "取消关注",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n}聪} other{{n}聪}}",
"jAmfGl": "你的 {site_name} 订阅已过期",
"jHa/ko": "清理你的订阅",
@ -442,6 +445,7 @@
"qz9fty": "PIN 码不正确",
"r3C4x/": "软件",
"r5srDR": "输入钱包密码",
"rMgF34": "Back up now",
"rT14Ow": "添加中继",
"rbrahO": "关闭",
"rfuMjE": "(默认)",

View File

@ -30,6 +30,7 @@
"1H4Keq": "{n} users",
"1Mo59U": "是否確定要從收藏中移除此條筆記?",
"1R43+L": "輸入 Nostr Wallet Connect 配置",
"1UWegE": "Be sure to back up your keys!",
"1c4YST": "已連接到:{node} 🎉",
"1nYUGC": "{n} 個關注",
"1o2BgB": "檢查簽名",
@ -298,6 +299,7 @@
"Xopqkl": "你的默認打閃金額是 {number} 聰,示例值是以此計算的。",
"XrSk2j": "兌現",
"YDURw6": "服務 URL",
"YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.",
"YXA3AH": "启用回應",
"Z4BMCZ": "輸入配對詞句",
"ZKORll": "立即激活",
@ -381,6 +383,7 @@
"ieGrWo": "關注",
"itPgxd": "個人檔案",
"izWS4J": "取消關注",
"j9xbzF": "Already backed up",
"jA3OE/": "{n,plural,=1{{n} 聰} other{{n} 聰}}",
"jAmfGl": "你的 {site_name} 訂閱已過期了",
"jHa/ko": "清理你的訂閱",
@ -442,6 +445,7 @@
"qz9fty": "PIN 碼不正確",
"r3C4x/": "軟件",
"r5srDR": "輸入錢包密碼",
"rMgF34": "Back up now",
"rT14Ow": "添加中繼器",
"rbrahO": "關閉",
"rfuMjE": "(默認)",

View File

@ -3,7 +3,6 @@ import { VitePWA } from "vite-plugin-pwa";
import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig } from "vite";
import { vitePluginVersionMark } from "vite-plugin-version-mark";
import appConfig from "config";
export default defineConfig({
@ -33,7 +32,7 @@ export default defineConfig({
build: {
outDir: "build",
},
base: "",
clearScreen: false,
publicDir: appConfig.get("publicDir"),
resolve: {
alias: {

View File

@ -1,4 +1,4 @@
import { createContext } from "react";
import { NostrSystem, SystemInterface } from "@snort/system";
export const SnortContext = createContext<SystemInterface>(new NostrSystem({}));
export const SnortContext = createContext<SystemInterface>({} as SystemInterface);

View File

@ -21,9 +21,9 @@ export function useEventReactions(link: NostrLink, related: ReadonlyArray<Tagged
{} as Record<string, Array<TaggedNostrEvent>>,
);
const deletions = reactionKinds[EventKind.Deletion.toString()] ?? [];
const reactions = reactionKinds[EventKind.Reaction.toString()] ?? [];
const reposts = reactionKinds[EventKind.Repost.toString()] ?? [];
const deletions = reactionKinds[String(EventKind.Deletion)] ?? [];
const reactions = reactionKinds[String(EventKind.Reaction)] ?? [];
const reposts = reactionKinds[String(EventKind.Repost)] ?? [];
const groupReactions = reactions?.reduce(
(acc, reaction) => {
@ -35,7 +35,7 @@ export function useEventReactions(link: NostrLink, related: ReadonlyArray<Tagged
{} as Record<Reaction, Array<TaggedNostrEvent>>,
);
const zaps = (reactionKinds[EventKind.ZapReceipt] ?? [])
const zaps = (reactionKinds[String(EventKind.ZapReceipt)] ?? [])
.map(a => parseZap(a))
.filter(a => a.valid)
.sort((a, b) => b.amount - a.amount);

View File

@ -10,13 +10,13 @@ export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined {
return useSyncExternalStore<MetadataCache | undefined>(
h => {
if (pubKey) {
system.ProfileLoader.TrackMetadata(pubKey);
system.ProfileLoader.TrackKeys(pubKey);
}
const release = system.ProfileLoader.Cache.hook(h, pubKey);
return () => {
release();
if (pubKey) {
system.ProfileLoader.UntrackMetadata(pubKey);
system.ProfileLoader.UntrackKeys(pubKey);
}
};
},

View File

@ -0,0 +1,136 @@
import debug from "debug";
import { FeedCache, removeUndefined } from "@snort/shared";
import { SystemInterface, TaggedNostrEvent, RequestBuilder } from ".";
export abstract class BackgroundLoader<T extends { loaded: number; created: number }> {
#system: SystemInterface;
#cache: FeedCache<T>;
#log = debug(this.name());
/**
* List of pubkeys to fetch metadata for
*/
#wantsKeys = new Set<string>();
/**
* Custom loader function for fetching data from alternative sources
*/
loaderFn?: (pubkeys: Array<string>) => Promise<Array<T>>;
constructor(system: SystemInterface, cache: FeedCache<T>) {
this.#system = system;
this.#cache = cache;
this.#FetchMetadata();
}
get Cache() {
return this.#cache;
}
/**
* Name of this loader service
*/
abstract name(): string;
/**
* Handle fetched data
*/
abstract onEvent(e: Readonly<TaggedNostrEvent>): T | undefined;
/**
* Get expire time as uxix milliseconds
*/
abstract getExpireCutoff(): number;
/**
* Build subscription for missing keys
*/
protected abstract buildSub(missing: Array<string>): RequestBuilder;
/**
* Create a placeholder value when no data can be found
*/
protected abstract makePlaceholder(key: string): T | undefined;
/**
* Start requesting a set of keys to be loaded
*/
TrackKeys(pk: string | Array<string>) {
for (const p of Array.isArray(pk) ? pk : [pk]) {
this.#wantsKeys.add(p);
}
}
/**
* Stop requesting a set of keys to be loaded
*/
UntrackKeys(pk: string | Array<string>) {
for (const p of Array.isArray(pk) ? pk : [pk]) {
this.#wantsKeys.delete(p);
}
}
/**
* Get object from cache or fetch if missing
*/
async fetch(key: string) {
const existing = this.Cache.get(key);
if (existing) {
return existing;
} else {
return await new Promise<T>((resolve, reject) => {
this.TrackKeys(key);
const release = this.Cache.hook(() => {
const existing = this.Cache.getFromCache(key);
if (existing) {
resolve(existing);
release();
this.UntrackKeys(key);
}
}, key);
});
}
}
async #FetchMetadata() {
const loading = [...this.#wantsKeys];
await this.#cache.buffer(loading);
const missing = loading.filter(a => (this.#cache.getFromCache(a)?.loaded ?? 0) < this.getExpireCutoff());
if (missing.length > 0) {
this.#log("Fetching keys: %O", missing);
try {
const found = await this.#loadData(missing);
const noResult = removeUndefined(
missing.filter(a => !found.some(b => a === this.#cache.key(b))).map(a => this.makePlaceholder(a)),
);
if (noResult.length > 0) {
await Promise.all(noResult.map(a => this.#cache.update(a)));
}
} catch (e) {
this.#log("Error: %O", e);
debugger;
}
}
setTimeout(() => this.#FetchMetadata(), 500);
}
async #loadData(missing: Array<string>) {
if (this.loaderFn) {
const results = await this.loaderFn(missing);
await Promise.all(results.map(a => this.#cache.update(a)));
return results;
} else {
const v = await this.#system.Fetch(this.buildSub(missing), async e => {
for (const pe of e) {
const m = this.onEvent(pe);
if (m) {
await this.#cache.update(m);
}
}
});
return removeUndefined(v.map(this.onEvent));
}
}
}

View File

@ -44,8 +44,9 @@ export interface RelayMetrics {
export interface UsersRelays {
pubkey: string;
created_at: number;
relays: FullRelaySettings[];
created: number;
loaded: number;
}
export function mapEventToProfile(ev: NostrEvent) {

View File

@ -19,7 +19,7 @@ export class UserRelaysCache extends FeedCache<UsersRelays> {
newest(): number {
let ret = 0;
this.cache.forEach(v => (ret = v.created_at > ret ? v.created_at : ret));
this.cache.forEach(v => (ret = v.created > ret ? v.created : ret));
return ret;
}

View File

@ -19,6 +19,11 @@ export const TagRefRegex = /(#\[\d+\])/gm;
*/
export const ProfileCacheExpire = 1_000 * 60 * 60 * 6;
/**
* How long before relay lists should be refreshed
*/
export const RelayListCacheExpire = 1_000 * 60 * 60 * 12;
/**
* Extract file extensions regex
*/

View File

@ -1,4 +1,4 @@
enum EventKind {
const enum EventKind {
Unknown = -1,
SetMetadata = 0,
TextNote = 1,
@ -27,9 +27,20 @@ enum EventKind {
MuteList = 10_000, // NIP-51
PinList = 10_001, // NIP-51
BookmarksList = 10_003, // NIP-51
CommunitiesList = 10_004, // NIP-51
PublicChatsList = 10_005, // NIP-51
BlockedRelaysList = 10_006, // NIP-51
SearchRelaysList = 10_007, // NIP-51
InterestsList = 10_015, // NIP-51
EmojisList = 10_030, // NIP-51
CategorizedPeople = 30000, // NIP-51a
CategorizedBookmarks = 30001, // NIP-51b
FollowSet = 30_000, // NIP-51
RelaySet = 30_002, // NIP-51
BookmarkSet = 30_003, // NIP-51
CurationSet = 30_004, // NIP-51
InterestSet = 30_015, // NIP-15
EmojiSet = 30_030, // NIP-51
Badge = 30009, // NIP-58
ProfileBadges = 30008, // NIP-58

View File

@ -137,9 +137,8 @@ export class EventPublisher {
* Build a categorized bookmarks event with a given label
* @param notes List of bookmarked links
*/
async bookmarks(notes: Array<ToNostrEventTag>, list: "bookmark" | "follow") {
const eb = this.#eb(EventKind.CategorizedBookmarks);
eb.tag(["d", list]);
async bookmarks(notes: Array<ToNostrEventTag>) {
const eb = this.#eb(EventKind.BookmarksList);
notes.forEach(n => {
eb.tag(unwrap(n.toEventTag()));
});

View File

@ -4,7 +4,7 @@ import { NoteStore, NoteStoreSnapshotData } from "./note-collection";
import { Query } from "./query";
import { NostrEvent, ReqFilter, TaggedNostrEvent } from "./nostr";
import { ProfileLoaderService } from "./profile-cache";
import { RelayCache } from "./gossip-model";
import { RelayCache } from "./outbox-model";
import { QueryOptimizer } from "./query-optimizer";
import { base64 } from "@scure/base";
@ -31,6 +31,7 @@ export * from "./pow";
export * from "./pow-util";
export * from "./query-optimizer";
export * from "./encrypted";
export * from "./outbox-model";
export * from "./impl/nip4";
export * from "./impl/nip44";
@ -71,7 +72,7 @@ export interface SystemInterface {
* @param req Request to send to relays
* @param cb A callback which will fire every 100ms when new data is received
*/
Fetch(req: RequestBuilder, cb?: (evs: Array<TaggedNostrEvent>) => void): Promise<NoteStoreSnapshotData>;
Fetch(req: RequestBuilder, cb?: (evs: Array<TaggedNostrEvent>) => void): Promise<Array<TaggedNostrEvent>>;
/**
* Create a new permanent connection to a relay

View File

@ -5,7 +5,7 @@ import { unwrap, sanitizeRelayUrl, FeedCache, removeUndefined } from "@snort/sha
import { NostrEvent, TaggedNostrEvent } from "./nostr";
import { Connection, RelaySettings, ConnectionStateSnapshot, OkResponse } from "./connection";
import { Query } from "./query";
import { NoteCollection, NoteStore, NoteStoreSnapshotData } from "./note-collection";
import { NoteCollection, NoteStore } from "./note-collection";
import { BuiltRawReqFilter, RequestBuilder, RequestStrategy } from "./request-builder";
import { RelayMetricHandler } from "./relay-metric-handler";
import {
@ -22,7 +22,7 @@ import {
EventExt,
} from ".";
import { EventsCache } from "./cache/events";
import { RelayCache } from "./gossip-model";
import { RelayCache, RelayMetadataLoader, pickRelaysForReply } from "./outbox-model";
import { QueryOptimizer, DefaultQueryOptimizer } from "./query-optimizer";
import { trimFilters } from "./request-trim";
@ -88,6 +88,8 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
*/
checkSigs: boolean;
#relayLoader: RelayMetadataLoader;
constructor(props: {
relayCache?: FeedCache<UsersRelays>;
profileCache?: FeedCache<MetadataCache>;
@ -106,6 +108,7 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
this.#profileLoader = new ProfileLoaderService(this, this.#profileCache);
this.#relayMetrics = new RelayMetricHandler(this.#relayMetricsCache);
this.#relayLoader = new RelayMetadataLoader(this, this.#relayCache);
this.checkSigs = props.checkSigs ?? true;
this.#cleanup();
}
@ -246,7 +249,7 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
Fetch(req: RequestBuilder, cb?: (evs: Array<TaggedNostrEvent>) => void) {
const q = this.Query(NoteCollection, req);
return new Promise<NoteStoreSnapshotData>(resolve => {
return new Promise<Array<TaggedNostrEvent>>(resolve => {
let t: ReturnType<typeof setTimeout> | undefined;
let tBuf: Array<TaggedNostrEvent> = [];
const releaseOnEvent = cb
@ -267,7 +270,7 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
releaseOnEvent?.();
releaseFeedHook();
q.cancel();
resolve(unwrap(q.feed.snapshot.data));
resolve(unwrap((q.feed as NoteCollection).snapshot.data));
}
});
});
@ -333,6 +336,9 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
);
}
}
if (f.authors) {
this.#relayLoader.TrackKeys(f.authors);
}
}
// check for empty filters
@ -382,8 +388,9 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
*/
async BroadcastEvent(ev: NostrEvent, cb?: (rsp: OkResponse) => void) {
const socks = [...this.#sockets.values()].filter(a => !a.Ephemeral && a.Settings.write);
const oks = await Promise.all(
socks.map(async s => {
const replyRelays = await pickRelaysForReply(ev, this);
const oks = await Promise.all([
...socks.map(async s => {
try {
const rsp = await s.SendAsync(ev);
cb?.(rsp);
@ -393,7 +400,8 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
}
return;
}),
);
...replyRelays.filter(a => !this.#sockets.has(a)).map(a => this.WriteOnceToRelay(a, ev)),
]);
return removeUndefined(oks);
}

View File

@ -1,7 +1,18 @@
import { ReqFilter, UsersRelays } from ".";
import { dedupe, unwrap } from "@snort/shared";
import {
EventKind,
FullRelaySettings,
NostrEvent,
ReqFilter,
RequestBuilder,
SystemInterface,
TaggedNostrEvent,
UsersRelays,
} from ".";
import { dedupe, removeUndefined, sanitizeRelayUrl, unixNowMs, unwrap } from "@snort/shared";
import debug from "debug";
import { FlatReqFilter } from "./query-optimizer";
import { RelayListCacheExpire } from "./const";
import { BackgroundLoader } from "./background-loader";
const PickNRelays = 2;
@ -20,8 +31,13 @@ export interface RelayTaggedFilters {
filters: Array<ReqFilter>;
}
const logger = debug("OutboxModel");
export interface RelayCache {
getFromCache(pubkey?: string): UsersRelays | undefined;
update(obj: UsersRelays): Promise<"new" | "updated" | "refresh" | "no_change">;
buffer(keys: Array<string>): Promise<Array<string>>;
bulkSet(objs: Array<UsersRelays>): Promise<void>;
}
export function splitAllByWriteRelays(cache: RelayCache, filters: Array<ReqFilter>) {
@ -61,7 +77,7 @@ export function splitByWriteRelays(cache: RelayCache, filter: ReqFilter): Array<
];
}
const topRelays = pickTopRelays(cache, unwrap(authors), PickNRelays);
const topRelays = pickTopRelays(cache, unwrap(authors), PickNRelays, "write");
const pickedRelays = dedupe(topRelays.flatMap(a => a.relays));
const picked = pickedRelays.map(a => {
@ -84,7 +100,7 @@ export function splitByWriteRelays(cache: RelayCache, filter: ReqFilter): Array<
},
});
}
debug("GOSSIP")("Picked %O => %O", filter, picked);
logger("Picked %O => %O", filter, picked);
return picked;
}
@ -101,7 +117,7 @@ export function splitFlatByWriteRelays(cache: RelayCache, input: Array<FlatReqFi
},
];
}
const topRelays = pickTopRelays(cache, authors, PickNRelays);
const topRelays = pickTopRelays(cache, authors, PickNRelays, "write");
const pickedRelays = dedupe(topRelays.flatMap(a => a.relays));
const picked = pickedRelays.map(a => {
@ -119,21 +135,21 @@ export function splitFlatByWriteRelays(cache: RelayCache, input: Array<FlatReqFi
} as RelayTaggedFlatFilters);
}
debug("GOSSIP")("Picked %d relays from %d filters", picked.length, input.length);
logger("Picked %d relays from %d filters", picked.length, input.length);
return picked;
}
/**
* Pick most popular relays for each authors
*/
function pickTopRelays(cache: RelayCache, authors: Array<string>, n: number) {
function pickTopRelays(cache: RelayCache, authors: Array<string>, n: number, type: "write" | "read") {
// map of pubkey -> [write relays]
const allRelays = authors.map(a => {
return {
key: a,
relays: cache
.getFromCache(a)
?.relays?.filter(a => a.settings.write)
?.relays?.filter(a => (type === "write" ? a.settings.write : a.settings.read))
.sort(() => (Math.random() < 0.5 ? 1 : -1)),
};
});
@ -178,3 +194,84 @@ function pickTopRelays(cache: RelayCache, authors: Array<string>, n: number) {
}),
);
}
/**
* Pick read relays for sending reply events
*/
export async function pickRelaysForReply(ev: NostrEvent, system: SystemInterface) {
const recipients = dedupe(ev.tags.filter(a => a[0] === "p").map(a => a[1]));
await updateRelayLists(recipients, system);
const relays = pickTopRelays(system.RelayCache, recipients, 2, "read");
const ret = removeUndefined(dedupe(relays.map(a => a.relays).flat()));
logger("Picked %O from authors %O", ret, recipients);
return ret;
}
export function parseRelayTag(tag: Array<string>) {
return {
url: sanitizeRelayUrl(tag[1]),
settings: {
read: tag[2] === "read" || tag[2] === undefined,
write: tag[2] === "write" || tag[2] === undefined,
},
} as FullRelaySettings;
}
export function parseRelayTags(tag: Array<Array<string>>) {
return tag.map(parseRelayTag).filter(a => a !== null);
}
export async function updateRelayLists(authors: Array<string>, system: SystemInterface) {
await system.RelayCache.buffer(authors);
const expire = unixNowMs() - RelayListCacheExpire;
const expired = authors.filter(a => (system.RelayCache.getFromCache(a)?.loaded ?? 0) < expire);
if (expired.length > 0) {
logger("Updating relays for authors: %O", expired);
const rb = new RequestBuilder("system-update-relays-for-outbox");
rb.withFilter().authors(expired).kinds([EventKind.Relays]);
const relayLists = await system.Fetch(rb);
await system.RelayCache.bulkSet(
relayLists.map(a => ({
relays: parseRelayTags(a.tags),
pubkey: a.pubkey,
created: a.created_at,
loaded: unixNowMs(),
})),
);
}
}
export class RelayMetadataLoader extends BackgroundLoader<UsersRelays> {
override name(): string {
return "RelayMetadataLoader";
}
override onEvent(e: Readonly<TaggedNostrEvent>): UsersRelays | undefined {
return {
relays: parseRelayTags(e.tags),
pubkey: e.pubkey,
created: e.created_at,
loaded: unixNowMs(),
};
}
override getExpireCutoff(): number {
return unixNowMs() - RelayListCacheExpire;
}
protected override buildSub(missing: string[]): RequestBuilder {
const rb = new RequestBuilder("relay-loader");
rb.withOptions({ skipDiff: true });
rb.withFilter().authors(missing).kinds([EventKind.Relays]);
return rb;
}
protected override makePlaceholder(key: string): UsersRelays | undefined {
return {
relays: [],
pubkey: key,
created: 0,
loaded: this.getExpireCutoff() + 300_000,
};
}
}

View File

@ -1,156 +1,40 @@
import debug from "debug";
import { unixNowMs, FeedCache } from "@snort/shared";
import { EventKind, HexKey, SystemInterface, TaggedNostrEvent, RequestBuilder } from ".";
import { unixNowMs } from "@snort/shared";
import { EventKind, TaggedNostrEvent, RequestBuilder } from ".";
import { ProfileCacheExpire } from "./const";
import { mapEventToProfile, MetadataCache } from "./cache";
import { v4 as uuid } from "uuid";
import { BackgroundLoader } from "./background-loader";
const MetadataRelays = ["wss://purplepag.es"];
export class ProfileLoaderService {
#system: SystemInterface;
#cache: FeedCache<MetadataCache>;
/**
* A set of pubkeys we could not find last run,
* This list will attempt to use known profile metadata relays
*/
#missingLastRun: Set<string> = new Set();
/**
* List of pubkeys to fetch metadata for
*/
#wantsMetadata: Set<HexKey> = new Set();
readonly #log = debug("ProfileCache");
/**
* Custom loader function for fetching profiles from alternative sources
*/
loaderFn?: (pubkeys: Array<string>) => Promise<Array<MetadataCache>>;
constructor(system: SystemInterface, cache: FeedCache<MetadataCache>) {
this.#system = system;
this.#cache = cache;
this.#FetchMetadata();
export class ProfileLoaderService extends BackgroundLoader<MetadataCache> {
override name(): string {
return "ProfileLoaderService";
}
get Cache() {
return this.#cache;
override onEvent(e: Readonly<TaggedNostrEvent>): MetadataCache | undefined {
return mapEventToProfile(e);
}
/**
* Request profile metadata for a set of pubkeys
*/
TrackMetadata(pk: HexKey | Array<HexKey>) {
for (const p of Array.isArray(pk) ? pk : [pk]) {
if (p.length === 64) {
this.#wantsMetadata.add(p);
}
}
override getExpireCutoff(): number {
return unixNowMs() - ProfileCacheExpire;
}
/**
* Stop tracking metadata for a set of pubkeys
*/
UntrackMetadata(pk: HexKey | Array<HexKey>) {
for (const p of Array.isArray(pk) ? pk : [pk]) {
if (p.length > 0) {
this.#wantsMetadata.delete(p);
}
}
override buildSub(missing: string[]): RequestBuilder {
const sub = new RequestBuilder(`profiles-${uuid()}`);
sub
.withOptions({
skipDiff: true,
})
.withFilter()
.kinds([EventKind.SetMetadata])
.authors(missing);
return sub;
}
async onProfileEvent(e: Readonly<TaggedNostrEvent>) {
const profile = mapEventToProfile(e);
if (profile) {
await this.#cache.update(profile);
}
}
async fetchProfile(key: string) {
const existing = this.Cache.get(key);
if (existing) {
return existing;
} else {
return await new Promise<MetadataCache>((resolve, reject) => {
this.TrackMetadata(key);
const release = this.Cache.hook(() => {
const existing = this.Cache.getFromCache(key);
if (existing) {
resolve(existing);
release();
this.UntrackMetadata(key);
}
}, key);
});
}
}
async #FetchMetadata() {
const missingFromCache = await this.#cache.buffer([...this.#wantsMetadata]);
const expire = unixNowMs() - ProfileCacheExpire;
const expired = [...this.#wantsMetadata]
.filter(a => !missingFromCache.includes(a))
.filter(a => (this.#cache.getFromCache(a)?.loaded ?? 0) < expire);
const missing = new Set([...missingFromCache, ...expired]);
if (missing.size > 0) {
this.#log("Wants profiles: %d missing, %d expired", missingFromCache.length, expired.length);
const results = await this.#loadProfiles([...missing]);
const couldNotFetch = [...missing].filter(a => !results.some(b => b.pubkey === a));
this.#missingLastRun = new Set(couldNotFetch);
if (couldNotFetch.length > 0) {
this.#log("No profiles: %o", couldNotFetch);
const empty = couldNotFetch.map(a =>
this.#cache.update({
pubkey: a,
loaded: unixNowMs() - ProfileCacheExpire + 30_000, // expire in 30s
created: 69,
} as MetadataCache),
);
await Promise.all(empty);
}
/* When we fetch an expired profile and its the same as what we already have
// onEvent is not fired and the loaded timestamp never gets updated
const expiredSame = results.filter(a => !newProfiles.has(a.id) && expired.includes(a.pubkey));
await Promise.all(expiredSame.map(v => this.onProfileEvent(v)));*/
}
setTimeout(() => this.#FetchMetadata(), 500);
}
async #loadProfiles(missing: Array<string>) {
if (this.loaderFn) {
const results = await this.loaderFn(missing);
await Promise.all(results.map(a => this.#cache.update(a)));
return results;
} else {
const sub = new RequestBuilder(`profiles-${uuid()}`);
sub
.withOptions({
skipDiff: true,
})
.withFilter()
.kinds([EventKind.SetMetadata])
.authors(missing);
if (this.#missingLastRun.size > 0) {
const fMissing = sub
.withFilter()
.kinds([EventKind.SetMetadata])
.authors([...this.#missingLastRun]);
MetadataRelays.forEach(r => fMissing.relay(r));
}
const results = (await this.#system.Fetch(sub, async e => {
for (const pe of e) {
await this.onProfileEvent(pe);
}
})) as ReadonlyArray<TaggedNostrEvent>;
return results;
}
protected override makePlaceholder(key: string): MetadataCache | undefined {
return {
pubkey: key,
loaded: unixNowMs() - ProfileCacheExpire + 30_000,
created: 0,
} as MetadataCache;
}
}

View File

@ -7,11 +7,12 @@ export interface RelayInfo {
software?: string;
version?: string;
limitation?: {
payment_required: boolean;
max_subscriptions: number;
max_filters: number;
max_event_tags: number;
auth_required: boolean;
payment_required?: boolean;
max_subscriptions?: number;
max_filters?: number;
max_event_tags?: number;
auth_required?: boolean;
write_restricted?: boolean;
};
relay_countries?: Array<string>;
language_tags?: Array<string>;

View File

@ -5,7 +5,7 @@ import { appendDedupe, dedupe, sanitizeRelayUrl, unixNowMs, unwrap } from "@snor
import EventKind from "./event-kind";
import { NostrLink, NostrPrefix, SystemInterface } from ".";
import { ReqFilter, u256, HexKey } from "./nostr";
import { RelayCache, splitByWriteRelays, splitFlatByWriteRelays } from "./gossip-model";
import { RelayCache, splitByWriteRelays, splitFlatByWriteRelays } from "./outbox-model";
/**
* Which strategy is used when building REQ filters

View File

@ -2940,7 +2940,6 @@ __metadata:
react-router-dom: ^6.5.0
react-tag-input-component: ^2.0.2
react-textarea-autosize: ^8.4.0
react-twitter-embed: ^4.0.4
recharts: ^2.8.0
rollup-plugin-visualizer: ^5.9.2
tailwindcss: ^3.3.3
@ -9384,18 +9383,6 @@ __metadata:
languageName: node
linkType: hard
"react-twitter-embed@npm:^4.0.4":
version: 4.0.4
resolution: "react-twitter-embed@npm:4.0.4"
dependencies:
scriptjs: ^2.5.9
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
checksum: cdb3c5bd04c4da0efa767476be47c0a3865fb6335f2a1b9e242170167b51615c38164223278cef60c77143c4bac27ba582cbea054d0af3f138104fa5ec537c4c
languageName: node
linkType: hard
"react@npm:^18.2.0":
version: 18.2.0
resolution: "react@npm:18.2.0"
@ -9803,13 +9790,6 @@ __metadata:
languageName: node
linkType: hard
"scriptjs@npm:^2.5.9":
version: 2.5.9
resolution: "scriptjs@npm:2.5.9"
checksum: fc84cb6b60b6fb9aa6f1b3bc59fc94b233bd5241ed3a04233579014382b5eb60640269c87d8657902acc09f9b785ee33230c218627cea00e653564bda8f5acb6
languageName: node
linkType: hard
"semver@npm:^6.3.0, semver@npm:^6.3.1":
version: 6.3.1
resolution: "semver@npm:6.3.1"