Skip to content

Commit 3f8e7be

Browse files
authored
Merge pull request #42 from pheuberger/claude/ios-pwa-share-sheet-Hwib1
2 parents 19d5558 + c9eaf2c commit 3f8e7be

6 files changed

Lines changed: 394 additions & 19 deletions

File tree

package-lock.json

Lines changed: 9 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/manifest.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,15 @@
2020
"type": "image/png",
2121
"purpose": "any maskable"
2222
}
23-
]
23+
],
24+
"share_target": {
25+
"action": "/_share-target",
26+
"method": "POST",
27+
"enctype": "application/x-www-form-urlencoded",
28+
"params": {
29+
"title": "title",
30+
"text": "text",
31+
"url": "url"
32+
}
33+
}
2434
}

public/sw.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,30 @@ self.addEventListener('activate', (event) => {
3434
})
3535

3636
self.addEventListener('fetch', (event) => {
37+
const url = new URL(event.request.url)
38+
39+
// Handle Web Share Target POST requests.
40+
// When a user shares a URL to Hypermark via the iOS/Android share sheet,
41+
// the OS sends a POST to /_share-target. We extract the shared data from
42+
// the form body and redirect to the app with query params so the client
43+
// can pick it up and create a bookmark.
44+
if (event.request.method === 'POST' && url.pathname === '/_share-target') {
45+
event.respondWith(
46+
(async () => {
47+
const formData = await event.request.formData()
48+
const title = formData.get('title') || ''
49+
const text = formData.get('text') || ''
50+
const sharedUrl = formData.get('url') || ''
51+
const params = new URLSearchParams()
52+
if (sharedUrl) params.set('shared_url', sharedUrl)
53+
if (title) params.set('shared_title', title)
54+
if (text) params.set('shared_text', text)
55+
return Response.redirect(`/?${params.toString()}`, 303)
56+
})()
57+
)
58+
return
59+
}
60+
3761
// Only intercept same-origin GET requests. In iOS PWA standalone mode,
3862
// calling event.respondWith() on WebSocket upgrade requests or cross-origin
3963
// requests can silently break connections (e.g. signaling server WebSocket).

src/app.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback } from 'react'
22
import { useYjs } from './hooks/useYjs'
33
import { useNostrSync } from './hooks/useNostrSync'
44
import { usePasteToBookmark } from './hooks/usePasteToBookmark'
5+
import { useShareTarget } from './hooks/useShareTarget'
56
import { useOnlineStatus } from './hooks/useOnlineStatus'
67
import { useRelayErrorToasts } from './hooks/useRelayErrorToasts'
78
import { BookmarkList } from './components/bookmarks/BookmarkList'
@@ -23,6 +24,7 @@ function AppContent() {
2324
}, [addToast])
2425

2526
usePasteToBookmark(handlePasteSuccess, handlePasteDuplicate)
27+
useShareTarget(handlePasteSuccess, handlePasteDuplicate)
2628

2729
const isOnline = useOnlineStatus()
2830

src/hooks/useShareTarget.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { useEffect, useRef } from 'react'
2+
import { isValidUrl } from '../services/bookmarks'
3+
4+
/**
5+
* Extract a valid URL from the share target query params.
6+
* iOS/Android may put the URL in either the `shared_url` or `shared_text` param.
7+
* @param {URLSearchParams} params
8+
* @returns {string|null}
9+
*/
10+
function extractSharedUrl(params) {
11+
const url = params.get('shared_url')
12+
if (url && isValidUrl(url)) return url
13+
14+
const text = params.get('shared_text') || ''
15+
// Some apps put the URL at the end of the text field
16+
const urlMatch = text.match(/https?:\/\/\S+/i)
17+
if (urlMatch && isValidUrl(urlMatch[0])) return urlMatch[0]
18+
19+
// text itself might be a bare URL
20+
if (isValidUrl(text)) return text
21+
22+
return null
23+
}
24+
25+
/**
26+
* Hook that handles incoming Web Share Target data.
27+
* When the PWA is launched via the share sheet, the service worker redirects
28+
* to /?shared_url=...&shared_title=...&shared_text=... — this hook picks up
29+
* those params, creates a bookmark, and cleans up the URL.
30+
*
31+
* @param {Function} onSuccess - Called with the URL when a bookmark is created
32+
* @param {Function} onDuplicate - Called when the shared URL already exists
33+
*/
34+
export function useShareTarget(onSuccess, onDuplicate) {
35+
const processed = useRef(false)
36+
37+
useEffect(() => {
38+
if (processed.current) return
39+
40+
const params = new URLSearchParams(window.location.search)
41+
if (!params.has('shared_url') && !params.has('shared_text')) return
42+
43+
processed.current = true
44+
45+
const url = extractSharedUrl(params)
46+
if (!url) {
47+
cleanUpUrl()
48+
return
49+
}
50+
51+
const title = params.get('shared_title') || ''
52+
53+
handleSharedUrl(url, title, onSuccess, onDuplicate).then(cleanUpUrl)
54+
}, [onSuccess, onDuplicate])
55+
}
56+
57+
/**
58+
* Remove share target query params from the URL without triggering navigation.
59+
*/
60+
function cleanUpUrl() {
61+
const url = new URL(window.location.href)
62+
url.searchParams.delete('shared_url')
63+
url.searchParams.delete('shared_title')
64+
url.searchParams.delete('shared_text')
65+
const clean = url.searchParams.toString()
66+
? `${url.pathname}?${url.searchParams.toString()}${url.hash}`
67+
: `${url.pathname}${url.hash}`
68+
window.history.replaceState(null, '', clean)
69+
}
70+
71+
/**
72+
* Create a bookmark from the shared URL, mirroring usePasteToBookmark logic.
73+
*/
74+
async function handleSharedUrl(sharedUrl, sharedTitle, onSuccess, onDuplicate) {
75+
try {
76+
const { createBookmark, findBookmarksByUrl, normalizeUrl, updateBookmark } =
77+
await import('../services/bookmarks')
78+
79+
const normalized = normalizeUrl(sharedUrl)
80+
const existing = findBookmarksByUrl(normalized)
81+
82+
if (existing.length > 0) {
83+
if (onDuplicate) onDuplicate(sharedUrl)
84+
return
85+
}
86+
87+
const domain = new URL(normalized).hostname.replace('www.', '')
88+
const title = sharedTitle || domain
89+
90+
const bookmark = createBookmark({
91+
url: sharedUrl,
92+
title,
93+
description: '',
94+
tags: [],
95+
readLater: false,
96+
})
97+
98+
if (onSuccess) onSuccess(sharedUrl)
99+
100+
// Async: fetch suggestions if enabled
101+
try {
102+
const { isSuggestionsEnabled, fetchSuggestions } =
103+
await import('../services/content-suggestion')
104+
if (isSuggestionsEnabled()) {
105+
const suggestions = await fetchSuggestions(normalized)
106+
const updates = {}
107+
if (suggestions.title) updates.title = suggestions.title
108+
if (suggestions.description) updates.description = suggestions.description
109+
if (suggestions.suggestedTags?.length) updates.tags = suggestions.suggestedTags
110+
if (suggestions.favicon) updates.favicon = suggestions.favicon
111+
if (Object.keys(updates).length > 0) {
112+
updateBookmark(bookmark.id, updates)
113+
}
114+
}
115+
} catch {
116+
// Suggestions are best-effort
117+
}
118+
} catch (error) {
119+
console.error('[useShareTarget] Error creating bookmark:', error)
120+
}
121+
}

0 commit comments

Comments
 (0)