Manage bidirectional encode/decode for routes more tidily

This commit is contained in:
Jonathan Staab 2023-10-10 15:43:30 -07:00
parent 90ca9defcd
commit 055698bcb3
43 changed files with 458 additions and 300 deletions

2
.env
View File

@ -1,5 +1,5 @@
VITE_THEME=transparent:transparent,black:#0f0f0e,white:#FFFFFF,accent:#EB5E28,accent-light:#FB652C,gray-dark:#8D8581,gray-light:#A8A5A4,danger:#ff0000,warning:#ebd112,success:#37ab51,input:#FAF6F1,input-hover:#F2EBE1
VITE_DEFAULT_FOLLOWS=1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef,fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52,cc8d072efdcc676fcbac14f6cd6825edc3576e55eb786a2a975ee034a6a026cb,d91191e30e00444b942c0e82cad470b32af171764c2275bee0bd99377efd4075,3335d373e6c1b5bc669b4b1220c08728ea8ce622e5a7cfeeb4c0001d91ded1de,0b118e40d6f3dfabb17f21a94a647701f140d8b063a9e84fe6e483644edc09cb,b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450,958b754a1d3de5b5eca0fe31d2d555f451325f8498a83da1997b7fcd5c39e88c,a4cb51f4618cfcd16b2d3171c466179bed8e197c43b8598823b04de266cef110,e56e7b4326618f3d626c0e398f5082c3b16732e469e0a048b7ddb544c2be294a,011c1b374c12fbd3633e98957d3c46bed67983abecef50706c73a77c171d0d2c,b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc,b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e,5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78,baf27a4cc4da49913e7fdecc951fd3b971c9279959af62b02b761a043c33384c,2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884,0fecf65daa26faf3f668e8143325a4c199a040b6345ed40a08614d7dd85b1823,1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411,f783ba3b12b91e375aba6594015b90bd95f7e132b03cc8c4c52ce0a7c36aab52,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322
VITE_DEFAULT_FOLLOWS=1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef,fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52,cc8d072efdcc676fcbac14f6cd6825edc3576e55eb786a2a975ee034a6a026cb,d91191e30e00444b942c0e82cad470b32af171764c2275bee0bd99377efd4075,3335d373e6c1b5bc669b4b1220c08728ea8ce622e5a7cfeeb4c0001d91ded1de,0b118e40d6f3dfabb17f21a94a647701f140d8b063a9e84fe6e483644edc09cb,b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450,958b754a1d3de5b5eca0fe31d2d555f451325f8498a83da1997b7fcd5c39e88c,b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc,b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e,5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78,baf27a4cc4da49913e7fdecc951fd3b971c9279959af62b02b761a043c33384c,2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884,0fecf65daa26faf3f668e8143325a4c199a040b6345ed40a08614d7dd85b1823,1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411,f783ba3b12b91e375aba6594015b90bd95f7e132b03cc8c4c52ce0a7c36aab52,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,a1fc5dfd7ffcf563c89155b466751b580d115e136e2f8c90e8913385bbedb1cf,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240
VITE_IMGPROXY_URL=https://imgproxy.coracle.social
VITE_DUFFLEPUD_URL=https://dufflepud.onrender.com
VITE_ENABLE_ZAPS=true

View File

@ -6,6 +6,8 @@
- [x] Handle a tag replies
- [x] Fix feed search
- [x] Simplify card theme using css
- [x] Make relay auth opt-in
- [x] Fix notification badge for non-accepted conversations
# 0.3.10

View File

@ -25,7 +25,7 @@
router.at("notes/create").qp({pubkey}).open()
}
$: showButtons = !$page.path.match(/conversations|channels|logout$/)
$: showButtons = !$page?.path.match(/conversations|channels|logout$/)
</script>
<svelte:window bind:scrollY />

View File

