Improve read receipts

This commit is contained in:
Jon Staab 2024-03-07 12:59:09 -08:00
parent b5ede0b883
commit c6a57993a8
10 changed files with 81 additions and 86 deletions

View File

@ -8,6 +8,7 @@
- [x] Publish q tags - [x] Publish q tags
- [x] Add sugggested relays based on follows - [x] Add sugggested relays based on follows
- [x] Improve invite creation - [x] Improve invite creation
- [x] Added docker image (@kornpow)
# 0.4.3 # 0.4.3

View File

@ -80,13 +80,25 @@
loadGroupMessages() loadGroupMessages()
loadNotifications() loadNotifications()
const unsubUnreadNotifications = unreadNotifications.subscribe(events => {
if (activeTab !== "Groups") {
markAsSeen(events)
}
})
const unsubUnreadGroupNotifications = unreadGroupNotifications.subscribe(events => {
if (activeTab === "Groups") {
markAsSeen(events)
}
})
const scroller = createScroller(async () => { const scroller = createScroller(async () => {
limit += 4 limit += 4
}) })
return () => { return () => {
markAsSeen($unreadNotifications) unsubUnreadNotifications()
markAsSeen($unreadGroupNotifications) unsubUnreadGroupNotifications()
scroller.stop() scroller.stop()
} }
}) })

View File

@ -1,70 +1,55 @@
import {pluck, uniq, flatten} from "ramda" import {pluck} from "ramda"
import {chunk, batch, seconds} from "hurdak" import {chunk, seconds} from "hurdak"
import {createEvent, now} from "paravel" import {createEvent, now} from "paravel"
import {generatePrivateKey} from "src/util/nostr" import {generatePrivateKey} from "src/util/nostr"
import {pubkey} from "src/engine/session/state" import {pubkey} from "src/engine/session/state"
import {signer, nip44, nip59} from "src/engine/session/derived" import {signer, nip59} from "src/engine/session/derived"
import {hints} from "src/engine/relays/utils" import {hints} from "src/engine/relays/utils"
import {Publisher} from "src/engine/network/utils" import {Publisher} from "src/engine/network/utils"
import type {Event} from "./model" import type {Event} from "./model"
import {seenIds} from "./state" import {seen} from "./state"
const getExpirationTag = () => ["expiration", String(now() + seconds(90, "day"))] export const markAsSeen = async (events: Event[]) => {
const createReadReceipt = ids =>
createEvent(15, {
tags: [getExpirationTag(), ...ids.map(id => ["e", id])],
})
export const markAsSeenPublicly = batch(5000, async idChunks => {
for (const ids of chunk(500, uniq(flatten(idChunks)))) {
const event = await signer.get().signAsUser(createReadReceipt(ids))
if (event) {
Publisher.publish({event, relays: hints.WriteRelays().getUrls()})
}
}
})
export const markAsSeenPrivately = batch(5000, async idChunks => {
for (const ids of chunk(500, uniq(flatten(idChunks)))) {
const template = createReadReceipt(ids)
const rumor = await nip59.get().wrap(template, {
wrap: {
author: generatePrivateKey(),
recipient: pubkey.get(),
},
})
rumor.wrap.tags.push(getExpirationTag())
Publisher.publish({
event: rumor.wrap,
relays: hints.WriteRelays().getUrls(),
})
}
})
export const markAsSeen = async (events: Event[], {visibility = "private"} = {}) => {
if (!signer.get().isEnabled() || events.length === 0) { if (!signer.get().isEnabled() || events.length === 0) {
return return
} }
const ids = pluck("id", events) const ids = pluck("id", events)
// Eagerly update seenIds to make the UX smooth // Eagerly update to make the UX smooth
seenIds.update($seenIds => { seen.mapStore.update($m => {
for (const id of ids) { for (const id of ids) {
$seenIds.add(id) if (!$m.has(id)) {
$m.set(id, {id})
}
} }
return $seenIds return $m
}) })
if (visibility === "private" && nip44.get().isEnabled()) { const notSynced = seen.get().filter(x => !x.published)
markAsSeenPrivately(ids)
} else { if (notSynced.length > 100) {
markAsSeenPublicly(ids) const expirationTag = ["expiration", String(now() + seconds(90, "day"))]
for (const ids of chunk(500, pluck("id", notSynced))) {
const template = createEvent(15, {
tags: [expirationTag, ...ids.map(id => ["e", id])],
})
const rumor = await nip59.get().wrap(template, {
wrap: {
author: generatePrivateKey(),
recipient: pubkey.get(),
},
})
rumor.wrap.tags.push(expirationTag)
Publisher.publish({
event: rumor.wrap,
relays: hints.WriteRelays().getUrls(),
})
}
} }
} }

