flotilla/src/app/state.ts

441 lines
11 KiB
TypeScript
Raw Normal View History

2024-09-11 18:09:25 +00:00
import {nip19} from "nostr-tools"
2024-10-08 18:39:16 +00:00
import twColors from "tailwindcss/colors"
2024-09-11 22:21:25 +00:00
import {get, derived} from "svelte/store"
2024-08-14 00:58:26 +00:00
import type {Maybe} from "@welshman/lib"
2024-10-08 18:39:16 +00:00
import {
setContext,
remove,
assoc,
sortBy,
sort,
uniq,
partition,
nth,
max,
pushToMapKey,
nthEq,
} from "@welshman/lib"
2024-08-16 17:50:38 +00:00
import {
getIdFilters,
2024-10-01 23:27:25 +00:00
WRAP,
RELAYS,
2024-08-30 23:25:41 +00:00
REACTION,
ZAP_RESPONSE,
2024-10-01 23:27:25 +00:00
DIRECT_MESSAGE,
2024-09-11 22:21:25 +00:00
EVENT_DATE,
EVENT_TIME,
getRelayTagValues,
isShareableRelayUrl,
2024-10-01 23:27:25 +00:00
getPubkeyTagValues,
2024-10-04 00:04:00 +00:00
isHashedEvent,
displayProfile,
2024-10-08 22:30:18 +00:00
readList,
getListTags,
asDecryptedEvent,
2024-08-16 17:50:38 +00:00
} from "@welshman/util"
2024-10-08 22:30:18 +00:00
import type {TrustedEvent, SignedEvent, PublishedList, List} from "@welshman/util"
2024-10-04 00:04:00 +00:00
import {Nip59} from "@welshman/signer"
2024-08-26 21:43:43 +00:00
import {
2024-08-30 23:25:41 +00:00
pubkey,
2024-08-26 21:43:43 +00:00
repository,
2024-08-14 00:58:26 +00:00
load,
subscribe,
2024-08-30 23:25:41 +00:00
collection,
loadRelay,
profilesByPubkey,
2024-09-11 03:19:57 +00:00
getDefaultAppContext,
getDefaultNetContext,
makeRouter,
2024-09-11 21:08:04 +00:00
trackerStore,
2024-10-04 00:04:00 +00:00
tracker,
relay,
getSession,
getSigner,
hasNegentropy,
pull,
createSearch,
2024-08-30 23:25:41 +00:00
} from "@welshman/app"
2024-10-04 00:04:00 +00:00
import type {AppSyncOpts} from "@welshman/app"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
2024-10-10 17:00:01 +00:00
import {deriveEvents, deriveEventsMapped, withGetter} from "@welshman/store"
2024-08-13 19:41:25 +00:00
2024-09-23 17:28:33 +00:00
export const ROOM = "~"
export const GENERAL = "general"
export const MESSAGE = 209
2024-09-23 17:28:33 +00:00
export const REPLY = 1111
2024-09-11 21:08:04 +00:00
export const MEMBERSHIPS = 10209
2024-08-13 19:41:25 +00:00
2024-09-24 23:30:32 +00:00
export const INDEXER_RELAYS = [
"wss://purplepag.es/",
"wss://relay.damus.io/",
"wss://relay.nostr.band/",
]
2024-08-13 19:41:25 +00:00
2024-08-30 23:25:41 +00:00
export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
2024-08-13 19:41:25 +00:00
2024-09-24 21:12:38 +00:00
export const IMGPROXY_URL = "https://imgproxy.coracle.social"
2024-08-30 23:25:41 +00:00
export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
2024-08-13 19:41:25 +00:00
2024-10-08 18:39:16 +00:00
export const colors = [
["amber", twColors.amber[600]],
["blue", twColors.blue[600]],
["cyan", twColors.cyan[600]],
["emerald", twColors.emerald[600]],
["fuchsia", twColors.fuchsia[600]],
["green", twColors.green[600]],
["indigo", twColors.indigo[600]],
["sky", twColors.sky[600]],
["lime", twColors.lime[600]],
["orange", twColors.orange[600]],
["pink", twColors.pink[600]],
["purple", twColors.purple[600]],
["red", twColors.red[600]],
["rose", twColors.rose[600]],
["sky", twColors.sky[600]],
["teal", twColors.teal[600]],
["violet", twColors.violet[600]],
["yellow", twColors.yellow[600]],
["zinc", twColors.zinc[600]],
]
export const dufflepud = (path: string) => DUFFLEPUD_URL + "/" + path
2024-09-24 21:12:38 +00:00
export const imgproxy = (url: string, {w = 640, h = 1024} = {}) => {
if (!url || url.match("gif$")) {
return url
}
url = url.split("?")[0]
try {
return url ? `${IMGPROXY_URL}/x/s:${w}:${h}/${btoa(url)}` : url
} catch (e) {
return url
}
}
2024-09-24 23:30:32 +00:00
export const entityLink = (entity: string) => `https://coracle.social/${entity}`
2024-09-24 21:12:38 +00:00
2024-10-01 18:47:21 +00:00
export const tagRoom = (room: string, url: string) => [ROOM, room, url]
2024-10-04 00:04:00 +00:00
export const ensureUnwrapped = async (event: TrustedEvent) => {
if (event.kind !== WRAP) {
return event
}
let rumor = repository.eventsByWrap.get(event.id)
if (rumor) {
return rumor
}
for (const recipient of getPubkeyTagValues(event.tags)) {
const session = getSession(recipient)
const signer = getSigner(session)
if (signer) {
try {
rumor = await Nip59.fromSigner(signer).unwrap(event as SignedEvent)
break
} catch (e) {
// pass
}
}
}
if (rumor && isHashedEvent(rumor)) {
2024-10-08 20:20:55 +00:00
// Copy urls over to the rumor
2024-10-04 00:04:00 +00:00
tracker.copy(event.id, rumor.id)
2024-10-08 20:20:55 +00:00
// Send the rumor via our relay so listeners get updated
2024-10-04 00:04:00 +00:00
relay.send("EVENT", rumor)
}
return rumor
}
export const pullConservatively = ({relays, filters}: AppSyncOpts) => {
const [smart, dumb] = partition(hasNegentropy, relays)
const promises = [pull({relays: smart, filters})]
// Since pulling from relays without negentropy is expensive, only do it 30% of the time,
// unless we have very few matching events. If that's the case, either we haven't synced
// this filter yet, or there are few enough events that we don't really need to worry about
// downloading duplicates. Otherwise, add a reasonable since value to make sure we at
// least fetch recent events.
if (Math.random() > 0.7 || repository.query(filters).length < 100) {
promises.push(pull({relays: dumb, filters}))
} else {
const events = sortBy(e => -e.created_at, repository.query(filters))
const since = events[50]!.created_at
promises.push(pull({relays: dumb, filters: filters.map(assoc("since", since))}))
}
return Promise.all(promises)
}
2024-09-11 03:19:57 +00:00
setContext({
net: getDefaultNetContext(),
app: getDefaultAppContext({
dufflepudUrl: DUFFLEPUD_URL,
indexerRelays: INDEXER_RELAYS,
requestTimeout: 5000,
router: makeRouter(),
}),
})
2024-08-16 17:50:38 +00:00
export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
let attempted = false
const filters = getIdFilters([idOrAddress])
const relays = [...hints, ...INDEXER_RELAYS]
return derived(
deriveEvents(repository, {filters, includeDeleted: true}),
2024-08-19 16:06:39 +00:00
(events: TrustedEvent[]) => {
2024-08-16 17:50:38 +00:00
if (!attempted && events.length === 0) {
load({relays, filters})
attempted = true
}
return events[0]
},
)
}
// Membership
2024-08-16 19:42:00 +00:00
2024-10-09 23:15:09 +00:00
export const getMembershipUrls = (list?: List) => sort(getRelayTagValues(getListTags(list)))
2024-08-06 22:46:37 +00:00
2024-10-08 22:30:18 +00:00
export const getMembershipRoomsByUrl = (url: string, list?: List) =>
2024-10-09 23:15:09 +00:00
sort(
getListTags(list)
.filter(t => t[0] === "~" && t[2] === url)
.map(nth(1)),
)
2024-08-13 19:41:25 +00:00
2024-10-08 22:30:18 +00:00
export const memberships = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [MEMBERSHIPS]}],
2024-08-13 19:41:25 +00:00
itemToEvent: item => item.event,
2024-10-09 23:15:09 +00:00
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
2024-08-13 19:41:25 +00:00
})
2024-08-06 22:46:37 +00:00
2024-08-13 19:41:25 +00:00
export const {
indexStore: membershipByPubkey,
deriveItem: deriveMembership,
loadItem: loadMembership,
2024-08-30 23:25:41 +00:00
} = collection({
name: "memberships",
store: memberships,
2024-10-08 22:30:18 +00:00
getKey: list => list.event.pubkey,
load: (pubkey: string, request: Partial<SubscribeRequestWithHandlers> = {}) =>
load({
2024-08-15 19:08:07 +00:00
...request,
filters: [{kinds: [MEMBERSHIPS], authors: [pubkey]}],
}),
2024-08-15 19:08:07 +00:00
})
// Messages
2024-08-15 19:08:07 +00:00
2024-10-03 21:43:17 +00:00
export type ChannelMessage = {
2024-09-23 17:28:33 +00:00
room: string
2024-08-19 16:06:39 +00:00
event: TrustedEvent
2024-08-15 19:08:07 +00:00
}
2024-10-03 21:43:17 +00:00
export const readMessage = (event: TrustedEvent): Maybe<ChannelMessage> => {
2024-09-23 17:28:33 +00:00
const rooms = event.tags.filter(nthEq(0, ROOM)).map(nth(1))
2024-08-15 19:08:07 +00:00
2024-09-23 17:28:33 +00:00
if (rooms.length > 1) return undefined
2024-09-23 17:28:33 +00:00
return {room: rooms[0] || "", event}
2024-08-15 19:08:07 +00:00
}
2024-10-03 21:43:17 +00:00
export const channelMessages = deriveEventsMapped<ChannelMessage>(repository, {
2024-09-11 18:21:16 +00:00
filters: [{kinds: [MESSAGE, REPLY]}],
eventToItem: readMessage,
2024-08-15 19:08:07 +00:00
itemToEvent: item => item.event,
})
2024-10-03 21:43:17 +00:00
// Channels
2024-08-15 19:08:07 +00:00
2024-10-03 21:43:17 +00:00
export type Channel = {
id: string
url: string
2024-09-23 17:28:33 +00:00
room: string
2024-10-03 21:43:17 +00:00
messages: ChannelMessage[]
2024-08-15 19:08:07 +00:00
}
2024-10-03 21:43:17 +00:00
export const makeChannelId = (url: string, room: string) => `${url}'${room}`
2024-08-15 19:08:07 +00:00
2024-10-03 21:43:17 +00:00
export const splitChannelId = (id: string) => id.split("'")
2024-10-03 21:43:17 +00:00
export const channels = derived([trackerStore, channelMessages], ([$tracker, $channelMessages]) => {
const messagesByChannelId = new Map<string, ChannelMessage[]>()
2024-10-03 21:43:17 +00:00
for (const message of $channelMessages) {
2024-09-11 22:21:25 +00:00
for (const url of $tracker.getRelays(message.event.id)) {
2024-10-03 21:43:17 +00:00
const channelId = makeChannelId(url, message.room)
2024-09-11 21:08:04 +00:00
2024-10-03 21:43:17 +00:00
pushToMapKey(messagesByChannelId, channelId, message)
2024-09-11 21:08:04 +00:00
}
2024-09-11 22:21:25 +00:00
}
2024-09-11 21:08:04 +00:00
2024-10-03 21:43:17 +00:00
return Array.from(messagesByChannelId.entries()).map(([id, messages]) => {
const [url, room] = splitChannelId(id)
2024-09-11 21:08:04 +00:00
2024-09-23 17:28:33 +00:00
return {id, url, room, messages}
2024-09-11 22:21:25 +00:00
})
})
2024-08-15 19:08:07 +00:00
export const {
2024-10-03 21:43:17 +00:00
indexStore: channelsById,
deriveItem: deriveChannel,
loadItem: loadChannel,
2024-08-30 23:25:41 +00:00
} = collection({
2024-10-03 21:43:17 +00:00
name: "channels",
store: channels,
getKey: channel => channel.id,
load: (id: string, request: Partial<SubscribeRequestWithHandlers> = {}) => {
2024-10-03 21:43:17 +00:00
const [url, room] = splitChannelId(id)
const channel = get(channelsById).get(id)
const timestamps = channel?.messages.map(m => m.event.created_at) || []
2024-08-16 17:50:38 +00:00
const since = Math.max(0, max(timestamps) - 3600)
2024-08-15 19:08:07 +00:00
2024-09-23 20:58:01 +00:00
return load({...request, relays: [url], filters: [{"#~": [room], since}]})
2024-08-15 19:08:07 +00:00
},
2024-08-06 22:46:37 +00:00
})
2024-10-03 22:28:06 +00:00
// Chats
2024-10-01 23:27:25 +00:00
2024-10-03 21:43:17 +00:00
export const chatMessages = deriveEvents(repository, {filters: [{kinds: [DIRECT_MESSAGE]}]})
2024-10-01 23:27:25 +00:00
2024-10-03 21:43:17 +00:00
export type Chat = {
2024-10-01 23:27:25 +00:00
id: string
pubkeys: string[]
messages: TrustedEvent[]
2024-10-04 00:04:00 +00:00
last_activity: number
search_text: string
2024-10-01 23:27:25 +00:00
}
2024-10-03 21:43:17 +00:00
export const makeChatId = (pubkeys: string[]) => sort(uniq(pubkeys)).join(",")
2024-10-01 23:27:25 +00:00
2024-10-03 21:43:17 +00:00
export const splitChatId = (id: string) => id.split(",")
2024-10-01 23:27:25 +00:00
2024-10-08 18:39:16 +00:00
export const chats = derived(
[pubkey, chatMessages, profilesByPubkey],
([$pubkey, $messages, $profilesByPubkey]) => {
const messagesByChatId = new Map<string, TrustedEvent[]>()
2024-10-01 23:27:25 +00:00
2024-10-08 18:39:16 +00:00
for (const message of $messages) {
2024-10-08 20:20:55 +00:00
const chatId = makeChatId(getPubkeyTagValues(message.tags).concat(message.pubkey))
2024-10-01 23:27:25 +00:00
2024-10-08 18:39:16 +00:00
pushToMapKey(messagesByChatId, chatId, message)
}
2024-10-01 23:27:25 +00:00
2024-10-08 18:39:16 +00:00
return sortBy(
c => -c.last_activity,
Array.from(messagesByChatId.entries()).map(([id, events]): Chat => {
const pubkeys = splitChatId(id)
const messages = sortBy(e => -e.created_at, events)
const last_activity = messages[0].created_at
const search_text = remove($pubkey as string, pubkeys)
.map(pubkey => {
const profile = $profilesByPubkey.get(pubkey)
return profile ? displayProfile(profile) : ""
})
.join(" ")
return {id, pubkeys, messages, last_activity, search_text}
}),
)
},
)
2024-10-01 23:27:25 +00:00
export const {
2024-10-03 21:43:17 +00:00
indexStore: chatsById,
deriveItem: deriveChat,
loadItem: loadChat,
2024-10-01 23:27:25 +00:00
} = collection({
2024-10-03 21:43:17 +00:00
name: "chats",
store: chats,
getKey: chat => chat.id,
2024-10-01 23:27:25 +00:00
})
2024-10-04 00:04:00 +00:00
export const chatSearch = derived(chats, $chats =>
createSearch($chats, {
getValue: (chat: Chat) => chat.id,
fuseOptions: {keys: ["search_text"]},
}),
)
2024-09-23 20:10:15 +00:00
// Calendar events
2024-09-11 22:21:25 +00:00
export const events = deriveEvents(repository, {filters: [{kinds: [EVENT_DATE, EVENT_TIME]}]})
export const eventsByUrl = derived([trackerStore, events], ([$tracker, $events]) => {
const eventsByUrl = new Map<string, TrustedEvent[]>()
for (const event of $events) {
for (const url of $tracker.getRelays(event.id)) {
pushToMapKey(eventsByUrl, url, event)
}
}
return eventsByUrl
})
2024-09-23 17:28:33 +00:00
// Rooms
2024-09-11 19:19:45 +00:00
2024-10-03 21:43:17 +00:00
export const roomsByUrl = derived(channels, $channels => {
2024-09-23 17:28:33 +00:00
const $roomsByUrl = new Map<string, string[]>()
2024-09-11 19:19:45 +00:00
2024-10-03 21:43:17 +00:00
for (const channel of $channels) {
if (channel.room) {
pushToMapKey($roomsByUrl, channel.url, channel.room)
2024-09-11 19:19:45 +00:00
}
}
2024-09-23 17:28:33 +00:00
return $roomsByUrl
2024-09-11 19:19:45 +00:00
})
// User stuff
export const userMembership = withGetter(
derived([pubkey, membershipByPubkey], ([$pubkey, $membershipByPubkey]) => {
2024-10-08 22:30:18 +00:00
if (!$pubkey) return undefined
loadMembership($pubkey)
return $membershipByPubkey.get($pubkey)
}),
2024-08-16 17:50:38 +00:00
)
// Other utils
2024-09-11 18:21:16 +00:00
export const decodeNRelay = (nevent: string) => nip19.decode(nevent).data as string
export const displayReaction = (content: string) => {
if (content === "+") return "❤️"
if (content === "-") return "👎"
return content
}
export const discoverRelays = () =>
subscribe({
filters: [{kinds: [RELAYS]}],
onEvent: (event: TrustedEvent) => {
for (const url of getRelayTagValues(event.tags)) {
if (isShareableRelayUrl(url)) {
loadRelay(url)
}
2024-08-16 17:50:38 +00:00
}
},
})