import { ShareIntent, ShareIntentFile } from 'expo-share-intent'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, BackHandler, Linking, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { WebView, WebViewMessageEvent, WebViewNavigation } from 'react-native-webview'; import { colors } from '../components/UI'; import { trackeepApi } from '../lib/api'; import { buildShareDraft, looksLikeYouTube, ShareDraft } from '../lib/share'; import { User } from '../types'; interface ShareIntentState { hasShareIntent: boolean; shareIntent: ShareIntent; resetShareIntent: (clearNativeModule?: boolean) => void; error: string | null; } interface WebAppScreenProps { instanceUrl: string; token: string; user: User; onLogout: () => Promise; shareIntentState: ShareIntentState; } type BridgeMessage = { type: 'NAV_CHANGE' | 'AUTH_LOGOUT' | 'AUTH_TOKEN' | 'BOOTSTRAP_DONE'; payload?: Record; }; const ROUTES: Array<{ label: string; path: string }> = [ { label: 'Dashboard', path: '/app' }, { label: 'Bookmarks', path: '/app/bookmarks' }, { label: 'Tasks', path: '/app/tasks' }, { label: 'Notes', path: '/app/notes' }, { label: 'Files', path: '/app/files' }, { label: 'YouTube', path: '/app/youtube' }, { label: 'Time', path: '/app/time-tracking' }, ]; function isInternalUrl(url: string, instanceUrl: string): boolean { if (!url) { return false; } if (url.startsWith('about:blank') || url.startsWith('data:') || url.startsWith('javascript:')) { return true; } try { const target = new URL(url); const base = new URL(instanceUrl); return target.origin === base.origin; } catch { return false; } } function pathFromUrl(url: string): string { try { const parsed = new URL(url); return `${parsed.pathname}${parsed.search}${parsed.hash}`; } catch { return '/app'; } } function toAbsolute(instanceUrl: string, path: string): string { return new URL(path, `${instanceUrl}/`).toString(); } function isGenericShareTitle(title: string, url: string): boolean { const normalized = title.trim().toLowerCase(); if (!normalized) { return true; } if (normalized === 'shared link' || normalized === 'youtube video') { return true; } try { const host = new URL(url).hostname.toLowerCase().replace(/^www\./, ''); return normalized === host; } catch { return false; } } function uniqueFiles(files: ShareIntentFile[]): ShareIntentFile[] { const seen = new Set(); const result: ShareIntentFile[] = []; for (const file of files) { const key = `${file.path}|${file.fileName}|${file.size || 0}`; if (seen.has(key)) { continue; } seen.add(key); result.push(file); } return result; } export function WebAppScreen({ instanceUrl, token, user, onLogout, shareIntentState }: WebAppScreenProps) { const { hasShareIntent, shareIntent, resetShareIntent, error: shareIntentError } = shareIntentState; const webRef = useRef(null); const [canGoBack, setCanGoBack] = useState(false); const [canGoForward, setCanGoForward] = useState(false); const [webLoading, setWebLoading] = useState(true); const [webError, setWebError] = useState(null); const [currentPath, setCurrentPath] = useState('/app'); const [showManualShare, setShowManualShare] = useState(false); const [manualTitle, setManualTitle] = useState(''); const [manualUrl, setManualUrl] = useState(''); const [manualDescription, setManualDescription] = useState(''); const [incomingDraft, setIncomingDraft] = useState(null); const [incomingFiles, setIncomingFiles] = useState([]); const [draftMetadataLoading, setDraftMetadataLoading] = useState(false); const [shareBusy, setShareBusy] = useState(false); const [shareError, setShareError] = useState(null); const [shareInfo, setShareInfo] = useState(null); const appUrl = toAbsolute(instanceUrl, '/app'); const incomingIsYouTube = incomingDraft ? looksLikeYouTube(incomingDraft.url) : false; const injectedBeforeLoad = useMemo(() => { const injectedUser = JSON.stringify(user); return ` (function() { try { localStorage.setItem('trackeep_token', ${JSON.stringify(token)}); localStorage.setItem('token', ${JSON.stringify(token)}); localStorage.setItem('trackeep_user', JSON.stringify(${injectedUser})); localStorage.setItem('user', JSON.stringify(${injectedUser})); window.__TRACKEEP_MOBILE__ = true; if (!window.__TRACKEEP_BRIDGE__) { window.__TRACKEEP_BRIDGE__ = true; var postBridge = function(type, payload) { try { window.ReactNativeWebView && window.ReactNativeWebView.postMessage( JSON.stringify({ type: type, payload: payload || {} }) ); } catch (_) {} }; var notifyAuthState = function() { var activeToken = localStorage.getItem('trackeep_token') || localStorage.getItem('token'); if (!activeToken) { postBridge('AUTH_LOGOUT', {}); } else { postBridge('AUTH_TOKEN', {}); } }; var notifyNav = function() { postBridge('NAV_CHANGE', { href: window.location.href, path: window.location.pathname + window.location.search + window.location.hash, }); }; var originalSetItem = localStorage.setItem.bind(localStorage); var originalRemoveItem = localStorage.removeItem.bind(localStorage); localStorage.setItem = function(key, value) { originalSetItem(key, value); if (key === 'trackeep_token' || key === 'token') { notifyAuthState(); } }; localStorage.removeItem = function(key) { originalRemoveItem(key); if (key === 'trackeep_token' || key === 'token') { notifyAuthState(); } }; var originalPushState = history.pushState.bind(history); history.pushState = function() { var result = originalPushState.apply(history, arguments); notifyNav(); return result; }; var originalReplaceState = history.replaceState.bind(history); history.replaceState = function() { var result = originalReplaceState.apply(history, arguments); notifyNav(); return result; }; window.addEventListener('popstate', notifyNav); notifyNav(); postBridge('BOOTSTRAP_DONE', { hasToken: true }); } } catch (error) { console.error('Mobile bootstrap failed', error); } true; })(); `; }, [token, user]); const instanceLabel = useMemo(() => { try { return new URL(instanceUrl).host; } catch { return instanceUrl; } }, [instanceUrl]); useEffect(() => { setShareError(shareIntentError); }, [shareIntentError]); useEffect(() => { if (!hasShareIntent) { return; } setShareInfo(null); setShareError(null); const draft = buildShareDraft(shareIntent); const files = uniqueFiles(shareIntent.files || []); if (draft) { setIncomingDraft(draft); setIncomingFiles([]); return; } if (files.length > 0) { setIncomingFiles(files); setIncomingDraft(null); return; } setShareError('Shared content was received but no supported URL or file was detected.'); resetShareIntent(); }, [ hasShareIntent, shareIntent.text, shareIntent.webUrl, shareIntent.files, shareIntent.meta, resetShareIntent, ]); useEffect(() => { if (!incomingDraft?.url) { return; } let cancelled = false; setDraftMetadataLoading(true); void trackeepApi.bookmarks .metadata(instanceUrl, token, incomingDraft.url) .then((metadata) => { if (cancelled || !metadata) { return; } setIncomingDraft((current) => { if (!current || current.url !== incomingDraft.url) { return current; } const betterTitle = metadata.title && metadata.title.trim().length > 0 && isGenericShareTitle(current.title, current.url) ? metadata.title.trim() : current.title; const betterDescription = metadata.description && metadata.description.trim().length > 0 && current.description.trim().length < 12 ? metadata.description.trim() : current.description; return { ...current, title: betterTitle, description: betterDescription, }; }); }) .catch(() => { // metadata enrichment is best-effort only }) .finally(() => { if (!cancelled) { setDraftMetadataLoading(false); } }); return () => { cancelled = true; }; }, [incomingDraft?.url, instanceUrl, token]); useEffect(() => { if (Platform.OS !== 'android') { return; } const sub = BackHandler.addEventListener('hardwareBackPress', () => { if (canGoBack) { webRef.current?.goBack(); return true; } return false; }); return () => sub.remove(); }, [canGoBack]); const clearIncomingShare = useCallback(() => { setIncomingDraft(null); setIncomingFiles([]); setDraftMetadataLoading(false); resetShareIntent(); }, [resetShareIntent]); const refreshWebView = useCallback(() => { webRef.current?.reload(); }, []); const navigateTo = useCallback( (path: string) => { const target = toAbsolute(instanceUrl, path); webRef.current?.injectJavaScript(`window.location.assign(${JSON.stringify(target)}); true;`); setCurrentPath(path); }, [instanceUrl], ); const openCurrentInBrowser = useCallback(async () => { const target = toAbsolute(instanceUrl, currentPath || '/app'); await Linking.openURL(target); }, [instanceUrl, currentPath]); const saveBookmark = async (draft: ShareDraft, routeAfterSave = '/app/bookmarks') => { setShareBusy(true); setShareError(null); setShareInfo(null); try { await trackeepApi.bookmarks.create(instanceUrl, token, { title: draft.title.trim(), url: draft.url.trim(), description: draft.description.trim() || 'Shared from Trackeep Mobile', }); const isYoutube = looksLikeYouTube(draft.url); setShareInfo(isYoutube ? 'Saved YouTube link to Trackeep.' : 'Saved bookmark to Trackeep.'); clearIncomingShare(); navigateTo(routeAfterSave); } catch (error) { setShareError(error instanceof Error ? error.message : 'Failed to save shared bookmark.'); } finally { setShareBusy(false); } }; const uploadIncomingFiles = async (files: ShareIntentFile[]) => { setShareBusy(true); setShareError(null); setShareInfo(null); try { for (const file of files) { await trackeepApi.files.uploadFromUri(instanceUrl, token, { uri: file.path, name: file.fileName, mimeType: file.mimeType, description: 'Shared from mobile', }); } setShareInfo( files.length === 1 ? `Uploaded \"${files[0].fileName}\" to Trackeep Files.` : `Uploaded ${files.length} shared files to Trackeep Files.`, ); clearIncomingShare(); navigateTo('/app/files'); } catch (error) { setShareError(error instanceof Error ? error.message : 'Failed to upload shared files.'); } finally { setShareBusy(false); } }; const submitManualShare = async () => { const draft: ShareDraft = { title: manualTitle.trim(), url: manualUrl.trim(), description: manualDescription.trim(), source: 'manual', }; if (!draft.title || !draft.url) { setShareError('Title and URL are required for quick share.'); return; } try { new URL(draft.url); } catch { setShareError('Quick share URL must be a valid absolute URL.'); return; } await saveBookmark(draft, looksLikeYouTube(draft.url) ? '/app/youtube' : '/app/bookmarks'); setManualTitle(''); setManualUrl(''); setManualDescription(''); setShowManualShare(false); }; const onWebMessage = useCallback( (event: WebViewMessageEvent) => { const raw = event.nativeEvent.data; if (!raw) { return; } try { const message = JSON.parse(raw) as BridgeMessage; if (message.type === 'NAV_CHANGE') { const path = typeof message.payload?.path === 'string' ? message.payload.path : null; if (path) { setCurrentPath(path); } return; } if (message.type === 'AUTH_LOGOUT') { void onLogout(); return; } } catch { // Ignore malformed bridge messages. } }, [onLogout], ); const onShouldStartLoadWithRequest = useCallback( (request: { url: string }) => { if (isInternalUrl(request.url, instanceUrl)) { return true; } void Linking.openURL(request.url); return false; }, [instanceUrl], ); const onNavigationStateChange = useCallback((state: WebViewNavigation) => { setCanGoBack(state.canGoBack); setCanGoForward(state.canGoForward); setCurrentPath(pathFromUrl(state.url)); }, []); return ( Connected: {instanceLabel} {ROUTES.map((route) => { const active = currentPath.startsWith(route.path); return ( navigateTo(route.path)} > {route.label} ); })} setShowManualShare((prev) => !prev)}> Quick Share Reload void openCurrentInBrowser()}> Browser void onLogout()}> Logout {showManualShare ? ( Quick Share To Trackeep {looksLikeYouTube(manualUrl.trim()) ? ( YouTube link detected. It will be saved and opened in YouTube section. ) : null} setShowManualShare(false)}> Cancel void submitManualShare()} disabled={shareBusy}> {shareBusy ? 'Saving...' : 'Save Bookmark'} ) : null} {incomingDraft ? ( Incoming Shared Link Title: {incomingDraft.title} URL: {incomingDraft.url} {incomingDraft.description ? Text: {incomingDraft.description} : null} {draftMetadataLoading ? Fetching page metadata... : null} Dismiss {incomingIsYouTube ? ( void saveBookmark(incomingDraft, '/app/youtube')} disabled={shareBusy} > {shareBusy ? 'Saving...' : 'Save + Open YouTube'} ) : null} void saveBookmark(incomingDraft)} disabled={shareBusy} > {shareBusy ? 'Saving...' : 'Save To Bookmarks'} ) : null} {incomingFiles.length > 0 ? ( Incoming Shared Files ({incomingFiles.length}) {incomingFiles.slice(0, 3).map((file) => ( • {file.fileName} ))} {incomingFiles.length > 3 ? ...and {incomingFiles.length - 3} more : null} Dismiss void uploadIncomingFiles(incomingFiles)} disabled={shareBusy} > {shareBusy ? 'Uploading...' : 'Upload To Files'} ) : null} {shareError ? {shareError} : null} {shareInfo ? {shareInfo} : null} {webError ? Web app failed to load: {webError} : null} { setWebLoading(true); setWebError(null); }} onLoadEnd={() => setWebLoading(false)} onError={(event) => { setWebLoading(false); setWebError(event.nativeEvent.description || 'Unknown network error'); }} onNavigationStateChange={onNavigationStateChange} /> {webLoading ? ( ) : null} webRef.current?.goBack()} disabled={!canGoBack} > Back webRef.current?.goForward()} disabled={!canGoForward} > Forward Refresh ); } const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.background, }, topBar: { borderBottomWidth: 1, borderColor: '#D8E1EC', backgroundColor: '#FFFFFF', paddingHorizontal: 12, paddingVertical: 8, gap: 8, }, instanceText: { color: colors.muted, fontSize: 12, fontWeight: '600', }, routePills: { gap: 8, paddingRight: 8, }, routePill: { borderWidth: 1, borderColor: '#D6DEE8', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6, backgroundColor: '#F8FAFC', }, routePillActive: { backgroundColor: '#E6F6FB', borderColor: '#8adcf2', }, routePillText: { fontSize: 12, fontWeight: '600', color: colors.muted, }, routePillTextActive: { color: colors.primaryDark, }, topButtons: { flexDirection: 'row', gap: 8, flexWrap: 'wrap', }, topButton: { backgroundColor: '#EDF2F7', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 7, }, topButtonText: { color: colors.text, fontSize: 12, fontWeight: '700', }, topButtonDanger: { backgroundColor: '#FEE2E2', }, topButtonDangerText: { color: '#991B1B', }, sharePanel: { marginHorizontal: 12, marginTop: 10, padding: 12, borderRadius: 12, borderWidth: 1, borderColor: '#D6DEE8', backgroundColor: '#FFFFFF', gap: 8, }, sharePanelTitle: { color: colors.text, fontWeight: '700', fontSize: 14, }, shareLine: { color: colors.muted, fontSize: 13, }, input: { borderWidth: 1, borderColor: '#D6DEE8', borderRadius: 10, backgroundColor: '#FFFFFF', color: colors.text, paddingHorizontal: 10, paddingVertical: 9, }, textarea: { minHeight: 80, textAlignVertical: 'top', }, shareActions: { flexDirection: 'row', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap', }, secondaryButton: { borderWidth: 1, borderColor: '#D6DEE8', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, backgroundColor: '#F8FAFC', }, secondaryButtonText: { color: colors.text, fontWeight: '600', fontSize: 12, }, primaryButton: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, backgroundColor: colors.primary, }, primaryButtonAlt: { backgroundColor: colors.primaryDark, }, primaryButtonText: { color: '#FFFFFF', fontWeight: '700', fontSize: 12, }, errorText: { color: '#B91C1C', fontSize: 12, fontWeight: '600', paddingHorizontal: 14, paddingTop: 8, }, infoText: { color: '#047857', fontSize: 12, fontWeight: '600', paddingHorizontal: 14, paddingTop: 8, }, infoTextInline: { color: '#047857', fontSize: 12, fontWeight: '600', }, webContainer: { flex: 1, marginTop: 8, overflow: 'hidden', backgroundColor: '#FFFFFF', }, loadingOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(255,255,255,0.7)', alignItems: 'center', justifyContent: 'center', }, navBar: { flexDirection: 'row', borderTopWidth: 1, borderColor: '#D8E1EC', backgroundColor: '#FFFFFF', paddingHorizontal: 10, paddingVertical: 8, gap: 8, }, navButton: { flex: 1, borderRadius: 8, borderWidth: 1, borderColor: '#D6DEE8', backgroundColor: '#F8FAFC', minHeight: 36, alignItems: 'center', justifyContent: 'center', }, navButtonDisabled: { opacity: 0.45, }, navButtonText: { color: colors.text, fontWeight: '700', fontSize: 12, }, });