chore: Update translations
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Kieran 2023-09-14 18:45:29 +00:00
parent a8f000d9d8
commit 9c05b6a101
21 changed files with 609 additions and 567 deletions

View File

@ -3,8 +3,12 @@ import { orderDescending } from "SnortUtils";
import Note from "../Note";
export default function Articles() {
const data = useArticles();
return <>
{orderDescending(data.data ?? []).map(a => <Note data={a} key={a.id} related={[]} />)}
const data = useArticles();
return (
<>
{orderDescending(data.data ?? []).map(a => (
<Note data={a} key={a.id} related={[]} />
))}
</>
}
);
}

View File

@ -1,12 +1,12 @@
nav.deck {
width: 48px;
height: calc(100vh - 20px);
padding: 10px 8px;
border-right: 1px solid var(--border-color);
text-align: center;
width: 48px;
height: calc(100vh - 20px);
padding: 10px 8px;
border-right: 1px solid var(--border-color);
text-align: center;
}
nav.deck .avatar {
width: 40px;
height: 40px;
}
width: 40px;
height: 40px;
}

View File

@ -7,36 +7,35 @@ import { Link } from "react-router-dom";
import { profileLink } from "SnortUtils";
export function DeckNav() {
const { publicKey } = useLogin();
const profile = useUserProfile(publicKey);
const { publicKey } = useLogin();
const profile = useUserProfile(publicKey);
const unreadDms = 0;
const hasNotifications = false;
const unreadDms = 0;
const hasNotifications = false;
return <nav className="deck flex-column f-space">
<div className="flex-column f-center g24">
<Link className="btn" to="/messages">
<Icon name="mail" size={24} />
{unreadDms > 0 && <span className="has-unread"></span>}
</Link>
<Link className="btn" to="/notifications">
<Icon name="bell-02" size={24} />
{hasNotifications && <span className="has-unread"></span>}
</Link>
</div>
<div className="flex-column f-center g16">
<Link className="btn" to="/">
<Icon name="grid-01" size={24} />
</Link>
<Link className="btn" to="/settings">
<Icon name="settings-02" size={24} />
</Link>
<Link to={profileLink(publicKey ?? "")}>
<Avatar
pubkey={publicKey ?? ""}
user={profile}
/>
</Link>
</div>
return (
<nav className="deck flex-column f-space">
<div className="flex-column f-center g24">
<Link className="btn" to="/messages">
<Icon name="mail" size={24} />
{unreadDms > 0 && <span className="has-unread"></span>}
</Link>
<Link className="btn" to="/notifications">
<Icon name="bell-02" size={24} />
{hasNotifications && <span className="has-unread"></span>}
</Link>
</div>
<div className="flex-column f-center g16">
<Link className="btn" to="/">
<Icon name="grid-01" size={24} />
</Link>
<Link className="btn" to="/settings">
<Icon name="settings-02" size={24} />
</Link>
<Link to={profileLink(publicKey ?? "")}>
<Avatar pubkey={publicKey ?? ""} user={profile} />
</Link>
</div>
</nav>
}
);
}

View File

