Get util/store checking

This commit is contained in:
Jonathan Staab 2023-07-20 14:25:53 -07:00
parent 906384f27a
commit 64a36f6b2a
7 changed files with 196 additions and 165 deletions

View File

@ -17,6 +17,8 @@
"devDependencies": {
"@capacitor/cli": "^4.7.3",
"@sveltejs/vite-plugin-svelte": "^1.1.0",
"@types/ramda": "^0.29.3",
"@types/throttle-debounce": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^5.50.0",
"@typescript-eslint/parser": "^5.50.0",
"autoprefixer": "^10.4.13",
@ -44,7 +46,7 @@
"classnames": "^2.3.2",
"compressorjs": "^1.1.1",
"fuse.js": "^6.6.2",
"hurdak": "^0.2.2",
"hurdak": "^0.2.3",
"husky": "^8.0.3",
"insane": "^2.6.2",
"lru-cache": "^7.18.3",

View File

@ -1,173 +1,204 @@
import {is, reject, filter, map, findIndex, equals} from "ramda"
import {ensurePlural} from "hurdak"
export type Readable<T> = {
notify: () => void
get: () => T
subscribe: (f: (v: T) => void) => () => void
derived: (f: (v: T) => void) => Readable<T>
type Derivable = Readable<any> | Readable<any>[]
type Unsubscriber = () => void
type Subscriber = <T>(v: T) => void | Unsubscriber
type R = Record<string, any>
type M = Map<string, R>
export interface Readable<T> {
get: () => T | undefined
subscribe: (f: Subscriber) => () => void
derived: <U>(f: <T>(v: T) => U) => Readable<U>
}
export type Writable<T> = Readable<T> & {
set: (v: T) => void
update: (f: (v: T) => T) => void
}
export class Writable<T> implements Readable<T> {
private value: T
private subs: Subscriber[] = []
export const writable = <T>(defaultValue = null): Writable<T> => {
let value = defaultValue
const subs = []
constructor(defaultValue: T) {
this.value = defaultValue
}
const notify = () => {
for (const sub of subs) {
sub(value)
notify() {
for (const sub of this.subs) {
sub(this.value)
}
}
const get = () => value
const set = newValue => {
value = newValue
notify()
get() {
return this.value
}
const update = f => {
value = f(value)
notify()
set(newValue: T) {
this.value = newValue
this.notify()
}
const subscribe = f => {
subs.push(f)
notify()
return () => subs.splice(findIndex(equals(f), subs), 1)
update(f: (v: T) => T) {
this.value = f(this.value)
this.notify()
}
const derived_ = f => derived<T>({get, subscribe}, f)
return {notify, get, set, update, subscribe, derived: derived_}
}
export const derived = <T>(stores, getValue): Readable<T> => {
const callerSubs = []
const mySubs = []
const get = () => getValue(Array.isArray(stores) ? stores.map(s => s.get()) : stores.get())
const notify = () => callerSubs.forEach(f => f(get()))
const subscribe = f => {
if (callerSubs.length === 0) {
for (const s of ensurePlural(stores)) {
mySubs.push(s.subscribe(v => notify()))
}
}
callerSubs.push(f)
notify()
subscribe(f: Subscriber) {
this.subs.push(f)
this.notify()
return () => {
callerSubs.splice(findIndex(equals(f), callerSubs), 1)
const idx = findIndex(equals(f), this.subs)
if (callerSubs.length == 0) {
for (const unsub of mySubs.splice(0)) {
this.subs.splice(idx, 1)
}
}
derived<U>(f: (v: T) => U): Derived<U> {
return new Derived<U>([this], f)
}
}
export class Derived<T> implements Readable<T> {
private callerSubs: Subscriber[] = []
private mySubs: Unsubscriber[] = []
private stores: Derivable
private getValue: (values: any) => T
constructor(stores: Derivable, getValue: (values: any) => T) {
this.stores = stores
this.getValue = getValue
}
notify() {
this.callerSubs.forEach(f => f(this.get()))
}
get() {
const isMulti = is(Array, this.stores)
const inputs = ensurePlural(this.stores).map(s => s.get())
return this.getValue(isMulti ? inputs : inputs[0])
}
subscribe(f: Subscriber) {
if (this.callerSubs.length === 0) {
for (const s of ensurePlural(this.stores)) {
this.mySubs.push(s.subscribe(() => this.notify()))
}
}
this.callerSubs.push(f)
this.notify()
return () => {
const idx = findIndex(equals(f), this.callerSubs)
this.callerSubs.splice(idx, 1)
if (this.callerSubs.length === 0) {
for (const unsub of this.mySubs.splice(0)) {
unsub()
}
}
}
}
const derived_ = f => derived<T>({get, subscribe}, f)
return {notify, get, subscribe, derived: derived_}
derived<U>(f: (v: T) => U): Readable<U> {
return new Derived([this], f) as Readable<U>
}
}
export class Key<T> {
export class Key implements Readable<R> {
readonly pk: string
readonly key: string
#base: Writable<Map<string, T>>
#store: Readable<T[]>
private base: Writable<M>
private store: Readable<R>
constructor(base, pk, key) {
constructor(base: Writable<M>, pk: string, key: string) {
if (!is(Map, base.get())) {
throw new Error("`key` can only be used on map collections")
}
this.pk = pk
this.key = key
this.#base = base
this.#store = derived(base, m => m.get(key))
this.base = base
this.store = base.derived<R>(m => m.get(key) as R)
}
get = () => this.#base.get().get(this.key)
get = () => this.base.get().get(this.key)
subscribe = f => this.#store.subscribe(f)
subscribe = (f: Subscriber) => this.store.subscribe(f)
derived = f => this.#store.derived(f)
derived = <U>(f: <V>(v: V) => U) => this.store.derived<U>(f)
exists = () => this.#base.get().has(this.key)
exists = () => this.base.get().has(this.key)
update = f =>
this.#base.update(m => {
update = (f: (v: R) => R) =>
this.base.update((m: M) => {
if (!this.key) {
throw new Error(`Cannot set key: "${this.key}"`)
}
const v = f(m.get(this.key))
const v = f(m.get(this.key) as R) as Record<string, any>
// Make sure the pk always get set on the record
if (v) {
v[this.pk] = this.key
m.set(this.key, v)
m.set(this.key, v as R)
}
return m
})
set = v => this.update(() => v)
set = (v: R) => this.update(() => v)
merge = d => this.update(v => ({...v, ...d}))
merge = (d: R) => this.update(v => ({...v, ...d}))
remove = () =>
this.#base.update(m => {
this.base.update(m => {
m.delete(this.key)
return m
})
}
export class Collection<T> {
export class Collection implements Readable<R[]> {
readonly pk: string
#map: Writable<Map<string, T>>
#list: Readable<T[]>
#map: Writable<M>
#list: Readable<R[]>
constructor(pk) {
constructor(pk: string) {
this.pk = pk
this.#map = writable(new Map())
this.#list = derived(this.#map, m => Array.from(m.values())) as Readable<T[]>
this.#list = this.#map.derived<R[]>((m: M) => Array.from(m.values()))
}
get = () => this.#list.get()
getMap = () => this.#map.get()
subscribe = f => this.#list.subscribe(f)
subscribe = (f: Subscriber) => this.#list.subscribe(f)
derived = f => this.#list.derived(f)
derived = <U>(f: <V>(v: V) => U) => this.#list.derived<U>(f)
key = k => new Key<T>(this.#map, this.pk, k)
key = (k: string) => new Key(this.#map, this.pk, k)
set = xs => this.#map.set(new Map(xs.map(x => [x[this.pk], x])))
set = (xs: R[]) => this.#map.set(new Map(xs.map(x => [x[this.pk], x])))
update = f => this.#map.update(m => new Map(f(Array.from(m.values())).map(x => [x[this.pk], x])))
update = (f: (v: R[]) => R[]) =>
this.#map.update(m => new Map(f(Array.from(m.values())).map(x => [x[this.pk], x])))
reject = f => this.update(reject(f))
reject = (f: (v: R) => boolean) => this.update(reject(f))
filter = f => this.update(filter(f))
filter = (f: (v: R) => boolean) => this.update(filter(f))
map = f => this.update(map(f))
map = (f: (v: R) => R) => this.update(map(f))
}
export const collection = <T>(pk) => new Collection<T>(pk)
export const writable = <T>(v: T) => new Writable(v)
export const derived = <U>(stores: Derivable, getValue: (values: any) => U) =>
new Derived(stores, getValue) as Readable<U>
export const key = (base: Writable<M>, pk: string, key: string) => new Key(base, pk, key)
export const collection = (pk: string) => new Collection(pk)

1
src/types.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'fuse.js/dist/fuse.min.js'

View File

@ -1,25 +1,25 @@
import {bech32, utf8} from "@scure/base"
import {debounce} from "throttle-debounce"
import {mergeDeepRight, pluck} from "ramda"
import {Storage, seconds, tryFunc, sleep, isObject, round} from "hurdak"
import {pluck} from "ramda"
import {Storage, seconds, tryFunc, sleep, round} from "hurdak"
import Fuse from "fuse.js/dist/fuse.min.js"
import {writable} from "svelte/store"
import {warn} from "src/util/logger"
export const fuzzy = (data, opts = {}) => {
const fuse = new Fuse(data, opts)
export const fuzzy = <T>(data: T[], opts = {}) => {
const {search} = new Fuse(data, opts) as {search: (q: string) => {item: T}[]}
// Slice pattern because the docs warn that it"ll crash if too long
return q => (q ? pluck("item", fuse.search(q.slice(0, 32))) : data)
return (q: string) => (q ? pluck("item", search(q.slice(0, 32))) : data)
}
export const now = () => Math.round(new Date().valueOf() / 1000)
export const getTimeZone = () => new Date().toString().match(/GMT[^\s]+/)
export const createLocalDate = dateString => new Date(`${dateString} ${getTimeZone()}`)
export const createLocalDate = (dateString: string) => new Date(`${dateString} ${getTimeZone()}`)
export const formatTimestamp = ts => {
export const formatTimestamp = (ts: number) => {
const formatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
@ -28,7 +28,7 @@ export const formatTimestamp = ts => {
return formatter.format(new Date(ts * 1000))
}
export const formatTimestampAsDate = ts => {
export const formatTimestampAsDate = (ts: number) => {
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
@ -38,7 +38,7 @@ export const formatTimestampAsDate = ts => {
return formatter.format(new Date(ts * 1000))
}
export const formatTimestampRelative = ts => {
export const formatTimestampRelative = (ts: number) => {
let unit
let delta = now() - ts
if (delta < seconds(1, "minute")) {
@ -61,7 +61,7 @@ export const formatTimestampRelative = ts => {
return formatter.format(-delta, unit as Intl.RelativeTimeFormatUnit)
}
export const formatTimestampAsLocalISODate = ts => {
export const formatTimestampAsLocalISODate = (ts: number) => {
const date = new Date(ts * 1000)
const offset = date.getTimezoneOffset() * 60000
const datetime = new Date(date.getTime() - offset).toISOString()
@ -69,12 +69,10 @@ export const formatTimestampAsLocalISODate = ts => {
return datetime
}
export const createScroller = (
loadMore,
{threshold = 2000, reverse = false, element = null} = {}
export const createScroller = <T>(
loadMore: () => Promise<T>,
{threshold = 2000, reverse = false, element = document.body} = {}
) => {
element = element || document.body
let done = false
const check = async () => {
// While we have empty space, fill it
@ -108,33 +106,16 @@ export const createScroller = (
}
}
export const synced = (key, defaultValue = null) => {
// If it's an object, merge defaults
const store = writable(
isObject(defaultValue)
? mergeDeepRight(defaultValue, Storage.getJson(key) || {})
: Storage.getJson(key) || defaultValue
)
export const synced = (key: string, defaultValue: any) => {
const store = writable(Storage.getJson(key) || defaultValue)
store.subscribe(debounce(1000, $value => Storage.setJson(key, $value)))
return store
}
// DANGER: don't use this if it's disposable, it does not clean up subscriptions,
// and will cause a memory leak
export const getter = store => {
let value
store.subscribe(_value => {
value = _value
})
return () => value
}
// https://stackoverflow.com/a/21682946
export const stringToHue = value => {
export const stringToHue = (value: string) => {
let hash = 0
for (let i = 0; i < value.length; i++) {
hash = value.charCodeAt(i) + ((hash << 5) - hash)
@ -144,28 +125,39 @@ export const stringToHue = value => {
return hash % 360
}
export const hsl = (hue, {saturation = 100, lightness = 50, opacity = 1} = {}) =>
export const hsl = (hue: string, {saturation = 100, lightness = 50, opacity = 1} = {}) =>
`hsl(${hue}, ${saturation}%, ${lightness}%, ${opacity})`
export const tryJson = f => tryFunc(f, e => e.toString().includes("JSON") || warn(e))
export const tryJson = (f: <T>() => T) =>
tryFunc(f, (e: Error) => {
if (!e.toString().includes("JSON")) {
warn(e)
}
})
export const tryFetch = f => tryFunc(f, e => e.toString().includes("fetch") || warn(e))
export const tryFetch = (f: <T>() => T) =>
tryFunc(f, (e: Error) => {
if (!e.toString().includes("fetch")) {
warn(e)
}
})
export const hexToBech32 = (prefix, url) =>
export const hexToBech32 = (prefix: string, url: string) =>
bech32.encode(prefix, bech32.toWords(utf8.decode(url)), false)
export const bech32ToHex = b32 => utf8.encode(bech32.fromWords(bech32.decode(b32, false).words))
export const bech32ToHex = (b32: string) =>
utf8.encode(bech32.fromWords(bech32.decode(b32, false).words))
export const numberFmt = new Intl.NumberFormat()
export const formatSats = sats => {
export const formatSats = (sats: number) => {
if (sats < 1_000) return numberFmt.format(sats)
if (sats < 1_000_000) return numberFmt.format(round(1, sats / 1000)) + "K"
if (sats < 100_000_000) return numberFmt.format(round(1, sats / 1_000_000)) + "MM"
return numberFmt.format(round(2, sats / 100_000_000)) + "BTC"
}
export const annotateMedia = url => {
export const annotateMedia = (url: string) => {
if (url.match(/\.(jpg|jpeg|png|gif|webp)/)) {
return {type: "image", url}
} else if (url.match(/\.(mov|webm|mp4)/)) {
@ -175,7 +167,7 @@ export const annotateMedia = url => {
}
}
export const shadeColor = (color, percent) => {
export const shadeColor = (color: string, percent: number) => {
let R = parseInt(color.substring(1, 3), 16)
let G = parseInt(color.substring(3, 5), 16)
let B = parseInt(color.substring(5, 7), 16)
@ -227,7 +219,7 @@ export const webSocketURLToPlainOrBase64 = (url: string): string => {
return url
}
export const pushToKey = (xs, k, v) => {
export const pushToKey = (xs: any[], k: number, v: any) => {
xs[k] = xs[k] || []
xs[k].push(v)
}

View File

@ -1,4 +1,4 @@
import type {DisplayEvent} from "src/engine/types"
import type {Filter, Event, DisplayEvent} from "src/engine/types"
import {is, fromPairs, mergeLeft, last, identity, prop, flatten, uniq} from "ramda"
import {nip19} from "nostr-tools"
import {ensurePlural, avg, first} from "hurdak"
@ -16,15 +16,15 @@ export const appDataKeys = {
}
export class Tags {
tags: Array<any>
constructor(tags) {
tags: any[]
constructor(tags: any[]) {
this.tags = tags
}
static from(events) {
static from(events: Event | Event[]) {
return new Tags(ensurePlural(events).flatMap(prop("tags")))
}
static wrap(tags) {
return new Tags((tags || []).filter(identity))
static wrap(tags: any[]) {
return new Tags(tags.filter(identity))
}
all() {
return this.tags
@ -38,7 +38,7 @@ export class Tags {
first() {
return first(this.tags)
}
nth(i) {
nth(i: number) {
return this.tags[i]
}
last() {
@ -62,35 +62,35 @@ export class Tags {
asMeta() {
return fromPairs(this.tags)
}
getMeta(k) {
getMeta(k: string) {
return this.type(k).values().first()
}
values() {
return new Tags(this.tags.map(t => t[1]))
}
filter(f) {
filter(f: (t: any) => boolean) {
return new Tags(this.tags.filter(f))
}
reject(f) {
reject(f: (t: any) => boolean) {
return new Tags(this.tags.filter(t => !f(t)))
}
any(f) {
any(f: (t: any) => boolean) {
return this.filter(f).exists()
}
type(type) {
type(type: string) {
const types = ensurePlural(type)
return new Tags(this.tags.filter(t => types.includes(t[0])))
}
equals(value) {
equals(value: string) {
return new Tags(this.tags.filter(t => t[1] === value))
}
mark(mark) {
mark(mark: string) {
return new Tags(this.tags.filter(t => last(t) === mark))
}
}
export const findReplyAndRoot = e => {
export const findReplyAndRoot = (e: Event) => {
const tags = Tags.from(e)
.type("e")
.filter(t => last(t) !== "mention")
@ -110,22 +110,19 @@ export const findReplyAndRoot = e => {
return {reply: reply || root, root}
}
export const findReply = e => prop("reply", findReplyAndRoot(e))
export const findReply = (e: Event) => prop("reply", findReplyAndRoot(e))
export const findReplyId = e => findReply(e)?.[1]
export const findReplyId = (e: Event) => findReply(e)?.[1]
export const findRoot = e => prop("root", findReplyAndRoot(e))
export const findRoot = (e: Event) => prop("root", findReplyAndRoot(e))
export const findRootId = e => findRoot(e)?.[1]
export const findRootId = (e: Event) => findRoot(e)?.[1]
export const isLike = content => ["", "+", "🤙", "👍", "❤️", "😎", "🏅"].includes(content)
export const isLike = (content: string) => ["", "+", "🤙", "👍", "❤️", "😎", "🏅"].includes(content)
export const isRelay = url =>
typeof url === "string" &&
// It should have the protocol included
url.match(/^wss:\/\/.+/)
export const isRelay = (url: string) => url.match(/^wss:\/\/.+/)
export const isShareableRelay = url =>
export const isShareableRelay = (url: string) =>
isRelay(url) &&
// Don't match stuff with a port number
!url.slice(6).match(/:\d+/) &&
@ -134,7 +131,7 @@ export const isShareableRelay = url =>
// Skip nostr.wine's virtual relays
!url.slice(6).match(/\/npub/)
export const normalizeRelayUrl = url => {
export const normalizeRelayUrl = (url: string) => {
url = url.replace(/\/+$/, "").toLowerCase().trim()
if (!url.startsWith("ws")) {
@ -146,8 +143,12 @@ export const normalizeRelayUrl = url => {
export const channelAttrs = ["name", "about", "picture"]
export const asDisplayEvent = event =>
({replies: [], reactions: [], zaps: [], ...event} as DisplayEvent)
export const asDisplayEvent = (event: Event): DisplayEvent => ({
replies: [],
reactions: [],
zaps: [],
...event,
})
export const toHex = (data: string): string | null => {
if (data.match(/[a-zA-Z0-9]{64}/)) {
@ -161,15 +162,18 @@ export const toHex = (data: string): string | null => {
}
}
export const mergeFilter = (filter, extra) =>
export const mergeFilter = (filter: Filter | Filter[], extra: Filter) =>
is(Array, filter) ? filter.map(mergeLeft(extra)) : {...filter, ...extra}
export const fromNostrURI = s => s.replace(/^[\w\+]+:\/?\/?/, "")
export const fromNostrURI = (s: string) => s.replace(/^[\w\+]+:\/?\/?/, "")
export const toNostrURI = s => `web+nostr://${s}`
export const toNostrURI = (s: string) => `web+nostr://${s}`
export const getLabelQuality = (label, event) =>
tryJson(() => JSON.parse(last(Tags.from(event).type("l").equals(label).first())).quality)
export const getLabelQuality = (label: string, event: Event) => {
const json = tryJson(() => JSON.parse(last(Tags.from(event).type("l").equals(label).first())))
export const getAvgQuality = (label, events) =>
return (json as {quality?: number})?.quality || 0
}
export const getAvgQuality = (label: string, events: Event[]) =>
avg(events.map(e => getLabelQuality(label, e)).filter(identity))

View File

@ -1,10 +1,11 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"src/*": ["src/*"]
}
},
"include": ["src/**/*"]
}
}

BIN
yarn.lock

Binary file not shown.