@ -1,9 +1,8 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {isNil, reverse} from "ramda"
import {onMount} from "svelte"
import {memoize, tryJson} from "src/util/misc"
import {fromNostrURI} from "src/util/nostr"
import {memoize, parseQueryString} from "src/util/misc"
import type {HistoryItem} from "src/util/router"
import Modal from "src/partials/Modal.svelte"
import About from "src/app/views/About.svelte"
import Apps from "src/app/views/Apps.svelte"
@ -30,6 +29,7 @@
import Logout from "src/app/views/Logout.svelte"
import MessagesDetail from "src/app/views/MessagesDetail.svelte"
import MessagesList from "src/app/views/MessagesList.svelte"
import ModalMessage from "src/app/views/ModalMessage.svelte"
import NoteCreate from "src/app/views/NoteCreate.svelte"
import NoteDetail from "src/app/views/NoteDetail.svelte"
import Notifications from "src/app/views/Notifications.svelte"
@ -55,140 +55,152 @@
import UserKeys from "src/app/views/UserKeys.svelte"
import UserProfile from "src/app/views/UserProfile.svelte"
import UserSettings from "src/app/views/UserSettings.svelte"
import {decodePerson, decodeRelay, decodeEvent, selectHints} from "src/engine"
import {menuIsOpen, logUsage} from "src/app/state"
import {router} from "src/app/router"
import {
router,
asChannelId,
asPerson,
asCsv,
asString,
asMedia,
asFilter,
asNote,
asRelays,
asRelay,
asEntity,
} from "src/app/router"
const defaultDecoders = {
url: v => ({url: decodeURIComponent(v)}),
relays: s => ({relays: s.split(",")}),
pubkeys: s => ({pubkeys: s.split(",")}),
filter: s => ({filter: tryJson(() => JSON.parse(s))}),
}
// Routes
router.register("/about", About)
router.register("/apps", Apps)
router.register("/bech32", Bech32Entity)
router.register("/channels", ChannelsList)
router.register("/channels/create", ChannelCreate)
router.register("/channels/requests", ChannelsList)
router.register("/channels/:channelId", ChannelsDetail, {
channelId: asChannelId,
})
router.register("/chat/redirect", ChatRedirect)
router.register("/requests", MessagesList)
router.register("/conversations", MessagesList)
router.register("/conversations/:entity", MessagesDetail)
router.register("/conversations/requests", MessagesList)
router.register("/conversations/:entity", MessagesDetail, {
entity: asPerson,
})
router.register("/explore", Explore)
router.register("/labels/:label", LabelDetail)
router.register("/login/intro", Login)
router.register("/labels/:label", LabelDetail, {
ids: asCsv("ids"),
})
router.register("/lists", ListList)
router.register("/lists/create", ListEdit)
router.register("/lists/select", ListSelect, {
type: asString("type"),
value: asString("value"),
})
router.register("/lists/:naddr", ListEdit)
router.register("/login/advanced", LoginAdvanced)
router.register("/login/bunker", LoginBunker)
router.register("/login/connect", LoginConnect)
router.register("/login/intro", Login)
router.register("/login/privkey", LoginPrivKey)
router.register("/login/pubkey", LoginPubKey)
router.register("/logout", Logout)
router.register("/qrcode/:code", QRCode)
router.register("/media/:url", MediaDetail, {
url: asMedia("url"),
})
router.register("/message", ModalMessage)
router.register("/", Feeds, {
filter: asFilter,
})
router.register("/notes", Feeds, {
filter: asFilter,
})
router.register("/notes/create", NoteCreate, {
pubkey: asPerson,
})
router.register("/notes/:entity", NoteDetail, {
entity: asNote,
})
router.register("/notes/:entity/label", LabelCreate, {
entity: asNote,
})
router.register("/notes/:entity/status", PublishInfo, {
entity: asNote,
relays: asRelays,
})
router.register("/notes/:entity/thread", ThreadDetail, {
entity: asNote,
})
// router.register("/notes/:entity/report", ReportCreate, {
// entity: asNote,
// pubkey: asPerson,
// })
router.register("/notifications", Notifications)
router.register("/notifications/:activeTab", Notifications)
router.register("/onboarding", Onboarding)
router.register("/messages/requests", MessagesList)
router.register("/messages/:id", MessagesDetail)
router.register("/people/list", PersonList, {
pubkeys: asCsv("pubkeys"),
})
router.register("/people/:entity", PersonDetail, {
entity: asPerson,
filter: asFilter,
})
router.register("/people/:entity/followers", PersonFollowers, {
entity: asPerson,
})
router.register("/people/:entity/follows", PersonFollows, {
entity: asPerson,
})
router.register("/people/:entity/info", PersonInfo, {
entity: asPerson,
})
router.register("/people/:entity/zap", PersonZap, {
entity: asPerson,
eid: asNote,
})
router.register("/qrcode/:code", QRCode)
router.register("/relays/browse", RelayBrowse)
router.register("/relays/:entity", RelayDetail, {
entity: asRelay,
})
router.register("/relays/:entity/review", RelayReview, {
entity: asRelay,
})
router.register("/settings", UserSettings)
router.register("/settings/keys", UserKeys)
router.register("/settings/relays", RelayList)
router.register("/settings/profile", UserProfile)
router.register("/settings/content", UserContent)
router.register("/settings/data", UserData)
router.register("/settings/data/export", DataExport)
router.register("/settings/data/import", DataImport)
router.register("/topic/:topic", TopicFeed)
router.register("/settings/keys", UserKeys)
router.register("/settings/profile", UserProfile)
router.register("/settings/relays", RelayList)
const mediaRouteOpts = {decode: defaultDecoders}
router.register("/topics/:topic", TopicFeed)
router.register("/media/:url", MediaDetail, mediaRouteOpts)
const listsRouteOpts = {
decode: {
...defaultDecoders,
list: json => tryJson(() => JSON.parse(json)),
},
}
router.register("/lists", ListList)
router.register("/lists/select", ListSelect)
router.register("/lists/create", ListEdit)
router.register("/lists/:naddr", ListEdit, listsRouteOpts)
const channelsRouteOpts = {
decode: {
...defaultDecoders,
entity: channelId => ({pubkeys: channelId.split(",")}),
},
}
router.register("/channels", ChannelsList)
router.register("/channels/requests", ChannelsList)
router.register("/channels/create", ChannelCreate)
router.register("/channels/:entity", ChannelsDetail, channelsRouteOpts)
const notesRouteOpts = {
decode: {
...defaultDecoders,
entity: decodeEvent,
},
}
router.register("/", Feeds, notesRouteOpts)
router.register("/notes", Feeds, notesRouteOpts)
router.register("/notes/create", NoteCreate, notesRouteOpts)
router.register("/notes/:entity", NoteDetail, notesRouteOpts)
router.register("/notes/:entity/thread", ThreadDetail, notesRouteOpts)
// router.register("/notes/:entity/report", ReportCreate)
router.register("/notes/:entity/label", LabelCreate)
router.register("/notes/:entity/status", PublishInfo)
const peopleRouteOpts = {
decode: {
...defaultDecoders,
entity: decodePerson,
},
}
router.register("/people/:entity", PersonDetail, peopleRouteOpts)
router.register("/people/:entity/detail", PersonDetail, peopleRouteOpts)
router.register("/people/:entity/followers", PersonFollowers, peopleRouteOpts)
router.register("/people/:entity/follows", PersonFollows, peopleRouteOpts)
router.register("/people/:entity/info", PersonInfo, peopleRouteOpts)
router.register("/people/:entity/list", PersonList, peopleRouteOpts)
router.register("/people/:entity/zap", PersonZap, peopleRouteOpts)
const relayRouteOpts = {
decode: {
...defaultDecoders,
entity: decodeRelay,
},
}
router.register("/relays/browse", RelayBrowse)
router.register("/relays/:entity", RelayDetail, relayRouteOpts)
router.register("/relays/:entity/review", RelayReview, relayRouteOpts)
const entityRouteOpts = {
decode: {
entity: entity => {
entity = fromNostrURI(entity)
let type, data, relays
try {
;({type, data} = nip19.decode(entity) as {type: string; data: any})
relays = selectHints(data.relays || [], 3)
} catch (e) {
// pass
}
return {type, data, relays}
},
},
}
router.register("/:entity", Bech32Entity, entityRouteOpts)
router.register("/:entity/*", Bech32Entity, entityRouteOpts)
router.register("/:entity", Bech32Entity, {
entity: asEntity,
filter: asFilter,
})
router.register("/:entity/*", Bech32Entity, {
entity: asEntity,
filter: asFilter,
})
router.init()
@ -199,30 +211,17 @@
menuIsOpen.set(false)
}
const getProps = ({config, path, params, route}) => {
const data = {...config.context}
const getProps = ({config, path, params, route}: HistoryItem) => {
const data = {...config.context, ...params}
const queryParams = parseQueryString(path)
for (const [k, v] of Object.entries(params)) {
const decode = route.opts?.decode?.[k]
// Prefer decoding route params. If there's an overload, ignore query params to avoid
// route param injection for anything other than white-listed decoders
for (const [k, serializer] of Object.entries(route.serializers || {})) {
const v = params[k] || queryParams[k]
data[k] = v
if (decode) {
Object.assign(data, decode(v))
}
}
const [qs] = path.split("?").slice(1)
if (qs) {
for (const [k, v] of new URLSearchParams(qs)) {
const decode = route.opts?.decode?.[k]
data[k] = v
if (decode) {
Object.assign(data, decode(v))
}
if (v) {
Object.assign(data, serializer.decode(v))
}
}

View File

@ -1,5 +1,4 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import Anchor from "src/partials/Anchor.svelte"
import Popover from "src/partials/Popover.svelte"
import Card from "src/partials/Card.svelte"
@ -20,7 +19,7 @@
<div class="-mx-3 -mt-1">
<Anchor
class="block p-3 px-4 transition-all hover:bg-accent hover:text-white"
href={`/${nip19.npubEncode($pubkey)}`}>
href={router.at("people").of($pubkey).toString()}>
<i class="fa fa-user mr-2" /> Profile
</Anchor>
<Anchor

View File

@ -1,6 +1,130 @@
import {last, fromPairs, identity} from "ramda"
import {nip19} from "nostr-tools"
import {Router} from "src/util/router"
import {getNip24ChannelId} from "src/engine"
import {tryJson} from "src/util/misc"
import {fromNostrURI} from "src/util/nostr"
import {
decodePerson,
decodeRelay,
decodeEvent,
selectHints,
normalizeRelayUrl,
getNip24ChannelId,
} from "src/engine"
// Decoders
export const decodeAs = (name, decode) => v => ({[name]: decode(v)})
export const encodeJson = value => JSON.stringify(value)
export const decodeJson = json => tryJson(() => JSON.parse(json))
export const encodeCsv = xs => xs.join(",")
export const decodeCsv = x => x.split(",")
export const encodeRelays = xs => xs.map(url => last(url.split("//"))).join(",")
export const decodeRelays = x => x.split(",").map(normalizeRelayUrl)
export const encodeFilter = f =>
Object.entries(f)
.map(([k, v]) => [k, Array.isArray(v) ? encodeCsv(v) : v].join(":"))
.join("|")
export const decodeFilter = s =>
fromPairs(
s.split("|").map(p => {
const [k, v] = p.split(":")
if (k === "search") {
return [k, v]
}
if (["since", "until", "limit"].includes(k)) {
return [k, parseInt(v)]
}
if (k === "kinds") {
return [k, v.split(",").map(k => parseInt(k))]
}
if (k === "authors" && v.length < 64) {
return [k, v]
}
return [k, v.split(",")]
})
)
export const decodeEntity = entity => {
entity = fromNostrURI(entity)
let type, data, relays
try {
;({type, data} = nip19.decode(entity) as {type: string; data: any})
relays = selectHints(data.relays || [], 3)
} catch (e) {
// pass
}
return {type, data, relays}
}
// Serializers
export const asString = name => ({
encode: identity,
decode: decodeAs(name, identity),
})
export const asJson = name => ({
encode: encodeJson,
decode: decodeAs(name, decodeJson),
})
export const asCsv = name => ({
encode: encodeCsv,
decode: decodeAs(name, decodeCsv),
})
export const asMedia = name => ({
encode: encodeURIComponent,
decode: decodeAs(name, decodeURIComponent),
})
export const asEntity = {
encode: identity,
decode: decodeEntity,
}
export const asNote = {
encode: nip19.noteEncode,
decode: decodeEvent,
}
export const asPerson = {
encode: nip19.npubEncode,
decode: decodePerson,
}
export const asRelay = {
encode: nip19.nrelayEncode,
decode: decodeRelay,
}
export const asFilter = {
encode: encodeFilter,
decode: decodeAs(name, decodeFilter),
}
export const asChannelId = {
encode: getNip24ChannelId,
decode: decodeAs("pubkeys", decodeCsv),
}
export const asRelays = {
encode: encodeRelays,
decode: decodeAs("relays", decodeRelays),
}
// Router and extensions
export const router = new Router()

View File

@ -8,12 +8,10 @@
import {getModal} from "src/partials/state"
import Spinner from "src/partials/Spinner.svelte"
import Content from "src/partials/Content.svelte"
import RelayTitle from "src/app/shared/RelayTitle.svelte"
import RelayActions from "src/app/shared/RelayActions.svelte"
import FeedControls from "src/app/shared/FeedControls.svelte"
import Note from "src/app/shared/Note.svelte"
import type {DynamicFilter} from "src/engine"
import {urlToRelay, compileFilter, searchableRelays, getRelaysFromFilters} from "src/engine"
import {compileFilter, searchableRelays, getRelaysFromFilters} from "src/engine"
export let relays = []
export let filter = {} as DynamicFilter
@ -70,20 +68,6 @@
</script>
<Content size="inherit" gap="gap-6">
{#if relays.length === 1}
{@const relay = urlToRelay(relays[0])}
<div class="flex items-center justify-between gap-2">
<RelayTitle {relay} />
<RelayActions {relay} />
</div>
{#if relay.info.description}
<p>{relay.info.description}</p>
{/if}
<p class="border-l-2 border-gray-6 pl-4 text-gray-4">
Below is your current feed including only notes seen on this relay.
</p>
{/if}
{#if $newNotes?.length > 0}
<div class="pointer-events-none fixed bottom-0 left-0 z-20 mb-8 flex w-full justify-center">
<button

View File

@ -85,11 +85,7 @@
return parts
}
const onChange = filter =>
router
.fromCurrent()
.qp({filter: JSON.stringify(filter)})
.push()
const onChange = filter => router.fromCurrent().qp({filter}).push()
const removePart = keys => {
filter = omit(keys, filter)

View File

@ -48,7 +48,7 @@
let showMuted = false
let actions = null
let collapsed = false
let ctx = context
let ctx = uniqBy(prop("id"), context)
const {ENABLE_ZAPS} = $env
const showEntire = anchorId === event.id
@ -57,14 +57,13 @@
let interval, border, childrenContainer, noteContainer
const onClick = e => {
const target = e.target as HTMLElement
const target = e.detail.target as HTMLElement
if (interactive && !["I"].includes(target.tagName) && !target.closest("a")) {
router
.at("notes")
.of(event.id)
.qp({relays: getEventHints(event)})
.cx({context: ctx.concat(event)})
.cx({context: ctx.concat(event), relays: getEventHints(event)})
.open()
}
}
@ -75,8 +74,7 @@
router
.at("notes")
.of(findReplyId(event))
.qp({relays: getParentHints(event)})
.cx({context: ctx.concat(event)})
.cx({context: ctx.concat(event), relays: getParentHints(event)})
.open()
const goToThread = () =>
@ -84,8 +82,7 @@
.at("notes")
.of(event.id)
.at("thread")
.qp({relays: getEventHints(event)})
.cx({context: ctx.concat(event)})
.cx({context: ctx.concat(event), relays: getEventHints(event)})
.open()
const setBorderHeight = () => {
@ -168,7 +165,7 @@
{#if event.pubkey}
<div class="note">
<div bind:this={noteContainer} class="group relative">
<Card class="relative flex gap-4" on:click={onClick} {interactive}>
<Card stopPropagation class="relative flex gap-4" on:click={onClick} {interactive}>
{#if !showParent && !topLevel}
<div
class="absolute z-10 -ml-4 h-px w-4 bg-gray-7 group-[.modal]:bg-gray-6"
@ -188,7 +185,8 @@
href={router
.at("notes")
.of(event.id)
.qp({relays: getEventHints(event)}).path}
.cx({relays: getEventHints(event)})
.toString()}
class="text-end text-sm text-gray-1"
type="unstyled">
{formatTimestamp(event.created_at)}

View File

@ -48,7 +48,7 @@
const zapsTotal = tweened(0, {interpolate})
const repliesCount = tweened(0, {interpolate})
//const report = () => router.at("notes").of(note.id).qp({pubkey: note.pubkey}).at('report').open()
//const report = () => router.at("notes").of(note.id).at('report').qp({pubkey: note.pubkey}).open()
const label = () => router.at("notes").of(note.id).at("label").open()
@ -76,7 +76,8 @@
.at("people")
.of(note.pubkey)
.at("zap")
.qp({eid: note.id, relays: getPublishHints(note)})
.qp({eid: note.id})
.cx({relays: getPublishHints(note)})
.open()
const broadcast = () => {
@ -91,7 +92,7 @@
const setFeedRelay = url =>
router
.fromCurrent()
.qp({relays: [url]})
.cx({relays: [url]})
.open()
let like, allLikes, zap

View File

@ -18,7 +18,7 @@
router
.at("people")
.of(pubkey)
.qp({relays: getEventHints(note)})
.cx({relays: getEventHints(note)})
.open()
</script>

View File

@ -2,7 +2,7 @@
import {annotateMedia, displayUrl} from "src/util/misc"
import Anchor from "src/partials/Anchor.svelte"
import Media from "src/partials/Media.svelte"
import {router} from 'src/app/router'
import {router} from "src/app/router"
export let value
export let showMedia
@ -18,12 +18,16 @@
<div class="py-2">
<Media link={annotateMedia(value.url)} onClose={close} />
</div>
{:else}
{:else if value.isMedia}
<Anchor
modal
stopPropagation
class="underline"
href={value.isMedia ? router.at('media').of(value.url).path : value.url}>
href={router.at("media").of(value.url).toString()}>
{displayUrl(value.url)}
</Anchor>
{:else}
<Anchor external stopPropagation class="underline" href={value.url}>
{displayUrl(value.url)}
</Anchor>
{/if}

View File

@ -9,6 +9,6 @@
const person = derivePerson(pubkey)
</script>
@<Anchor class="underline" killEvent href={router.at("people").of(pubkey).path}>
@<Anchor class="underline" killEvent href={router.at("people").of(pubkey).toString()}>
{displayPerson($person)}
</Anchor>

View File

@ -54,12 +54,11 @@
const noteId = id || quote?.id
// stopPropagation wasn't working for some reason
if (noteId && e.target.textContent !== "Show") {
if (noteId && e.detail.target.textContent !== "Show") {
router
.at("notes")
.of(noteId)
.qp({relays})
.cx({context: asArray(quote)})
.cx({relays, context: asArray(quote)})
.open()
}
}
@ -70,7 +69,7 @@
</script>
<div class="py-2" on:click|stopPropagation>
<Card interactive class="my-2" on:click={openQuote}>
<Card interactive stopPropagation class="my-2" on:click={openQuote}>
{#if loading}
<div class="px-20">
<Spinner />
@ -89,7 +88,7 @@
stopPropagation
type="unstyled"
class="flex items-center gap-2"
href={router.at("people").of(quote.pubkey).path}>
href={router.at("people").of(quote.pubkey).toString()}>
<h2 class="text-lg">{displayPubkey(quote.pubkey)}</h2>
</Anchor>
</div>

View File

@ -20,9 +20,9 @@
const [type, value] = tag
href = switcherFn(type, {
r: () => router.at("relays").of(value).path,
p: () => router.at("people").of(value).path,
e: () => router.at("notes").of(value).path,
r: () => router.at("relays").of(value).toString(),
p: () => router.at("people").of(value).toString(),
e: () => router.at("notes").of(value).toString(),
})
display = switcherFn(type, {

View File

@ -5,6 +5,6 @@
export let value
</script>
<Anchor class="underline" killEvent href={router.at("topics").of(value).path}>
<Anchor class="underline" killEvent href={router.at("topics").of(value).toString()}>
#{value}
</Anchor>

View File

@ -21,7 +21,7 @@
{:else}
<Anchor
killEvent
href={router.at("people").of(pubkey).path}
href={router.at("people").of(pubkey).toString()}
class={cx($$props.class, "relative z-10 flex gap-4")}>
<PersonCircle class="h-12 w-12" {pubkey} />
<div class="flex flex-col" style="min-width: 48px;">

View File

@ -17,7 +17,7 @@
{:else}
<Anchor
killEvent
href={router.at("people").of(pubkey).path}
href={router.at("people").of(pubkey).toString()}
class={cx($$props.class, "relative z-10 flex items-center gap-2")}>
<PersonCircle {pubkey} />
<span>{displayPubkey(pubkey)}</span>

View File

@ -1,7 +0,0 @@
<script lang="ts">
import Feed from "src/app/shared/Feed.svelte"
export let pubkey
</script>
<Feed hideControls filter={{kinds: [7], authors: [pubkey]}} />

View File

@ -1,8 +0,0 @@
<script lang="ts">
import {noteKinds} from "src/util/nostr"
import Feed from "src/app/shared/Feed.svelte"
export let pubkey
</script>
<Feed filter={{kinds: noteKinds, authors: [pubkey]}} />

View File

@ -24,7 +24,7 @@
<div class="flex min-w-0 items-center gap-2 text-xl">
<i class={relay.url.startsWith("ws://") ? "fa fa-unlock" : "fa fa-lock"} />
<Anchor
href={router.at("relays").of(relay.url).path}
href={router.at("relays").of(relay.url).toString()}
class="overflow-hidden text-ellipsis whitespace-nowrap">
{displayRelay(relay)}
</Anchor>

View File

@ -14,7 +14,7 @@
<i class={relay.url.startsWith("wss") ? "fa fa-lock" : "fa fa-unlock"} />
<Anchor
type="unstyled"
href={router.at("relays").of(relay.url).path}
href={router.at("relays").of(relay.url).toString()}
class="border-b border-solid"
style={`border-color: ${hsl(stringToHue(relay.url))}`}>
{displayRelay(relay)}

View File

@ -116,10 +116,7 @@ export const loadAppData = async () => {
export const boot = async () => {
if (env.get().FORCE_RELAYS.length > 0) {
router
.at("message")
.qp({message: "Logging you in...", spinner: true})
.replaceModal({noEscape: true})
router.at("message").cx({message: "Logging you in..."}).replaceModal({noEscape: true})
await Promise.all([
sleep(1500),

View File

@ -49,7 +49,10 @@
All funds donated will be used to support server costs and development.
</p>
<div class="flex justify-center">
<Anchor modal theme="button-accent" href={router.at("people").of(pubkey).at("zap").path}>
<Anchor
modal
theme="button-accent"
href={router.at("people").of(pubkey).at("zap").toString()}>
Zap the developer
</Anchor>
</div>
@ -69,8 +72,10 @@
</div>
<div class="flex flex-col gap-4">
<p class="text-center">
Built with 💜 by @<Anchor modal theme="anchor" href={router.at("people").of(pubkey).path}
>hodlbod</Anchor>
Built with 💜 by @<Anchor
modal
theme="anchor"
href={router.at("people").of(pubkey).toString()}>hodlbod</Anchor>
</p>
<p class="flex justify-center gap-4">
<Popover triggerType="mouseenter">

View File

@ -17,14 +17,14 @@
listenForNip59Messages,
} from "src/engine"
export let entity
export let channelId
export let pubkeys
const channel = channels.key(entity)
const channel = channels.key(channelId)
nip24MarkChannelRead(entity)
nip24MarkChannelRead(channelId)
const sendMessage = content => createNip24Message(entity, content)
const sendMessage = content => createNip24Message(channelId, content)
const showPerson = pubkey => router.at("people").of(pubkey).open()
@ -35,7 +35,7 @@
})
onDestroy(() => {
nip24MarkChannelRead(entity)
nip24MarkChannelRead(channelId)
})
document.title = `Direct Messages`

View File

@ -28,7 +28,7 @@
$: tabChannels = sortChannels(activeTab === "conversations" ? $accepted : $requests)
const createChannel = () => router.at("channel/create").open()
const createChannel = () => router.at("channels/create").open()
document.title = "Direct Messages"
@ -54,15 +54,17 @@
</div>
</div>
</Tabs>
<Popover triggerType="mouseenter" class="absolute right-5 top-7 hidden sm:block">
<div slot="trigger">
<i
class="fa fa-bell cursor-pointer"
class:text-gray-5={!$hasNewNip24Messages}
on:click={nip24MarkAllRead} />
</div>
<div slot="tooltip">Mark all as read</div>
</Popover>
{#if activeTab === "conversations"}
<Popover triggerType="mouseenter" class="absolute right-5 top-7 hidden sm:block">
<div slot="trigger">
<i
class="fa fa-bell cursor-pointer"
class:text-gray-5={!$hasNewNip24Messages}
on:click={nip24MarkAllRead} />
</div>
<div slot="tooltip">Mark all as read</div>
</Popover>
{/if}
</div>
{#each tabChannels as channel (channel.id)}
<ChannelsListItem {channel} />

View File

@ -8,11 +8,12 @@
export let channel
const pubkeys = without([$session.pubkey], channel.id.split(",")) as string[]
const allPubkeys = channel.id.split(",") as string[]
const pubkeys = without([$session.pubkey], allPubkeys)
const showAlert = channels.key(channel.id).derived(hasNewMessages)
const members = people.mapStore.derived($p => pubkeys.map(pk => $p.get(pk)))
const enter = () => router.at("channels").of(channel.id).push()
const enter = () => router.at("channels").of(allPubkeys).push()
loadPubkeys(pubkeys)
</script>

View File

@ -64,7 +64,8 @@
)
})
const showGroup = ({label, ids, hints}) => router.at("labels").of(label).qp({ids, hints}).open()
const showGroup = ({label, ids, hints}) =>
router.at("labels").of(label).qp({ids}).cx({hints}).open()
onMount(() => {
const sub = subscribe({

View File

@ -11,6 +11,12 @@
import {session, canSign, follows, lists, userLists} from "src/engine"
export let relays = []
export let filter: DynamicFilter = {
kinds: noteKinds,
authors: $follows.size > 0 ? "follows" : "network",
}
let key = Math.random()
const showLists = () => router.at("lists").open()
@ -26,25 +32,19 @@
relays = urls
}
feedFilter = {kinds: noteKinds, authors: "global"} as DynamicFilter
filter = {kinds: noteKinds, authors: "global"} as DynamicFilter
if (authors.length > 0) {
feedFilter = {...feedFilter, authors}
filter = {...filter, authors}
}
if (topics.length > 0) {
feedFilter = {...feedFilter, "#t": topics}
filter = {...filter, "#t": topics}
}
key = Math.random()
}
let key = Math.random()
let feedFilter = {
kinds: noteKinds,
authors: $follows.size > 0 ? "follows" : "network",
} as DynamicFilter
document.title = "Feeds"
</script>
@ -58,7 +58,7 @@
</Content>
{/if}
{#key key}
<Feed filter={feedFilter} {relays}>
<Feed {filter} {relays}>
<div slot="controls">
{#if $canSign}
{#if $userLists.length > 0}

View File

@ -1,7 +1,6 @@
<script lang="ts">
import cx from "classnames"
import {onMount, onDestroy} from "svelte"
import {toHex} from "src/util/nostr"
import {formatTimestamp} from "src/util/misc"
import Channel from "src/partials/Channel.svelte"
import Anchor from "src/partials/Anchor.svelte"
@ -19,9 +18,8 @@
import PersonCircle from "src/app/shared/PersonCircle.svelte"
import PersonAbout from "src/app/shared/PersonAbout.svelte"
export let entity
export let pubkey
const pubkey = toHex(entity)
const person = derivePerson(pubkey)
const channel = channels.key(pubkey)
@ -57,7 +55,7 @@
</div>
<div class="flex h-12 w-full flex-col overflow-hidden pt-px">
<div class="flex w-full items-center justify-between">
<Anchor modal href={router.at("people").of(pubkey).path} class="font-bold">
<Anchor modal href={router.at("people").of(pubkey).toString()} class="font-bold">
{displayPerson($person)}
</Anchor>
</div>

View File

@ -14,10 +14,15 @@
} from "src/engine"
import {router} from "src/app/router"
const activeTab = window.location.pathname.slice(1)
const activeTab =
window.location.pathname.slice(1) === "conversations" ? "conversations" : "requests"
const accepted = nip04Channels.derived(filter(prop("last_sent")))
const requests = nip04Channels.derived(filter(complement(prop("last_sent"))))
const setActiveTab = tab => router.at(tab).push()
const setActiveTab = tab => {
const path = tab === "requests" ? "conversations/requests" : "conversations"
router.at(path).push()
}
$: tabChannels = sortChannels(activeTab === "conversations" ? $accepted : $requests)
@ -36,15 +41,17 @@
</div>
</div>
</Tabs>
<Popover triggerType="mouseenter" class="absolute right-7 top-7 hidden sm:block">
<div slot="trigger">
<i
class="fa fa-bell cursor-pointer"
class:text-gray-5={!$hasNewNip04Messages}
on:click={nip04MarkAllRead} />
</div>
<div slot="tooltip">Mark all as read</div>
</Popover>
{#if activeTab === 'conversations'}
<Popover triggerType="mouseenter" class="absolute right-7 top-7 hidden sm:block">
<div slot="trigger">
<i
class="fa fa-bell cursor-pointer"
class:text-gray-5={!$hasNewNip04Messages}
on:click={nip04MarkAllRead} />
</div>
<div slot="tooltip">Mark all as read</div>
</Popover>
{/if}
</div>
{#each tabChannels as channel (channel.id)}
<MessagesListItem {channel} />

View File

@ -0,0 +1,10 @@
<script lang="ts">
import Content from "src/partials/Content.svelte"
import Spinner from "src/partials/Spinner.svelte"
export let message
</script>
<Content size="lg">
<Spinner>{message}</Spinner>
</Content>

View File

@ -15,7 +15,7 @@
const zaps = interactions.filter(e => e.kind === EventKind.ZapRequest)
const context = interactions.concat(note)
const goToNote = () => router.at("notes").of(note.id).qp({relays: note.seen_on}).cx({context}).open()
const goToNote = () => router.at("notes").of(note.id).cx({relays: note.seen_on, context}).open()
const actionText = closure(() => {
if (likes.length === 0) return "zapped"

View File

@ -2,15 +2,15 @@
import {identity} from "ramda"
import {info} from "src/util/logger"
import {stripProto, ensureProto} from "src/util/misc"
import {noteKinds} from "src/util/nostr"
import {getThemeBackgroundGradient} from "src/partials/state"
import Tabs from "src/partials/Tabs.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte"
import Spinner from "src/partials/Spinner.svelte"
import Feed from "src/app/shared/Feed.svelte"
import PersonName from "src/app/shared/PersonName.svelte"
import PersonActions from "src/app/shared/PersonActions.svelte"
import PersonNotes from "src/app/shared/PersonNotes.svelte"
import PersonLikes from "src/app/shared/PersonLikes.svelte"
import PersonRelays from "src/app/shared/PersonRelays.svelte"
import PersonHandle from "src/app/shared/PersonHandle.svelte"
import PersonCircle from "src/app/shared/PersonCircle.svelte"
@ -29,6 +29,8 @@
export let npub
export let pubkey
export let relays = []
export let filter = {kinds: noteKinds, authors: [pubkey]}
console.log("======", filter)
const tabs = ["notes", "likes", $env.FORCE_RELAYS.length === 0 && "relays"].filter(identity)
const person = derivePerson(pubkey)
@ -96,9 +98,9 @@
{#if $mutes.has(pubkey)}
<Content size="lg" class="text-center">You have muted this person.</Content>
{:else if activeTab === "notes"}
<PersonNotes {pubkey} />
<Feed {filter} />
{:else if activeTab === "likes"}
<PersonLikes {pubkey} />
<Feed hideControls filter={{kinds: [7], authors: [pubkey]}} />
{:else if activeTab === "relays"}
{#if ownRelays.length > 0}
<PersonRelays relays={ownRelays} />

View File

@ -64,7 +64,8 @@
href={router
.at("notes")
.of(eid)
.qp({relays: Array.from(succeeded)}).path}>
.qp({relays: Array.from(succeeded)})
.toString()}>
View your note
</Anchor>
{/if}

View File

@ -70,7 +70,8 @@
href={router
.at("notes")
.of(event.id)
.qp({relays: getEventHints(event)}).path}>
.cx({relays: getEventHints(event)})
.toString()}>
<i class="fa fa-link text-accent" />
</Anchor>
</td>

View File

@ -2,6 +2,7 @@
import {fly} from "src/util/transition"
import {toast, appName} from "src/partials/state"
import Field from "src/partials/Field.svelte"
import FieldInline from "src/partials/FieldInline.svelte"
import Toggle from "src/partials/Toggle.svelte"
import Input from "src/partials/Input.svelte"
import Anchor from "src/partials/Anchor.svelte"
@ -42,6 +43,12 @@
faster, but will require more bandwidth and processing power.
</p>
</Field>
<FieldInline label="Authenticate with relays">
<Toggle bind:value={settings.auto_authenticate} />
<p slot="info">
Allows {appName} to authenticate with relays that have access controls automatically.
</p>
</FieldInline>
<Field label="Dufflepud URL">
<Input bind:value={settings.dufflepud_url}>
<i slot="before" class="fa-solid fa-server" />
@ -76,13 +83,13 @@
</p>
</Field>
{/if}
<Field label="Report errors and analytics">
<FieldInline label="Report errors and analytics">
<Toggle bind:value={settings.report_analytics} />
<p slot="info">
Keep this enabled if you would like developers to be able to know what features are used,
and to diagnose and fix bugs.
</p>
</Field>
</FieldInline>
<Anchor tag="button" theme="button" type="submit" class="text-center">Save</Anchor>
</div>
</Content>

View File

@ -1,4 +1,4 @@
import {none, find, filter, whereEq} from "ramda"
import {prop, none, any, filter, whereEq} from "ramda"
import {derived} from "src/engine/core/utils"
import {pubkey} from "src/engine/session/state"
import {mutes} from "src/engine/people/derived"
@ -27,10 +27,14 @@ export const userChannels = derived(
export const nip04Channels = userChannels.derived(filter(whereEq({type: "nip04"})))
export const hasNewNip04Messages = nip04Channels.derived(find(hasNewMessages))
export const unreadNip04Channels = nip04Channels.derived(filter(hasNewMessages))
export const hasNewNip04Messages = unreadNip04Channels.derived(any(prop("last_sent")))
// Nip24
export const nip24Channels = userChannels.derived(filter(whereEq({type: "nip24"})))
export const hasNewNip24Messages = nip24Channels.derived(find(hasNewMessages))
export const unreadNip24Channels = nip24Channels.derived(filter(hasNewMessages))
export const hasNewNip24Messages = unreadNip24Channels.derived(any(prop("last_sent")))

View File

@ -55,7 +55,7 @@ export const getTarget = (urls: string[]) => {
const seenChallenges = new Set()
export const onAuth = async (url, challenge) => {
if (canSign.get() && !seenChallenges.has(challenge)) {
if (canSign.get() && !seenChallenges.has(challenge) && getSetting("auto_authenticate")) {
seenChallenges.add(challenge)
const event = await signer.get().signAsUser(

View File

@ -8,6 +8,7 @@ export const getDefaultSettings = () => ({
muted_words: [],
hide_sensitive: true,
report_analytics: true,
auto_authenticate: false,
imgproxy_url: env.get().IMGPROXY_URL,
dufflepud_url: env.get().DUFFLEPUD_URL,
multiplextr_url: env.get().MULTIPLEXTR_URL,

View File

@ -1,13 +1,25 @@
<script lang="ts">
import cx from "classnames"
import {createEventDispatcher} from "svelte"
import {fly} from "src/util/transition"
export let interactive = false
export let stopPropagation = false
const dispatch = createEventDispatcher()
const onClick = e => {
if (stopPropagation) {
e.stopPropagation()
}
dispatch("click", e)
}
</script>
<div
on:click|stopPropagation
in:fly={{y: 20}}
on:click={onClick}
class={cx(
$$props.class,
"card group rounded-2xl border border-solid border-gray-6 bg-gray-7 p-3 text-gray-2",

View File

@ -1,6 +1,6 @@
import {bech32, utf8} from "@scure/base"
import {debounce} from "throttle-debounce"
import {pluck, identity, sum, is, equals} from "ramda"
import {pluck, fromPairs, last, identity, sum, is, equals} from "ramda"
import {ensurePlural, Storage, defer, isPojo, first, seconds, tryFunc, sleep, round} from "hurdak"
import Fuse from "fuse.js/dist/fuse.min.js"
import {writable} from "svelte/store"
@ -341,3 +341,8 @@ export const createBatcher = <T, U>(t, execute: (request: T[]) => U[] | Promise<
}
export const asArray = v => ensurePlural(v).filter(identity)
export const buildQueryString = params => "?" + new URLSearchParams(params)
export const parseQueryString = path =>
fromPairs(Array.from(new URLSearchParams(last(path.split("?")))))

View File

@ -1,20 +1,24 @@
import {takeWhile, fromPairs, filter, identity, reject, path as getPath} from "ramda"
import {takeWhile, filter, identity, reject, path as getPath} from "ramda"
import {first, filterVals} from "hurdak"
import type {ComponentType, SvelteComponentTyped} from "svelte"
import {globalHistory} from "svelte-routing/src/history"
import {pick} from "svelte-routing/src/utils"
import {buildQueryString, parseQueryString} from "src/util/misc"
import {writable} from "src/engine"
export type Component = ComponentType<SvelteComponentTyped>
export type ComponentOpts = {
decode: Record<string, (v: string) => Record<string, any>>
export type Serializer = {
encode: (v: any) => string
decode: (v: string) => any
}
export type ComponentSerializers = Record<string, Serializer>
export type Route = {
path: string
component: Component
opts?: ComponentOpts
serializers?: ComponentSerializers
}
export type HistoryItem = {
@ -30,7 +34,9 @@ export type HistoryItem = {
context?: Record<string, any>
}
route: {
path: string
component: Component
serializers?: ComponentSerializers
}
}
@ -75,10 +81,21 @@ class RouterExtension {
at = path => this.clone({path: asPath(this.path, path)})
qp = queryParams =>
this.clone({
qp = queryParams => {
const match = pick(this.router.routes, this.path)
for (const [k, v] of Object.entries(queryParams)) {
const serializer = match.route.serializers?.[k]
if (serializer) {
queryParams[k] = serializer.encode(v)
}
}
return this.clone({
queryParams: filterVals(identity, {...this.queryParams, ...queryParams}),
})
}
cx = context =>
this.clone({
@ -89,7 +106,7 @@ class RouterExtension {
let path = this.path
if (this.queryParams) {
path += "?" + new URLSearchParams(this.queryParams)
path += buildQueryString(this.queryParams)
}
return path
@ -117,39 +134,29 @@ export class Router {
modal = this.modals.derived(first)
init() {
this.at(window.location.pathname).push()
this.at(window.location.pathname + window.location.search).push()
}
listen() {
return globalHistory.listen(({location}) => {
return globalHistory.listen(({location, action}) => {
const {pathname, search} = location
const $nonVirtual = this.nonVirtual.get()
const [cur, prev] = this.nonVirtual.get()
const path = search ? pathname + search : pathname
// If we're going back, splice instead of push
if (path === $nonVirtual[1]?.path) {
this.history.update($history => {
if ($history.length >= 2) {
$history.splice(0, 1)
}
return $history
})
} else if (path !== $nonVirtual[0].path) {
if (action === "POP" && path === prev?.path) {
this.history.update($history => $history.slice(1))
} else if (path !== cur.path) {
this.go(path)
}
})
}
register = (path: string, component: Component, opts?: ComponentOpts) => {
this.routes.push({path, component, opts})
register = (path: string, component: Component, serializers?: ComponentSerializers) => {
this.routes.push({path, component, serializers})
}
go(path, config: HistoryItem["config"] = {}) {
if (!path.startsWith("/")) {
path = "/" + path
}
const match = pick(this.routes, path)
if (!match) {
@ -206,17 +213,23 @@ export class Router {
// Extensions
extend(path: string, getId) {
this.extensions[path] = new RouterExtension(this, {path}, getId)
this.extensions[path] = new RouterExtension(this, {path: asPath(path)}, getId)
}
at(path) {
return this.extensions[path] || new RouterExtension(this, {path})
return this.extensions[path] || new RouterExtension(this, {path: asPath(path)})
}
from(historyItem) {
const [path, qs] = historyItem.path.split("?")
return this.at(path).qp(fromPairs(Array.from(new URLSearchParams(qs).entries())))
let ext = this.at(path)
if (qs) {
ext = ext.qp(parseQueryString(qs || ""))
}
return ext
}
fromCurrent() {