feat: pinned notes and bookmarks (#255)
This commit is contained in:
parent
9a608ee5c8
commit
dfcc963fa5
@ -11,7 +11,7 @@
|
||||
content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src wss://* ws://*:* 'self' https://*; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>snort.social - Nostr interface</title>
|
||||
|
61
src/Element/Bookmarks.tsx
Normal file
61
src/Element/Bookmarks.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { useState, useMemo, ChangeEvent } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { dedupeByPubkey } from "Util";
|
||||
import Note from "Element/Note";
|
||||
import { HexKey, TaggedRawEvent } from "Nostr";
|
||||
import { useUserProfiles } from "Feed/ProfileFeed";
|
||||
import { RootState } from "State/Store";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface BookmarksProps {
|
||||
pubkey: HexKey;
|
||||
bookmarks: TaggedRawEvent[];
|
||||
related: TaggedRawEvent[];
|
||||
}
|
||||
|
||||
const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
|
||||
const [onlyPubkey, setOnlyPubkey] = useState<HexKey | "all">("all");
|
||||
const loginPubKey = useSelector((s: RootState) => s.login.publicKey);
|
||||
const ps = useMemo(() => {
|
||||
return dedupeByPubkey(bookmarks).map(ev => ev.pubkey);
|
||||
}, [bookmarks]);
|
||||
const profiles = useUserProfiles(ps);
|
||||
|
||||
function renderOption(p: HexKey) {
|
||||
const profile = profiles?.get(p);
|
||||
return profile ? <option value={p}>{profile?.display_name || profile?.name}</option> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<div className="icon-title">
|
||||
<select
|
||||
disabled={ps.length <= 1}
|
||||
value={onlyPubkey}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => setOnlyPubkey(e.target.value)}>
|
||||
<option value="all">
|
||||
<FormattedMessage {...messages.All} />
|
||||
</option>
|
||||
{ps.map(renderOption)}
|
||||
</select>
|
||||
</div>
|
||||
{bookmarks
|
||||
.filter(b => (onlyPubkey === "all" ? true : b.pubkey === onlyPubkey))
|
||||
.map(n => {
|
||||
return (
|
||||
<Note
|
||||
key={n.id}
|
||||
data={n}
|
||||
related={related}
|
||||
options={{ showTime: false, showBookmarked: true, canUnbookmark: loginPubKey === pubkey }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bookmarks;
|
@ -20,6 +20,36 @@
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
color: var(--font-secondary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note > .header > .info .saved {
|
||||
margin-right: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.note > .header > .info .saved svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.note > .header > .pinned {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--font-secondary-color);
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note > .header > .pinned svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.note > .body {
|
||||
|
@ -1,20 +1,25 @@
|
||||
import "./Note.css";
|
||||
import React, { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import Bookmark from "Icons/Bookmark";
|
||||
import Pin from "Icons/Pin";
|
||||
import { default as NEvent } from "Nostr/Event";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import Text from "Element/Text";
|
||||
|
||||
import { eventLink, getReactions, hexToBech32 } from "Util";
|
||||
import NoteFooter, { Translation } from "Element/NoteFooter";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import { useUserProfiles } from "Feed/ProfileFeed";
|
||||
import { TaggedRawEvent, u256 } from "Nostr";
|
||||
import { TaggedRawEvent, u256, HexKey } from "Nostr";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { setPinned, setBookmarked } from "State/Login";
|
||||
import type { RootState } from "State/Store";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -27,7 +32,11 @@ export interface NoteProps {
|
||||
options?: {
|
||||
showHeader?: boolean;
|
||||
showTime?: boolean;
|
||||
showPinned?: boolean;
|
||||
showBookmarked?: boolean;
|
||||
showFooter?: boolean;
|
||||
canUnpin?: boolean;
|
||||
canUnbookmark?: boolean;
|
||||
};
|
||||
["data-ev"]?: NEvent;
|
||||
}
|
||||
@ -52,6 +61,7 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
export default function Note(props: NoteProps) {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { data, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false } = props;
|
||||
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
|
||||
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
||||
@ -63,6 +73,8 @@ export default function Note(props: NoteProps) {
|
||||
const [extendable, setExtendable] = useState<boolean>(false);
|
||||
const [showMore, setShowMore] = useState<boolean>(false);
|
||||
const baseClassName = `note card ${props.className ? props.className : ""}`;
|
||||
const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
|
||||
const publisher = useEventPublisher();
|
||||
const [translated, setTranslated] = useState<Translation>();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
@ -70,9 +82,33 @@ export default function Note(props: NoteProps) {
|
||||
showHeader: true,
|
||||
showTime: true,
|
||||
showFooter: true,
|
||||
canUnpin: false,
|
||||
canUnbookmark: false,
|
||||
...opt,
|
||||
};
|
||||
|
||||
async function unpin(id: HexKey) {
|
||||
if (options.canUnpin) {
|
||||
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
|
||||
const es = pinned.filter(e => e !== id);
|
||||
const ev = await publisher.pinned(es);
|
||||
publisher.broadcast(ev);
|
||||
dispatch(setPinned({ keys: es, createdAt: new Date().getTime() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unbookmark(id: HexKey) {
|
||||
if (options.canUnbookmark) {
|
||||
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
|
||||
const es = bookmarked.filter(e => e !== id);
|
||||
const ev = await publisher.bookmarked(es);
|
||||
publisher.broadcast(ev);
|
||||
dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transformBody = useCallback(() => {
|
||||
const body = ev?.Content ?? "";
|
||||
if (deletions?.length > 0) {
|
||||
@ -190,9 +226,19 @@ export default function Note(props: NoteProps) {
|
||||
{options.showHeader && (
|
||||
<div className="header flex">
|
||||
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
|
||||
{options.showTime && (
|
||||
{(options.showTime || options.showBookmarked) && (
|
||||
<div className="info">
|
||||
<NoteTime from={ev.CreatedAt * 1000} />
|
||||
{options.showBookmarked && (
|
||||
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark(ev.Id)}>
|
||||
<Bookmark /> <FormattedMessage {...messages.Bookmarked} />
|
||||
</div>
|
||||
)}
|
||||
{!options.showBookmarked && <NoteTime from={ev.CreatedAt * 1000} />}
|
||||
</div>
|
||||
)}
|
||||
{options.showPinned && (
|
||||
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.Id)}>
|
||||
<Pin /> <FormattedMessage {...messages.Pinned} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
|
||||
import Bookmark from "Icons/Bookmark";
|
||||
import Pin from "Icons/Pin";
|
||||
import Json from "Icons/Json";
|
||||
import Repost from "Icons/Repost";
|
||||
import Trash from "Icons/Trash";
|
||||
@ -28,7 +30,7 @@ import { default as NEvent } from "Nostr/Event";
|
||||
import { RootState } from "State/Store";
|
||||
import { HexKey, TaggedRawEvent } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import { UserPreferences } from "State/Login";
|
||||
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { TranslateHost } from "Const";
|
||||
|
||||
@ -48,7 +50,9 @@ export interface NoteFooterProps {
|
||||
|
||||
export default function NoteFooter(props: NoteFooterProps) {
|
||||
const { related, ev } = props;
|
||||
const dispatch = useDispatch();
|
||||
const { formatMessage } = useIntl();
|
||||
const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
|
||||
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
const { mute, block } = useModeration();
|
||||
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
||||
@ -213,6 +217,20 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
await navigator.clipboard.writeText(hexToBech32("note", ev.Id));
|
||||
}
|
||||
|
||||
async function pin(id: HexKey) {
|
||||
const es = [...pinned, id];
|
||||
const ev = await publisher.pinned(es);
|
||||
publisher.broadcast(ev);
|
||||
dispatch(setPinned({ keys: es, createdAt: new Date().getTime() }));
|
||||
}
|
||||
|
||||
async function bookmark(id: HexKey) {
|
||||
const es = [...bookmarked, id];
|
||||
const ev = await publisher.bookmarked(es);
|
||||
publisher.broadcast(ev);
|
||||
dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() }));
|
||||
}
|
||||
|
||||
async function copyEvent() {
|
||||
await navigator.clipboard.writeText(JSON.stringify(ev.Original, undefined, " "));
|
||||
}
|
||||
@ -230,6 +248,18 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
<Share />
|
||||
<FormattedMessage {...messages.Share} />
|
||||
</MenuItem>
|
||||
{!pinned.includes(ev.Id) && (
|
||||
<MenuItem onClick={() => pin(ev.Id)}>
|
||||
<Pin />
|
||||
<FormattedMessage {...messages.Pin} />
|
||||
</MenuItem>
|
||||
)}
|
||||
{!bookmarked.includes(ev.Id) && (
|
||||
<MenuItem onClick={() => bookmark(ev.Id)}>
|
||||
<Bookmark width={18} height={18} />
|
||||
<FormattedMessage {...messages.Bookmark} />
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => copyId()}>
|
||||
<Copy />
|
||||
<FormattedMessage {...messages.CopyID} />
|
||||
|
@ -1,8 +1,8 @@
|
||||
import "./Tabs.css";
|
||||
import { ReactElement } from "react";
|
||||
import useHorizontalScroll from "Hooks/useHorizontalScroll";
|
||||
|
||||
export interface Tab {
|
||||
text: ReactElement | string;
|
||||
text: string;
|
||||
value: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@ -28,8 +28,9 @@ export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
|
||||
};
|
||||
|
||||
const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
|
||||
const horizontalScroll = useHorizontalScroll();
|
||||
return (
|
||||
<div className="tabs">
|
||||
<div className="tabs" ref={horizontalScroll}>
|
||||
{tabs.map(t => (
|
||||
<TabElement tab={tab} setTab={setTab} t={t} />
|
||||
))}
|
||||
|
@ -93,4 +93,13 @@ export default defineMessages({
|
||||
defaultMessage: "Please make sure to save the following password in order to manage your handle in the future",
|
||||
},
|
||||
Handle: { defaultMessage: "Handle" },
|
||||
Pin: { defaultMessage: "Pin" },
|
||||
Pinned: { defaultMessage: "Pinned" },
|
||||
Bookmark: { defaultMessage: "Bookmark" },
|
||||
Bookmarks: { defaultMessage: "Bookmarks" },
|
||||
BookmarksCount: { defaultMessage: "Bookmarks ({n})" },
|
||||
Bookmarked: { defaultMessage: "Saved" },
|
||||
All: { defaultMessage: "All" },
|
||||
ConfirmUnbookmark: { defaultMessage: "Are you sure you want to remove this note from bookmarks?" },
|
||||
ConfirmUnpin: { defaultMessage: "Are you sure you want to unpin this note?" },
|
||||
});
|
||||
|
10
src/Feed/BookmarkFeed.tsx
Normal file
10
src/Feed/BookmarkFeed.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { RootState } from "State/Store";
|
||||
import { HexKey, Lists } from "Nostr";
|
||||
import useNotelistSubscription from "Feed/useNotelistSubscription";
|
||||
|
||||
export default function useBookmarkFeed(pubkey: HexKey) {
|
||||
const { bookmarked } = useSelector((s: RootState) => s.login);
|
||||
return useNotelistSubscription(pubkey, Lists.Bookmarked, bookmarked);
|
||||
}
|
@ -122,7 +122,7 @@ export default function useEventPublisher() {
|
||||
muted: async (keys: HexKey[], priv: HexKey[]) => {
|
||||
if (pubKey) {
|
||||
const ev = NEvent.ForPubKey(pubKey);
|
||||
ev.Kind = EventKind.Lists;
|
||||
ev.Kind = EventKind.PubkeyLists;
|
||||
ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length));
|
||||
keys.forEach(p => {
|
||||
ev.Tags.push(new Tag(["p", p], ev.Tags.length));
|
||||
@ -141,6 +141,42 @@ export default function useEventPublisher() {
|
||||
return await signEvent(ev);
|
||||
}
|
||||
},
|
||||
pinned: async (notes: HexKey[]) => {
|
||||
if (pubKey) {
|
||||
const ev = NEvent.ForPubKey(pubKey);
|
||||
ev.Kind = EventKind.NoteLists;
|
||||
ev.Tags.push(new Tag(["d", Lists.Pinned], ev.Tags.length));
|
||||
notes.forEach(n => {
|
||||
ev.Tags.push(new Tag(["e", n], ev.Tags.length));
|
||||
});
|
||||
ev.Content = "";
|
||||
return await signEvent(ev);
|
||||
}
|
||||
},
|
||||
bookmarked: async (notes: HexKey[]) => {
|
||||
if (pubKey) {
|
||||
const ev = NEvent.ForPubKey(pubKey);
|
||||
ev.Kind = EventKind.NoteLists;
|
||||
ev.Tags.push(new Tag(["d", Lists.Bookmarked], ev.Tags.length));
|
||||
notes.forEach(n => {
|
||||
ev.Tags.push(new Tag(["e", n], ev.Tags.length));
|
||||
});
|
||||
ev.Content = "";
|
||||
return await signEvent(ev);
|
||||
}
|
||||
},
|
||||
tags: async (tags: string[]) => {
|
||||
if (pubKey) {
|
||||
const ev = NEvent.ForPubKey(pubKey);
|
||||
ev.Kind = EventKind.TagLists;
|
||||
ev.Tags.push(new Tag(["d", Lists.Followed], ev.Tags.length));
|
||||
tags.forEach(t => {
|
||||
ev.Tags.push(new Tag(["t", t], ev.Tags.length));
|
||||
});
|
||||
ev.Content = "";
|
||||
return await signEvent(ev);
|
||||
}
|
||||
},
|
||||
metadata: async (obj: UserMetadata) => {
|
||||
if (pubKey) {
|
||||
const ev = NEvent.ForPubKey(pubKey);
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import { getNewest } from "Util";
|
||||
import { makeNotification } from "Notifications";
|
||||
import { TaggedRawEvent, HexKey, Lists } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
@ -11,6 +12,9 @@ import {
|
||||
setFollows,
|
||||
setRelays,
|
||||
setMuted,
|
||||
setTags,
|
||||
setPinned,
|
||||
setBookmarked,
|
||||
setBlocked,
|
||||
sendNotification,
|
||||
setLatestNotifications,
|
||||
@ -20,7 +24,7 @@ import { mapEventToProfile, MetadataCache } from "State/Users";
|
||||
import { useDb } from "State/Users/Db";
|
||||
import useSubscription from "Feed/Subscription";
|
||||
import { barrierNip07 } from "Feed/EventPublisher";
|
||||
import { getMutedKeys, getNewest } from "Feed/MuteList";
|
||||
import { getMutedKeys } from "Feed/MuteList";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { unwrap } from "Util";
|
||||
import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit";
|
||||
@ -68,7 +72,7 @@ export default function useLoginFeed() {
|
||||
|
||||
const sub = new Subscriptions();
|
||||
sub.Id = "login:muted";
|
||||
sub.Kinds = new Set([EventKind.Lists]);
|
||||
sub.Kinds = new Set([EventKind.PubkeyLists]);
|
||||
sub.Authors = new Set([pubKey]);
|
||||
sub.DTags = new Set([Lists.Muted]);
|
||||
sub.Limit = 1;
|
||||
@ -76,6 +80,45 @@ export default function useLoginFeed() {
|
||||
return sub;
|
||||
}, [pubKey]);
|
||||
|
||||
const subTags = useMemo(() => {
|
||||
if (!pubKey) return null;
|
||||
|
||||
const sub = new Subscriptions();
|
||||
sub.Id = "login:tags";
|
||||
sub.Kinds = new Set([EventKind.TagLists]);
|
||||
sub.Authors = new Set([pubKey]);
|
||||
sub.DTags = new Set([Lists.Followed]);
|
||||
sub.Limit = 1;
|
||||
|
||||
return sub;
|
||||
}, [pubKey]);
|
||||
|
||||
const subPinned = useMemo(() => {
|
||||
if (!pubKey) return null;
|
||||
|
||||
const sub = new Subscriptions();
|
||||
sub.Id = "login:pinned";
|
||||
sub.Kinds = new Set([EventKind.NoteLists]);
|
||||
sub.Authors = new Set([pubKey]);
|
||||
sub.DTags = new Set([Lists.Pinned]);
|
||||
sub.Limit = 1;
|
||||
|
||||
return sub;
|
||||
}, [pubKey]);
|
||||
|
||||
const subBookmarks = useMemo(() => {
|
||||
if (!pubKey) return null;
|
||||
|
||||
const sub = new Subscriptions();
|
||||
sub.Id = "login:bookmarks";
|
||||
sub.Kinds = new Set([EventKind.NoteLists]);
|
||||
sub.Authors = new Set([pubKey]);
|
||||
sub.DTags = new Set([Lists.Bookmarked]);
|
||||
sub.Limit = 1;
|
||||
|
||||
return sub;
|
||||
}, [pubKey]);
|
||||
|
||||
const subDms = useMemo(() => {
|
||||
if (!pubKey) return null;
|
||||
|
||||
@ -102,6 +145,9 @@ export default function useLoginFeed() {
|
||||
});
|
||||
const dmsFeed = useSubscription(subDms, { leaveOpen: true, cache: true });
|
||||
const mutedFeed = useSubscription(subMuted, { leaveOpen: true, cache: true });
|
||||
const pinnedFeed = useSubscription(subPinned, { leaveOpen: true, cache: true });
|
||||
const tagsFeed = useSubscription(subTags, { leaveOpen: true, cache: true });
|
||||
const bookmarkFeed = useSubscription(subBookmarks, { leaveOpen: true, cache: true });
|
||||
|
||||
useEffect(() => {
|
||||
const contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList);
|
||||
@ -179,6 +225,45 @@ export default function useLoginFeed() {
|
||||
}
|
||||
}, [dispatch, mutedFeed.store]);
|
||||
|
||||
useEffect(() => {
|
||||
const newest = getNewest(pinnedFeed.store.notes);
|
||||
if (newest) {
|
||||
const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === "e").map(p => p[1]);
|
||||
dispatch(
|
||||
setPinned({
|
||||
keys,
|
||||
createdAt: newest.created_at,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [dispatch, pinnedFeed.store]);
|
||||
|
||||
useEffect(() => {
|
||||
const newest = getNewest(tagsFeed.store.notes);
|
||||
if (newest) {
|
||||
const tags = newest.tags.filter(p => p && p.length === 2 && p[0] === "t").map(p => p[1]);
|
||||
dispatch(
|
||||
setTags({
|
||||
tags,
|
||||
createdAt: newest.created_at,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [dispatch, tagsFeed.store]);
|
||||
|
||||
useEffect(() => {
|
||||
const newest = getNewest(bookmarkFeed.store.notes);
|
||||
if (newest) {
|
||||
const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === "e").map(p => p[1]);
|
||||
dispatch(
|
||||
setBookmarked({
|
||||
keys,
|
||||
createdAt: newest.created_at,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [dispatch, bookmarkFeed.store]);
|
||||
|
||||
useEffect(() => {
|
||||
const dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage);
|
||||
dispatch(addDirectMessage(dms));
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { getNewest } from "Util";
|
||||
import { HexKey, TaggedRawEvent, Lists } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import { Subscriptions } from "Nostr/Subscriptions";
|
||||
@ -9,7 +10,7 @@ export default function useMutedFeed(pubkey: HexKey) {
|
||||
const sub = useMemo(() => {
|
||||
const sub = new Subscriptions();
|
||||
sub.Id = `muted:${pubkey.slice(0, 12)}`;
|
||||
sub.Kinds = new Set([EventKind.Lists]);
|
||||
sub.Kinds = new Set([EventKind.PubkeyLists]);
|
||||
sub.Authors = new Set([pubkey]);
|
||||
sub.DTags = new Set([Lists.Muted]);
|
||||
sub.Limit = 1;
|
||||
@ -19,14 +20,6 @@ export default function useMutedFeed(pubkey: HexKey) {
|
||||
return useSubscription(sub);
|
||||
}
|
||||
|
||||
export function getNewest(rawNotes: TaggedRawEvent[]) {
|
||||
const notes = [...rawNotes];
|
||||
notes.sort((a, b) => a.created_at - b.created_at);
|
||||
if (notes.length > 0) {
|
||||
return notes[0];
|
||||
}
|
||||
}
|
||||
|
||||
export function getMutedKeys(rawNotes: TaggedRawEvent[]): {
|
||||
createdAt: number;
|
||||
keys: HexKey[];
|
||||
@ -44,6 +37,6 @@ export function getMutedKeys(rawNotes: TaggedRawEvent[]): {
|
||||
}
|
||||
|
||||
export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] {
|
||||
const lists = feed?.notes.filter(a => a.kind === EventKind.Lists && a.pubkey === pubkey);
|
||||
const lists = feed?.notes.filter(a => a.kind === EventKind.PubkeyLists && a.pubkey === pubkey);
|
||||
return getMutedKeys(lists).keys;
|
||||
}
|
||||
|
10
src/Feed/PinnedFeed.tsx
Normal file
10
src/Feed/PinnedFeed.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { RootState } from "State/Store";
|
||||
import { HexKey, Lists } from "Nostr";
|
||||
import useNotelistSubscription from "Feed/useNotelistSubscription";
|
||||
|
||||
export default function usePinnedFeed(pubkey: HexKey) {
|
||||
const { pinned } = useSelector((s: RootState) => s.login);
|
||||
return useNotelistSubscription(pubkey, Lists.Pinned, pinned);
|
||||
}
|
63
src/Feed/useNotelistSubscription.ts
Normal file
63
src/Feed/useNotelistSubscription.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { getNewest } from "Util";
|
||||
import { HexKey, Lists } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import { Subscriptions } from "Nostr/Subscriptions";
|
||||
import useSubscription from "Feed/Subscription";
|
||||
import { RootState } from "State/Store";
|
||||
|
||||
export default function useNotelistSubscription(pubkey: HexKey, l: Lists, defaultIds: HexKey[]) {
|
||||
const { preferences, publicKey } = useSelector((s: RootState) => s.login);
|
||||
const isMe = publicKey === pubkey;
|
||||
|
||||
const sub = useMemo(() => {
|
||||
if (isMe) return null;
|
||||
const sub = new Subscriptions();
|
||||
sub.Id = `note-list-${l}:${pubkey.slice(0, 12)}`;
|
||||
sub.Kinds = new Set([EventKind.NoteLists]);
|
||||
sub.Authors = new Set([pubkey]);
|
||||
sub.DTags = new Set([l]);
|
||||
sub.Limit = 1;
|
||||
return sub;
|
||||
}, [pubkey]);
|
||||
|
||||
const { store } = useSubscription(sub, { leaveOpen: true, cache: true });
|
||||
const etags = useMemo(() => {
|
||||
if (isMe) return defaultIds;
|
||||
const newest = getNewest(store.notes);
|
||||
if (newest) {
|
||||
const { tags } = newest;
|
||||
return tags.filter(t => t[0] === "e").map(t => t[1]);
|
||||
}
|
||||
return [];
|
||||
}, [store.notes, isMe, defaultIds]);
|
||||
|
||||
const esub = useMemo(() => {
|
||||
const s = new Subscriptions();
|
||||
s.Id = `${l}-notes:${pubkey.slice(0, 12)}`;
|
||||
s.Kinds = new Set([EventKind.TextNote]);
|
||||
s.Ids = new Set(etags);
|
||||
return s;
|
||||
}, [etags]);
|
||||
|
||||
const subRelated = useMemo(() => {
|
||||
let sub: Subscriptions | undefined;
|
||||
if (etags.length > 0 && preferences.enableReactions) {
|
||||
sub = new Subscriptions();
|
||||
sub.Id = `${l}-related`;
|
||||
sub.Kinds = new Set([EventKind.Reaction, EventKind.Repost, EventKind.Deletion, EventKind.ZapReceipt]);
|
||||
sub.ETags = new Set(etags);
|
||||
}
|
||||
return sub ?? null;
|
||||
}, [etags, preferences]);
|
||||
|
||||
const mainSub = useSubscription(esub, { leaveOpen: true, cache: true });
|
||||
const relatedSub = useSubscription(subRelated, { leaveOpen: true, cache: true });
|
||||
|
||||
const notes = mainSub.store.notes.filter(e => etags.includes(e.id));
|
||||
const related = relatedSub.store.notes;
|
||||
|
||||
return { notes, related };
|
||||
}
|
17
src/Icons/Bookmark.tsx
Normal file
17
src/Icons/Bookmark.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import IconProps from "./IconProps";
|
||||
|
||||
const Bookmark = (props: IconProps) => {
|
||||
return (
|
||||
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M1.3335 4.2C1.3335 3.0799 1.3335 2.51984 1.55148 2.09202C1.74323 1.71569 2.04919 1.40973 2.42552 1.21799C2.85334 1 3.41339 1 4.5335 1H7.46683C8.58693 1 9.14699 1 9.57481 1.21799C9.95114 1.40973 10.2571 1.71569 10.4488 2.09202C10.6668 2.51984 10.6668 3.0799 10.6668 4.2V13L6.00016 10.3333L1.3335 13V4.2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.33333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bookmark;
|
14
src/Icons/Pin.tsx
Normal file
14
src/Icons/Pin.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
const Pin = () => {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.9224 1.60798C10.752 1.43747 10.5936 1.27906 10.4521 1.16112C10.3041 1.03772 10.1054 0.897647 9.84544 0.844944C9.50107 0.775136 9.14307 0.84408 8.84926 1.03679C8.62745 1.18228 8.49504 1.38611 8.40342 1.55566C8.31586 1.7177 8.22763 1.92361 8.13268 2.14523L7.4096 3.83242C7.39337 3.8703 7.38541 3.88876 7.37934 3.90209L7.37892 3.903L7.37824 3.90372C7.36811 3.91431 7.35393 3.92856 7.32479 3.9577L6.28419 4.99829C6.23867 5.04382 6.21617 5.06618 6.19929 5.08193L6.19809 5.08305L6.19649 5.08343C6.17402 5.08874 6.14294 5.09505 6.0798 5.10768L3.60856 5.60192C3.31543 5.66053 3.05184 5.71323 2.84493 5.77469C2.63787 5.83621 2.37038 5.93737 2.16809 6.16535C1.90934 6.45696 1.79118 6.84722 1.84472 7.23339C1.88657 7.53529 2.05302 7.76784 2.19118 7.93388C2.32925 8.09979 2.51933 8.28985 2.73071 8.50121L4.64158 10.4121L1.34175 13.7119C1.0814 13.9723 1.0814 14.3944 1.34175 14.6547C1.6021 14.9151 2.02421 14.9151 2.28456 14.6547L5.58439 11.3549L7.49532 13.2658C7.70668 13.4772 7.89673 13.6673 8.06265 13.8053C8.22869 13.9435 8.46123 14.11 8.76314 14.1518C9.14931 14.2053 9.53956 14.0872 9.83117 13.8284C10.0592 13.6261 10.1603 13.3587 10.2218 13.1516C10.2833 12.9447 10.336 12.6811 10.3946 12.388L10.8889 9.91673C10.9015 9.85359 10.9078 9.82251 10.9131 9.80003L10.9135 9.79844L10.9146 9.79724C10.9303 9.78035 10.9527 9.75786 10.9982 9.71233L12.0388 8.67174C12.068 8.6426 12.0822 8.62841 12.0928 8.61829L12.0935 8.6176L12.0944 8.61719C12.1078 8.61111 12.1262 8.60316 12.1641 8.58692L13.8513 7.86385C14.0729 7.7689 14.2788 7.68066 14.4409 7.5931C14.6104 7.50149 14.8142 7.36908 14.9597 7.14726C15.1524 6.85346 15.2214 6.49545 15.1516 6.15109C15.0989 5.89111 14.9588 5.69247 14.8354 5.54443C14.7175 5.40295 14.5591 5.24456 14.3885 5.07409L10.9224 1.60798Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pin;
|
@ -10,9 +10,11 @@ const enum EventKind {
|
||||
Reaction = 7, // NIP-25
|
||||
Relays = 10002, // NIP-65
|
||||
Auth = 22242, // NIP-42
|
||||
Lists = 30000, // NIP-51
|
||||
ZapRequest = 9734, // NIP tba
|
||||
ZapReceipt = 9735, // NIP tba
|
||||
PubkeyLists = 30000, // NIP-51a
|
||||
NoteLists = 30001, // NIP-51b
|
||||
TagLists = 30002, // NIP-51c
|
||||
ZapRequest = 9734, // NIP 57
|
||||
ZapReceipt = 9735, // NIP 57
|
||||
}
|
||||
|
||||
export default EventKind;
|
||||
|
@ -68,6 +68,9 @@ export type UserMetadata = {
|
||||
*/
|
||||
export enum Lists {
|
||||
Muted = "mute",
|
||||
Pinned = "pin",
|
||||
Bookmarked = "bookmark",
|
||||
Followed = "follow",
|
||||
}
|
||||
|
||||
export interface RelaySettings {
|
||||
|
@ -1,13 +1,48 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import Timeline from "Element/Timeline";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { setTags } from "State/Login";
|
||||
import type { RootState } from "State/Store";
|
||||
|
||||
const HashTagsPage = () => {
|
||||
const params = useParams();
|
||||
const tag = (params.tag ?? "").toLowerCase();
|
||||
const dispatch = useDispatch();
|
||||
const { tags } = useSelector((s: RootState) => s.login);
|
||||
const isFollowing = useMemo(() => {
|
||||
return tags.includes(tag);
|
||||
}, [tags, tag]);
|
||||
const publisher = useEventPublisher();
|
||||
|
||||
function followTags(ts: string[]) {
|
||||
dispatch(
|
||||
setTags({
|
||||
tags: ts,
|
||||
createdAt: new Date().getTime(),
|
||||
})
|
||||
);
|
||||
publisher.tags(ts).then(ev => publisher.broadcast(ev));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>#{tag}</h2>
|
||||
<div className="main-content">
|
||||
<div className="action-heading">
|
||||
<h2>#{tag}</h2>
|
||||
{isFollowing ? (
|
||||
<button type="button" className="secondary" onClick={() => followTags(tags.filter(t => t !== tag))}>
|
||||
<FormattedMessage defaultMessage="Unfollow" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => followTags(tags.concat([tag]))}>
|
||||
<FormattedMessage defaultMessage="Follow" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Timeline
|
||||
key={tag}
|
||||
subject={{ type: "hashtag", items: [tag], discriminator: tag }}
|
||||
|
@ -230,3 +230,24 @@
|
||||
font-weight: normal;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.icon-title {
|
||||
font-weight: 600;
|
||||
font-size: 19px;
|
||||
line-height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.icon-title svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.icon-title h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-title select {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
@ -6,6 +6,8 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { unwrap } from "Util";
|
||||
import { formatShort } from "Number";
|
||||
import Note from "Element/Note";
|
||||
import Bookmarks from "Element/Bookmarks";
|
||||
import RelaysMetadata from "Element/RelaysMetadata";
|
||||
import { Tab, TabElement } from "Element/Tabs";
|
||||
import Link from "Icons/Link";
|
||||
@ -13,6 +15,8 @@ import Qr from "Icons/Qr";
|
||||
import Zap from "Icons/Zap";
|
||||
import Envelope from "Icons/Envelope";
|
||||
import useRelaysFeed from "Feed/RelaysFeed";
|
||||
import usePinnedFeed from "Feed/PinnedFeed";
|
||||
import useBookmarkFeed from "Feed/BookmarkFeed";
|
||||
import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import useZapsFeed from "Feed/ZapsFeed";
|
||||
import { default as ZapElement, parseZap } from "Element/Zap";
|
||||
@ -49,6 +53,7 @@ const ZAPS = 4;
|
||||
const MUTED = 5;
|
||||
const BLOCKED = 6;
|
||||
const RELAYS = 7;
|
||||
const BOOKMARKS = 8;
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { formatMessage } = useIntl();
|
||||
@ -62,6 +67,8 @@ export default function ProfilePage() {
|
||||
const isMe = loginPubKey === id;
|
||||
const [showLnQr, setShowLnQr] = useState<boolean>(false);
|
||||
const [showProfileQr, setShowProfileQr] = useState<boolean>(false);
|
||||
const { notes: pinned, related: pinRelated } = usePinnedFeed(id);
|
||||
const { notes: bookmarks, related: bookmarkRelated } = useBookmarkFeed(id);
|
||||
const aboutText = user?.about || "";
|
||||
const about = Text({
|
||||
content: aboutText,
|
||||
@ -90,11 +97,14 @@ export default function ProfilePage() {
|
||||
Muted: { text: formatMessage(messages.Muted), value: MUTED },
|
||||
Blocked: { text: formatMessage(messages.Blocked), value: BLOCKED },
|
||||
Relays: { text: formatMessage(messages.Relays), value: RELAYS },
|
||||
Bookmarks: { text: formatMessage(messages.Bookmarks), value: BOOKMARKS },
|
||||
};
|
||||
const [tab, setTab] = useState<Tab>(ProfileTab.Notes);
|
||||
const optionalTabs = [zapsTotal > 0 && ProfileTab.Zaps, relays.length > 0 && ProfileTab.Relays].filter(a =>
|
||||
unwrap(a)
|
||||
) as Tab[];
|
||||
const optionalTabs = [
|
||||
zapsTotal > 0 && ProfileTab.Zaps,
|
||||
relays.length > 0 && ProfileTab.Relays,
|
||||
bookmarks.length > 0 && ProfileTab.Bookmarks,
|
||||
].filter(a => unwrap(a)) as Tab[];
|
||||
|
||||
useEffect(() => {
|
||||
setTab(ProfileTab.Notes);
|
||||
@ -162,17 +172,31 @@ export default function ProfilePage() {
|
||||
switch (tab.value) {
|
||||
case NOTES:
|
||||
return (
|
||||
<Timeline
|
||||
key={id}
|
||||
subject={{
|
||||
type: "pubkey",
|
||||
items: [id],
|
||||
discriminator: id.slice(0, 12),
|
||||
}}
|
||||
postsOnly={false}
|
||||
method={"TIME_RANGE"}
|
||||
ignoreModeration={true}
|
||||
/>
|
||||
<>
|
||||
<div className="main-content">
|
||||
{pinned.map(n => {
|
||||
return (
|
||||
<Note
|
||||
key={`pinned-${n.id}`}
|
||||
data={n}
|
||||
related={pinRelated}
|
||||
options={{ showTime: false, showPinned: true, canUnpin: id === loginPubKey }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Timeline
|
||||
key={id}
|
||||
subject={{
|
||||
type: "pubkey",
|
||||
items: [id],
|
||||
discriminator: id.slice(0, 12),
|
||||
}}
|
||||
postsOnly={false}
|
||||
method={"TIME_RANGE"}
|
||||
ignoreModeration={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case ZAPS: {
|
||||
return (
|
||||
@ -215,6 +239,9 @@ export default function ProfilePage() {
|
||||
case RELAYS: {
|
||||
return <RelaysMetadata relays={relays} />;
|
||||
}
|
||||
case BOOKMARKS: {
|
||||
return <Bookmarks pubkey={id} bookmarks={bookmarks} related={bookmarkRelated} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,36 +2,37 @@ import "./Root.css";
|
||||
import { useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
|
||||
import Tabs, { Tab } from "Element/Tabs";
|
||||
import { RootState } from "State/Store";
|
||||
import Timeline from "Element/Timeline";
|
||||
import { HexKey } from "Nostr";
|
||||
import { TimelineSubject } from "Feed/TimelineFeed";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
const RootTab: Record<string, Tab> = {
|
||||
Posts: {
|
||||
text: <FormattedMessage {...messages.Posts} />,
|
||||
value: 0,
|
||||
},
|
||||
PostsAndReplies: {
|
||||
text: <FormattedMessage {...messages.Conversations} />,
|
||||
value: 1,
|
||||
},
|
||||
Global: {
|
||||
text: <FormattedMessage {...messages.Global} />,
|
||||
value: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootPage() {
|
||||
const [loggedOut, pubKey, follows] = useSelector<RootState, [boolean | undefined, HexKey | undefined, HexKey[]]>(
|
||||
s => [s.login.loggedOut, s.login.publicKey, s.login.follows]
|
||||
);
|
||||
const { formatMessage } = useIntl();
|
||||
const { loggedOut, publicKey: pubKey, follows, tags } = useSelector((s: RootState) => s.login);
|
||||
const RootTab: Record<string, Tab> = {
|
||||
Posts: {
|
||||
text: formatMessage(messages.Posts),
|
||||
value: 0,
|
||||
},
|
||||
PostsAndReplies: {
|
||||
text: formatMessage(messages.Conversations),
|
||||
value: 1,
|
||||
},
|
||||
Global: {
|
||||
text: formatMessage(messages.Global),
|
||||
value: 2,
|
||||
},
|
||||
};
|
||||
const [tab, setTab] = useState<Tab>(RootTab.Posts);
|
||||
const tagTabs = tags.map((t, idx) => {
|
||||
return { text: `#${t}`, value: idx + 3 };
|
||||
});
|
||||
const tabs = [RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global, ...tagTabs];
|
||||
|
||||
function followHints() {
|
||||
if (follows?.length === 0 && pubKey && tab !== RootTab.Global) {
|
||||
@ -51,14 +52,20 @@ export default function RootPage() {
|
||||
}
|
||||
|
||||
const isGlobal = loggedOut || tab.value === RootTab.Global.value;
|
||||
const timelineSubect: TimelineSubject = isGlobal
|
||||
? { type: "global", items: [], discriminator: "all" }
|
||||
: { type: "pubkey", items: follows, discriminator: "follows" };
|
||||
const timelineSubect: TimelineSubject = (() => {
|
||||
if (isGlobal) {
|
||||
return { type: "global", items: [], discriminator: "all" };
|
||||
}
|
||||
if (tab.value >= 3) {
|
||||
const hashtag = tab.text.slice(1);
|
||||
return { type: "hashtag", items: [hashtag], discriminator: hashtag };
|
||||
}
|
||||
|
||||
return { type: "pubkey", items: follows, discriminator: "follows" };
|
||||
})();
|
||||
return (
|
||||
<>
|
||||
<div className="main-content">
|
||||
{pubKey && <Tabs tabs={[RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global]} tab={tab} setTab={setTab} />}
|
||||
</div>
|
||||
<div className="main-content">{pubKey && <Tabs tabs={tabs} tab={tab} setTab={setTab} />}</div>
|
||||
{followHints()}
|
||||
<Timeline
|
||||
key={tab.value}
|
||||
|
@ -36,4 +36,6 @@ export default defineMessages({
|
||||
Relays: {
|
||||
defaultMessage: "Relays",
|
||||
},
|
||||
Bookmarks: { defaultMessage: "Bookmarks" },
|
||||
BookmarksCount: { defaultMessage: "Bookmarks ({n})" },
|
||||
});
|
||||
|
@ -115,6 +115,36 @@ export interface LoginStore {
|
||||
*/
|
||||
latestFollows: number;
|
||||
|
||||
/**
|
||||
* A list of tags this user follows
|
||||
*/
|
||||
tags: string[];
|
||||
|
||||
/**
|
||||
* Newest tag list timestamp
|
||||
*/
|
||||
latestTags: number;
|
||||
|
||||
/**
|
||||
* A list of event ids this user has pinned
|
||||
*/
|
||||
pinned: HexKey[];
|
||||
|
||||
/**
|
||||
* Last seen pinned list event timestamp
|
||||
*/
|
||||
latestPinned: number;
|
||||
|
||||
/**
|
||||
* A list of event ids this user has bookmarked
|
||||
*/
|
||||
bookmarked: HexKey[];
|
||||
|
||||
/**
|
||||
* Last seen bookmark list event timestamp
|
||||
*/
|
||||
latestBookmarked: number;
|
||||
|
||||
/**
|
||||
* A list of pubkeys this user has muted
|
||||
*/
|
||||
@ -172,6 +202,12 @@ export const InitState = {
|
||||
latestRelays: 0,
|
||||
follows: [],
|
||||
latestFollows: 0,
|
||||
tags: [],
|
||||
latestTags: 0,
|
||||
pinned: [],
|
||||
latestPinned: 0,
|
||||
bookmarked: [],
|
||||
latestBookmarked: 0,
|
||||
muted: [],
|
||||
blocked: [],
|
||||
latestMuted: 0,
|
||||
@ -320,6 +356,14 @@ const LoginSlice = createSlice({
|
||||
|
||||
window.localStorage.setItem(FollowList, JSON.stringify(state.follows));
|
||||
},
|
||||
setTags(state, action: PayloadAction<{ createdAt: number; tags: string[] }>) {
|
||||
const { createdAt, tags } = action.payload;
|
||||
if (createdAt >= state.latestTags) {
|
||||
const newTags = new Set([...tags]);
|
||||
state.tags = Array.from(newTags);
|
||||
state.latestTags = createdAt;
|
||||
}
|
||||
},
|
||||
setMuted(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) {
|
||||
const { createdAt, keys } = action.payload;
|
||||
if (createdAt >= state.latestMuted) {
|
||||
@ -328,6 +372,22 @@ const LoginSlice = createSlice({
|
||||
state.latestMuted = createdAt;
|
||||
}
|
||||
},
|
||||
setPinned(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) {
|
||||
const { createdAt, keys } = action.payload;
|
||||
if (createdAt >= state.latestPinned) {
|
||||
const pinned = new Set([...keys]);
|
||||
state.pinned = Array.from(pinned);
|
||||
state.latestPinned = createdAt;
|
||||
}
|
||||
},
|
||||
setBookmarked(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) {
|
||||
const { createdAt, keys } = action.payload;
|
||||
if (createdAt >= state.latestBookmarked) {
|
||||
const bookmarked = new Set([...keys]);
|
||||
state.bookmarked = Array.from(bookmarked);
|
||||
state.latestBookmarked = createdAt;
|
||||
}
|
||||
},
|
||||
setBlocked(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) {
|
||||
const { createdAt, keys } = action.payload;
|
||||
if (createdAt >= state.latestMuted) {
|
||||
@ -387,7 +447,10 @@ export const {
|
||||
setRelays,
|
||||
removeRelay,
|
||||
setFollows,
|
||||
setTags,
|
||||
setMuted,
|
||||
setPinned,
|
||||
setBookmarked,
|
||||
setBlocked,
|
||||
addDirectMessage,
|
||||
incDmInteraction,
|
||||
|
@ -190,3 +190,11 @@ export function randomSample<T>(coll: T[], size: number) {
|
||||
const random = [...coll];
|
||||
return random.sort(() => (Math.random() >= 0.5 ? 1 : -1)).slice(0, size);
|
||||
}
|
||||
|
||||
export function getNewest(rawNotes: TaggedRawEvent[]) {
|
||||
const notes = [...rawNotes];
|
||||
notes.sort((a, b) => a.created_at - b.created_at);
|
||||
if (notes.length > 0) {
|
||||
return notes[0];
|
||||
}
|
||||
}
|
||||
|
@ -570,3 +570,14 @@ button.tall {
|
||||
transform: rotate(180deg);
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.action-heading {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.action-heading button {
|
||||
width: 98px;
|
||||
}
|
||||
|
@ -32,6 +32,9 @@
|
||||
"1A7TZk": {
|
||||
"string": "What is Snort and how does it work?"
|
||||
},
|
||||
"1Mo59U": {
|
||||
"string": "Are you sure you want to remove this note from bookmarks?"
|
||||
},
|
||||
"1udzha": {
|
||||
"string": "Conversations"
|
||||
},
|
||||
@ -222,6 +225,9 @@
|
||||
"GFOoEE": {
|
||||
"string": "Salt"
|
||||
},
|
||||
"GL8aXW": {
|
||||
"string": "Bookmarks ({n})"
|
||||
},
|
||||
"GspYR7": {
|
||||
"string": "{n} Dislike"
|
||||
},
|
||||
@ -247,6 +253,9 @@
|
||||
"HbefNb": {
|
||||
"string": "Open Wallet"
|
||||
},
|
||||
"IEwZvs": {
|
||||
"string": "Are you sure you want to unpin this note?"
|
||||
},
|
||||
"IKKHqV": {
|
||||
"string": "Follows"
|
||||
},
|
||||
@ -358,6 +367,9 @@
|
||||
"RoOyAh": {
|
||||
"string": "Relays"
|
||||
},
|
||||
"Rs4kCE": {
|
||||
"string": "Bookmark"
|
||||
},
|
||||
"Sjo1P4": {
|
||||
"string": "Custom"
|
||||
},
|
||||
@ -463,12 +475,18 @@
|
||||
"eR3YIn": {
|
||||
"string": "Posts"
|
||||
},
|
||||
"fWZYP5": {
|
||||
"string": "Pinned"
|
||||
},
|
||||
"filwqD": {
|
||||
"string": "Read"
|
||||
},
|
||||
"flnGvv": {
|
||||
"string": "What's on your mind?"
|
||||
},
|
||||
"fsB/4p": {
|
||||
"string": "Saved"
|
||||
},
|
||||
"g5pX+a": {
|
||||
"string": "About"
|
||||
},
|
||||
@ -578,6 +596,9 @@
|
||||
"nDejmx": {
|
||||
"string": "Unblock"
|
||||
},
|
||||
"nGBrvw": {
|
||||
"string": "Bookmarks"
|
||||
},
|
||||
"nN9XTz": {
|
||||
"string": "Share your thoughts with {link}"
|
||||
},
|
||||
@ -607,6 +628,9 @@
|
||||
"oxCa4R": {
|
||||
"string": "Getting an identifier helps confirm the real you to people who know you. Many people can have a username @jack, but there is only one jack@cash.app."
|
||||
},
|
||||
"puLNUJ": {
|
||||
"string": "Pin"
|
||||
},
|
||||
"pzTOmv": {
|
||||
"string": "Followers"
|
||||
},
|
||||
|
@ -10,6 +10,7 @@
|
||||
"0mch2Y": "name has disallowed characters",
|
||||
"0yO7wF": "{n} secs",
|
||||
"1A7TZk": "What is Snort and how does it work?",
|
||||
"1Mo59U": "Are you sure you want to remove this note from bookmarks?",
|
||||
"1udzha": "Conversations",
|
||||
"2/2yg+": "Add",
|
||||
"25V4l1": "Banner",
|
||||
@ -72,6 +73,7 @@
|
||||
"FmXUJg": "follows you",
|
||||
"G/yZLu": "Remove",
|
||||
"GFOoEE": "Salt",
|
||||
"GL8aXW": "Bookmarks ({n})",
|
||||
"GspYR7": "{n} Dislike",
|
||||
"H+vHiz": "Hex Key..",
|
||||
"H0JBH6": "Log Out",
|
||||
@ -80,6 +82,7 @@
|
||||
"HFls6j": "name will be available later",
|
||||
"HOzFdo": "Muted",
|
||||
"HbefNb": "Open Wallet",
|
||||
"IEwZvs": "Are you sure you want to unpin this note?",
|
||||
"IKKHqV": "Follows",
|
||||
"INSqIz": "Twitter username...",
|
||||
"IUZC+0": "This means that nobody can modify notes which you have created and everybody can easily verify that the notes they are reading are created by you.",
|
||||
@ -116,6 +119,7 @@
|
||||
"RahCRH": "Expired",
|
||||
"RhDAoS": "Are you sure you want to delete {id}",
|
||||
"RoOyAh": "Relays",
|
||||
"Rs4kCE": "Bookmark",
|
||||
"Sjo1P4": "Custom",
|
||||
"TpgeGw": "Hex Salt..",
|
||||
"UQ3pOC": "On Nostr, many people have the same username. User names and identity are separate things. You can get a unique identifier in the next step.",
|
||||
@ -150,8 +154,10 @@
|
||||
"eHAneD": "Reaction emoji",
|
||||
"eJj8HD": "Get Verified",
|
||||
"eR3YIn": "Posts",
|
||||
"fWZYP5": "Pinned",
|
||||
"filwqD": "Read",
|
||||
"flnGvv": "What's on your mind?",
|
||||
"fsB/4p": "Saved",
|
||||
"g5pX+a": "About",
|
||||
"gBdUXk": "Save your keys!",
|
||||
"gDZkld": "Snort is a Nostr UI, nostr is a decentralised protocol for saving and distributing \"notes\".",
|
||||
@ -188,6 +194,7 @@
|
||||
"mKhgP9": "{n,plural,=0{} =1{zapped} other{zapped}}",
|
||||
"n1xHAH": "Get an identifier (optional)",
|
||||
"nDejmx": "Unblock",
|
||||
"nGBrvw": "Bookmarks",
|
||||
"nN9XTz": "Share your thoughts with {link}",
|
||||
"nn1qb3": "Your donations are greatly appreciated",
|
||||
"nwZXeh": "{n} blocked",
|
||||
@ -197,6 +204,7 @@
|
||||
"odhABf": "Login",
|
||||
"osUr8O": "You can also use these extensions to login to most Nostr sites.",
|
||||
"oxCa4R": "Getting an identifier helps confirm the real you to people who know you. Many people can have a username @jack, but there is only one jack@cash.app.",
|
||||
"puLNUJ": "Pin",
|
||||
"pzTOmv": "Followers",
|
||||
"qUJTsT": "Blocked",
|
||||
"qdGuQo": "Your Private Key Is (do not share this with anyone)",
|
||||
|
Loading…
x
Reference in New Issue
Block a user