diff --git a/src/app/Nav.svelte b/src/app/Nav.svelte index 568daeaff..3fd978b24 100644 --- a/src/app/Nav.svelte +++ b/src/app/Nav.svelte @@ -8,7 +8,7 @@ import PersonBadge from "src/app/shared/PersonBadge.svelte" import {menuIsOpen, searchTerm} from "src/app/state" import {router} from "src/app/util/router" - import {hasNewNotifications, hasNewMessages} from "src/engine" + import {hasNewMessages, hasNewNotifications} from "src/engine" let innerWidth = 0 let searching = false diff --git a/src/app/state.ts b/src/app/state.ts index 8b3fb6fcb..21dad6943 100644 --- a/src/app/state.ts +++ b/src/app/state.ts @@ -19,7 +19,6 @@ import { env, load, loadPubkeys, - loadSeen, loadDeletes, loadHandlers, loadMessages, @@ -28,6 +27,7 @@ import { listenForNotifications, userFeedFavorites, getSetting, + setChecked, } from "src/engine" export const drafts = new Map() @@ -91,7 +91,6 @@ export const loadUserData = async (hints: string[] = []) => { loadPubkeys([env.PLATFORM_PUBKEY]) // Load anything they might need to be notified about - loadSeen() loadMessages() loadNotifications() loadFeedsAndLists() @@ -102,4 +101,7 @@ export const loadUserData = async (hints: string[] = []) => { listenForNotifications() } -export const boot = () => router.at("login/connect").open({noEscape: true, mini: true}) +export const boot = () => { + router.at("login/connect").open({noEscape: true, mini: true}) + setChecked("*") +} diff --git a/src/app/views/ChannelsDetail.svelte b/src/app/views/ChannelsDetail.svelte index 06ab9a97e..ff3820360 100644 --- a/src/app/views/ChannelsDetail.svelte +++ b/src/app/views/ChannelsDetail.svelte @@ -10,8 +10,8 @@ import PersonCircles from "src/app/shared/PersonCircles.svelte" import PersonAbout from "src/app/shared/PersonAbout.svelte" import {router} from "src/app/util/router" - import {markChannelRead, getChannelIdFromEvent, listenForMessages} from "src/engine" import Popover from "src/partials/Popover.svelte" + import {getChannelIdFromEvent, listenForMessages, setChecked} from "src/engine" export let pubkeys export let channelId @@ -34,7 +34,7 @@ const sub = listenForMessages(pubkeys) isAccepted = $messages.some(m => m.pubkey === $session.pubkey) - markChannelRead(channelId) + setChecked("channels/" + channelId) for (const pubkey of pubkeys) { loadInboxRelaySelections(pubkey) @@ -46,7 +46,7 @@ }) onDestroy(() => { - markChannelRead(channelId) + setChecked("channels/" + channelId) }) document.title = `Direct Messages` diff --git a/src/app/views/ChannelsList.svelte b/src/app/views/ChannelsList.svelte index 5857bdaf4..1f3d20ebe 100644 --- a/src/app/views/ChannelsList.svelte +++ b/src/app/views/ChannelsList.svelte @@ -3,6 +3,7 @@ import {toTitle} from "hurdak" import {derived} from "svelte/store" import {signer} from "@welshman/app" + import {now} from "@welshman/lib" import {createScroller} from "src/util/misc" import Tabs from "src/partials/Tabs.svelte" import Anchor from "src/partials/Anchor.svelte" @@ -11,7 +12,7 @@ import Content from "src/partials/Content.svelte" import ChannelsListItem from "src/app/views/ChannelsListItem.svelte" import {router} from "src/app/util/router" - import {channels, hasNewMessages, markAllChannelsRead} from "src/engine" + import {channels, hasNewMessages, setChecked} from "src/engine" const activeTab = window.location.pathname.slice(1) === "channels" ? "conversations" : "requests" const setActiveTab = tab => { @@ -39,6 +40,8 @@ } }) + const markAllChannelsRead = () => setChecked("channels/*", now()) + document.title = "Direct Messages" diff --git a/src/app/views/NotificationSectionMain.svelte b/src/app/views/NotificationSectionMain.svelte index 3013311ce..901367a7b 100644 --- a/src/app/views/NotificationSectionMain.svelte +++ b/src/app/views/NotificationSectionMain.svelte @@ -3,11 +3,11 @@ import {derived} from "svelte/store" import {max, ago, int, HOUR, pushToMapKey} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {getAncestorTagValues, SEEN_GENERAL} from "@welshman/util" + import {getAncestorTagValues} from "@welshman/util" import NotificationList from "src/app/views/NotificationList.svelte" import NotificationMention from "src/app/views/NotificationMention.svelte" import NotificationReplies from "src/app/views/NotificationReplies.svelte" - import {mainNotifications, unreadMainNotifications, markAsSeen} from "src/engine" + import {mainNotifications, setChecked} from "src/engine" export let limit @@ -34,31 +34,12 @@ }) }) - let loading = false - onMount(() => { - const tracked = new Set() - - const unsub = unreadMainNotifications.subscribe(async events => { - const untracked = events.filter(e => !tracked.has(e.id)) - - if (!loading && untracked.length > 0) { - for (const id of untracked) { - tracked.add(id) - } - - loading = true + setChecked("notes/*") - await markAsSeen(SEEN_GENERAL, { - mentions: $mainNotifications, - replies: $mainNotifications, - }) - - loading = false - } - }) - - return unsub + return () => { + setChecked("notes/*") + } }) diff --git a/src/app/views/NotificationSectionReactions.svelte b/src/app/views/NotificationSectionReactions.svelte index decb8099b..9ca887e22 100644 --- a/src/app/views/NotificationSectionReactions.svelte +++ b/src/app/views/NotificationSectionReactions.svelte @@ -1,13 +1,13 @@ diff --git a/src/app/views/Onboarding.svelte b/src/app/views/Onboarding.svelte index 79542e9c2..767794a25 100644 --- a/src/app/views/Onboarding.svelte +++ b/src/app/views/Onboarding.svelte @@ -19,6 +19,7 @@ broadcastUserData, } from "src/engine" import {router} from "src/app/util/router" + import {setChecked} from "src/engine" export let stage = "intro" export let invite = null @@ -68,6 +69,7 @@ // Start our notifications listener listenForNotifications() + setChecked("*") } onMount(async () => { diff --git a/src/engine/commands.ts b/src/engine/commands.ts index 5e6453804..28f4d9b5b 100644 --- a/src/engine/commands.ts +++ b/src/engine/commands.ts @@ -2,7 +2,6 @@ import type {Session} from "@welshman/app" import { follow as baseFollow, unfollow as baseUnfollow, - ensurePlaintext, getRelayUrls, inboxRelaySelectionsByPubkey, nip46Perms, @@ -15,34 +14,16 @@ import { userInboxRelaySelections, userRelaySelections, } from "@welshman/app" -import { - identity, - append, - cached, - ctx, - equals, - first, - groupBy, - indexBy, - last, - now, - nthEq, - nthNe, - remove, - splitAt, -} from "@welshman/lib" +import {identity, append, cached, ctx, groupBy, now, nthEq, remove} from "@welshman/lib" import {Nip01Signer, Nip46Broker, Nip59, makeSecret} from "@welshman/signer" import type {Profile, TrustedEvent} from "@welshman/util" import { Address, - DIRECT_MESSAGE, FEEDS, FOLLOWS, INBOX_RELAYS, - LOCAL_RELAY_URL, PROFILE, RELAYS, - SEEN_CONVERSATION, addToListPublicly, createEvent, createProfile, @@ -57,26 +38,20 @@ import { uniqTags, } from "@welshman/util" import crypto from "crypto" -import {Fetch, seconds, sleep, tryFunc} from "hurdak" +import {Fetch, sleep, tryFunc} from "hurdak" import {assoc, flatten, omit, prop, reject, uniq} from "ramda" import { addClientTags, anonymous, - channels, createAndPublish, - getChannelIdFromEvent, - getChannelSeenKey, getClientTags, - hasNip44, publish, sign, userFeedFavorites, - userSeenStatusEvents, withIndexers, } from "src/engine/state" -import {sortEventsDesc} from "src/engine/utils" import {blobToFile, stripExifData} from "src/util/html" -import {joinPath, parseJson} from "src/util/misc" +import {joinPath} from "src/util/misc" import {appDataKeys} from "src/util/nostr" import {get} from "svelte/store" @@ -367,47 +342,6 @@ export const joinRelay = async (url: string, claim?: string) => { } } -// Read receipts - -export const markAsSeen = async (kind: number, eventsByKey: Record) => { - if (!get(hasNip44) || Object.entries(eventsByKey).length === 0) { - return - } - - const cutoff = now() - seconds(180, "day") - const prev = get(userSeenStatusEvents).find(e => e.kind === kind) - const prevTags = prev ? parseJson(await ensurePlaintext(prev))?.filter?.(nthNe(1, "*")) : [] - const data = indexBy(t => t[1], prevTags || []) - - for (const [key, events] of Object.entries(eventsByKey)) { - if (events.length === 0) { - continue - } - - const [newer, older] = splitAt(1, sortEventsDesc(events)) - const ts = first(older)?.created_at || last(newer).created_at - seconds(3, "hour") - - if (ts >= cutoff) { - data.set(key, ["seen", key, String(ts), ...newer.map(e => e.id)]) - } else { - data.delete(key) - } - } - - const tags = Array.from(data.values()) - - if (equals(tags, prevTags)) { - return - } - - // Wait until after comparing for equality to add our current timestamp - const json = JSON.stringify([...tags, ["seen", "*", String(cutoff)]]) - // const relays = ctx.app.router.FromUser().getUrls() - const content = await signer.get().nip44.encrypt(pubkey.get(), json) - - await createAndPublish({kind, content, relays: [LOCAL_RELAY_URL]}) -} - // Messages export const sendMessage = async (channelId: string, content: string, delay: number) => { @@ -436,34 +370,6 @@ export const sendMessage = async (channelId: string, content: string, delay: num } } -export const markChannelsRead = (ids: Set) => { - const $pubkey = pubkey.get() - const eventsByKey = {} - - for (const {id, last_sent = 0, last_received = 0, last_checked = 0} of get(channels)) { - if (!ids.has(id) || Math.max(last_sent, last_checked) > last_received) { - continue - } - - const members = id.split(",") - const key = getChannelSeenKey(id) - const since = Math.max(last_sent, last_checked) - const events = repository - .query([{kinds: [4, DIRECT_MESSAGE], authors: members, "#p": members, since}]) - .filter(e => getChannelIdFromEvent(e) === id && e.pubkey !== $pubkey) - - if (events.length > 0) { - eventsByKey[key] = events - } - } - - markAsSeen(SEEN_CONVERSATION, eventsByKey) -} - -export const markAllChannelsRead = () => markChannelsRead(new Set(get(channels).map(c => c.id))) - -export const markChannelRead = (id: string) => markChannelsRead(new Set([id])) - // Session/login const addSession = (s: Session) => { diff --git a/src/engine/index.ts b/src/engine/index.ts index fa942f6c8..6e25eef85 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -3,3 +3,4 @@ export * from "src/engine/utils" export * from "src/engine/state" export * from "src/engine/requests" export * from "src/engine/commands" +export * from "src/engine/notifications" diff --git a/src/engine/notifications.ts b/src/engine/notifications.ts new file mode 100644 index 000000000..373790ff6 --- /dev/null +++ b/src/engine/notifications.ts @@ -0,0 +1,77 @@ +import {pubkey, repository} from "@welshman/app" +import {now} from "@welshman/lib" +import {deriveEvents} from "@welshman/store" +import {type TrustedEvent} from "@welshman/util" +import {without} from "ramda" +import {OnboardingTask} from "src/engine/model" +import {checked, getSeenAt, isEventMuted, sessionWithMeta} from "src/engine/state" +import {isLike, noteKinds, reactionKinds} from "src/util/nostr" +import {derived} from "svelte/store" + +export const isSeen = derived( + getSeenAt, + $getSeenAt => (key: string, event: TrustedEvent) => $getSeenAt(key, event) > 0, +) + +export const setChecked = (path: string, ts = now()) => + checked.update(state => ({...state, [path]: ts})) + +// Notifications + +// -- Main Notifications + +export const mainNotifications = derived( + [pubkey, isEventMuted, deriveEvents(repository, {throttle: 800, filters: [{kinds: noteKinds}]})], + ([$pubkey, $isEventMuted, $events]) => + $events.filter( + e => + e.pubkey !== $pubkey && + e.tags.some(t => t[0] === "p" && t[1] === $pubkey) && + !$isEventMuted(e), + ), +) + +export const unreadMainNotifications = derived([isSeen, mainNotifications], ([$isSeen, events]) => + events.filter(e => !$isSeen("notes/*", e)), +) + +export const hasNewNotifications = derived( + [sessionWithMeta, unreadMainNotifications], + ([$sessionWithMeta, $unread]) => { + if ($unread.length > 0) { + return true + } + + if ($sessionWithMeta?.onboarding_tasks_completed) { + return ( + without($sessionWithMeta.onboarding_tasks_completed, Object.values(OnboardingTask)).length > + 0 + ) + } + + return false + }, +) + +// -- Reaction Notifications + +export const reactionNotifications = derived( + [ + pubkey, + isEventMuted, + deriveEvents(repository, {throttle: 800, filters: [{kinds: reactionKinds}]}), + ], + ([$pubkey, $isEventMuted, $events]) => + $events.filter( + e => + e.pubkey !== $pubkey && + e.tags.some(t => t[0] === "p" && t[1] === $pubkey) && + !$isEventMuted(e) && + isLike(e), + ), +) + +export const unreadReactionNotifications = derived( + [isSeen, reactionNotifications], + ([$isSeen, events]) => events.filter(e => !$isSeen("reactions/*", e)), +) diff --git a/src/engine/requests.ts b/src/engine/requests.ts index 900070d2d..384d69032 100644 --- a/src/engine/requests.ts +++ b/src/engine/requests.ts @@ -26,9 +26,6 @@ import { LABEL, DELETE, FEED, - SEEN_CONVERSATION, - SEEN_GENERAL, - SEEN_CONTEXT, NAMED_BOOKMARKS, HANDLER_INFORMATION, HANDLER_RECOMMENDATION, @@ -289,17 +286,6 @@ export const loadDeletes = () => filters: [addSinceToFilter({kinds: [DELETE], authors: [pubkey.get()]})], }) -export const loadSeen = () => - load({ - skipCache: true, - filters: [ - addSinceToFilter({ - kinds: [SEEN_CONVERSATION, SEEN_GENERAL, SEEN_CONTEXT], - authors: [pubkey.get()], - }), - ], - }) - export const loadFeedsAndLists = () => load({ skipCache: true, diff --git a/src/engine/state.ts b/src/engine/state.ts index 9ebddda29..c4ee8b243 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -48,9 +48,9 @@ import { simpleCache, sort, take, - tryCatch, uniq, uniqBy, + max, } from "@welshman/lib" import type {Connection, PublishRequest, Target} from "@welshman/net" import { @@ -77,9 +77,6 @@ import { LOCAL_RELAY_URL, MUTES, NAMED_BOOKMARKS, - SEEN_CONTEXT, - SEEN_CONVERSATION, - SEEN_GENERAL, WRAP, asDecryptedEvent, createEvent, @@ -99,10 +96,9 @@ import { normalizeRelayUrl, readList, } from "@welshman/util" -import crypto from "crypto" import Fuse from "fuse.js" import {batch, doPipe, seconds} from "hurdak" -import {equals, partition, prop, sortBy, without} from "ramda" +import {equals, partition, prop, sortBy} from "ramda" import type {PublishedFeed, PublishedListFeed, PublishedUserList} from "src/domain" import { CollectionSearch, @@ -117,11 +113,9 @@ import { readUserList, } from "src/domain" import type {AnonymousUserState, Channel, SessionWithMeta} from "src/engine/model" -import {OnboardingTask} from "src/engine/model" -import {sortEventsAsc} from "src/engine/utils" import logger from "src/util/logger" import {SearchHelper, fromCsv, parseJson} from "src/util/misc" -import {appDataKeys, isLike, metaKinds, noteKinds, reactionKinds, repostKinds} from "src/util/nostr" +import {appDataKeys, metaKinds, noteKinds, reactionKinds, repostKinds} from "src/util/nostr" import {derived, get, writable} from "svelte/store" export const env = { @@ -232,7 +226,7 @@ unwrapper.addGlobalHandler(async (event: TrustedEvent) => { } }) -const decryptKinds = [SEEN_GENERAL, SEEN_CONTEXT, SEEN_CONVERSATION, APP_DATA, FOLLOWS, MUTES] +const decryptKinds = [APP_DATA, FOLLOWS, MUTES] repository.on("update", ({added}: {added: TrustedEvent[]}) => { for (const event of added) { @@ -373,120 +367,17 @@ export const isEventMuted = withGetter( // Read receipts -export const seenStatusEvents = deriveEvents(repository, { - filters: [{kinds: [SEEN_GENERAL, SEEN_CONTEXT, SEEN_CONVERSATION]}], -}) - -export const userSeenStatusEvents = derived( - [pubkey, seenStatusEvents], - ([$pubkey, $seenStatusEvents]) => $seenStatusEvents.filter(e => e.pubkey === $pubkey), -) - -export const userSeenStatuses = derived( - [pubkey, plaintext, userSeenStatusEvents], - ([$pubkey, $plaintext, $userSeenStatusEvents]) => { - const data = {} - - for (const event of sortEventsAsc($userSeenStatusEvents)) { - const tags = tryCatch(() => JSON.parse($plaintext[event.id])) - - if (!Array.isArray(tags)) { - continue - } - - for (const tag of tags) { - if (tag[0] === "seen") { - data[tag[1]] = { - ts: parseInt(tag[2]), - ids: new Set(tag.slice(3)), - } - } - } - } - - return data - }, -) - -export const getSeenAt = derived( - [userSeenStatuses], - ([$userSeenStatuses]) => - (key: string, event: TrustedEvent) => { - const match = $userSeenStatuses[key] - const fallback = $userSeenStatuses["*"] - const ts = Math.max(match?.ts || 0, fallback?.ts || 0) - - if (ts >= event.created_at) return ts - if (match?.ids.has(event.id)) return event.created_at - - return 0 - }, -) - -export const isSeen = derived( - getSeenAt, - $getSeenAt => (key: string, event: TrustedEvent) => $getSeenAt(key, event) > 0, -) - -// Notifications +export const checked = writable>({}) -// -- Main Notifications +export const deriveChecked = (key: string) => derived(checked, prop(key)) -export const mainNotifications = derived( - [pubkey, isEventMuted, deriveEvents(repository, {throttle: 800, filters: [{kinds: noteKinds}]})], - ([$pubkey, $isEventMuted, $events]) => - $events.filter( - e => - e.pubkey !== $pubkey && - e.tags.some(t => t[0] === "p" && t[1] === $pubkey) && - !$isEventMuted(e), - ), -) +export const getSeenAt = derived([checked], ([$checked]) => (path: string, event: TrustedEvent) => { + const ts = max([$checked[path], $checked[path.split("/")[0] + "/*"], $checked["*"]]) -export const unreadMainNotifications = derived([isSeen, mainNotifications], ([$isSeen, events]) => - events.filter(e => !$isSeen("replies", e) && !$isSeen("mentions", e)), -) + if (ts >= event.created_at) return ts -export const hasNewNotifications = derived( - [sessionWithMeta, unreadMainNotifications], - ([$sessionWithMeta, $unread]) => { - if ($unread.length > 0) { - return true - } - - if ($sessionWithMeta?.onboarding_tasks_completed) { - return ( - without($sessionWithMeta.onboarding_tasks_completed, Object.values(OnboardingTask)).length > - 0 - ) - } - - return false - }, -) - -// -- Reaction Notifications - -export const reactionNotifications = derived( - [ - pubkey, - isEventMuted, - deriveEvents(repository, {throttle: 800, filters: [{kinds: reactionKinds}]}), - ], - ([$pubkey, $isEventMuted, $events]) => - $events.filter( - e => - e.pubkey !== $pubkey && - e.tags.some(t => t[0] === "p" && t[1] === $pubkey) && - !$isEventMuted(e) && - isLike(e), - ), -) - -export const unreadReactionNotifications = derived( - [isSeen, reactionNotifications], - ([$isSeen, events]) => events.filter(e => !$isSeen("reactions", e) && !$isSeen("zaps", e)), -) + return 0 +}) // Channels @@ -495,13 +386,7 @@ export const getChannelId = (pubkeys: string[]) => sort(uniq(pubkeys)).join(",") export const getChannelIdFromEvent = (event: TrustedEvent) => getChannelId([event.pubkey, ...getPubkeyTagValues(event.tags)]) -export const getChannelSeenKey = (id: string) => - crypto.createHash("sha256").update(id.replace(",", "")).digest("hex") - -export const messages = deriveEvents(repository, { - throttle: 300, - filters: [{kinds: [4, DIRECT_MESSAGE]}], -}) +export const messages = deriveEvents(repository, {filters: [{kinds: [4, DIRECT_MESSAGE]}]}) export const channels = derived( [pubkey, messages, getSeenAt], @@ -515,7 +400,6 @@ export const channels = derived( continue } - const key = getChannelSeenKey(id) const chan = channelsById[id] || { id, last_sent: 0, @@ -525,7 +409,7 @@ export const channels = derived( } chan.messages.push(e) - chan.last_checked = Math.max(chan.last_checked, $getSeenAt(key, e)) + chan.last_checked = Math.max(chan.last_checked, $getSeenAt("channels/" + id, e)) if (e.pubkey === $pubkey) { chan.last_sent = Math.max(chan.last_sent, e.created_at) @@ -951,6 +835,7 @@ export class ThreadLoader { // Remove the old database. TODO remove this import {deleteDB} from "idb" import {subscriptionNotices} from "src/domain/connection" + deleteDB("nostr-engine/Storage") let ready: Promise = Promise.resolve() @@ -1066,10 +951,11 @@ if (!db) { }) }) - ready = initStorage("coracle", 2, { + ready = initStorage("coracle", 3, { relays: {keyPath: "url", store: throttled(1000, relays)}, handles: {keyPath: "nip05", store: throttled(1000, handles)}, zappers: {keyPath: "lnurl", store: throttled(1000, zappers)}, + checked: storageAdapters.fromObjectStore(checked, {throttle: 1000}), freshness: storageAdapters.fromObjectStore(freshness, { throttle: 1000, migrate: migrateFreshness,