@ -3,7 +3,17 @@ import React, { useMemo, useState, ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useInView } from "react-intersection-observer";
import { useIntl, FormattedMessage } from "react-intl";
import { TaggedNostrEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt, parseZap, tagToNostrLink, createNostrLinkToEvent } from "@snort/system";
import {
TaggedNostrEvent,
HexKey,
EventKind,
NostrPrefix,
Lists,
EventExt,
parseZap,
tagToNostrLink,
createNostrLinkToEvent,
} from "@snort/system";
import { System } from "index";
import useEventPublisher from "Feed/EventPublisher";
@ -200,19 +210,22 @@ export function NoteInner(props: NoteProps) {
const image = findTag(ev, "image");
return (
<div className="long-form-note">
<h3>
{title}
</h3>
<h3>{title}</h3>
<div className="text">
<p>
{summary}
</p>
<Text id={ev.id} content={ev.content} tags={ev.tags} creator={ev.pubkey} depth={props.depth} truncate={255} disableLinkPreview={true} />
<p>{summary}</p>
<Text
id={ev.id}
content={ev.content}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
truncate={255}
disableLinkPreview={true}
/>
{image && <ProxyImg src={image} />}
</div>
</div>
)
);
} else {
const body = ev?.content ?? "";
return (
@ -226,7 +239,7 @@ export function NoteInner(props: NoteProps) {
/>
);
}
}
};
const transformBody = () => {
if (deletions?.length > 0) {
@ -297,7 +310,11 @@ export function NoteInner(props: NoteProps) {
const maxMentions = 2;
const replyTo = thread?.replyTo ?? thread?.root;
const replyLink = replyTo ? tagToNostrLink([replyTo.key, replyTo.value ?? "", replyTo.relay ?? "", replyTo.marker ?? ""].filter(a => a.length > 0)) : undefined;
const replyLink = replyTo
? tagToNostrLink(
[replyTo.key, replyTo.value ?? "", replyTo.relay ?? "", replyTo.marker ?? ""].filter(a => a.length > 0),
)
: undefined;
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
for (const pk of thread?.pubKeys ?? []) {
const u = UserCache.getFromCache(pk);
@ -330,11 +347,7 @@ export function NoteInner(props: NoteProps) {
{pubMentions} {others}
</>
) : (
replyLink && (
<Link to={`/e/${replyLink.encode()}`}>
{replyLink.encode().substring(0, 12)}
</Link>
)
replyLink && <Link to={`/e/${replyLink.encode()}`}>{replyLink.encode().substring(0, 12)}</Link>
)}
</div>
);
@ -418,7 +431,7 @@ export function NoteInner(props: NoteProps) {
{options.showContextMenu && (
<NoteContextMenu
ev={ev}
react={async () => { }}
react={async () => {}}
onTranslated={t => setTranslated(t)}
setShowReactions={setShowReactions}
/>

View File

@ -1,18 +1,17 @@
.root-type {
display: flex;
align-items: center;
justify-content: center;
}
.root-type > button {
background: white;
color: black;
font-size: 16px;
padding: 10px 16px;
border-radius: 1000px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
display: flex;
align-items: center;
justify-content: center;
}
.root-type > button {
background: white;
color: black;
font-size: 16px;
padding: 10px 16px;
border-radius: 1000px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}

View File

@ -7,145 +7,150 @@ import { FormattedMessage } from "react-intl";
import useLogin from "Hooks/useLogin";
import Icon from "Icons/Icon";
export type RootTab = "following" | "conversations" | "trending-notes" | "trending-people" | "suggested" | "tags" | "global";
export type RootTab =
| "following"
| "conversations"
| "trending-notes"
| "trending-people"
| "suggested"
| "tags"
| "global";
export function RootTabs({ base }: { base?: string }) {
const navigate = useNavigate();
const location = useLocation();
const { publicKey: pubKey, tags } = useLogin();
const [rootType, setRootType] = useState<RootTab>("following");
const navigate = useNavigate();
const location = useLocation();
const { publicKey: pubKey, tags } = useLogin();
const [rootType, setRootType] = useState<RootTab>("following");
const menuItems = [
{
tab: "following",
path: `${base}/notes`,
show: Boolean(pubKey),
element: (
<>
<Icon name="user-v2" />
<FormattedMessage defaultMessage="Following" />
</>
),
},
{
tab: "trending-notes",
path: `${base}/trending/notes`,
show: true,
element: (
<>
<Icon name="fire" />
<FormattedMessage defaultMessage="Trending Notes" />
</>
),
},
{
tab: "conversations",
path: `${base}/conversations`,
show: Boolean(pubKey),
element: (
<>
<Icon name="message-chat-circle" />
<FormattedMessage defaultMessage="Conversations" />
</>
),
},
{
tab: "trending-people",
path: `${base}/trending/people`,
show: true,
element: (
<>
<Icon name="user-up" />
<FormattedMessage defaultMessage="Trending People" />
</>
),
},
{
tab: "suggested",
path: `${base}/suggested`,
show: Boolean(pubKey),
element: (
<>
<Icon name="thumbs-up" />
<FormattedMessage defaultMessage="Suggested Follows" />
</>
),
},
{
tab: "global",
path: `${base}/global`,
show: true,
element: (
<>
<Icon name="globe" />
<FormattedMessage defaultMessage="Global" />
</>
),
},
] as Array<{
tab: RootTab;
path: string;
show: boolean;
element: ReactNode;
}>;
const menuItems = [
{
tab: "following",
path: `${base}/notes`,
show: Boolean(pubKey),
element: (
<>
<Icon name="user-v2" />
<FormattedMessage defaultMessage="Following" />
</>
),
},
{
tab: "trending-notes",
path: `${base}/trending/notes`,
show: true,
element: (
<>
<Icon name="fire" />
<FormattedMessage defaultMessage="Trending Notes" />
</>
),
},
{
tab: "conversations",
path: `${base}/conversations`,
show: Boolean(pubKey),
element: (
<>
<Icon name="message-chat-circle" />
<FormattedMessage defaultMessage="Conversations" />
</>
),
},
{
tab: "trending-people",
path: `${base}/trending/people`,
show: true,
element: (
<>
<Icon name="user-up" />
<FormattedMessage defaultMessage="Trending People" />
</>
),
},
{
tab: "suggested",
path: `${base}/suggested`,
show: Boolean(pubKey),
element: (
<>
<Icon name="thumbs-up" />
<FormattedMessage defaultMessage="Suggested Follows" />
</>
),
},
{
tab: "global",
path: `${base}/global`,
show: true,
element: (
<>
<Icon name="globe" />
<FormattedMessage defaultMessage="Global" />
</>
),
},
] as Array<{
tab: RootTab;
path: string;
show: boolean;
element: ReactNode;
}>;
useEffect(() => {
const currentTab = menuItems.find(a => a.path === location.pathname)?.tab;
if (currentTab) {
setRootType(currentTab);
}
}, [location]);
function currentMenuItem() {
if (location.pathname.startsWith(`${base}/t/`)) {
return (
<>
<Icon name="hash" />
{location.pathname.split("/").slice(-1)}
</>
);
}
return menuItems.find(a => a.tab === rootType)?.element;
useEffect(() => {
const currentTab = menuItems.find(a => a.path === location.pathname)?.tab;
if (currentTab) {
setRootType(currentTab);
}
}, [location]);
function currentMenuItem() {
if (location.pathname.startsWith(`${base}/t/`)) {
return (
<>
<Icon name="hash" />
{location.pathname.split("/").slice(-1)}
</>
);
}
return menuItems.find(a => a.tab === rootType)?.element;
}
return (
<div className="root-type">
<Menu
menuButton={
<button type="button">
{currentMenuItem()}
<Icon name="chevronDown" />
</button>
}
align="center"
menuClassName={() => "ctx-menu"}>
<div className="close-menu-container">
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
{menuItems
.filter(a => a.show)
.map(a => (
<MenuItem
onClick={() => {
navigate(a.path);
}}>
{a.element}
</MenuItem>
))}
{tags.item.map(v => (
<MenuItem
onClick={() => {
navigate(`${base}/t/${v}`);
}}>
<Icon name="hash" />
{v}
</MenuItem>
))}
</Menu>
return (
<div className="root-type">
<Menu
menuButton={
<button type="button">
{currentMenuItem()}
<Icon name="chevronDown" />
</button>
}
align="center"
menuClassName={() => "ctx-menu"}>
<div className="close-menu-container">
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
);
}
{menuItems
.filter(a => a.show)
.map(a => (
<MenuItem
onClick={() => {
navigate(a.path);
}}>
{a.element}
</MenuItem>
))}
{tags.item.map(v => (
<MenuItem
onClick={() => {
navigate(`${base}/t/${v}`);
}}>
<Icon name="hash" />
{v}
</MenuItem>
))}
</Menu>
</div>
);
}

View File

@ -53,7 +53,6 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
);
}
export function SpotlightMediaModal(props: SpotlightMediaProps) {
return (
<Modal onClose={props.onClose} className="spotlight">

View File

@ -2,13 +2,7 @@ import "./Thread.css";
import { useMemo, useState, ReactNode, useContext } from "react";
import { useIntl } from "react-intl";
import { useNavigate, Link, useParams } from "react-router-dom";
import {
TaggedNostrEvent,
u256,
NostrPrefix,
EventExt,
parseNostrLink,
} from "@snort/system";
import { TaggedNostrEvent, u256, NostrPrefix, EventExt, parseNostrLink } from "@snort/system";
import { eventLink, getReactions, getAllReactions } from "SnortUtils";
import BackButton from "Element/BackButton";
@ -160,8 +154,9 @@ const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate
return (
<>
<div
className={`subthread-container ${hasMultipleNotes ? "subthread-multi" : ""} ${isLast ? "subthread-last" : "subthread-mid"
}`}>
className={`subthread-container ${hasMultipleNotes ? "subthread-multi" : ""} ${
isLast ? "subthread-last" : "subthread-mid"
}`}>
<Divider variant="small" />
<Note
highlight={active === first.id}
@ -190,8 +185,9 @@ const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate
return (
<div
key={r.id}
className={`subthread-container ${lastReply ? "" : "subthread-multi"} ${lastReply ? "subthread-last" : "subthread-mid"
}`}>
className={`subthread-container ${lastReply ? "" : "subthread-multi"} ${
lastReply ? "subthread-last" : "subthread-mid"
}`}>
<Divider variant="small" />
<Note
className={`thread-note ${lastNote ? "is-last-note" : ""}`}
@ -213,9 +209,11 @@ export function ThreadRoute() {
const params = useParams();
const link = parseNostrLink(params.id ?? "", NostrPrefix.Note);
return <ThreadContextWrapper link={link}>
<Thread />
</ThreadContextWrapper>
return (
<ThreadContextWrapper link={link}>
<Thread />
</ThreadContextWrapper>
);
}
export function Thread() {

View File

@ -113,9 +113,12 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
)}
</>
)}
{mainFeed.map(a => props.noteRenderer?.(a) ?? (
<Note data={a as TaggedNostrEvent} related={relatedFeed(a.id)} key={a.id} depth={0} />
))}
{mainFeed.map(
a =>
props.noteRenderer?.(a) ?? (
<Note data={a as TaggedNostrEvent} related={relatedFeed(a.id)} key={a.id} depth={0} />
),
)}
<div className="flex f-center p">
<AsyncButton
onClick={async () => {

View File

@ -4,18 +4,15 @@ import useLogin from "Hooks/useLogin";
import { useMemo } from "react";
export function useArticles() {
const {publicKey, follows} = useLogin();
const { publicKey, follows } = useLogin();
const sub = useMemo(() => {
if(!publicKey) return null;
const rb = new RequestBuilder(`articles:${publicKey}`);
rb.withFilter()
.kinds([EventKind.LongFormTextNote])
.authors(follows.item)
.limit(20);
return rb;
}, [follows.timestamp]);
const sub = useMemo(() => {
if (!publicKey) return null;
const rb = new RequestBuilder(`articles:${publicKey}`);
rb.withFilter().kinds([EventKind.LongFormTextNote]).authors(follows.item).limit(20);
return useRequestBuilder(NoteCollection, sub);
}
return rb;
}, [follows.timestamp]);
return useRequestBuilder(NoteCollection, sub);
}

View File

@ -3,20 +3,20 @@ import { useEffect } from "react";
import useLogin from "./useLogin";
export function useLoginRelays() {
const { relays } = useLogin();
const { relays } = useLogin();
useEffect(() => {
if (relays) {
(async () => {
for (const [k, v] of Object.entries(relays.item)) {
await System.ConnectToRelay(k, v);
}
for (const v of System.Sockets) {
if (!relays.item[v.address] && !v.ephemeral) {
System.DisconnectRelay(v.address);
}
}
})();
useEffect(() => {
if (relays) {
(async () => {
for (const [k, v] of Object.entries(relays.item)) {
await System.ConnectToRelay(k, v);
}
}, [relays]);
}
for (const v of System.Sockets) {
if (!relays.item[v.address] && !v.ephemeral) {
System.DisconnectRelay(v.address);
}
}
})();
}
}, [relays]);
}

View File

@ -3,13 +3,13 @@ import { ParsedFragment, transformText } from "@snort/system";
const TextCache = new Map<string, Array<ParsedFragment>>();
export function transformTextCached(id: string, content: string, tags: Array<Array<string>>) {
const cached = TextCache.get(id);
if (cached) return cached;
const newCache = transformText(content, tags);
TextCache.set(id, newCache);
return newCache;
const cached = TextCache.get(id);
if (cached) return cached;
const newCache = transformText(content, tags);
TextCache.set(id, newCache);
return newCache;
}
export function useTextTransformer(id: string, content: string, tags: Array<Array<string>>) {
return transformTextCached(id, content, tags);
}
return transformTextCached(id, content, tags);
}

View File

@ -2,31 +2,30 @@ import { useEffect } from "react";
import useLogin from "./useLogin";
export function useTheme() {
const { preferences } = useLogin();
const { preferences } = useLogin();
function setTheme(theme: "light" | "dark") {
const elm = document.documentElement;
if (theme === "light" && !elm.classList.contains("light")) {
elm.classList.add("light");
} else if (theme === "dark" && elm.classList.contains("light")) {
elm.classList.remove("light");
}
function setTheme(theme: "light" | "dark") {
const elm = document.documentElement;
if (theme === "light" && !elm.classList.contains("light")) {
elm.classList.add("light");
} else if (theme === "dark" && elm.classList.contains("light")) {
elm.classList.remove("light");
}
}
useEffect(() => {
const osTheme = window.matchMedia("(prefers-color-scheme: light)");
setTheme(
preferences.theme === "system" && osTheme.matches ? "light" : preferences.theme === "light" ? "light" : "dark"
);
useEffect(() => {
const osTheme = window.matchMedia("(prefers-color-scheme: light)");
setTheme(
preferences.theme === "system" && osTheme.matches ? "light" : preferences.theme === "light" ? "light" : "dark",
);
osTheme.onchange = e => {
if (preferences.theme === "system") {
setTheme(e.matches ? "light" : "dark");
}
};
return () => {
osTheme.onchange = null;
};
}, [preferences.theme]);
}
osTheme.onchange = e => {
if (preferences.theme === "system") {
setTheme(e.matches ? "light" : "dark");
}
};
return () => {
osTheme.onchange = null;
};
}, [preferences.theme]);
}

View File

@ -1,110 +1,116 @@
import { unwrap } from "@snort/shared";
import { EventExt, EventKind, NostrLink, NostrPrefix, TaggedNostrEvent, u256, Thread as ThreadInfo, } from "@snort/system";
import {
EventExt,
EventKind,
NostrLink,
NostrPrefix,
TaggedNostrEvent,
u256,
Thread as ThreadInfo,
} from "@snort/system";
import useThreadFeed from "Feed/ThreadFeed";
import { findTag } from "SnortUtils";
import { ReactNode, createContext, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
export interface ThreadContext {
current: string,
root?: TaggedNostrEvent,
chains: Map<string, Array<TaggedNostrEvent>>,
data: Array<TaggedNostrEvent>,
setCurrent: (i: string) => void;
current: string;
root?: TaggedNostrEvent;
chains: Map<string, Array<TaggedNostrEvent>>;
data: Array<TaggedNostrEvent>;
setCurrent: (i: string) => void;
}
export const ThreadContext = createContext({} as ThreadContext)
export const ThreadContext = createContext({} as ThreadContext);
export function ThreadContextWrapper({ link, children }: { link: NostrLink, children?: ReactNode }) {
const location = useLocation();
const [currentId, setCurrentId] = useState(link.id);
const thread = useThreadFeed(link);
export function ThreadContextWrapper({ link, children }: { link: NostrLink; children?: ReactNode }) {
const location = useLocation();
const [currentId, setCurrentId] = useState(link.id);
const thread = useThreadFeed(link);
const chains = useMemo(() => {
const chains = new Map<u256, Array<TaggedNostrEvent>>();
if (thread.data) {
thread.data
?.filter(a => a.kind === EventKind.TextNote)
.sort((a, b) => b.created_at - a.created_at)
.forEach(v => {
const t = EventExt.extractThread(v);
let replyTo = t?.replyTo?.value ?? t?.root?.value;
if (t?.root?.key === "a" && t?.root?.value) {
const parsed = t.root.value.split(":");
replyTo = thread.data?.find(
a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2]
)?.id;
}
if (replyTo) {
if (!chains.has(replyTo)) {
chains.set(replyTo, [v]);
} else {
unwrap(chains.get(replyTo)).push(v);
}
}
});
const chains = useMemo(() => {
const chains = new Map<u256, Array<TaggedNostrEvent>>();
if (thread.data) {
thread.data
?.filter(a => a.kind === EventKind.TextNote)
.sort((a, b) => b.created_at - a.created_at)
.forEach(v => {
const t = EventExt.extractThread(v);
let replyTo = t?.replyTo?.value ?? t?.root?.value;
if (t?.root?.key === "a" && t?.root?.value) {
const parsed = t.root.value.split(":");
replyTo = thread.data?.find(
a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2],
)?.id;
}
if (replyTo) {
if (!chains.has(replyTo)) {
chains.set(replyTo, [v]);
} else {
unwrap(chains.get(replyTo)).push(v);
}
}
});
}
return chains;
}, [thread.data]);
// Root is the parent of the current note or the current note if its a root note or the root of the thread
const root = useMemo(() => {
const currentNote =
thread.data?.find(
ne =>
ne.id === currentId ||
(link.type === NostrPrefix.Address && findTag(ne, "d") === currentId && ne.pubkey === link.author),
) ?? (location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined);
if (currentNote) {
const currentThread = EventExt.extractThread(currentNote);
const isRoot = (ne?: ThreadInfo) => ne === undefined;
if (isRoot(currentThread)) {
return currentNote;
}
const replyTo = currentThread?.replyTo ?? currentThread?.root;
// sometimes the root event ID is missing, and we can only take the happy path if the root event ID exists
if (replyTo) {
if (replyTo.key === "a" && replyTo.value) {
const parsed = replyTo.value.split(":");
return thread.data?.find(
a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2],
);
}
return chains;
}, [thread.data]);
// Root is the parent of the current note or the current note if its a root note or the root of the thread
const root = useMemo(() => {
const currentNote =
thread.data?.find(
ne =>
ne.id === currentId ||
(link.type === NostrPrefix.Address && findTag(ne, "d") === currentId && ne.pubkey === link.author)
) ?? (location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined);
if (currentNote) {
const currentThread = EventExt.extractThread(currentNote);
const isRoot = (ne?: ThreadInfo) => ne === undefined;
if (isRoot(currentThread)) {
return currentNote;
}
const replyTo = currentThread?.replyTo ?? currentThread?.root;
// sometimes the root event ID is missing, and we can only take the happy path if the root event ID exists
if (replyTo) {
if (replyTo.key === "a" && replyTo.value) {
const parsed = replyTo.value.split(":");
return thread.data?.find(
a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2]
);
}
if (replyTo.value) {
return thread.data?.find(a => a.id === replyTo.value);
}
}
const possibleRoots = thread.data?.filter(a => {
const thread = EventExt.extractThread(a);
return isRoot(thread);
});
if (possibleRoots) {
// worst case we need to check every possible root to see which one contains the current note as a child
for (const ne of possibleRoots) {
const children = chains.get(ne.id) ?? [];
if (children.find(ne => ne.id === currentId)) {
return ne;
}
}
}
if (replyTo.value) {
return thread.data?.find(a => a.id === replyTo.value);
}
}, [thread.data, currentId, location]);
}
const ctxValue = useMemo(() => {
return {
current: currentId,
root,
chains,
data: thread.data,
setCurrent: v => setCurrentId(v)
} as ThreadContext
}, [root, chains]);
const possibleRoots = thread.data?.filter(a => {
const thread = EventExt.extractThread(a);
return isRoot(thread);
});
if (possibleRoots) {
// worst case we need to check every possible root to see which one contains the current note as a child
for (const ne of possibleRoots) {
const children = chains.get(ne.id) ?? [];
return <ThreadContext.Provider value={ctxValue}>
{children}
</ThreadContext.Provider>
}
if (children.find(ne => ne.id === currentId)) {
return ne;
}
}
}
}
}, [thread.data, currentId, location]);
const ctxValue = useMemo(() => {
return {
current: currentId,
root,
chains,
data: thread.data,
setCurrent: v => setCurrentId(v),
} as ThreadContext;
}, [root, chains]);
return <ThreadContext.Provider value={ctxValue}>{children}</ThreadContext.Provider>;
}

View File

@ -1,97 +1,97 @@
.deck-layout {
display: flex;
height: 100vh;
overflow-y: hidden;
display: flex;
height: 100vh;
overflow-y: hidden;
}
.deck-layout .deck-cols {
display: flex;
height: 100vh;
overflow-y: hidden;
overflow-x: auto;
.deck-layout .deck-cols {
display: flex;
height: 100vh;
overflow-y: hidden;
overflow-x: auto;
}
.deck-layout .deck-cols .deck-col-header {
padding: 8px 16px;
border: 1px solid var(--border-color);
border-collapse: collapse;
font-size: 20px;
font-weight: 700;
min-height: 40px;
max-height: 40px;
padding: 8px 16px;
border: 1px solid var(--border-color);
border-collapse: collapse;
font-size: 20px;
font-weight: 700;
min-height: 40px;
max-height: 40px;
}
.deck-layout .deck-cols .deck-col-header:not(:last-of-type) {
border-right: 0;
border-right: 0;
}
.deck-layout .deck-cols > div {
display: flex;
flex-direction: column;
height: 100vh;
width: 550px;
min-width: 550px;
display: flex;
flex-direction: column;
height: 100vh;
width: 550px;
min-width: 550px;
}
.deck-layout .deck-cols > div > div:not(:first-of-type) {
overflow-y: scroll;
overflow-y: scroll;
}
.image-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.image-grid > .media-note {
border: 1px solid var(--border-color);
background-image: var(--img);
background-position: center;
background-size: cover;
aspect-ratio: 1;
cursor: pointer;
border: 1px solid var(--border-color);
background-image: var(--img);
background-position: center;
background-size: cover;
aspect-ratio: 1;
cursor: pointer;
}
.thread-overlay .modal-body {
background-color: unset;
padding: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: row;
border-radius: unset;
gap: 16px;
--border-color: #3A3A3A;
background-color: unset;
padding: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: row;
border-radius: unset;
gap: 16px;
--border-color: #3a3a3a;
}
.thread-overlay .modal-body > div:last-of-type {
width: 550px;
min-width: 550px;
height: 100vh;
overflow-y: auto;
background-color: var(--gray-superdark);
width: 550px;
min-width: 550px;
height: 100vh;
overflow-y: auto;
background-color: var(--gray-superdark);
}
.thread-overlay .spotlight {
flex-grow: 1;
margin: auto;
text-align: center;
flex-grow: 1;
margin: auto;
text-align: center;
}
.thread-overlay .spotlight .details {
right: calc(28px + 550px + 16px);
right: calc(28px + 550px + 16px);
}
.thread-overlay .spotlight .right {
right: calc(24px + 550px + 16px);
right: calc(24px + 550px + 16px);
}
.thread-overlay .spotlight img,
.thread-overlay .spotlight video {
max-width: calc(100vw - 550px - 16px);
max-width: calc(100vw - 550px - 16px);
}
.thread-overlay .main-content {
border: 0;
border-bottom: 1px solid var(--border-color);
}
border: 0;
border-bottom: 1px solid var(--border-color);
}

View File

@ -25,127 +25,147 @@ import useLogin from "Hooks/useLogin";
type Cols = "notes" | "articles" | "media" | "streams" | "notifications";
export function SnortDeckLayout() {
const login = useLogin();
const navigate = useNavigate();
const [thread, setThread] = useState<string>();
const login = useLogin();
const navigate = useNavigate();
const [thread, setThread] = useState<string>();
useLoginFeed();
useTheme();
useLoginRelays();
useLoginFeed();
useTheme();
useLoginRelays();
useEffect(() => {
if (!login.publicKey) {
navigate("/");
}
}, [login]);
useEffect(() => {
if (!login.publicKey) {
navigate("/");
}
}, [login]);
if (!login.publicKey) return null;
const cols = ["notes", "media", "notifications", "articles"] as Array<Cols>;
return <div className="deck-layout">
<DeckNav />
<div className="deck-cols">
{cols.map(c => {
switch (c) {
case "notes": return <NotesCol />
case "media": return <MediaCol setThread={setThread} />
case "articles": return <ArticlesCol />
case "notifications": return <NotificationsCol />
}
})}
</div>
{thread && <>
<Modal onClose={() => setThread(undefined)} className="thread-overlay">
<ThreadContextWrapper link={createNostrLink(NostrPrefix.Note, thread)}>
<SpotlightFromThread onClose={() => setThread(undefined)} />
<div>
<Thread />
</div>
</ThreadContextWrapper>
</Modal>
</>}
<Toaster />
if (!login.publicKey) return null;
const cols = ["notes", "media", "notifications", "articles"] as Array<Cols>;
return (
<div className="deck-layout">
<DeckNav />
<div className="deck-cols">
{cols.map(c => {
switch (c) {
case "notes":
return <NotesCol />;
case "media":
return <MediaCol setThread={setThread} />;
case "articles":
return <ArticlesCol />;
case "notifications":
return <NotificationsCol />;
}
})}
</div>
{thread && (
<>
<Modal onClose={() => setThread(undefined)} className="thread-overlay">
<ThreadContextWrapper link={createNostrLink(NostrPrefix.Note, thread)}>
<SpotlightFromThread onClose={() => setThread(undefined)} />
<div>
<Thread />
</div>
</ThreadContextWrapper>
</Modal>
</>
)}
<Toaster />
</div>
);
}
function SpotlightFromThread({ onClose }: { onClose: () => void }) {
const thread = useContext(ThreadContext);
const thread = useContext(ThreadContext);
const parsed = thread.root ? transformTextCached(thread.root.id, thread.root.content, thread.root.tags) : [];
const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));
const parsed = thread.root ? transformTextCached(thread.root.id, thread.root.content, thread.root.tags) : [];
const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));
return <SpotlightMedia images={images.map(a => a.content)} idx={0} onClose={onClose} />
return <SpotlightMedia images={images.map(a => a.content)} idx={0} onClose={onClose} />;
}
function NotesCol() {
return (
<div>
<div className="deck-col-header flex">
<div className="flex f-1 g8">
<Icon name="rows-01" size={24} />
<FormattedMessage defaultMessage="Notes" />
</div>
<div className="f-1">
<RootTabs base="/deck" />
</div>
</div>
<div>
<Outlet />
</div>
return (
<div>
<div className="deck-col-header flex">
<div className="flex f-1 g8">
<Icon name="rows-01" size={24} />
<FormattedMessage defaultMessage="Notes" />
</div>
);
<div className="f-1">
<RootTabs base="/deck" />
</div>
</div>
<div>
<Outlet />
</div>
</div>
);
}
function ArticlesCol() {
return (
<div>
<div className="deck-col-header flex g8">
<Icon name="file-06" size={24} />
<FormattedMessage defaultMessage="Articles" />
</div>
<div>
<Articles />
</div>
</div>
);
return (
<div>
<div className="deck-col-header flex g8">
<Icon name="file-06" size={24} />
<FormattedMessage defaultMessage="Articles" />
</div>
<div>
<Articles />
</div>
</div>
);
}
function MediaCol({ setThread }: { setThread: (e: string) => void }) {
const { proxy } = useImgProxy();
return (
<div>
<div className="deck-col-header flex g8">
<Icon name="camera-lens" size={24} />
<FormattedMessage defaultMessage="Media" />
</div>
<div className="image-grid p">
<TimelineFollows postsOnly={true} liveStreams={false} noteFilter={e => {
const parsed = transformTextCached(e.id, e.content, e.tags);
const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));
return images.length > 0;
}} noteRenderer={e => {
const parsed = transformTextCached(e.id, e.content, e.tags);
const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));
const { proxy } = useImgProxy();
return (
<div>
<div className="deck-col-header flex g8">
<Icon name="camera-lens" size={24} />
<FormattedMessage defaultMessage="Media" />
</div>
<div className="image-grid p">
<TimelineFollows
postsOnly={true}
liveStreams={false}
noteFilter={e => {
const parsed = transformTextCached(e.id, e.content, e.tags);
const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));
return images.length > 0;
}}
noteRenderer={e => {
const parsed = transformTextCached(e.id, e.content, e.tags);
const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));
return <div className="media-note" key={e.id} style={{
"--img": `url(${proxy(images[0].content)})`
} as CSSProperties} onClick={() => setThread(e.id)}></div>
}} />
</div>
</div>
);
return (
<div
className="media-note"
key={e.id}
style={
{
"--img": `url(${proxy(images[0].content)})`,
} as CSSProperties
}
onClick={() => setThread(e.id)}></div>
);
}}
/>
</div>
</div>
);
}
function NotificationsCol() {
return (
<div>
<div className="deck-col-header flex g8">
<Icon name="bell-02" size={24} />
<FormattedMessage defaultMessage="Notifications" />
</div>
<div>
<NotificationsPage />
</div>
</div>
);
}
return (
<div>
<div className="deck-col-header flex g8">
<Icon name="bell-02" size={24} />
<FormattedMessage defaultMessage="Notifications" />
</div>
<div>
<NotificationsPage />
</div>
</div>
);
}

