feat: add deep link customization

This commit is contained in:
Bhavishy
2026-04-08 01:18:19 +05:30
parent 613cad31c0
commit 1d6e2fab59
8 changed files with 302 additions and 30 deletions
+273
View File
@@ -0,0 +1,273 @@
<script lang="ts">
import {onMount} from "svelte"
import {get} from "svelte/store"
import {goto} from "$app/navigation"
import {
userFollowList,
userBlossomServerList,
tagPubkey,
publishThunk,
addRelay,
pubkey,
profilesByPubkey,
waitForThunkError,
} from "@welshman/app"
import {
makeEvent,
FOLLOWS,
BLOSSOM_SERVERS,
getListTags,
getPubkeyTagValues,
getTagValues,
tagger,
RelayMode,
makeProfile,
} from "@welshman/util"
import {Router} from "@welshman/router"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import Button from "@lib/components/Button.svelte"
import {theme} from "@app/util/theme"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {errorMessage} from "@lib/util"
import Modal from "@lib/components/Modal.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {addSpaceMembership, updateProfile} from "@app/core/commands"
type Props = {
params: URLSearchParams
back: () => void
}
const {params, back}: Props = $props()
const t = params.get("theme")
const relays = params.getAll("add_relay")
const blossoms = params.getAll("add_blossom")
const follows = params.getAll("add_follow")
const joins = params.getAll("join")
const profile_name = params.get("profile_name")
const profile_about = params.get("profile_about")
const profile_picture = params.get("profile_picture")
const profile_banner = params.get("profile_banner")
const profile_nip05 = params.get("profile_nip05")
const profile_lud16 = params.get("profile_lud16")
const shareRelay = params.get("share_relay") || params.get("share_url")
const shareH = params.get("share_h")
const shareText = params.get("share_text")
const hasProfile =
!!profile_name ||
!!profile_about ||
!!profile_picture ||
!!profile_banner ||
!!profile_nip05 ||
!!profile_lud16
const hasSettings =
!!t ||
relays.length > 0 ||
blossoms.length > 0 ||
follows.length > 0 ||
joins.length > 0 ||
hasProfile
const hasShare = !!shareRelay && !!shareH
let processing = $state(false)
let errors = $state<string[]>([])
onMount(() => {
if (!hasSettings) {
if (hasShare) {
void openShare()
} else {
pushToast({message: "No valid intent actions found", theme: "error"})
back()
}
}
})
const openShare = async () => {
await goto(`/spaces/${encodeURIComponent(shareRelay!)}/${shareH}`)
pushModal(ThreadCreate, {url: shareRelay!, h: shareH!, initialContent: shareText || ""})
}
const accept = async () => {
processing = true
errors = []
const nextErrors: string[] = []
try {
if (t) {
theme.set(t)
}
if (relays.length > 0) {
for (const url of relays) {
const readError = await waitForThunkError(await addRelay(url, RelayMode.Read))
const writeError = await waitForThunkError(await addRelay(url, RelayMode.Write))
if (readError) {
nextErrors.push(`Relay ${url} (read): ${errorMessage(readError)}`)
}
if (writeError) {
nextErrors.push(`Relay ${url} (write): ${errorMessage(writeError)}`)
}
}
}
if (blossoms.length > 0) {
const current = getTagValues("server", getListTags(get(userBlossomServerList)))
const updated = Array.from(new Set([...current, ...blossoms]))
const error = await waitForThunkError(
publishThunk({
event: makeEvent(BLOSSOM_SERVERS, {tags: updated.map(tagger("server"))}),
relays: Router.get().FromUser().getUrls(),
}),
)
if (error) {
nextErrors.push(`Blossom servers: ${errorMessage(error)}`)
}
}
if (follows.length > 0) {
const current = getPubkeyTagValues(getListTags(get(userFollowList)))
const updated = Array.from(new Set([...current, ...follows]))
const error = await waitForThunkError(
publishThunk({
event: makeEvent(FOLLOWS, {tags: updated.map(tagPubkey)}),
relays: Router.get().FromUser().getUrls(),
}),
)
if (error) {
nextErrors.push(`Follows: ${errorMessage(error)}`)
}
}
if (joins.length > 0) {
for (const url of joins) {
const error = await waitForThunkError(await addSpaceMembership(url))
if (error) {
nextErrors.push(`Join ${url}: ${errorMessage(error)}`)
}
}
}
if (hasProfile) {
const profile = {...(get(profilesByPubkey).get(get(pubkey)!) || makeProfile())}
if (profile_name) profile.name = profile_name
if (profile_about) profile.about = profile_about
if (profile_picture) profile.picture = profile_picture
if (profile_banner) profile.banner = profile_banner
if (profile_nip05) profile.nip05 = profile_nip05
if (profile_lud16) profile.lud16 = profile_lud16
const error = await waitForThunkError(updateProfile({profile, shouldBroadcast: true}))
if (error) {
nextErrors.push(`Profile: ${errorMessage(error)}`)
}
}
if (nextErrors.length > 0) {
errors = nextErrors
return
}
pushToast({message: "Customizations Applied!"})
if (hasShare) {
await openShare()
} else {
back()
}
} finally {
processing = false
}
}
const continueWithErrors = async () => {
pushToast({message: "Applied what we could. Some actions failed."})
if (hasShare) {
await openShare()
} else {
back()
}
}
</script>
{#if hasSettings}
<Modal tag="form" onsubmit={(event: SubmitEvent) => event.preventDefault()}>
<div class="flex flex-col gap-4 text-center">
<ModalHeader>
<div class="flex flex-col items-start gap-1">
<ModalTitle>Apply Customization?</ModalTitle>
<ModalSubtitle>A link is requesting to customize your app.</ModalSubtitle>
</div>
</ModalHeader>
<ModalBody>
{#if errors.length === 0}
<p>This link will apply the following changes:</p>
<ul class="text-left list-disc list-inside px-6 py-4">
{#if t}
<li>Set theme to "{t}"</li>
{/if}
{#if relays.length > 0}
<li>Add {relays.length} relay{relays.length > 1 ? "s" : ""} to your settings</li>
{/if}
{#if blossoms.length > 0}
<li>Add {blossoms.length} blossom server{blossoms.length > 1 ? "s" : ""}</li>
{/if}
{#if follows.length > 0}
<li>Follow {follows.length} person{follows.length > 1 ? "s" : ""}</li>
{/if}
{#if joins.length > 0}
<li>Join {joins.length} communit{joins.length > 1 ? "ies" : "y"}</li>
{/if}
{#if hasProfile}
<li>Update your profile metadata</li>
{/if}
{#if hasShare}
<li>Open a new post dialog in a specific room</li>
{/if}
</ul>
{:else}
<p>Some actions failed. You can abort or continue with successful actions only.</p>
<ul class="text-left list-disc list-inside px-6 py-4 text-error">
{#each errors as error}
<li>{error}</li>
{/each}
</ul>
{/if}
</ModalBody>
<ModalFooter>
{#if errors.length === 0}
<Button class="btn btn-neutral" onclick={back}>Cancel</Button>
<Button class="btn btn-primary" onclick={accept} disabled={processing}>
{#if processing}
<span class="loading loading-spinner"></span>
{/if}
Accept
</Button>
{:else}
<Button class="btn btn-neutral" onclick={back}>Abort</Button>
<Button class="btn btn-primary" onclick={continueWithErrors}>Continue</Button>
{/if}
</ModalFooter>
</div>
</Modal>
{/if}
+5 -22
View File
@@ -18,22 +18,16 @@
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70} from "@app/core/commands" import {canEnforceNip70} from "@app/core/commands"
type Values = {
content?: string | object
title?: string
}
type Props = { type Props = {
url: string url: string
h?: string h?: string
initialContent?: string
} }
const {url, h}: Props = $props() const {url, h, initialContent = ""}: Props = $props()
const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
const uploading = writable(false) const uploading = writable(false)
@@ -77,29 +71,18 @@
event: makeEvent(THREAD, {content, tags}), event: makeEvent(THREAD, {content, tags}),
}) })
draftKey.clear()
history.back() history.back()
} }
let title = $state(initialValues?.title ?? "")
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({ const editor = makeEditor({
content: initialContent,
url, url,
submit, submit,
uploading, uploading,
onChange,
placeholder: "What's on your mind?", placeholder: "What's on your mind?",
content,
}) })
$effect(() => { let title: string = $state("")
draftKey.update({title, content})
})
</script> </script>
<Modal tag="form" onsubmit={preventDefault(submit)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
+2 -1
View File
@@ -259,8 +259,9 @@ export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pu
export const bootstrapPubkeys = derived(userFollowList, $userFollowList => { export const bootstrapPubkeys = derived(userFollowList, $userFollowList => {
const appPubkeys = DEFAULT_PUBKEYS.split(",") const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollowList))) const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollowList)))
const mergedPubkeys = userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys] return uniq(mergedPubkeys)
}) })
export const deriveEvent = makeDeriveEvent({ export const deriveEvent = makeDeriveEvent({
+1 -2
View File
@@ -6,7 +6,6 @@
import Close from "@assets/icons/close.svg?dataurl" import Close from "@assets/icons/close.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {clearModals} from "@app/util/modal"
type Props = { type Props = {
onClose?: any onClose?: any
@@ -56,7 +55,7 @@
<div class={wrapperClass}> <div class={wrapperClass}>
<div class={innerClass} transition:fly> <div class={innerClass} transition:fly>
{#if !noEscape} {#if !noEscape}
<Button class={buttonClass} onclick={clearModals}> <Button class={buttonClass} onclick={onClose}>
<Icon icon={Close} size={6} /> <Icon icon={Close} size={6} />
</Button> </Button>
{/if} {/if}
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import Dialog from "@lib/components/Dialog.svelte"
import IntentHandler from "@app/components/IntentHandler.svelte"
const children = {
component: IntentHandler,
props: {
params: $page.url.searchParams,
back: () => goto("/home"),
},
}
</script>
<Dialog {children} onClose={() => goto("/home")} />
+1 -1
View File
@@ -13,4 +13,4 @@
} }
</script> </script>
<Dialog {children} /> <Dialog {children} onClose={() => goto("/home")} />
+2 -2
View File
@@ -17,9 +17,9 @@
const search = debounce(200, (term: string) => { const search = debounce(200, (term: string) => {
if (term) { if (term) {
pubkeys = $profileSearch.searchValues(term) pubkeys = Array.from(new Set($profileSearch.searchValues(term)))
} else { } else {
pubkeys = $bootstrapPubkeys pubkeys = Array.from(new Set($bootstrapPubkeys))
} }
}) })
+1 -1
View File
@@ -67,6 +67,6 @@
</SecondaryNavSection> </SecondaryNavSection>
</SecondaryNav> </SecondaryNav>
<Page> <Page class="scroll-container overflow-y-auto overflow-x-hidden">
{@render children?.()} {@render children?.()}
</Page> </Page>