View File

@ -7,7 +7,7 @@ import {getWotScore} from "src/engine/people/utils"
import {mutes, follows} from "src/engine/people/derived" import {mutes, follows} from "src/engine/people/derived"
import {deriveIsGroupMember} from "src/engine/groups/utils" import {deriveIsGroupMember} from "src/engine/groups/utils"
import type {Event} from "./model" import type {Event} from "./model"
import {deletes, _events} from "./state" import {deletes, seen, _events} from "./state"
export const events = new DerivedCollection<Event>("id", [_events, deletes], ([$e, $d]) => export const events = new DerivedCollection<Event>("id", [_events, deletes], ([$e, $d]) =>
$e.filter(e => !$d.has(e.id)), $e.filter(e => !$d.has(e.id)),
@ -72,3 +72,5 @@ export const isEventMuted = derived([mutes, settings, pubkey], ([$mutes, $settin
export const isDeleted = deletes.derived( export const isDeleted = deletes.derived(
$d => e => Boolean(getIdAndAddress(e).find(k => $d.has(k))), $d => e => Boolean(getIdAndAddress(e).find(k => $d.has(k))),
) )
export const isSeen = seen.mapStore.derived($m => e => $m.has(e.id))

View File

@ -11,3 +11,8 @@ export type Event = Omit<NostrToolsEvent, "kind"> & {
export type Rumor = UnsignedEvent & { export type Rumor = UnsignedEvent & {
id: string id: string
} }
export type ReadReceipt = {
id: string
published?: number
}

View File

@ -65,7 +65,9 @@ projections.addHandler(
seen.mapStore.update($m => { seen.mapStore.update($m => {
for (const e of chunk) { for (const e of chunk) {
$m.set(e.id, e) for (const id of Tags.fromEvent(e).values("e").valueOf()) {
$m.set(id, {id, published: e.created_at})
}
} }
return $m return $m

View File

@ -1,20 +1,6 @@
import {Tags} from "paravel"
import {Collection, Writable} from "src/engine/core/utils" import {Collection, Writable} from "src/engine/core/utils"
import type {Event} from "./model" import type {Event, ReadReceipt} from "./model"
export const _events = new Collection<Event>("id", 1000) export const _events = new Collection<Event>("id", 1000)
export const seen = new Collection<Event>("id", 1000) export const seen = new Collection<ReadReceipt>("id", 1000)
export const seenIds = new Writable(new Set<string>(), 1000)
export const deletes = new Writable(new Set<string>(), 10000) export const deletes = new Writable(new Set<string>(), 10000)
seen.subscribe($seen => {
seenIds.update($seenIds => {
for (const e of $seen) {
for (const id of Tags.fromEvent(e).values("e").uniq().valueOf()) {
$seenIds.add(id)
}
}
return $seenIds
})
})

View File

@ -54,11 +54,11 @@ const sessionsAdapter = {
dump: identity, dump: identity,
} }
export const storage = new Storage(9, [ export const storage = new Storage(10, [
new LocalStorageAdapter("pubkey", pubkey), new LocalStorageAdapter("pubkey", pubkey),
new LocalStorageAdapter("sessions", sessions, sessionsAdapter), new LocalStorageAdapter("sessions", sessions, sessionsAdapter),
new LocalStorageAdapter("deletes2", deletes, setAdapter), new LocalStorageAdapter("deletes2", deletes, setAdapter),
new IndexedDBAdapter("seen2", seen, 1000, sortBy(prop("created_at"))), new IndexedDBAdapter("seen3", seen, 10000, sortBy(prop("created_at"))),
new IndexedDBAdapter("events", _events, 10000, sortByPubkeyWhitelist(prop("created_at"))), new IndexedDBAdapter("events", _events, 10000, sortByPubkeyWhitelist(prop("created_at"))),
new IndexedDBAdapter("labels", _labels, 1000, sortBy(prop("created_at"))), new IndexedDBAdapter("labels", _labels, 1000, sortBy(prop("created_at"))),
new IndexedDBAdapter("topics", topics, 1000, sortBy(prop("last_seen"))), new IndexedDBAdapter("topics", topics, 1000, sortBy(prop("last_seen"))),

View File

@ -7,7 +7,7 @@ import {noteKinds, reactionKinds, repostKinds} from "src/util/nostr"
import type {DisplayEvent} from "src/engine/notes/model" import type {DisplayEvent} from "src/engine/notes/model"
import type {Event} from "src/engine/events/model" import type {Event} from "src/engine/events/model"
import {sortEventsDesc, unwrapRepost} from "src/engine/events/utils" import {sortEventsDesc, unwrapRepost} from "src/engine/events/utils"
import {isEventMuted, isDeleted} from "src/engine/events/derived" import {isEventMuted, isDeleted, isSeen} from "src/engine/events/derived"
import {writable} from "src/engine/core/utils" import {writable} from "src/engine/core/utils"
import type {Filter} from "../model" import type {Filter} from "../model"
import {getIdFilters, guessFilterDelta} from "./filters" import {getIdFilters, guessFilterDelta} from "./filters"
@ -42,6 +42,7 @@ export class FeedLoader {
ready: Promise<void> ready: Promise<void>
isEventMuted = isEventMuted.get() isEventMuted = isEventMuted.get()
isDeleted = isDeleted.get() isDeleted = isDeleted.get()
isSeen = isSeen.get()
constructor(readonly opts: FeedOpts) { constructor(readonly opts: FeedOpts) {
const urls = getUrls(opts.relays) const urls = getUrls(opts.relays)
@ -97,6 +98,10 @@ export class FeedLoader {
const strict = this.opts.filters.some(f => f["#a"]) const strict = this.opts.filters.some(f => f["#a"])
return events.filter(e => { return events.filter(e => {
if (this.isSeen(e)) {
return false
}
if (this.isDeleted(e)) { if (this.isDeleted(e)) {
return false return false
} }

View File

@ -3,7 +3,7 @@ import {seconds} from "hurdak"
import {now, Tags} from "paravel" import {now, Tags} from "paravel"
import {isLike, reactionKinds, noteKinds, repostKinds} from "src/util/nostr" import {isLike, reactionKinds, noteKinds, repostKinds} from "src/util/nostr"
import {tryJson} from "src/util/misc" import {tryJson} from "src/util/misc"
import {seenIds} from "src/engine/events/state" import {isSeen} from "src/engine/events/derived"
import {unwrapRepost} from "src/engine/events/utils" import {unwrapRepost} from "src/engine/events/utils"
import {events, isEventMuted} from "src/engine/events/derived" import {events, isEventMuted} from "src/engine/events/derived"
import {derived} from "src/engine/core/utils" import {derived} from "src/engine/core/utils"
@ -40,16 +40,13 @@ export const notifications = derived(
}, },
) )
export const unreadNotifications = derived( export const unreadNotifications = derived([isSeen, notifications], ([$isSeen, $notifications]) => {
[seenIds, notifications], const since = now() - seconds(30, "day")
([$seenIds, $notifications]) => {
const since = now() - seconds(30, "day")
return $notifications.filter( return $notifications.filter(
e => !reactionKinds.includes(e.kind) && e.created_at > since && !$seenIds.has(e.id), e => !reactionKinds.includes(e.kind) && e.created_at > since && !$isSeen(e),
) )
}, })
)
export const groupNotifications = derived( export const groupNotifications = derived(
[session, events, groupRequests, groupAlerts, groupAdminKeys], [session, events, groupRequests, groupAlerts, groupAdminKeys],
@ -88,11 +85,11 @@ export const groupNotifications = derived(
}) })
export const unreadGroupNotifications = derived( export const unreadGroupNotifications = derived(
[seenIds, groupNotifications], [isSeen, groupNotifications],
([$seenIds, $groupNotifications]) => { ([$isSeen, $groupNotifications]) => {
const since = now() - seconds(30, "day") const since = now() - seconds(30, "day")
return $groupNotifications.filter(e => e.created_at > since && !$seenIds.has(e.id)) return $groupNotifications.filter(e => e.created_at > since && !$isSeen(e))
}, },
) )