diff --git a/packages/app/src/Cache/Notifications.ts b/packages/app/src/Cache/Notifications.ts new file mode 100644 index 00000000..645b2552 --- /dev/null +++ b/packages/app/src/Cache/Notifications.ts @@ -0,0 +1,39 @@ +import { EventKind, NostrEvent, RequestBuilder, TaggedRawEvent } from "@snort/system"; +import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache"; +import { LoginSession } from "Login"; +import { unixNow } from "SnortUtils"; +import { db } from "Db"; + +export class NotificationsCache extends RefreshFeedCache { + #kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]; + + constructor() { + super("notifications", db.notifications); + } + + buildSub(session: LoginSession, rb: RequestBuilder) { + if (session.publicKey) { + const newest = this.newest(); + rb.withFilter() + .kinds(this.#kinds) + .tag("p", [session.publicKey]) + .since(newest === 0 ? unixNow() - 60 * 60 * 24 * 30 : newest); + } + } + + async onEvent(evs: readonly TaggedRawEvent[]) { + const filtered = evs.filter(a => this.#kinds.includes(a.kind) && a.tags.some(b => b[0] === "p")); + if (filtered.length > 0) { + await this.bulkSet(filtered); + this.notifyChange(filtered.map(v => this.key(v))); + } + } + + key(of: TWithCreated): string { + return of.id; + } + + takeSnapshot(): TWithCreated[] { + return [...this.cache.values()]; + } +} diff --git a/packages/app/src/Cache/RefreshFeedCache.ts b/packages/app/src/Cache/RefreshFeedCache.ts new file mode 100644 index 00000000..783b7f13 --- /dev/null +++ b/packages/app/src/Cache/RefreshFeedCache.ts @@ -0,0 +1,25 @@ +import { FeedCache } from "@snort/shared"; +import { RequestBuilder, TaggedRawEvent } from "@snort/system"; +import { LoginSession } from "Login"; + +export type TWithCreated = T & { created_at: number }; + +export abstract class RefreshFeedCache extends FeedCache> { + abstract buildSub(session: LoginSession, rb: RequestBuilder): void; + abstract onEvent(evs: Readonly>): void; + + /** + * Get latest event + */ + protected newest() { + let ret = 0; + this.cache.forEach(v => (ret = v.created_at > ret ? v.created_at : ret)); + return ret; + } + + override async preload(): Promise { + await super.preload(); + // load all dms to memory + await this.buffer([...this.onTable]); + } +} diff --git a/packages/app/src/Cache/index.ts b/packages/app/src/Cache/index.ts index 905ba539..81921532 100644 --- a/packages/app/src/Cache/index.ts +++ b/packages/app/src/Cache/index.ts @@ -3,6 +3,7 @@ import { EventInteractionCache } from "./EventInteractionCache"; import { ChatCache } from "./ChatCache"; import { Payments } from "./PaymentsCache"; import { GiftWrapCache } from "./GiftWrapCache"; +import { NotificationsCache } from "./Notifications"; export const UserCache = new UserProfileCache(); export const UserRelays = new UserRelaysCache(); @@ -11,6 +12,7 @@ export const Chats = new ChatCache(); export const PaymentsCache = new Payments(); export const InteractionCache = new EventInteractionCache(); export const GiftsCache = new GiftWrapCache(); +export const Notifications = new NotificationsCache(); export async function preload(follows?: Array) { const preloads = [ @@ -20,6 +22,7 @@ export async function preload(follows?: Array) { UserRelays.preload(follows), RelayMetrics.preload(), GiftsCache.preload(), + Notifications.preload(), ]; await Promise.all(preloads); } diff --git a/packages/app/src/Db/index.ts b/packages/app/src/Db/index.ts index 8d29e7f9..963382ff 100644 --- a/packages/app/src/Db/index.ts +++ b/packages/app/src/Db/index.ts @@ -2,7 +2,7 @@ import Dexie, { Table } from "dexie"; import { HexKey, NostrEvent, u256 } from "@snort/system"; export const NAME = "snortDB"; -export const VERSION = 12; +export const VERSION = 13; export interface SubCache { id: string; @@ -40,6 +40,7 @@ const STORES = { eventInteraction: "++id", payments: "++url", gifts: "++id", + notifications: "++id", }; export class SnortDB extends Dexie { @@ -48,6 +49,7 @@ export class SnortDB extends Dexie { eventInteraction!: Table; payments!: Table; gifts!: Table; + notifications!: Table; constructor() { super(NAME); diff --git a/packages/app/src/Element/ProfileImage.tsx b/packages/app/src/Element/ProfileImage.tsx index 9bed17c8..624389c2 100644 --- a/packages/app/src/Element/ProfileImage.tsx +++ b/packages/app/src/Element/ProfileImage.tsx @@ -20,6 +20,7 @@ export interface ProfileImageProps { verifyNip?: boolean; overrideUsername?: string; profile?: UserMetadata; + size?: number; } export default function ProfileImage({ @@ -32,6 +33,7 @@ export default function ProfileImage({ verifyNip, overrideUsername, profile, + size, }: ProfileImageProps) { const user = profile ?? useUserProfile(System, pubkey); const nip05 = defaultNip ? defaultNip : user?.nip05; @@ -52,7 +54,7 @@ export default function ProfileImage({ to={link === undefined ? profileLink(pubkey) : link} onClick={handleClick}>
- +
{showUsername && (
diff --git a/packages/app/src/Element/RevealMedia.tsx b/packages/app/src/Element/RevealMedia.tsx index a0f472bb..bf7e39d2 100644 --- a/packages/app/src/Element/RevealMedia.tsx +++ b/packages/app/src/Element/RevealMedia.tsx @@ -42,7 +42,6 @@ export default function RevealMedia(props: RevealMediaProps) { case "avi": case "m4v": case "webm": - case "m3u8": return "video"; default: return "unknown"; diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index d0fd0e1d..9dc386a7 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo } from "react"; -import { TaggedNostrEvent, Lists, EventKind, FlatNoteStore, RequestBuilder } from "@snort/system"; +import { TaggedNostrEvent, Lists, EventKind, FlatNoteStore, RequestBuilder, NoteCollection } from "@snort/system"; import { useRequestBuilder } from "@snort/system-react"; import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "SnortUtils"; @@ -12,7 +12,7 @@ import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPi import { SnortPubKey } from "Const"; import { SubscriptionEvent } from "Subscription"; import useRelaysFeedFollows from "./RelaysFeedFollows"; -import { GiftsCache, UserRelays } from "Cache"; +import { GiftsCache, Notifications, UserRelays } from "Cache"; import { System } from "index"; import { Nip29Chats, Nip4Chats } from "chat"; @@ -33,7 +33,6 @@ export default function useLoginFeed() { leaveOpen: true, }); b.withFilter().authors([pubKey]).kinds([EventKind.ContactList]); - b.withFilter().kinds([EventKind.TextNote]).tag("p", [pubKey]).limit(1); b.withFilter() .kinds([EventKind.SnortSubscriptions]) .authors([bech32ToHex(SnortPubKey)]) @@ -42,6 +41,7 @@ export default function useLoginFeed() { b.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubKey]).since(GiftsCache.newest()); b.add(Nip4Chats.subscription(pubKey)); + Notifications.buildSub(login, b); return b; }, [pubKey]); @@ -60,7 +60,7 @@ export default function useLoginFeed() { return b; }, [pubKey]); - const loginFeed = useRequestBuilder(System, FlatNoteStore, subLogin); + const loginFeed = useRequestBuilder(System, NoteCollection, subLogin); // update relays and follow lists useEffect(() => { @@ -75,13 +75,9 @@ export default function useLoginFeed() { setFollows(login, pTags, contactList.created_at * 1000); } - const dms = loginFeed.data.filter(a => a.kind === EventKind.DirectMessage && a.tags.some(b => b[0] === "p")); - Nip4Chats.onEvent(dms); - - const nip29Messages = loginFeed.data.filter( - a => a.kind === EventKind.SimpleChatMessage && a.tags.some(b => b[0] === "g") - ); - Nip29Chats.onEvent(nip29Messages); + Nip4Chats.onEvent(loginFeed.data); + Nip29Chats.onEvent(loginFeed.data); + Notifications.onEvent(loginFeed.data); const giftWraps = loginFeed.data.filter(a => a.kind === EventKind.GiftWrap); GiftsCache.onEvent(giftWraps, publisher); diff --git a/packages/app/src/Pages/Notifications.css b/packages/app/src/Pages/Notifications.css new file mode 100644 index 00000000..d4afbe8c --- /dev/null +++ b/packages/app/src/Pages/Notifications.css @@ -0,0 +1,34 @@ +.notification-group { + display: flex; + flex-direction: column; + gap: 12px; +} + +.notification-group .avatar { + width: 40px; + height: 40px; +} + +.notification-group .pfp { + gap: unset; +} + +.notification-group .names { + display: flex; + gap: 24px; + font-size: 16px; + font-weight: 600; + line-height: 1em; +} + +.notification-group .names > div:first-of-type { + width: 24px; +} + +.notification-group .content { + margin-left: 48px; + font-size: 14px; + line-height: 22px; + color: var(--font-secondary-color); + word-break: break-all; +} diff --git a/packages/app/src/Pages/Notifications.tsx b/packages/app/src/Pages/Notifications.tsx index c5a83077..66e4dc3c 100644 --- a/packages/app/src/Pages/Notifications.tsx +++ b/packages/app/src/Pages/Notifications.tsx @@ -1,35 +1,181 @@ -import { useEffect, useState } from "react"; +import "./Notifications.css"; +import { useEffect, useMemo, useSyncExternalStore } from "react"; +import { + EventExt, + EventKind, + NostrEvent, + NostrLink, + NostrPrefix, + TaggedRawEvent, + createNostrLink, +} from "@snort/system"; +import { unwrap } from "@snort/shared"; +import { useUserProfile } from "@snort/system-react"; +import { useInView } from "react-intersection-observer"; +import { FormattedMessage } from "react-intl"; -import Timeline from "Element/Timeline"; -import { TaskList } from "Tasks/TaskList"; import useLogin from "Hooks/useLogin"; import { markNotificationsRead } from "Login"; -import { unixNow } from "SnortUtils"; +import { Notifications } from "Cache"; +import { dedupe, findTag, orderDescending } from "SnortUtils"; +import Note from "Element/Note"; +import Icon from "Icons/Icon"; +import ProfileImage, { getDisplayName } from "Element/ProfileImage"; +import useModeration from "Hooks/useModeration"; +import { System } from "index"; +import useEventFeed from "Feed/EventFeed"; +import Text from "Element/Text"; + +function notificationContext(ev: TaggedRawEvent) { + switch (ev.kind) { + case EventKind.ZapReceipt: { + const aTag = findTag(ev, "a"); + if (aTag) { + const [kind, author, d] = aTag.split(":"); + return createNostrLink(NostrPrefix.Address, d, undefined, Number(kind), author); + } + const eTag = findTag(ev, "e"); + if (eTag) { + return createNostrLink(NostrPrefix.Event, eTag); + } + const pTag = ev.tags.filter(a => a[0] === "p").slice(-1)?.[0]; + if (pTag) { + return createNostrLink(NostrPrefix.PublicKey, pTag[1]); + } + break; + } + case EventKind.Repost: + case EventKind.TextNote: + case EventKind.Reaction: { + const thread = EventExt.extractThread(ev); + const id = unwrap(thread?.replyTo?.value ?? thread?.root?.value ?? ev.id); + return createNostrLink(NostrPrefix.Event, id); + } + } +} export default function NotificationsPage() { const login = useLogin(); - const [now] = useState(unixNow()); + const { isMuted } = useModeration(); + const groupInterval = 3600 * 3; useEffect(() => { markNotificationsRead(login); }, []); + const notifications = useSyncExternalStore( + c => Notifications.hook(c, "*"), + () => Notifications.snapshot() + ); + + const timeKey = (ev: NostrEvent) => { + const onHour = ev.created_at - (ev.created_at % groupInterval); + return onHour.toString(); + }; + + const timeGrouped = useMemo(() => { + return orderDescending([...notifications]) + .filter(a => !isMuted(a.pubkey)) + .reduce((acc, v) => { + const key = `${timeKey(v)}:${notificationContext(v as TaggedRawEvent)?.encode()}:${v.kind}`; + if (acc.has(key)) { + unwrap(acc.get(key)).push(v as TaggedRawEvent); + } else { + acc.set(key, [v as TaggedRawEvent]); + } + return acc; + }, new Map>()); + }, [notifications]); + return (
- - {login.publicKey && ( - - )} + {login.publicKey && [...timeGrouped.entries()].map(([k, g]) => )} +
+ ); +} + +function NotificationGroup({ evs }: { evs: Array }) { + const { ref, inView } = useInView({ triggerOnce: true }); + const kind = evs[0].kind; + + const iconName = () => { + switch (kind) { + case EventKind.Reaction: + return "heart-solid"; + case EventKind.ZapReceipt: + return "zap-solid"; + case EventKind.Repost: + return "repeat"; + } + return ""; + }; + + const actionName = (n: number, name: string) => { + switch (kind) { + case EventKind.Reaction: + return ( + + ); + case EventKind.Repost: + return ( + + ); + } + return `${kind}'d your post`; + }; + + if (kind === EventKind.TextNote) { + return ( + <> + {evs.map(v => ( + + ))} + + ); + } + + const pubkeys = dedupe(evs.map(a => a.pubkey)); + const firstPubkey = pubkeys[0]; + const firstPubkeyProfile = useUserProfile(System, inView ? firstPubkey : ""); + const context = notificationContext(evs[0]); + + return ( +
+
+ +
+ {pubkeys.map(v => ( + + ))} +
+
+
+
+ {actionName(pubkeys.length - 1, getDisplayName(firstPubkeyProfile, firstPubkey))} +
+
{context && }
+
+ ); +} + +function NotificationContext({ link }: { link: NostrLink }) { + const { data: ev } = useEventFeed(link); + + return ( +
+
); } diff --git a/packages/app/src/Pages/Root.tsx b/packages/app/src/Pages/Root.tsx index b2b0f3f7..9fd13325 100644 --- a/packages/app/src/Pages/Root.tsx +++ b/packages/app/src/Pages/Root.tsx @@ -14,9 +14,10 @@ import Icon from "Icons/Icon"; import TrendingUsers from "Element/TrendingUsers"; import TrendingNotes from "Element/TrendingPosts"; import HashTagsPage from "Pages/HashTagsPage"; +import SuggestedProfiles from "Element/SuggestedProfiles"; +import { TaskList } from "Tasks/TaskList"; import messages from "./messages"; -import SuggestedProfiles from "Element/SuggestedProfiles"; interface RelayOption { url: string; @@ -272,6 +273,7 @@ const NotesTab = () => { return ( <> + ); diff --git a/packages/app/src/SnortUtils/index.ts b/packages/app/src/SnortUtils/index.ts index d7ca3781..81e90ced 100644 --- a/packages/app/src/SnortUtils/index.ts +++ b/packages/app/src/SnortUtils/index.ts @@ -323,6 +323,10 @@ export const delay = (t: number) => { }); }; +export function orderDescending(arr: Array) { + return arr.sort((a, b) => (b.created_at > a.created_at ? 1 : -1)); +} + export interface Magnet { dn?: string | string[]; tr?: string | string[]; diff --git a/packages/app/src/chat/index.ts b/packages/app/src/chat/index.ts index 6f99fecc..fd1747af 100644 --- a/packages/app/src/chat/index.ts +++ b/packages/app/src/chat/index.ts @@ -1,6 +1,14 @@ import { useSyncExternalStore } from "react"; import { Nip4ChatSystem } from "./nip4"; -import { EventKind, EventPublisher, NostrEvent, RequestBuilder, SystemInterface, UserMetadata } from "@snort/system"; +import { + EventKind, + EventPublisher, + NostrEvent, + RequestBuilder, + SystemInterface, + TaggedRawEvent, + UserMetadata, +} from "@snort/system"; import { unwrap } from "@snort/shared"; import { Chats, GiftsCache } from "Cache"; import { findTag, unixNow } from "SnortUtils"; @@ -49,7 +57,7 @@ export interface ChatSystem { * Create a request for this system to get updates */ subscription(id: string): RequestBuilder | undefined; - onEvent(evs: Array): Promise | void; + onEvent(evs: readonly TaggedRawEvent[]): Promise | void; listChats(pk: string): Array; } diff --git a/packages/app/src/chat/nip29.ts b/packages/app/src/chat/nip29.ts index 94964cba..45951bc4 100644 --- a/packages/app/src/chat/nip29.ts +++ b/packages/app/src/chat/nip29.ts @@ -1,5 +1,5 @@ import { ExternalStore, FeedCache, dedupe } from "@snort/shared"; -import { RequestBuilder, NostrEvent, EventKind, SystemInterface } from "@snort/system"; +import { RequestBuilder, NostrEvent, EventKind, SystemInterface, TaggedRawEvent } from "@snort/system"; import { unwrap } from "SnortUtils"; import { Chat, ChatSystem, ChatType, lastReadInChat } from "chat"; @@ -31,8 +31,8 @@ export class Nip29ChatSystem extends ExternalStore> implements ChatS return rb; } - async onEvent(evs: NostrEvent[]) { - const msg = evs.filter(a => a.kind === EventKind.SimpleChatMessage); + async onEvent(evs: readonly TaggedRawEvent[]) { + const msg = evs.filter(a => a.kind === EventKind.SimpleChatMessage && a.tags.some(b => b[0] === "g")); if (msg.length > 0) { await this.#cache.bulkSet(msg); this.notifyChange(); diff --git a/packages/app/src/chat/nip4.ts b/packages/app/src/chat/nip4.ts index 2a8b80ef..52f9a5ab 100644 --- a/packages/app/src/chat/nip4.ts +++ b/packages/app/src/chat/nip4.ts @@ -8,8 +8,9 @@ import { TLVEntryType, decodeTLV, encodeTLVEntries, + TaggedNostrEvent, } from "@snort/system"; -import { Chat, ChatSystem, ChatType, inChatWith, lastReadInChat, selfChat } from "chat"; +import { Chat, ChatSystem, ChatType, inChatWith, lastReadInChat } from "chat"; import { debug } from "debug"; export class Nip4ChatSystem extends ExternalStore> implements ChatSystem { @@ -21,8 +22,8 @@ export class Nip4ChatSystem extends ExternalStore> implements ChatSy this.#cache = cache; } - async onEvent(evs: Array) { - const dms = evs.filter(a => a.kind === EventKind.DirectMessage); + async onEvent(evs: readonly TaggedNostrEvent[]) { + const dms = evs.filter(a => a.kind === EventKind.DirectMessage && a.tags.some(b => b[0] === "p")); if (dms.length > 0) { await this.#cache.bulkSet(dms); this.notifyChange(); diff --git a/packages/app/src/index.css b/packages/app/src/index.css index edcb2a1d..76186209 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -398,6 +398,10 @@ input:disabled { gap: 12px; } +.g24 { + gap: 24px; +} + .w-max { width: 100%; width: stretch; diff --git a/packages/system/src/nostr-link.ts b/packages/system/src/nostr-link.ts index 2f572f6e..c89aa231 100644 --- a/packages/system/src/nostr-link.ts +++ b/packages/system/src/nostr-link.ts @@ -1,5 +1,5 @@ import { bech32ToHex, hexToBech32 } from "@snort/shared"; -import { NostrPrefix, decodeTLV, TLVEntryType } from "."; +import { NostrPrefix, decodeTLV, TLVEntryType, encodeTLV } from "."; export interface NostrLink { type: NostrPrefix; @@ -10,6 +10,24 @@ export interface NostrLink { encode(): string; } +export function createNostrLink(prefix: NostrPrefix, id: string, relays?: string[], kind?: number, author?: string) { + return { + type: prefix, + id, + relays, + kind, author, + encode: () => { + if(prefix === NostrPrefix.Note || prefix === NostrPrefix.PublicKey) { + return hexToBech32(prefix, id); + } + if(prefix === NostrPrefix.Address || prefix === NostrPrefix.Event || prefix === NostrPrefix.Profile) { + return encodeTLV(prefix, id, relays, kind, author); + } + return ""; + } + } as NostrLink; +} + export function validateNostrLink(link: string): boolean { try { const parsedLink = parseNostrLink(link); diff --git a/packages/system/src/request-builder.ts b/packages/system/src/request-builder.ts index 747a97a4..de1bdda6 100644 --- a/packages/system/src/request-builder.ts +++ b/packages/system/src/request-builder.ts @@ -2,7 +2,7 @@ import debug from "debug"; import { v4 as uuid } from "uuid"; import { appendDedupe, sanitizeRelayUrl, unixNowMs } from "@snort/shared"; -import { ReqFilter, u256, HexKey, EventKind } from "."; +import { ReqFilter, u256, HexKey, EventKind, TaggedRawEvent, OnEventCallback, OnEventCallbackRelease } from "."; import { diffFilters } from "./request-splitter"; import { RelayCache, splitByWriteRelays, splitFlatByWriteRelays } from "./gossip-model"; import { flatMerge, mergeSimilar } from "./request-merger";