feat: pinned notes and bookmarks (#255)

This commit is contained in:
Alejandro 2023-02-13 16:49:19 +01:00 committed by GitHub
parent 9a608ee5c8
commit dfcc963fa5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 683 additions and 67 deletions

View File

@ -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
View 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;

View File

@ -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 {

View File

@ -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>

View File

@ -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} />

View File

@ -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} />
))}

View File

@ -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
View 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);
}

View File

@ -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);

View File

@ -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));

View File

@ -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
View 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);
}

View 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
View 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
View 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;

View File

@ -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;

View File

@ -68,6 +68,9 @@ export type UserMetadata = {
*/
export enum Lists {
Muted = "mute",
Pinned = "pin",
Bookmarked = "bookmark",
Followed = "follow",
}
export interface RelaySettings {

View File

@ -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 }}

View File

@ -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;
}

View File

@ -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} />;
}
}
}

View File

@ -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}

View File

@ -36,4 +36,6 @@ export default defineMessages({
Relays: {
defaultMessage: "Relays",
},
Bookmarks: { defaultMessage: "Bookmarks" },
BookmarksCount: { defaultMessage: "Bookmarks ({n})" },
});

View File

@ -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,

View File

@ -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];
}
}

View File

@ -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;
}

View File

@ -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"
},

View File

@ -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)",