feat(pwa): add web share target for text and images with offline queue
This commit is contained in:
1 parent
c181ffea54
commit
86d2cd0138
9 files changed
+360
-2
No files matched your search
@@ -67,6 +67,7 @@ import { UserAvatar } from './components/UserAvatar';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { AppFooter } from './components/AppFooter';
|
||||
import { AboutModal } from './components/AboutModal';
|
||||
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
||||
import { APP_VERSION } from './version';
|
||||
|
||||
type ActiveTab =
|
||||
@@ -555,6 +556,46 @@ export const App: React.FC = () => {
|
||||
);
|
||||
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
||||
|
||||
// Web Share Target: import shared thoughts from external apps
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const hasPendingShare = params.get('shared') === 'pending';
|
||||
|
||||
const processShares = async () => {
|
||||
try {
|
||||
const shares = await consumePendingShares();
|
||||
if (shares.length === 0) return;
|
||||
|
||||
let importedCount = 0;
|
||||
for (const share of shares) {
|
||||
const content = formatSharedThought(share);
|
||||
if (content) {
|
||||
await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' });
|
||||
importedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (importedCount > 0) {
|
||||
triggerHaptic(15);
|
||||
setDemoNotice('Erfolgreich im Quick Dump abgelegt');
|
||||
setTimeout(() => setDemoNotice(null), 4000);
|
||||
refreshAll();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Import geteilter Daten:', err);
|
||||
} finally {
|
||||
if (hasPendingShare) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('shared');
|
||||
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processShares();
|
||||
}, [user, refreshAll]);
|
||||
|
||||
// The demo database was reset: drop cached answers of the old data, remount the views (they load
|
||||
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
|
||||
const handleDemoReset = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatSharedThought } from '../shareTarget';
|
||||
|
||||
describe('formatSharedThought', () => {
|
||||
it('formats pure text correctly', () => {
|
||||
expect(formatSharedThought({ text: 'Spontaner Einfall' })).toBe('Spontaner Einfall');
|
||||
});
|
||||
|
||||
it('formats pure url correctly', () => {
|
||||
expect(formatSharedThought({ url: 'https://example.com/article' })).toBe('https://example.com/article');
|
||||
});
|
||||
|
||||
it('combines title and url when text is empty', () => {
|
||||
expect(formatSharedThought({ title: 'Toller Artikel', url: 'https://example.com/article' }))
|
||||
.toBe('Toller Artikel\nhttps://example.com/article');
|
||||
});
|
||||
|
||||
it('does not duplicate URL when text already contains it', () => {
|
||||
const input = {
|
||||
title: 'Artikel',
|
||||
text: 'Lies das hier: https://example.com/article unbedingt!',
|
||||
url: 'https://example.com/article',
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('Artikel\nLies das hier: https://example.com/article unbedingt!');
|
||||
});
|
||||
|
||||
it('does not duplicate URL when text is exactly the URL', () => {
|
||||
const input = {
|
||||
title: 'News',
|
||||
text: 'https://news.example.com',
|
||||
url: 'https://news.example.com',
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('News\nhttps://news.example.com');
|
||||
});
|
||||
|
||||
it('does not duplicate title if text starts with title', () => {
|
||||
const input = {
|
||||
title: 'MindSift Update',
|
||||
text: 'MindSift Update ist jetzt verfügbar.',
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('MindSift Update ist jetzt verfügbar.');
|
||||
});
|
||||
|
||||
it('combines title, text and distinct URL', () => {
|
||||
const input = {
|
||||
title: 'Empfehlung',
|
||||
text: 'Ein großartiges Tool für schnelles Erfassen.',
|
||||
url: 'https://mindsift.app',
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('Empfehlung\nEin großartiges Tool für schnelles Erfassen.\nhttps://mindsift.app');
|
||||
});
|
||||
|
||||
it('handles image with text', () => {
|
||||
const input = {
|
||||
title: 'Foto vom Ausflug',
|
||||
fileName: 'berge.jpg',
|
||||
hasMedia: true,
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('Foto vom Ausflug\n(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||
});
|
||||
|
||||
it('handles image only with filename', () => {
|
||||
const input = {
|
||||
fileName: 'screenshot.png',
|
||||
hasMedia: true,
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('[screenshot.png] (Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||
});
|
||||
|
||||
it('handles image only without filename', () => {
|
||||
const input = {
|
||||
hasMedia: true,
|
||||
};
|
||||
expect(formatSharedThought(input))
|
||||
.toBe('(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||
});
|
||||
|
||||
it('returns empty string if nothing is provided', () => {
|
||||
expect(formatSharedThought({})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
export interface SharedData {
|
||||
id?: number;
|
||||
title?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
hasMedia?: boolean;
|
||||
createdAt?: number;
|
||||
}
|
||||
|
||||
const SHARE_DB_NAME = 'mindsift-share-target';
|
||||
const SHARE_STORE_NAME = 'shared_items';
|
||||
|
||||
/**
|
||||
* Combines title, text, url and media note into a single thought string.
|
||||
* Ensures URLs are not duplicated if already present in the shared text or title.
|
||||
*/
|
||||
export function formatSharedThought(share: SharedData): string {
|
||||
const title = (share.title || '').trim();
|
||||
const text = (share.text || '').trim();
|
||||
const url = (share.url || '').trim();
|
||||
const mediaNote = '(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)';
|
||||
|
||||
let content = '';
|
||||
|
||||
// Check if url is already contained in text or title
|
||||
const urlInText = url !== '' && text.includes(url);
|
||||
const urlInTitle = url !== '' && title.includes(url);
|
||||
|
||||
// Combine title and text
|
||||
if (title && text) {
|
||||
if (text === title) {
|
||||
content = text;
|
||||
} else if (text.startsWith(title)) {
|
||||
content = text;
|
||||
} else {
|
||||
content = `${title}\n${text}`;
|
||||
}
|
||||
} else if (title) {
|
||||
content = title;
|
||||
} else if (text) {
|
||||
content = text;
|
||||
}
|
||||
|
||||
// Append URL if not already included in text or title
|
||||
if (url && !urlInText && !urlInTitle) {
|
||||
if (content) {
|
||||
content = `${content}\n${url}`;
|
||||
} else {
|
||||
content = url;
|
||||
}
|
||||
}
|
||||
|
||||
// Media note handling
|
||||
if (share.hasMedia || share.fileName) {
|
||||
if (content) {
|
||||
content = `${content}\n${mediaNote}`;
|
||||
} else if (share.fileName) {
|
||||
content = `[${share.fileName}] ${mediaNote}`;
|
||||
} else {
|
||||
content = mediaNote;
|
||||
}
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and removes all pending shared items stored in IndexedDB by the Service Worker.
|
||||
*/
|
||||
export async function consumePendingShares(): Promise<SharedData[]> {
|
||||
if (typeof indexedDB === 'undefined') return [];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const req = indexedDB.open(SHARE_DB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
|
||||
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
|
||||
db.close();
|
||||
return resolve([]);
|
||||
}
|
||||
|
||||
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(SHARE_STORE_NAME);
|
||||
const getAllReq = store.getAll();
|
||||
|
||||
getAllReq.onsuccess = () => {
|
||||
const items: SharedData[] = getAllReq.result || [];
|
||||
store.clear();
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve(items);
|
||||
};
|
||||
tx.onerror = () => {
|
||||
db.close();
|
||||
resolve(items);
|
||||
};
|
||||
};
|
||||
|
||||
getAllReq.onerror = () => {
|
||||
db.close();
|
||||
resolve([]);
|
||||
};
|
||||
};
|
||||
req.onerror = () => resolve([]);
|
||||
} catch {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user