diff --git a/package-lock.json b/package-lock.json index 5d3c205..0adebd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,7 +111,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -462,7 +461,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" }, @@ -485,7 +483,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" } @@ -2433,7 +2430,8 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2611,7 +2609,6 @@ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-2.1.9.tgz", "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", "dev": true, - "peer": true, "dependencies": { "@vitest/utils": "2.1.9", "fflate": "^0.8.2", @@ -2665,6 +2662,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "peer": true, "engines": { "node": ">=10" }, @@ -2816,7 +2814,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3079,7 +3076,8 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3715,7 +3713,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -4101,6 +4098,7 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4360,7 +4358,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4382,6 +4379,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4434,7 +4432,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4447,7 +4444,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4460,7 +4456,8 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -5028,7 +5025,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5206,7 +5202,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5787,7 +5782,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5800,7 +5794,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, - "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", @@ -6298,7 +6291,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -6650,7 +6642,6 @@ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz", "integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.99" }, diff --git a/public/manifest.json b/public/manifest.json index 0e0602a..bf52ca5 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -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" + } + } } diff --git a/public/sw.js b/public/sw.js index eb4e1e1..a8ed075 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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). diff --git a/src/app.jsx b/src/app.jsx index e9ab723..7b9adb7 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -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' @@ -23,6 +24,7 @@ function AppContent() { }, [addToast]) usePasteToBookmark(handlePasteSuccess, handlePasteDuplicate) + useShareTarget(handlePasteSuccess, handlePasteDuplicate) const isOnline = useOnlineStatus() diff --git a/src/hooks/useShareTarget.js b/src/hooks/useShareTarget.js new file mode 100644 index 0000000..20bfcb8 --- /dev/null +++ b/src/hooks/useShareTarget.js @@ -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) + } +} diff --git a/src/hooks/useShareTarget.test.js b/src/hooks/useShareTarget.test.js new file mode 100644 index 0000000..e491ef5 --- /dev/null +++ b/src/hooks/useShareTarget.test.js @@ -0,0 +1,227 @@ +/** + * useShareTarget Tests + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useShareTarget } from './useShareTarget.js' + +// Mock the bookmarks service +vi.mock('../services/bookmarks', () => ({ + isValidUrl: vi.fn((text) => { + try { + const url = new URL(text) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } + }), + createBookmark: vi.fn(() => ({ + id: 'bookmark:new-1', + url: 'https://example.com/', + title: 'example.com', + })), + findBookmarksByUrl: vi.fn(() => []), + normalizeUrl: vi.fn((url) => url), + updateBookmark: vi.fn(), +})) + +// Mock content suggestion service +vi.mock('../services/content-suggestion', () => ({ + isSuggestionsEnabled: vi.fn(() => false), + fetchSuggestions: vi.fn(), +})) + +let replaceStateSpy + +beforeEach(async () => { + vi.clearAllMocks() + const bookmarks = await import('../services/bookmarks') + bookmarks.findBookmarksByUrl.mockReturnValue([]) + bookmarks.createBookmark.mockReturnValue({ + id: 'bookmark:new-1', + url: 'https://example.com/', + title: 'example.com', + }) + replaceStateSpy = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}) +}) + +afterEach(() => { + // Reset URL search params + replaceStateSpy.mockRestore() + // Reset location to clean state via jsdom + Object.defineProperty(window, 'location', { + value: new URL('http://localhost/'), + writable: true, + configurable: true, + }) +}) + +function setSearchParams(params) { + const url = new URL('http://localhost/') + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + Object.defineProperty(window, 'location', { + value: url, + writable: true, + configurable: true, + }) +} + +describe('useShareTarget', () => { + it('creates bookmark from shared_url param', async () => { + setSearchParams({ shared_url: 'https://example.com' }) + + const onSuccess = vi.fn() + renderHook(() => useShareTarget(onSuccess)) + + // Wait for async handleSharedUrl + await vi.waitFor(async () => { + const bookmarks = await import('../services/bookmarks') + expect(bookmarks.createBookmark).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://example.com', + title: 'example.com', + }) + ) + }) + + expect(onSuccess).toHaveBeenCalledWith('https://example.com') + }) + + it('uses shared_title when provided', async () => { + setSearchParams({ + shared_url: 'https://example.com', + shared_title: 'Example Site', + }) + + renderHook(() => useShareTarget(vi.fn())) + + await vi.waitFor(async () => { + const bookmarks = await import('../services/bookmarks') + expect(bookmarks.createBookmark).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://example.com', + title: 'Example Site', + }) + ) + }) + }) + + it('extracts URL from shared_text param', async () => { + setSearchParams({ shared_text: 'Check this out https://example.com/page' }) + + const onSuccess = vi.fn() + renderHook(() => useShareTarget(onSuccess)) + + await vi.waitFor(async () => { + const bookmarks = await import('../services/bookmarks') + expect(bookmarks.createBookmark).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://example.com/page', + }) + ) + }) + + expect(onSuccess).toHaveBeenCalledWith('https://example.com/page') + }) + + it('does nothing when no share params present', async () => { + // No search params set (default clean URL) + const onSuccess = vi.fn() + renderHook(() => useShareTarget(onSuccess)) + + // Give it a tick to ensure nothing fires + await new Promise((r) => setTimeout(r, 10)) + + const bookmarks = await import('../services/bookmarks') + expect(bookmarks.createBookmark).not.toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + }) + + it('calls onDuplicate when URL already exists', async () => { + setSearchParams({ shared_url: 'https://existing.com' }) + + const bookmarks = await import('../services/bookmarks') + bookmarks.findBookmarksByUrl.mockReturnValue([{ _id: 'existing' }]) + + const onSuccess = vi.fn() + const onDuplicate = vi.fn() + renderHook(() => useShareTarget(onSuccess, onDuplicate)) + + await vi.waitFor(() => { + expect(onDuplicate).toHaveBeenCalledWith('https://existing.com') + }) + + expect(onSuccess).not.toHaveBeenCalled() + expect(bookmarks.createBookmark).not.toHaveBeenCalled() + }) + + it('cleans up URL params after processing', async () => { + setSearchParams({ shared_url: 'https://example.com' }) + + renderHook(() => useShareTarget(vi.fn())) + + await vi.waitFor(() => { + expect(replaceStateSpy).toHaveBeenCalled() + }) + + // The cleaned URL should not contain share params + const cleanedUrl = replaceStateSpy.mock.calls[0][2] + expect(cleanedUrl).not.toContain('shared_url') + expect(cleanedUrl).not.toContain('shared_title') + expect(cleanedUrl).not.toContain('shared_text') + }) + + it('cleans up URL even when shared text has no valid URL', async () => { + setSearchParams({ shared_text: 'just plain text no url' }) + + renderHook(() => useShareTarget(vi.fn())) + + await vi.waitFor(() => { + expect(replaceStateSpy).toHaveBeenCalled() + }) + + const bookmarks = await import('../services/bookmarks') + expect(bookmarks.createBookmark).not.toHaveBeenCalled() + }) + + it('handles createBookmark errors gracefully', async () => { + setSearchParams({ shared_url: 'https://example.com' }) + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const bookmarks = await import('../services/bookmarks') + bookmarks.createBookmark.mockImplementation(() => { + throw new Error('DB error') + }) + + const onSuccess = vi.fn() + renderHook(() => useShareTarget(onSuccess)) + + await vi.waitFor(() => { + expect(consoleSpy).toHaveBeenCalled() + }) + + expect(onSuccess).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('processes share params only once', async () => { + setSearchParams({ shared_url: 'https://example.com' }) + + const onSuccess = vi.fn() + const { rerender } = renderHook(() => useShareTarget(onSuccess)) + + await vi.waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + + // Re-render should not process again + rerender() + await new Promise((r) => setTimeout(r, 10)) + + const bookmarks = await import('../services/bookmarks') + expect(bookmarks.createBookmark).toHaveBeenCalledTimes(1) + }) +})