feat: add Web Share Target support for auto-saving shared URLs

- Add share_target to manifest.webmanifest with POST method
- Implement service worker handler for POST /share-target requests
- Create ShareTargetHandler component to process and save shared URLs
- Add /share-target route in App.tsx
- Auto-saves shared URLs as web bookmarks (NIP-B0)
- Handles Android case where url param is omitted from share data
This commit is contained in:
Gigi
2025-10-21 21:32:50 +02:00
parent cadf4dcb48
commit 3cddcd850e
4 changed files with 141 additions and 1 deletions

View File

@@ -9,6 +9,16 @@
"background_color": "#0b1220",
"orientation": "any",
"categories": ["productivity", "social", "utilities"],
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "link"
}
},
"icons": [
{
"src": "/icon-192.png",

View File

@@ -15,6 +15,7 @@ import Debug from './components/Debug'
import Bookmarks from './components/Bookmarks'
import RouteDebug from './components/RouteDebug'
import Toast from './components/Toast'
import ShareTargetHandler from './components/ShareTargetHandler'
import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
@@ -159,6 +160,10 @@ function AppRoutes({
return (
<Routes>
<Route
path="/share-target"
element={<ShareTargetHandler relayPool={relayPool} />}
/>
<Route
path="/a/:naddr"
element={

View File

@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { createWebBookmark } from '../services/webBookmarkService'
import { getActiveRelayUrls } from '../services/relayManager'
import { useToast } from '../hooks/useToast'
interface ShareTargetHandlerProps {
relayPool: RelayPool
}
/**
* Handles incoming shared URLs from the Web Share Target API.
* Auto-saves the shared URL as a web bookmark (NIP-B0).
*/
export default function ShareTargetHandler({ relayPool }: ShareTargetHandlerProps) {
const navigate = useNavigate()
const location = useLocation()
const activeAccount = Hooks.useActiveAccount()
const { showToast } = useToast()
const [processing, setProcessing] = useState(false)
const [waitingForLogin, setWaitingForLogin] = useState(false)
useEffect(() => {
const handleSharedContent = async () => {
// Parse query parameters
const params = new URLSearchParams(location.search)
const link = params.get('link')
const title = params.get('title')
const text = params.get('text')
// Validate we have a URL
if (!link) {
showToast('No URL to save')
navigate('/')
return
}
// If no active account, wait for login
if (!activeAccount) {
setWaitingForLogin(true)
showToast('Please log in to save this bookmark')
return
}
// We have account and URL, proceed with saving
if (!processing) {
setProcessing(true)
try {
await createWebBookmark(
link,
title || undefined,
text || undefined,
undefined,
activeAccount,
relayPool,
getActiveRelayUrls(relayPool)
)
showToast('Bookmark saved!')
navigate('/me/links')
} catch (err) {
console.error('Failed to save shared bookmark:', err)
showToast('Failed to save bookmark')
navigate('/')
} finally {
setProcessing(false)
}
}
}
handleSharedContent()
}, [activeAccount, location.search, navigate, relayPool, showToast, processing])
// Show waiting for login state
if (waitingForLogin && !activeAccount) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
<p className="text-lg">Waiting for login...</p>
</div>
</div>
)
}
// Show processing state
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<FontAwesomeIcon icon={faSpinner} spin className="text-4xl mb-4" />
<p className="text-lg">Saving bookmark...</p>
</div>
</div>
)
}

View File

@@ -98,10 +98,36 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => {
}
})
// Log fetch errors for debugging (doesn't affect functionality)
// Handle Web Share Target POST requests
sw.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url)
// Handle POST to /share-target (Web Share Target API)
if (event.request.method === 'POST' && url.pathname === '/share-target') {
event.respondWith((async () => {
const formData = await event.request.formData()
const title = (formData.get('title') || '').toString()
const text = (formData.get('text') || '').toString()
let link = (formData.get('link') || '').toString()
// Android often omits url param, extract from text
if (!link && text) {
const urlMatch = text.match(/https?:\/\/[^\s]+/)
if (urlMatch) {
link = urlMatch[0]
}
}
const queryParams = new URLSearchParams()
if (link) queryParams.set('link', link)
if (title) queryParams.set('title', title)
if (text) queryParams.set('text', text)
return Response.redirect(`/share-target?${queryParams.toString()}`, 303)
})())
return
}
// Don't interfere with WebSocket connections (relay traffic)
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
return