Remember message/chat notification read status

This commit is contained in:
Jonathan Staab 2023-04-20 14:50:02 -05:00
parent 55d38ef113
commit 7916ed501c
16 changed files with 140 additions and 121 deletions

View File

@ -5,6 +5,7 @@
- [x] Add purplepag.es to sign in flow
- [x] Include people with only a display_name in search
- [x] Fix AUTH over multiplextr
- [x] Remember whether messages/notifications have been read
## 0.2.24

View File

@ -1,7 +1,7 @@
# Current
- [ ] Remember message/chat status
- [ ] Add way to turn off likes/zaps
- [ ] Remember joined rooms
- [ ] Image classification
- https://github.com/bhky/opennsfw2
- [ ] Claim relays bounty
@ -44,6 +44,7 @@
# UI/Features
- [ ] Use real links so cmd+click or right click work
- [ ] Allow sharing of lists/following other people's lists
- [ ] Add suggestion list for topics on compose
- [ ] Badges link to https://badges.page/p/97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322

View File

@ -31,8 +31,7 @@ const setRelays = newRelays =>
}),
})
const setSettings = content =>
new PublishableEvent(30078, {content, tags: [["d", "coracle/settings/v1"]]})
const setAppData = (d, content) => new PublishableEvent(30078, {content, tags: [["d", d]]})
const setPetnames = petnames => new PublishableEvent(3, {tags: petnames})
@ -203,7 +202,7 @@ export default {
authenticate,
updateUser,
setRelays,
setSettings,
setAppData,
setPetnames,
setMutes,
createList,

View File

@ -1,6 +1,6 @@
import type {MyEvent} from "src/util/types"
import {sortBy, assoc, uniq, uniqBy, prop, propEq, groupBy, pluck} from "ramda"
import {personKinds, findReplyId} from "src/util/nostr"
import {without, sortBy, assoc, uniq, uniqBy, prop, propEq, groupBy, pluck} from "ramda"
import {personKinds, appDataKeys, findReplyId} from "src/util/nostr"
import {chunk} from "hurdak/lib/hurdak"
import {batch, now, timedelta} from "src/util/misc"
import {
@ -107,10 +107,18 @@ const loadPeople = async (pubkeys, {relays = null, kinds = personKinds, force =
await Promise.all(
chunk(256, pubkeys).map(async chunk => {
await load({
relays: sampleRelays(relays || getAllPubkeyWriteRelays(chunk), 0.5),
filter: {kinds, authors: chunk},
})
const chunkRelays = sampleRelays(relays || getAllPubkeyWriteRelays(chunk), 0.5)
const chunkFilter = [] as Array<Record<string, any>>
chunkFilter.push({kinds: without([30078], kinds), authors: chunk})
// Add a separate filter for app data so we're not pulling down other people's stuff,
// or obsolete events of our own.
if (kinds.includes(30078)) {
chunkFilter.push({kinds: [30078], authors: chunk, "#d": appDataKeys})
}
await load({relays: chunkRelays, filter: chunkFilter})
})
)
}

View File

@ -149,27 +149,29 @@ addHandler(3, e => {
// User profile, except for events also handled for other users
const profileHandler = (key, getValue) => async e => {
const profile = user.getProfile()
if (e.pubkey !== profile.pubkey) {
return
}
const updated_at_key = `${key}_updated_at`
if (e.created_at < profile?.[updated_at_key]) {
return
}
const value = await getValue(e, profile)
// If we didn't get a value, don't update the key
if (value) {
user.profile.set({...profile, [key]: value, [updated_at_key]: e.created_at})
const userHandler = cb => e => {
if (e.pubkey === user.getPubkey()) {
cb(e)
}
}
const profileHandler = (key, getValue) =>
userHandler(async e => {
const profile = user.getProfile()
const updated_at_key = `${key}_updated_at`
if (e.created_at < profile?.[updated_at_key]) {
return
}
const value = await getValue(e, profile)
// If we didn't get a value, don't update the key
if (value) {
user.profile.set({...profile, [key]: value, [updated_at_key]: e.created_at})
}
})
addHandler(
2,
profileHandler("relays", (e, p) => uniqByUrl(p.relays.concat({url: e.content})))
@ -245,6 +247,15 @@ addHandler(
})
)
addHandler(
30078,
profileHandler("lastChecked", async (e, p) => {
if (Tags.from(e).getMeta("d") === "coracle/last_checked/v1") {
return {...p.lastChecked, ...(await keys.decryptJson(e.content))}
}
})
)
// Rooms
addHandler(40, e => {
@ -269,7 +280,7 @@ addHandler(40, e => {
})
addHandler(41, e => {
const roomId = Tags.from(e).type("e").values().first()
const roomId = Tags.from(e).getMeta("e")
if (!roomId) {
return

View File

@ -34,6 +34,7 @@ const profile = synced("agent/user/profile", {
dufflepudUrl: import.meta.env.VITE_DUFFLEPUD_URL,
multiplextrUrl: import.meta.env.VITE_MULTIPLEXTR_URL,
},
lastChecked: {},
petnames: [],
relays: [],
mutes: [],
@ -41,6 +42,7 @@ const profile = synced("agent/user/profile", {
})
const settings = derived(profile, prop("settings"))
const lastChecked = derived(profile, prop("lastChecked")) as Readable<Record<string, number>>
const petnames = derived(profile, prop("petnames")) as Readable<Array<Array<string>>>
const relays = derived(profile, prop("relays")) as Readable<Array<Relay>>
const mutes = derived(profile, prop("mutes")) as Readable<Array<[string, string]>>
@ -85,12 +87,29 @@ export default {
async setSettings(settings) {
profile.update($p => ({...$p, settings}))
if (keys.canSign()) {
const content = await keys.encryptJson(settings)
return this.setAppData("settings/v1", settings)
},
return cmd.setSettings(content).publish(profileCopy.relays)
// App data
lastChecked,
async setAppData(key, content) {
if (keys.canSign()) {
const d = `coracle/${key}`
const v = await keys.encryptJson(content)
return cmd.setAppData(d, v).publish(profileCopy.relays)
}
},
setLastChecked(k, v) {
profile.update($profile => {
const lastChecked = {...$profile.lastChecked, [k]: v}
this.setAppData("last_checked/v1", lastChecked)
return {...$profile, lastChecked}
})
},
// Petnames

View File

@ -57,7 +57,12 @@
<ChatDetail entity={params.entity} />
{/key}
</Route>
<Route path="/messages" component={MessagesList} />
<Route path="/messages">
<MessagesList activeTab="messages" />
</Route>
<Route path="/requests">
<MessagesList activeTab="requests" />
</Route>
<Route path="/messages/:entity" let:params>
{#key params.entity}
<MessagesDetail entity={params.entity} />

View File

@ -4,11 +4,11 @@ import {nip19} from "nostr-tools"
import {navigate} from "svelte-routing"
import {derived} from "svelte/store"
import {writable} from "svelte/store"
import {omit, pluck, sortBy, max, find, slice, propEq} from "ramda"
import {max, omit, pluck, sortBy, find, slice, propEq} from "ramda"
import {createMap, doPipe, first} from "hurdak/lib/hurdak"
import {warn} from "src/util/logger"
import {hash} from "src/util/misc"
import {synced, now, timedelta} from "src/util/misc"
import {now, timedelta} from "src/util/misc"
import {Tags, isNotification, userKinds} from "src/util/nostr"
import {findReplyId} from "src/util/nostr"
import {modal, toast} from "src/partials/state"
@ -96,21 +96,24 @@ export const feedsTab = writable("Follows")
// State
export const lastChecked = synced("app/alerts/lastChecked", {})
export const newNotifications = derived(
[watch("notifications", t => pluck("created_at", t.all()).reduce(max, 0)), lastChecked],
[watch("notifications", t => pluck("created_at", t.all()).reduce(max, 0)), user.lastChecked],
([$lastNotification, $lastChecked]) => $lastNotification > ($lastChecked.notifications || 0)
)
export const hasNewMessages = ({lastReceived, lastSent}, lastChecked) =>
lastReceived > Math.max(lastSent, lastChecked || 0)
export const newDirectMessages = derived(
[watch("contacts", t => t.all()), lastChecked],
([contacts, $lastChecked]) => Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], contacts))
[watch("contacts", t => t.all()), user.lastChecked],
([contacts, $lastChecked]) =>
Boolean(find(c => hasNewMessages(c, $lastChecked[`dm/${c.pubkey}`]), contacts))
)
export const newChatMessages = derived(
[watch("rooms", t => t.all()), lastChecked],
([rooms, $lastChecked]) => Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], rooms))
[watch("rooms", t => t.all()), user.lastChecked],
([rooms, $lastChecked]) =>
Boolean(find(r => hasNewMessages(r, $lastChecked[`chat/${r.id}`]), rooms))
)
// Synchronization from events to state
@ -126,23 +129,17 @@ const processMessages = async (pubkey, events) => {
return
}
lastChecked.update($lastChecked => {
for (const message of messages) {
if (message.pubkey === pubkey) {
const recipient = Tags.from(message).type("p").values().first()
for (const message of messages) {
const fromSelf = message.pubkey === pubkey
const contactPubkey = fromSelf ? Tags.from(message).getMeta("p") : message.pubkey
const contact = contacts.get(contactPubkey)
const key = fromSelf ? "lastSent" : "lastReceived"
$lastChecked[recipient] = Math.max($lastChecked[recipient] || 0, message.created_at)
contacts.patch({pubkey: recipient, accepted: true})
} else {
const contact = contacts.get(message.pubkey)
const lastMessage = Math.max(contact?.lastMessage || 0, message.created_at)
contacts.patch({pubkey: message.pubkey, lastMessage})
}
}
return $lastChecked
})
contacts.patch({
pubkey: contactPubkey,
[key]: Math.max(contact?.[key] || 0, message.created_at),
})
}
}
const processChats = async (pubkey, events) => {
@ -152,22 +149,14 @@ const processChats = async (pubkey, events) => {
return
}
lastChecked.update($lastChecked => {
for (const message of messages) {
const id = Tags.from(message).getMeta("e")
for (const message of messages) {
const fromSelf = message.pubkey === pubkey
const id = Tags.from(message).getMeta("e")
const room = rooms.get(id)
const key = fromSelf ? "lastSent" : "lastReceived"
if (message.pubkey === pubkey) {
$lastChecked[id] = Math.max($lastChecked[id] || 0, message.created_at)
} else {
const room = rooms.get(id)
const lastMessage = Math.max(room?.lastMessage || 0, message.created_at)
rooms.patch({id, lastMessage})
}
}
return $lastChecked
})
rooms.patch({id, [key]: Math.max(room?.[key] || 0, message.created_at)})
}
}
export const listen = async pubkey => {

View File

@ -1,6 +1,4 @@
<script lang="ts">
import {assoc} from "ramda"
import {updateIn} from "hurdak/lib/hurdak"
import {now, formatTimestamp} from "src/util/misc"
import {toHex} from "src/util/nostr"
import {modal} from "src/partials/state"
@ -13,7 +11,6 @@
import network from "src/agent/network"
import {watch} from "src/agent/db"
import cmd from "src/agent/cmd"
import {lastChecked} from "src/app/state"
export let entity
@ -21,6 +18,8 @@
const room = watch("rooms", t => t.get(id) || {id})
const getRelays = () => sampleRelays($room ? getRelaysForEventChildren($room) : [])
user.setLastChecked(`chat/${id}`, now())
const listenForMessages = onChunk =>
network.listen({
relays: getRelays(),
@ -47,8 +46,6 @@
}
document.title = $room.name
lastChecked.update(updateIn(assoc(id, now())))
</script>
<Channel {loadMessages} {listenForMessages} {sendMessage}>

View File

@ -27,24 +27,12 @@
<h2 class="text-lg">{room.name || ""}</h2>
</div>
{#if room.joined}
<Anchor
type="button"
class="flex items-center gap-2"
on:click={e => {
e.stopPropagation()
leave()
}}>
<Anchor type="button" preventDefault class="flex items-center gap-2" on:click={leave}>
<i class="fa fa-right-from-bracket" />
<span>Leave</span>
</Anchor>
{:else}
<Anchor
type="button"
class="flex items-center gap-2"
on:click={e => {
e.stopPropagation()
join()
}}>
<Anchor type="button" preventDefault class="flex items-center gap-2" on:click={join}>
<i class="fa fa-right-to-bracket" />
<span>Join</span>
</Anchor>

View File

@ -1,6 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {assoc} from "ramda"
import {toHex, displayPerson} from "src/util/nostr"
import {now, formatTimestamp} from "src/util/misc"
import {Tags} from "src/util/nostr"
@ -15,7 +14,6 @@
import user from "src/agent/user"
import cmd from "src/agent/cmd"
import {routes} from "src/app/state"
import {lastChecked} from "src/app/state"
import PersonCircle from "src/app/shared/PersonCircle.svelte"
import PersonAbout from "src/app/shared/PersonAbout.svelte"
@ -25,17 +23,16 @@
let pubkey = toHex(entity)
let person = watch("people", () => getPersonWithFallback(pubkey))
lastChecked.update(assoc(pubkey, now()))
user.setLastChecked(`dm/${pubkey}`, now())
const getRelays = () => {
return sampleRelays(getAllPubkeyRelays([pubkey, user.getPubkey()]))
}
const getRelays = () => sampleRelays(getAllPubkeyRelays([pubkey, user.getPubkey()]))
const decryptMessages = async events => {
const results = []
// Gotta do it in serial because of extension limitations
for (const event of events) {
const key = event.pubkey === pubkey ? pubkey : Tags.from(event).type("p").values().first()
const key = event.pubkey === pubkey ? pubkey : Tags.from(event).getMeta("p")
results.push({...event, content: await crypt.decrypt(key, event.content)})
}
@ -76,7 +73,10 @@
<Channel {loadMessages} {listenForMessages} {sendMessage}>
<div slot="header" class="mb-2 flex h-20 items-start gap-4 overflow-hidden p-4">
<div class="flex items-center gap-4">
<Anchor type="unstyled" class="fa fa-arrow-left cursor-pointer text-2xl" href="/messages" />
<Anchor
type="unstyled"
class="fa fa-arrow-left cursor-pointer text-2xl"
on:click={() => history.back()} />
<PersonCircle person={$person} size={12} />
</div>
<div class="flex w-full flex-col gap-2">
@ -103,7 +103,8 @@
})}>
<div
class={cx("inline-block max-w-xl rounded-2xl py-2 px-4", {
"rounded-br-none bg-white text-end text-black": message.person.pubkey === user.getPubkey(),
"rounded-br-none bg-gray-2 text-end text-gray-8":
message.person.pubkey === user.getPubkey(),
"rounded-bl-none bg-gray-7": message.person.pubkey !== user.getPubkey(),
})}>
<div class="break-words">

View File

@ -1,25 +1,19 @@
<script>
import {sortBy} from "ramda"
import {toTitle} from "hurdak/lib/hurdak"
import {navigate} from "svelte-routing"
import Tabs from "src/partials/Tabs.svelte"
import Content from "src/partials/Content.svelte"
import MessagesListItem from "src/app/views/MessagesListItem.svelte"
import {watch} from "src/agent/db"
let activeTab = "messages"
let contacts = []
export let activeTab = "messages"
const accepted = watch("contacts", t => t.all({lastSent: {$exists: true}}))
const requests = watch("contacts", t => t.all({lastSent: {$exists: false}}))
const getContacts = tab =>
sortBy(c => -c.lastMessage || 0, tab === "messages" ? $accepted : $requests)
$: contacts = getContacts(activeTab)
const setActiveTab = tab => {
activeTab = tab
}
const accepted = watch("contacts", t => t.all({accepted: true}))
const requests = watch("contacts", t => t.all({accepted: {$ne: true}}))
sortBy(c => -(c.lastSent || c.lastReceived || 0), tab === "messages" ? $accepted : $requests)
const getDisplay = tab => ({
title: toTitle(tab),
@ -30,7 +24,7 @@
</script>
<Content>
<Tabs tabs={["messages", "requests"]} {activeTab} {setActiveTab} {getDisplay} />
<Tabs tabs={["messages", "requests"]} {activeTab} setActiveTab={navigate} {getDisplay} />
{#each getContacts(activeTab) as contact (contact.pubkey)}
<MessagesListItem {contact} />
{:else}

View File

@ -4,15 +4,17 @@
import {ellipsize} from "hurdak/lib/hurdak"
import {displayPerson} from "src/util/nostr"
import {getPersonWithFallback} from "src/agent/db"
import {lastChecked} from "src/app/state"
import user from "src/agent/user"
import PersonCircle from "src/app/shared/PersonCircle.svelte"
import Card from "src/partials/Card.svelte"
import {hasNewMessages} from "src/app/state"
export let contact
const newMessages = contact.lastMessage > $lastChecked[contact.pubkey]
const {lastChecked} = user
const person = getPersonWithFallback(contact.pubkey)
const enter = () => navigate(`/messages/${nip19.npubEncode(contact.pubkey)}`)
const newMessages = hasNewMessages(contact, $lastChecked[`dm/${contact.pubkey}`])
</script>
<Card interactive on:click={enter}>

View File

@ -1,5 +1,6 @@
<script>
import {pluck, reverse, max, last, sortBy, assoc} from "ramda"
import {throttle} from "throttle-debounce"
import {pluck, reverse, max, last, sortBy} from "ramda"
import {onMount} from "svelte"
import {fly} from "svelte/transition"
import {now, timedelta, createScroller} from "src/util/misc"
@ -10,14 +11,16 @@
import {watch} from "src/agent/db"
import user from "src/agent/user"
import {userEvents} from "src/agent/db"
import {lastChecked} from "src/app/state"
let limit = 0
let events = null
const {lastChecked} = user
const prevChecked = $lastChecked.notifications || 0
const updateLastChecked = throttle(30_000, () => user.setLastChecked("notifications", now() + 30))
const notifications = watch("notifications", t => {
lastChecked.update(assoc("notifications", now()))
updateLastChecked()
// Sort by rounded timestamp so we can group reactions to the same parent
return reverse(

View File

@ -41,8 +41,8 @@
const stickToBottom = async cb => {
const lastMessage = pluck("created_at", annotatedMessages).reduce(max, 0)
const {scrollTop} = document.querySelector(".channel-messages")
const shouldStick = scrollTop > -200
const $channelMessages = document.querySelector(".channel-messages")
const shouldStick = $channelMessages?.scrollTop > -200
await cb()

View File

@ -7,6 +7,7 @@ import {invoiceAmount} from "src/util/lightning"
export const personKinds = [0, 2, 3, 10001, 10002]
export const userKinds = personKinds.concat([10000, 30001, 30078])
export const appDataKeys = ["coracle/settings/v1", "coracle/last_checked/v1"]
export class Tags {
tags: Array<any>