NIP-65: Relay list metada (#238)

This commit is contained in:
Alejandro 2023-02-10 20:23:52 +01:00 committed by GitHub
parent d13904b8e2
commit 5153f5c90a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 304 additions and 20 deletions

View File

@ -1,7 +1,3 @@
{
"plugins": [
[
"formatjs"
]
]
}
"plugins": [["formatjs"]]
}

View File

@ -29,3 +29,4 @@ Snort supports the following NIP's:
- [ ] NIP-42: Authentication of clients to relays
- [x] NIP-50: Search
- [x] NIP-51: Lists
- [x] NIP-65: Relay List Metadata

View File

@ -161,7 +161,9 @@ export default function NoteFooter(props: NoteFooterProps) {
}
return (
<>
<div className={`reaction-pill ${hasReacted("+") ? "reacted" : ""} `} onClick={() => react("+")}>
<div
className={`reaction-pill ${hasReacted("+") ? "reacted" : ""} `}
onClick={() => react(prefs.reactionEmoji)}>
<div className="reaction-pill-icon">
<Heart />
</div>

View File

@ -0,0 +1,36 @@
.favicon {
width: 21px;
height: 21px;
border-radius: 100%;
margin-right: 12px;
}
.relay-card {
display: flex;
flex-direction: row;
align-items: center;
}
.relay-settings {
margin-left: auto;
}
.relay-settings svg:not(:last-child) {
margin-right: 12px;
}
.relay-settings svg.enabled {
color: var(--highlight);
}
.relay-settings svg.disabled {
opacity: 0.3;
}
.relay-url {
font-size: 14px;
}
@media (min-width: 520px) {
.relay-url {
font-size: 16px;
}
}

View File

@ -0,0 +1,44 @@
import "./RelaysMetadata.css";
import Nostrich from "nostrich.webp";
import { useState } from "react";
import { RelaySettings } from "Nostr";
import Read from "Icons/Read";
import Write from "Icons/Write";
const RelayFavicon = ({ url }: { url: string }) => {
const cleanUrl = url
.replace("wss://relay.", "https://")
.replace("wss://nostr.", "https://")
.replace("wss://", "https://")
.replace("ws://", "http://")
.replace(/\/$/, "");
const [faviconUrl, setFaviconUrl] = useState(`${cleanUrl}/favicon.ico`);
return <img className="favicon" src={faviconUrl} onError={() => setFaviconUrl(Nostrich)} />;
};
interface RelaysMetadataProps {
relays: RelaySettings[];
}
const RelaysMetadata = ({ relays }: RelaysMetadataProps) => {
return (
<div className="main-content">
{relays?.map(({ url, settings }) => {
return (
<div className="card relay-card">
<RelayFavicon url={url} />
<code className="relay-url">{url}</code>
<div className="relay-settings">
<Read className={settings.read ? "enabled" : "disabled"} />
<Write className={settings.write ? "enabled" : "disabled"} />
</div>
</div>
);
})}
</div>
);
};
export default RelaysMetadata;

View File

@ -12,12 +12,12 @@
}
.zap .header .amount {
font-size: 32px;
font-size: 24px;
}
@media (max-width: 520px) {
.zap .header .amount {
font-size: 21px;
font-size: 16px;
}
}

View File

@ -109,6 +109,16 @@ export default function useEventPublisher() {
}
}
},
/**
* Write event to all given relays.
*/
broadcastAll: (ev: NEvent | undefined, relays: string[]) => {
if (ev) {
for (const k of relays) {
System.WriteOnceToRelay(k, ev);
}
}
},
muted: async (keys: HexKey[], priv: HexKey[]) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
@ -220,6 +230,24 @@ export default function useEventPublisher() {
return await signEvent(ev);
}
},
saveRelaysSettings: async () => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Relays;
ev.Content = "";
for (const [url, settings] of Object.entries(relays)) {
const rTag = ["r", url];
if (settings.read && !settings.write) {
rTag.push("read");
}
if (settings.write && !settings.read) {
rTag.push("write");
}
ev.Tags.push(new Tag(rTag, ev.Tags.length));
}
return await signEvent(ev);
}
},
addFollow: async (pkAdd: HexKey | HexKey[], newRelays?: Record<string, RelaySettings>) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);

