Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 9 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,15 @@
"type": "image/png",
"purpose": "any maskable"
}
]
],
"share_target": {
"action": "/_share-target",
"method": "POST",
"enctype": "application/x-www-form-urlencoded",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
}
}
24 changes: 24 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,30 @@ self.addEventListener('activate', (event) => {
})

self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)

// Handle Web Share Target POST requests.
// When a user shares a URL to Hypermark via the iOS/Android share sheet,
// the OS sends a POST to /_share-target. We extract the shared data from
// the form body and redirect to the app with query params so the client
// can pick it up and create a bookmark.
if (event.request.method === 'POST' && url.pathname === '/_share-target') {
event.respondWith(
(async () => {
const formData = await event.request.formData()
const title = formData.get('title') || ''
const text = formData.get('text') || ''
const sharedUrl = formData.get('url') || ''
const params = new URLSearchParams()
if (sharedUrl) params.set('shared_url', sharedUrl)
if (title) params.set('shared_title', title)
if (text) params.set('shared_text', text)
return Response.redirect(`/?${params.toString()}`, 303)
})()
)
return
}

// Only intercept same-origin GET requests. In iOS PWA standalone mode,
// calling event.respondWith() on WebSocket upgrade requests or cross-origin
// requests can silently break connections (e.g. signaling server WebSocket).
Expand Down
2 changes: 2 additions & 0 deletions src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback } from 'react'
import { useYjs } from './hooks/useYjs'
import { useNostrSync } from './hooks/useNostrSync'
import { usePasteToBookmark } from './hooks/usePasteToBookmark'
import { useShareTarget } from './hooks/useShareTarget'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { useRelayErrorToasts } from './hooks/useRelayErrorToasts'
import { BookmarkList } from './components/bookmarks/BookmarkList'
Expand All @@ -23,6 +24,7 @@ function AppContent() {
}, [addToast])

usePasteToBookmark(handlePasteSuccess, handlePasteDuplicate)
useShareTarget(handlePasteSuccess, handlePasteDuplicate)

const isOnline = useOnlineStatus()

Expand Down
121 changes: 121 additions & 0 deletions src/hooks/useShareTarget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useEffect, useRef } from 'react'
import { isValidUrl } from '../services/bookmarks'

/**
* Extract a valid URL from the share target query params.
* iOS/Android may put the URL in either the `shared_url` or `shared_text` param.
* @param {URLSearchParams} params
* @returns {string|null}
*/
function extractSharedUrl(params) {
const url = params.get('shared_url')
if (url && isValidUrl(url)) return url

const text = params.get('shared_text') || ''
// Some apps put the URL at the end of the text field
const urlMatch = text.match(/https?:\/\/\S+/i)
if (urlMatch && isValidUrl(urlMatch[0])) return urlMatch[0]

// text itself might be a bare URL
if (isValidUrl(text)) return text

return null
}

/**
* Hook that handles incoming Web Share Target data.
* When the PWA is launched via the share sheet, the service worker redirects
* to /?shared_url=...&shared_title=...&shared_text=... — this hook picks up
* those params, creates a bookmark, and cleans up the URL.
*
* @param {Function} onSuccess - Called with the URL when a bookmark is created
* @param {Function} onDuplicate - Called when the shared URL already exists
*/
export function useShareTarget(onSuccess, onDuplicate) {
const processed = useRef(false)

useEffect(() => {
if (processed.current) return

const params = new URLSearchParams(window.location.search)
if (!params.has('shared_url') && !params.has('shared_text')) return

processed.current = true

const url = extractSharedUrl(params)
if (!url) {
cleanUpUrl()
return
}

const title = params.get('shared_title') || ''

handleSharedUrl(url, title, onSuccess, onDuplicate).then(cleanUpUrl)
}, [onSuccess, onDuplicate])
}

/**
* Remove share target query params from the URL without triggering navigation.
*/
function cleanUpUrl() {
const url = new URL(window.location.href)
url.searchParams.delete('shared_url')
url.searchParams.delete('shared_title')
url.searchParams.delete('shared_text')
const clean = url.searchParams.toString()
? `${url.pathname}?${url.searchParams.toString()}${url.hash}`
: `${url.pathname}${url.hash}`
window.history.replaceState(null, '', clean)
}

/**
* Create a bookmark from the shared URL, mirroring usePasteToBookmark logic.
*/
async function handleSharedUrl(sharedUrl, sharedTitle, onSuccess, onDuplicate) {
try {
const { createBookmark, findBookmarksByUrl, normalizeUrl, updateBookmark } =
await import('../services/bookmarks')

const normalized = normalizeUrl(sharedUrl)
const existing = findBookmarksByUrl(normalized)

if (existing.length > 0) {
if (onDuplicate) onDuplicate(sharedUrl)
return
}

const domain = new URL(normalized).hostname.replace('www.', '')
const title = sharedTitle || domain

const bookmark = createBookmark({
url: sharedUrl,
title,
description: '',
tags: [],
readLater: false,
})

if (onSuccess) onSuccess(sharedUrl)

// Async: fetch suggestions if enabled
try {
const { isSuggestionsEnabled, fetchSuggestions } =
await import('../services/content-suggestion')
if (isSuggestionsEnabled()) {
const suggestions = await fetchSuggestions(normalized)
const updates = {}
if (suggestions.title) updates.title = suggestions.title
if (suggestions.description) updates.description = suggestions.description
if (suggestions.suggestedTags?.length) updates.tags = suggestions.suggestedTags
if (suggestions.favicon) updates.favicon = suggestions.favicon
if (Object.keys(updates).length > 0) {
updateBookmark(bookmark.id, updates)
}
}
} catch {
// Suggestions are best-effort
}
} catch (error) {
console.error('[useShareTarget] Error creating bookmark:', error)
}
}
Loading