View File

@ -164,12 +164,12 @@ const DefaultTab = () => {
const tab = publicKey ? preferences.defaultRootTab ?? `notes` : `trending/notes`;
const elm = RootTabRoutes.find(a => a.path === tab)?.element;
return elm;
}
};
export const RootTabRoutes = [
{
path: "",
element: <DefaultTab />
element: <DefaultTab />,
},
{
path: "global",

View File

@ -262,7 +262,7 @@ button.icon:hover {
}
.light .btn {
color: #64748B;
color: #64748b;
}
.btn-warn {

View File

@ -211,8 +211,8 @@ export const router = createBrowserRouter([
}
return null;
},
children: RootTabRoutes
}
children: RootTabRoutes,
},
]);
const root = ReactDOM.createRoot(unwrap(document.getElementById("root")));

View File

@ -10,8 +10,8 @@
"/4tOwT": "Überspringen",
"/JE/X+": "Konto Hilfe",
"/PCavi": "Öffentlich",
"/RD0e2": "Nostr nutzt digitale Signaturen, um manipulationssichere Notes zu erstellen, welche sicher auf viele Relays repliziert werden können, um eine redundante Speicherung deiner Inhalte zu bieten.",
"/Xf4UW": "Send anonymous usage metrics",
"/RD0e2": "Nostr nutzt digitale Signaturen, um manipulationssichere Notes zu erstellen, welche sicher auf viele Relais repliziert werden können, um eine redundante Speicherung deiner Inhalte zu bieten.",
"/Xf4UW": "Anonyme Nutzungsmetriken senden",
"/d6vEc": "Mach dein Profil leichter zu finden und zu teilen",
"/n5KSF": "{n} ms",
"00LcfG": "Mehr laden",
@ -52,7 +52,7 @@
"4Z3t5i": "Verwende imgproxy um Bilder zu komprimieren",
"4rYCjn": "Notiz an mich selbst",
"5BVs2e": "Zap",
"5CB6zB": "Zap Splits",
"5CB6zB": "Zap-Aufteilungen",
"5JcXdV": "Konto erstellen",
"5oTnfy": "Handle kaufen",
"5rOdPG": "Sobald du deine Erweiterung für die Schlüsselverwaltung eingerichtet und einen Schlüssel generiert hast, kannst du unserem Prozess für neue Benutzer folgen, um dein Profil einzurichten und einige interessante Leute auf Nostr zu finden, denen du folgen kannst.",
@ -61,7 +61,7 @@
"5ykRmX": "Zap senden",
"65BmHb": "Bild von {host} konnte nicht durch Proxy geladen werden, klicke hier, um es direkt zu laden",
"6Yfvvp": "Bekomme eine Identifikation",
"6bgpn+": "Not all clients support this, you may still receive some zaps as if zap splits was not configured",
"6bgpn+": "Nicht alle Clients unterstützen dies, deshalb kann es sein, dass du immer noch einige Zaps erhältst, als ob Zap-Aufteilungen nicht konfiguriert wäre",
"6ewQqw": "Gefällt ({n})",
"6uMqL1": "Nicht bezahlt",
"7+Domh": "Notes",
@ -69,8 +69,8 @@
"7hp70g": "NIP-05",
"8/vBbP": "Reposts ({n})",
"89q5wc": "Reposts bestätigen",
"8QDesP": "Zap {n} sats",
"8Y6bZQ": "Invalid zap split: {input}",
"8QDesP": "{n} sats zappen",
"8Y6bZQ": "Ungültige Zap-Aufteilung: {input}",
"8g2vyB": "Name ist zu lang",
"8v1NN+": "Verbindungsphrase",
"9+Ddtu": "Weiter",
@ -81,7 +81,7 @@
"9pMqYs": "Nostr-Adresse",
"9wO4wJ": "Lightning Zahlungsanforderung",
"ADmfQT": "Vorherige",
"AGNz71": "Zap {n} sats an Alle",
"AGNz71": "Allen {n} sats zappen",
"ASRK0S": "Dieser Autor wurde stummgeschalten",
"Adk34V": "Profil erstellen",
"Ai8VHU": "Unbegrenzte Note Speicherung auf Snort Relais",
@ -91,7 +91,7 @@
"B4C47Y": "Name ist zu kurz",
"B6+XJy": "hat gezappt",
"B6H7eJ": "nsec, npub, nip-05, hex",
"BGCM48": "Schreibe Zugriff auf Snort-Relais mit 1 Jahr Event Speicherung",
"BGCM48": "Schreibzugriff auf Snort Relais, mit 1 Jahr Speicherung von Events",
"BOUMjw": "Keine Nostr-Benutzer gefunden für {twitterUsername}",
"BOr9z/": "Snort ist ein Open-Source-Projekt, von begeisterten in ihrer Freizeit entwickelt",
"BWpuKl": "Aktualisieren",
@ -116,7 +116,7 @@
"EPYwm7": "Dein privater Schlüssel ist dein Passwort. Wenn du diesen Schlüssel verlierst, hast du keinen Zugang mehr zu deinem Konto! Kopiere ihn und bewahre ihn an einem sicheren Ort auf. Es gibt keine Möglichkeit, deinen privaten Schlüssel wiederherzustellen.",
"EWyQH5": "Global",
"Ebl/B2": "Auf {lang} Übersetzen",
"EcZF24": "Benutzerdefinierte Relays",
"EcZF24": "Benutzerdefinierte Relais",
"EcglP9": "Schlüssel",
"EnCOBJ": "Kaufen",
"Eqjl5K": "Nur Snort und unsere integrierten Partner bieten farbige Domainnamen als NIP-05 Kennzeichen. Sie können aber auch gerne andere Dienste nutzen.",
@ -145,7 +145,7 @@
"HOzFdo": "Stummgeschaltet",
"HWbkEK": "Cache leeren und neu laden",
"HbefNb": "Wallet öffnen",
"I9zn6f": "Pubkey",
"I9zn6f": "Öfftl. Schlüssel",
"IDjHJ6": "Danke für die Verwendung von Snort. Wir würden uns über eine Spende freuen.",
"IEwZvs": "Sind sie sicher, dass sie diese Notiz entpinnen möchten?",
"IKKHqV": "Folgt",
@ -155,7 +155,7 @@
"Ix8l+B": "Angesagte Notes",
"J+dIsA": "Abonnements",
"JCIgkj": "Benutzername",
"JGrt9q": "Send sats to {name}",
"JGrt9q": "Sats an {name} senden",
"JHEHCk": "Zaps ({n})",
"JPFYIM": "Keine Lightning-Adresse",
"JeoS4y": "Repost",
@ -175,7 +175,7 @@
"LgbKvU": "Kommentar",
"Lu5/Bj": "In Zapstr öffnen",
"Lw+I+J": "{n,plural,=0{{name} zappte} other{{name} & {n} andere zappten}}",
"LwYmVi": "Zaps on this note will be split to the following users.",
"LwYmVi": "Zaps auf diese Note werden an die folgenden Nutzer aufgeteilt.",
"M3Oirc": "Debug Menüs",
"MBAYRO": "Zeigt \"ID kopieren\" und \"Event JSON kopieren\" im Kontextmenu jeder Nachricht an",
"MI2jkA": "Nicht verfügbar:",
@ -221,7 +221,7 @@
"RoOyAh": "Relais",
"Rs4kCE": "Lesezeichen",
"RwFaYs": "Sortieren",
"SMO+on": "Send zap to {name}",
"SMO+on": "Zap an {name} senden",
"SOqbe9": "Lightning-Adresse aktualisieren",
"SP0+yi": "Abonnement kaufen",
"SX58hM": "Kopieren",
@ -251,10 +251,10 @@
"W2PiAr": "{n} Blockiert",
"W9355R": "Stummschaltung aufheben",
"WONP5O": "Finden Sie Ihre Twitter Kontakte auf Nostr (Daten zur Verfügung gestellt von {provider})",
"WvGmZT": "npub / nprofile / nostr address",
"WvGmZT": "npub / nprofile / Nostr-Adresse",
"WxthCV": "z.B. Jack",
"X7xU8J": "nsec, npub, nip-05, hex, mnemonic",
"XECMfW": "Send usage metrics",
"XECMfW": "Nutzungsmetriken senden",
"XICsE8": "Datei-Hosts",
"XgWvGA": "Reaktionen",
"Xopqkl": "Dein standardmäßiger Zap-Betrag ist {number} sats, Beispielwerte werden daraus berechnet.",
@ -263,7 +263,7 @@
"Y31HTH": "Unterstütze die Entwicklung von Snort",
"YDURw6": "URL des Dienstes",
"YXA3AH": "Reaktionen aktivieren",
"Z0FDj+": "Abonniere Snort {plan} für {price} und erhalte folgende Belohnungen",
"Z0FDj+": "Abonniere Snort {plan} für {price} und erhalte folgende Prämien",
"Z4BMCZ": "Verbindungs-Passphrase eingeben",
"ZKORll": "Jetzt aktivieren",
"ZLmyG9": "Mitwirkende",
@ -340,11 +340,11 @@
"jvo0vs": "Speichern",
"jzgQ2z": "{n} Reaktionen",
"k2veDA": "Schreiben",
"k7sKNy": "Unser eigener NIP-05-Verifizierungsdienst unterstützt die Entwicklung dieser Website. Unterstütze uns und erhalte ein Sonderabzeichen für deine Webseite!",
"k7sKNy": "Unser eigener NIP-05-Verifizierungsdienst unterstützt die Entwicklung dieser Website. Unterstütze uns und erhalte ein Abzeichen auf unserer Seite!",
"kJYo0u": "{n,plural,=0{{name} hat gerepostet} other{{name} & {n} andere haben gerepostet}}",
"kaaf1E": "jetzt",
"kuPHYE": "{n,plural,=0{{name} gefällt das} other{{name} & {n} anderen gefällt das}}",
"l+ikU1": "Alles im {plan}",
"l+ikU1": "Alles aus {plan}",
"lBboHo": "Wenn du ein paar andere ausprobieren möchtest, besuche {link} für mehr!",
"lCILNz": "Jetzt kaufen",
"lD3+8a": "Bezahlen",
@ -380,7 +380,7 @@
"oxCa4R": "Das Erhalten eines Identifikators hilft Menschen, die Sie kennen, Ihre Identität zu bestätigen. Viele Leute können einen Benutzernamen wie @jack haben, aber es kann nur einen jack@cash.app geben.",
"p4N05H": "Hochladen",
"p85Uwy": "Aktive Abonnements",
"pI+77w": "Herunterladbare Backups von Snort Relais",
"pI+77w": "Herunterladbare Backups vom Snort Relais",
"puLNUJ": "Anheften",
"pzTOmv": "Follower",
"qD9EUF": "E-Mail <> DM Brücke für deine Snort Nostr-Adresse",
@ -401,19 +401,19 @@
"rudscU": "Abonnements konnten nicht geladen werden, bitte versuchen Sie es später erneut",
"sUNhQE": "Nutzer",
"sWnYKw": "Snort wurde darauf konzipiert ein Twitter ähnliches Erlebnis zu bieten.",
"sZQzjQ": "Failed to parse zap split: {input}",
"sZQzjQ": "Fehler beim Parsen der Zap-Aufteilung: {input}",
"svOoEH": "Namens-Squatting und Impersonation sind nicht erlaubt. Snort und unsere Partner behalten sich das Recht vor, bei einem Verstoß gegen diese Regel dein Handle zu löschen (nicht dein Konto - das kann dir niemand wegnehmen).",
"tOdNiY": "Dunkel",
"th5lxp": "Sende Note zu einer Untergruppe deiner Schreib-Relays",
"thnRpU": "Eine NIP-05 Verifizierung kann helfen:",
"ttxS0b": "Unterstützer Abzeichen",
"ttxS0b": "Unterstützer-Abzeichen",
"u/vOPu": "Bezahlt",
"u4bHcR": "Sieh dir hier den Code an: {link}",
"uSV4Ti": "Reposts müssen manuell bestätigt werden",
"usAvMr": "Profil anpassen",
"ut+2Cd": "Holen Sie sich einen Partner-Identifikator",
"v8lolG": "Chat starten",
"vOKedj": "{n,plural,one {}=1{& {n} other} other{& {n} andere}}",
"vOKedj": "{n,plural,one {}=1{& {n} anderer} other{& {n} andere}}",
"vU71Ez": "Bezahlen mit {wallet}",
"vZ4quW": "NIP-05 ist eine DNS-basierte Verifizierungsspezifikation, die dabei hilft, dich als echten Benutzer zu validieren.",
"vhlWFg": "Umfrageoptionen",
@ -442,7 +442,7 @@
"y1Z3or": "Sprache",
"yCLnBC": "LNURL oder Lightning-Adresse",
"yCmnnm": "Global lesen von",
"zCb8fX": "Weight",
"zCb8fX": "Gewichtung",
"zFegDD": "Kontakt",
"zINlao": "Eigentümer",
"zQvVDJ": "Alle",

View File

@ -23,7 +23,7 @@ export function linkToEventTag(link: NostrLink) {
}
export function tagToNostrLink(tag: Array<string>) {
switch(tag[0]) {
switch (tag[0]) {
case "e": {
return createNostrLink(NostrPrefix.Event, tag[1], tag.slice(2));
}