36
src/Feed/RelaysFeed.tsx Normal file
View File

@ -0,0 +1,36 @@
import { useMemo } from "react";
import { HexKey, RelaySettings } from "Nostr";
import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
import useSubscription from "./Subscription";
export default function useRelaysFeed(pubkey: HexKey) {
const sub = useMemo(() => {
const x = new Subscriptions();
x.Id = `relays:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.Relays]);
x.Authors = new Set([pubkey]);
x.Limit = 1;
return x;
}, [pubkey]);
const relays = useSubscription(sub, { leaveOpen: false, cache: true });
const notes = relays.store.notes;
const tags = notes.slice(-1)[0]?.tags || [];
return tags.reduce((rs, tag) => {
const [t, url, ...settings] = tag;
if (t === "r") {
return [
...rs,
{
url,
settings: {
read: settings.length === 0 || settings.includes("read"),
write: settings.length === 0 || settings.includes("write"),
},
},
];
}
return rs;
}, [] as RelaySettings[]);
}

View File

@ -13,9 +13,9 @@ const Gear = (props: IconProps) => {
<path
d="M9.99992 14C11.6568 14 12.9999 12.6569 12.9999 11C12.9999 9.34315 11.6568 8 9.99992 8C8.34307 8 6.99992 9.34315 6.99992 11C6.99992 12.6569 8.34307 14 9.99992 14Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

17
src/Icons/Read.tsx Normal file
View File

@ -0,0 +1,17 @@
import IconProps from "./IconProps";
const Read = (props: IconProps) => {
return (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M14.9996 2V7M14.9996 7L19.9996 7M14.9996 7L20.9996 1M10.2266 11.8631C9.02506 10.6615 8.07627 9.30285 7.38028 7.85323C7.32041 7.72854 7.29048 7.66619 7.26748 7.5873C7.18576 7.30695 7.24446 6.96269 7.41447 6.72526C7.46231 6.65845 7.51947 6.60129 7.63378 6.48698C7.98338 6.13737 8.15819 5.96257 8.27247 5.78679C8.70347 5.1239 8.70347 4.26932 8.27247 3.60643C8.15819 3.43065 7.98338 3.25585 7.63378 2.90624L7.43891 2.71137C6.90747 2.17993 6.64174 1.91421 6.35636 1.76987C5.7888 1.4828 5.11854 1.4828 4.55098 1.76987C4.2656 1.91421 3.99987 2.17993 3.46843 2.71137L3.3108 2.86901C2.78117 3.39863 2.51636 3.66344 2.31411 4.02348C2.08969 4.42298 1.92833 5.04347 1.9297 5.5017C1.93092 5.91464 2.01103 6.19687 2.17124 6.76131C3.03221 9.79471 4.65668 12.6571 7.04466 15.045C9.43264 17.433 12.295 19.0575 15.3284 19.9185C15.8928 20.0787 16.1751 20.1588 16.588 20.16C17.0462 20.1614 17.6667 20 18.0662 19.7756C18.4263 19.5733 18.6911 19.3085 19.2207 18.7789L19.3783 18.6213C19.9098 18.0898 20.1755 17.8241 20.3198 17.5387C20.6069 16.9712 20.6069 16.3009 20.3198 15.7333C20.1755 15.448 19.9098 15.1822 19.3783 14.6508L19.1835 14.4559C18.8339 14.1063 18.6591 13.9315 18.4833 13.8172C17.8204 13.3862 16.9658 13.3862 16.3029 13.8172C16.1271 13.9315 15.9523 14.1063 15.6027 14.4559C15.4884 14.5702 15.4313 14.6274 15.3644 14.6752C15.127 14.8453 14.7828 14.904 14.5024 14.8222C14.4235 14.7992 14.3612 14.7693 14.2365 14.7094C12.7869 14.0134 11.4282 13.0646 10.2266 11.8631Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default Read;

17
src/Icons/Write.tsx Normal file
View File

@ -0,0 +1,17 @@
import IconProps from "./IconProps";
const Write = (props: IconProps) => {
return (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M20.9996 6V1M20.9996 1H15.9996M20.9996 1L14.9996 7M10.2266 11.8631C9.02506 10.6615 8.07627 9.30285 7.38028 7.85323C7.32041 7.72854 7.29048 7.66619 7.26748 7.5873C7.18576 7.30695 7.24446 6.96269 7.41447 6.72526C7.46231 6.65845 7.51947 6.60129 7.63378 6.48698C7.98338 6.13737 8.15819 5.96257 8.27247 5.78679C8.70347 5.1239 8.70347 4.26932 8.27247 3.60643C8.15819 3.43065 7.98338 3.25585 7.63378 2.90624L7.43891 2.71137C6.90747 2.17993 6.64174 1.91421 6.35636 1.76987C5.7888 1.4828 5.11854 1.4828 4.55098 1.76987C4.2656 1.91421 3.99987 2.17993 3.46843 2.71137L3.3108 2.86901C2.78117 3.39863 2.51636 3.66344 2.31411 4.02348C2.08969 4.42298 1.92833 5.04347 1.9297 5.5017C1.93092 5.91464 2.01103 6.19687 2.17124 6.76131C3.03221 9.79471 4.65668 12.6571 7.04466 15.045C9.43264 17.433 12.295 19.0575 15.3284 19.9185C15.8928 20.0787 16.1751 20.1588 16.588 20.16C17.0462 20.1614 17.6667 20 18.0662 19.7756C18.4263 19.5733 18.6911 19.3085 19.2207 18.7789L19.3783 18.6213C19.9098 18.0898 20.1755 17.8241 20.3198 17.5387C20.6069 16.9712 20.6069 16.3009 20.3198 15.7333C20.1755 15.448 19.9098 15.1822 19.3783 14.6508L19.1835 14.4559C18.8339 14.1063 18.6591 13.9315 18.4833 13.8172C17.8204 13.3862 16.9658 13.3862 16.3029 13.8172C16.1271 13.9315 15.9523 14.1063 15.6027 14.4559C15.4884 14.5702 15.4313 14.6274 15.3644 14.6752C15.127 14.8453 14.7828 14.904 14.5024 14.8222C14.4235 14.7992 14.3612 14.7693 14.2365 14.7094C12.7869 14.0134 11.4282 13.0646 10.2266 11.8631Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default Write;

View File

@ -179,7 +179,7 @@ export default class Connection {
}
case "OK": {
// feedback to broadcast call
console.debug("OK: ", msg);
console.debug(`${this.Address} OK: `, msg);
const id = msg[1];
if (this.EventsCallback.has(id)) {
const cb = unwrap(this.EventsCallback.get(id));

View File

@ -8,6 +8,7 @@ const enum EventKind {
Deletion = 5, // NIP-09
Repost = 6, // NIP-18
Reaction = 7, // NIP-25
Relays = 10002, // NIP-65
Auth = 22242, // NIP-42
Lists = 30000, // NIP-51
ZapRequest = 9734, // NIP tba

View File

@ -47,6 +47,11 @@ export class Subscriptions {
*/
DTags?: Set<string>;
/**
* A litst of "r" tags to search
*/
RTags?: Set<string>;
/**
* A list of search terms
*/
@ -100,6 +105,7 @@ export class Subscriptions {
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined;
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined;
this.DTags = sub?.["#d"] ? new Set(["#d"]) : undefined;
this.RTags = sub?.["#r"] ? new Set(["#r"]) : undefined;
this.Search = sub?.search ?? undefined;
this.Since = sub?.since ?? undefined;
this.Until = sub?.until ?? undefined;
@ -150,6 +156,9 @@ export class Subscriptions {
if (this.DTags) {
ret["#d"] = Array.from(this.DTags);
}
if (this.RTags) {
ret["#r"] = Array.from(this.RTags);
}
if (this.Search) {
ret.search = this.Search;
}

View File

@ -41,6 +41,7 @@ export type RawReqFilter = {
"#p"?: u256[];
"#t"?: string[];
"#d"?: string[];
"#r"?: string[];
search?: string;
since?: number;
until?: number;
@ -68,3 +69,8 @@ export type UserMetadata = {
export enum Lists {
Muted = "mute",
}
export interface RelaySettings {
url: string;
settings: { read: boolean; write: boolean };
}

View File

@ -2,10 +2,11 @@ import "./Layout.css";
import { useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { randomSample } from "Util";
import Envelope from "Icons/Envelope";
import Bell from "Icons/Bell";
import Search from "Icons/Search";
import { RootState } from "State/Store";
import { init, setRelays } from "State/Login";
import { System } from "Nostr/System";
@ -147,8 +148,7 @@ export default function Layout() {
const rsp = await fetch("https://api.nostr.watch/v1/online");
if (rsp.ok) {
const online: string[] = await rsp.json();
const pickRandom = online.sort(() => (Math.random() >= 0.5 ? 1 : -1)).slice(0, 4); // pick 4 random relays
const pickRandom = randomSample(online, 4);
const relayObjects = pickRandom.map(a => [a, { read: true, write: true }]);
newRelays = Object.fromEntries(relayObjects);
dispatch(

View File

@ -4,19 +4,21 @@ import { useIntl, FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import { unwrap } from "Util";
import { formatShort } from "Number";
import RelaysMetadata from "Element/RelaysMetadata";
import { Tab, TabElement } from "Element/Tabs";
import Link from "Icons/Link";
import Qr from "Icons/Qr";
import Zap from "Icons/Zap";
import Envelope from "Icons/Envelope";
import useRelaysFeed from "Feed/RelaysFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import useZapsFeed from "Feed/ZapsFeed";
import { default as ZapElement, parseZap } from "Element/Zap";
import FollowButton from "Element/FollowButton";
import { extractLnAddress, parseId, hexToBech32 } from "Util";
import Avatar from "Element/Avatar";
import LogoutButton from "Element/LogoutButton";
import Timeline from "Element/Timeline";
import Text from "Element/Text";
import SendSats from "Element/SendSats";
@ -46,6 +48,7 @@ const FOLLOWS = 3;
const ZAPS = 4;
const MUTED = 5;
const BLOCKED = 6;
const RELAYS = 7;
export default function ProfilePage() {
const { formatMessage } = useIntl();
@ -69,6 +72,7 @@ export default function ProfilePage() {
const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || "");
const website_url =
user?.website && !user.website.startsWith("http") ? "https://" + user.website : user?.website || "";
const relays = useRelaysFeed(id);
const zapFeed = useZapsFeed(id);
const zaps = useMemo(() => {
const profileZaps = zapFeed.store.notes.map(parseZap).filter(z => z.valid && z.p === id && !z.e && z.zapper !== id);
@ -85,8 +89,12 @@ export default function ProfilePage() {
Zaps: { text: formatMessage(messages.Zaps), value: ZAPS },
Muted: { text: formatMessage(messages.Muted), value: MUTED },
Blocked: { text: formatMessage(messages.Blocked), value: BLOCKED },
Relays: { text: formatMessage(messages.Relays), value: RELAYS },
};
const [tab, setTab] = useState<Tab>(ProfileTab.Notes);
const optionalTabs = [zapsTotal > 0 && ProfileTab.Zaps, relays.length > 0 && ProfileTab.Relays].filter(a =>
unwrap(a)
) as Tab[];
useEffect(() => {
setTab(ProfileTab.Notes);
@ -204,6 +212,9 @@ export default function ProfilePage() {
case BLOCKED: {
return isMe ? <BlockList variant="blocked" /> : null;
}
case RELAYS: {
return <RelaysMetadata relays={relays} />;
}
}
}
@ -229,7 +240,6 @@ export default function ProfilePage() {
)}
{isMe ? (
<>
<LogoutButton />
<button type="button" onClick={() => navigate("/settings")}>
<FormattedMessage {...messages.Settings} />
</button>
@ -282,7 +292,8 @@ export default function ProfilePage() {
</div>
</div>
<div className="tabs main-content" ref={horizontalScroll}>
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Zaps, ProfileTab.Muted].map(renderTab)}
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(renderTab)}
{optionalTabs.map(renderTab)}
{isMe && renderTab(ProfileTab.Blocked)}
</div>
{tabContent()}

View File

@ -33,4 +33,7 @@ export default defineMessages({
NostrPlebsNip: {
defaultMessage: `Nostr Plebs is one of the first NIP-05 providers in the space and offers a good collection of domains at reasonable prices`,
},
Relays: {
defaultMessage: "Relays",
},
});

View File

@ -5,6 +5,7 @@ import { FormattedMessage } from "react-intl";
import { DefaultImgProxy, setPreferences, UserPreferences } from "State/Login";
import { RootState } from "State/Store";
import emoji from "@jukben/emoji-search";
import messages from "./messages";
import { unwrap } from "Util";
@ -197,6 +198,40 @@ const PreferencesPage = () => {
/>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>
<FormattedMessage {...messages.ReactionEmoji} />
</div>
<small>
<FormattedMessage {...messages.ReactionEmojiHelp} />
</small>
</div>
<div>
<select
className="emoji-selector"
value={perf.reactionEmoji}
onChange={e =>
dispatch(
setPreferences({
...perf,
reactionEmoji: e.target.value,
} as UserPreferences)
)
}>
<option value="+">
+ <FormattedMessage {...messages.Default} />
</option>
{emoji("").map(({ name, char }) => {
return (
<option value={char}>
{name} {char}
</option>
);
})}
</select>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>

View File

@ -2,6 +2,7 @@ import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
import { randomSample } from "Util";
import Relay from "Element/Relay";
import useEventPublisher from "Feed/EventPublisher";
import { RootState } from "State/Store";
@ -20,6 +21,14 @@ const RelaySettingsPage = () => {
const ev = await publisher.saveRelays();
publisher.broadcast(ev);
publisher.broadcastForBootstrap(ev);
try {
const onlineRelays = await fetch("https://api.nostr.watch/v1/online").then(r => r.json());
const settingsEv = await publisher.saveRelaysSettings();
const rs = Object.keys(relays).concat(randomSample(onlineRelays, 20));
publisher.broadcastAll(settingsEv, rs);
} catch (error) {
console.error(error);
}
}
function addRelay() {

View File

@ -55,4 +55,6 @@ export default defineMessages({
DisplayName: { defaultMessage: "Display name" },
Buy: { defaultMessage: "Buy" },
Nip05: { defaultMessage: "NIP-05" },
ReactionEmoji: { defaultMessage: "Reaction emoji" },
ReactionEmojiHelp: { defaultMessage: "Emoji to send when reactiong to a note" },
});

View File

@ -26,6 +26,11 @@ export interface UserPreferences {
*/
enableReactions: boolean;
/**
* Reaction emoji
*/
reactionEmoji: string;
/**
* Automatically load media (show link only) (bandwidth/privacy)
*/
@ -176,6 +181,7 @@ export const InitState = {
dmInteraction: 0,
preferences: {
enableReactions: true,
reactionEmoji: "+",
autoLoadMedia: "follows-only",
theme: "system",
confirmReposts: false,

View File

@ -185,3 +185,8 @@ export function unwrap<T>(v: T | undefined | null): T {
}
return v;
}
export function randomSample<T>(coll: T[], size: number) {
const random = [...coll];
return random.sort(() => (Math.random() >= 0.5 ? 1 : -1)).slice(0, size);
}

View File

@ -383,6 +383,9 @@
"e7qqly": {
"string": "Mark All Read"
},
"eHAneD": {
"string": "Reaction emoji"
},
"eJj8HD": {
"string": "Get Verified"
},
@ -404,6 +407,9 @@
"gDZkld": {
"string": "Snort is a Nostr UI, nostr is a decentralised protocol for saving and distributing \"notes\"."
},
"gDzDRs": {
"string": "Emoji to send when reactiong to a note"
},
"gjBiyj": {
"string": "Loading..."
},

View File

@ -127,6 +127,7 @@
"d7d0/x": "LN Address",
"dOQCL8": "Display name",
"e7qqly": "Mark All Read",
"eHAneD": "Reaction emoji",
"eJj8HD": "Get Verified",
"eR3YIn": "Posts",
"filwqD": "Read",
@ -134,6 +135,7 @@
"g5pX+a": "About",
"gBdUXk": "Save your keys!",
"gDZkld": "Snort is a Nostr UI, nostr is a decentralised protocol for saving and distributing \"notes\".",
"gDzDRs": "Emoji to send when reactiong to a note",
"gjBiyj": "Loading...",
"hCUivF": "Notes will stream in real time into global and posts tab",
"hK5ZDk": "the world",
@ -209,4 +211,4 @@
"zjJZBd": "You're ready!",
"zonsdq": "Failed to load LNURL service",
"zvCDao": "Automatically show latest notes"
}
}

View File

@ -111,6 +111,7 @@
"Pages.Notes": "Notas",
"Pages.Posts": "Notas",
"Pages.Reactions": "Reacciones",
"Pages.Relays": "",
"Pages.Sats": "{n} {n, plural, =1 {sat} other {sats}}",
"Pages.Search": "Búsqueda",
"Pages.SearchPlaceholder": "Buscar...",
@ -201,6 +202,8 @@
"Pages.settings.Preferences": "Preferencias",
"Pages.settings.PrivateKey": "Tu Clave Privada (no la compartas con nadie) es",
"Pages.settings.Profile": "Perfil",
"Pages.settings.ReactionEmoji": "",
"Pages.settings.ReactionEmojiHelp": "",
"Pages.settings.Relays": "Relays",
"Pages.settings.Remove": "Eliminar",
"Pages.settings.Save": "Guardar",

View File

@ -110,6 +110,7 @@
"Pages.Notes": "Notes",
"Pages.Posts": "Publications",
"Pages.Reactions": "Réactions",
"Pages.Relays": "",
"Pages.Sats": "{n} {n, plural, =1 {sat} other {sats}}",
"Pages.Search": "Chercher",
"Pages.SearchPlaceholder": "Chercher...",
@ -200,6 +201,8 @@
"Pages.settings.Preferences": "Préférences",
"Pages.settings.PrivateKey": "Votre Clé Privée Est (ne la partagez avec personne)",
"Pages.settings.Profile": "Profil",
"Pages.settings.ReactionEmoji": "",
"Pages.settings.ReactionEmojiHelp": "",
"Pages.settings.Relays": "Relais",
"Pages.settings.Remove": "Retirer",
"Pages.settings.Save": "Sauvegarder",

View File

@ -112,6 +112,7 @@
"Pages.Posts": "ポスト",
"Pages.Reactions": "リアクション",
"Pages.Sats": "{n} {n, plural, =1 {sat} other {sats}}",
"Pages.Relays": "",
"Pages.Search": "検索",
"Pages.SearchPlaceholder": "検索する",
"Pages.Settings": "設定",
@ -201,6 +202,8 @@
"Pages.settings.Preferences": "ユーザー設定",
"Pages.settings.PrivateKey": "あなたの秘密鍵(誰とも共有しないこと)",
"Pages.settings.Profile": "プロフィール",
"Pages.settings.ReactionEmoji": "",
"Pages.settings.ReactionEmojiHelp": "",
"Pages.settings.Relays": "リレー",
"Pages.settings.Remove": "削除",
"Pages.settings.Save": "保存",

View File

@ -111,6 +111,7 @@
"Pages.Notes": "",
"Pages.Posts": "",
"Pages.Reactions": "",
"Pages.Relays": "",
"Pages.Sats": "",
"Pages.Search": "",
"Pages.SearchPlaceholder": "",
@ -201,6 +202,8 @@
"Pages.settings.Preferences": "",
"Pages.settings.PrivateKey": "",
"Pages.settings.Profile": "",
"Pages.settings.ReactionEmoji": "",
"Pages.settings.ReactionEmojiHelp": "",
"Pages.settings.Relays": "",
"Pages.settings.Remove": "",
"Pages.settings.Save": "",