Merge remote-tracking branch 'kieran/main'
This commit is contained in:
commit
c2bd6ae856
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -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
|
||||
|
@ -4,4 +4,5 @@ build/
|
||||
.github/
|
||||
transifex.yml
|
||||
dist/
|
||||
src-tauri/
|
||||
src-tauri/
|
||||
target/
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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) {
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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";
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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") ?? []);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -95,11 +95,6 @@ export interface LoginSession {
|
||||
*/
|
||||
blocked: Newest<Array<HexKey>>;
|
||||
|
||||
/**
|
||||
* Latest notification
|
||||
*/
|
||||
latestNotification: number;
|
||||
|
||||
/**
|
||||
* Timestamp of last read notification
|
||||
*/
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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));
|
||||
}}
|
||||
/>
|
||||
|
@ -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 ?? []} />;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
53
packages/app/src/Tasks/BackupKey.tsx
Normal file
53
packages/app/src/Tasks/BackupKey.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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?.();
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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": "(افتراضي)",
|
||||
|
@ -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)",
|
||||
|
@ -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)",
|
||||
|
@ -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)",
|
||||
|
@ -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)",
|
||||
|
@ -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": "(پیشفرض)",
|
||||
|
@ -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)",
|
||||
|
@ -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)",
|
||||
|
@ -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)",
|
||||
|
@ -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)",
|
||||
|
@ -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)",
|
||||
|
@ -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)",
|
||||
|
@ -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": "(デフォルト)",
|
||||
|
@ -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)",
|
||||
|
@ -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)",
|
||||
|
@ -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": "(по умолчанию)",
|
||||
|
@ -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",
|
||||
|
@ -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)",
|
||||
|
@ -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": "(இயல்புநிலை)",
|
||||
|
@ -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": "(ค่าเริ่มต้น)",
|
||||
|
@ -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": "(默认)",
|
||||
|
@ -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": "(默認)",
|
||||
|
@ -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: {
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
136
packages/system/src/background-loader.ts
Normal file
136
packages/system/src/background-loader.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
3
packages/system/src/cache/index.ts
vendored
3
packages/system/src/cache/index.ts
vendored
@ -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) {
|
||||
|
2
packages/system/src/cache/user-relays.ts
vendored
2
packages/system/src/cache/user-relays.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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
|
||||
|
@ -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()));
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
|
@ -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
|
||||
|
20
yarn.lock
20
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user