diff --git a/public/index.html b/public/index.html index d2f1ab0f..db4da4b7 100644 --- a/public/index.html +++ b/public/index.html @@ -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;" /> - + snort.social - Nostr interface diff --git a/src/Element/Bookmarks.tsx b/src/Element/Bookmarks.tsx new file mode 100644 index 00000000..10c8d3d4 --- /dev/null +++ b/src/Element/Bookmarks.tsx @@ -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("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 ? : null; + } + + return ( +
+
+ +
+ {bookmarks + .filter(b => (onlyPubkey === "all" ? true : b.pubkey === onlyPubkey)) + .map(n => { + return ( + + ); + })} +
+ ); +}; + +export default Bookmarks; diff --git a/src/Element/Note.css b/src/Element/Note.css index 2be012a4..53b379c9 100644 --- a/src/Element/Note.css +++ b/src/Element/Note.css @@ -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 { diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index ade8d71d..aec98cca 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -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(false); const [showMore, setShowMore] = useState(false); const baseClassName = `note card ${props.className ? props.className : ""}`; + const { pinned, bookmarked } = useSelector((s: RootState) => s.login); + const publisher = useEventPublisher(); const [translated, setTranslated] = useState(); 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 && (
- {options.showTime && ( + {(options.showTime || options.showBookmarked) && (
- + {options.showBookmarked && ( +
unbookmark(ev.Id)}> + +
+ )} + {!options.showBookmarked && } +
+ )} + {options.showPinned && ( +
unpin(ev.Id)}> +
)}
diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index 4b0a7de5..6f999f92 100644 --- a/src/Element/NoteFooter.tsx +++ b/src/Element/NoteFooter.tsx @@ -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(s => s.login.publicKey); const { mute, block } = useModeration(); const prefs = useSelector(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) { + {!pinned.includes(ev.Id) && ( + pin(ev.Id)}> + + + + )} + {!bookmarked.includes(ev.Id) && ( + bookmark(ev.Id)}> + + + + )} copyId()}> diff --git a/src/Element/Tabs.tsx b/src/Element/Tabs.tsx index 3da948e4..661ea67d 100644 --- a/src/Element/Tabs.tsx +++ b/src/Element/Tabs.tsx @@ -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 ( -
+
{tabs.map(t => ( ))} diff --git a/src/Element/messages.ts b/src/Element/messages.ts index 852552ba..e740b670 100644 --- a/src/Element/messages.ts +++ b/src/Element/messages.ts @@ -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?" }, }); diff --git a/src/Feed/BookmarkFeed.tsx b/src/Feed/BookmarkFeed.tsx new file mode 100644 index 00000000..2335b052 --- /dev/null +++ b/src/Feed/BookmarkFeed.tsx @@ -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); +} diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index cbb83b2a..5c0d24b9 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -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); diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 1fe3c459..59bfe021 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -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)); diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index 6ad2d8f5..d24dbba4 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -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; } diff --git a/src/Feed/PinnedFeed.tsx b/src/Feed/PinnedFeed.tsx new file mode 100644 index 00000000..d9e43a48 --- /dev/null +++ b/src/Feed/PinnedFeed.tsx @@ -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); +} diff --git a/src/Feed/useNotelistSubscription.ts b/src/Feed/useNotelistSubscription.ts new file mode 100644 index 00000000..b89969ac --- /dev/null +++ b/src/Feed/useNotelistSubscription.ts @@ -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 }; +} diff --git a/src/Icons/Bookmark.tsx b/src/Icons/Bookmark.tsx new file mode 100644 index 00000000..121f4179 --- /dev/null +++ b/src/Icons/Bookmark.tsx @@ -0,0 +1,17 @@ +import IconProps from "./IconProps"; + +const Bookmark = (props: IconProps) => { + return ( + + + + ); +}; + +export default Bookmark; diff --git a/src/Icons/Pin.tsx b/src/Icons/Pin.tsx new file mode 100644 index 00000000..3f3af489 --- /dev/null +++ b/src/Icons/Pin.tsx @@ -0,0 +1,14 @@ +const Pin = () => { + return ( + + + + ); +}; + +export default Pin; diff --git a/src/Nostr/EventKind.ts b/src/Nostr/EventKind.ts index ec16373b..021a4bb5 100644 --- a/src/Nostr/EventKind.ts +++ b/src/Nostr/EventKind.ts @@ -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; diff --git a/src/Nostr/index.ts b/src/Nostr/index.ts index f3949914..f961b280 100644 --- a/src/Nostr/index.ts +++ b/src/Nostr/index.ts @@ -68,6 +68,9 @@ export type UserMetadata = { */ export enum Lists { Muted = "mute", + Pinned = "pin", + Bookmarked = "bookmark", + Followed = "follow", } export interface RelaySettings { diff --git a/src/Pages/HashTagsPage.tsx b/src/Pages/HashTagsPage.tsx index 9d04633a..01b5a358 100644 --- a/src/Pages/HashTagsPage.tsx +++ b/src/Pages/HashTagsPage.tsx @@ -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 ( <> -

#{tag}

+
+
+

#{tag}

+ {isFollowing ? ( + + ) : ( + + )} +
+
(false); const [showProfileQr, setShowProfileQr] = useState(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(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 ( - + <> +
+ {pinned.map(n => { + return ( + + ); + })} +
+ + ); case ZAPS: { return ( @@ -215,6 +239,9 @@ export default function ProfilePage() { case RELAYS: { return ; } + case BOOKMARKS: { + return ; + } } } diff --git a/src/Pages/Root.tsx b/src/Pages/Root.tsx index cc35c85c..549507b6 100644 --- a/src/Pages/Root.tsx +++ b/src/Pages/Root.tsx @@ -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 = { - Posts: { - text: , - value: 0, - }, - PostsAndReplies: { - text: , - value: 1, - }, - Global: { - text: , - value: 2, - }, -}; - export default function RootPage() { - const [loggedOut, pubKey, follows] = useSelector( - 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 = { + 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(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 ( <> -
- {pubKey && } -
+
{pubKey && }
{followHints()} ) { + 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, diff --git a/src/Util.ts b/src/Util.ts index b63c2168..2c7685d1 100644 --- a/src/Util.ts +++ b/src/Util.ts @@ -190,3 +190,11 @@ export function randomSample(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]; + } +} diff --git a/src/index.css b/src/index.css index 9efe3be6..15712cd2 100644 --- a/src/index.css +++ b/src/index.css @@ -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; +} diff --git a/src/lang.json b/src/lang.json index 43ac0696..37c1e26a 100644 --- a/src/lang.json +++ b/src/lang.json @@ -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" }, diff --git a/src/translations/en.json b/src/translations/en.json index e5f0be4c..69094ad0 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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)",