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
47 changes: 39 additions & 8 deletions mobile/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// browser behind it keeps its tabs of WebViews, address bar, and the shared
// suggestion engine from core/.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, BackHandler, Keyboard, PanResponder, Platform, Pressable, Text, TextInput, View, useWindowDimensions } from "react-native";
import { AccessibilityInfo, ActivityIndicator, BackHandler, Keyboard, PanResponder, Platform, Pressable, Text, TextInput, View, useWindowDimensions } from "react-native";
import { StatusBar } from "expo-status-bar";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";

Expand All @@ -17,6 +17,7 @@ import TabsSheet from "./src/components/TabsSheet";
import AddressSuggestions from "./src/components/AddressSuggestions";
import AskSheet from "./src/components/AskSheet";
import TrailHome from "./src/components/TrailHome";
import FullTextSheet from "./src/components/FullTextSheet";
import { captureRef } from "react-native-view-shot";
import { Directory, File, Paths } from "expo-file-system";
import SettingsSheet from "./src/components/SettingsSheet";
Expand Down Expand Up @@ -155,6 +156,12 @@ function Browser({ services }) {
} catch {
return;
}
if (msg.type === "capinfo" && String(msg.id).startsWith("ft:")) {
// M19: the page's DOI or PMID, found on the phone; nothing is sent.
const ids = Capture.findIds(msg);
updateTab(tabId, { doi: ids.doi || "", pmid: ids.pmid || "" });
return;
}
if (msg.type === "capinfo") {
const p = pending.current.get(msg.id);
if (!p) return;
Expand Down Expand Up @@ -206,6 +213,7 @@ function Browser({ services }) {
// new load, so onLoadEnd does not run: start the new page's media here.
if (before && !nav.loading && before.url && nav.url !== before.url) {
if (lastSelection.current.tabId === tabId) lastSelection.current = { tabId: null, url: "", text: "" };
updateTab(tabId, { doi: "", pmid: "" });
services.history.record(nav.url, nav.title);
const tab = withRule({ ...before, url: nav.url });
refs.current.get(tabId)?.injectJavaScript(autoplayCall(tab.rule.autoplay));
Expand All @@ -217,6 +225,7 @@ function Browser({ services }) {
// recorded as a capture card. No screen change; a note with Undo shows.
const showToast = (t) => {
clearTimeout(toastTimer.current);
AccessibilityInfo.announceForAccessibility?.(t.text); // VoiceOver reads the note; TalkBack uses the live region
setToast(t);
toastTimer.current = setTimeout(() => setToast(null), 6000);
};
Expand Down Expand Up @@ -278,6 +287,7 @@ function Browser({ services }) {
playback.navigated(tabId); // media of the previous page will not resume
view.injectJavaScript(autoplayCall(tab.rule.autoplay));
if (tab.rule.center !== false) view.injectJavaScript(CENTER_ON_TAP);
view.injectJavaScript(Capture.captureInfoCall(`ft:${tabId}`)); // DOI for the Full text button
if (tabId !== activeRef.current) {
if (!soundAllRef.current) view.injectJavaScript(MUTE_ALL); // only the active tab plays sound
return;
Expand Down Expand Up @@ -397,8 +407,8 @@ function Browser({ services }) {
{/* Address bar, with suggestions from history and Wikipedia while typing */}
<View style={{ zIndex: 20, elevation: 20 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, padding: 8, borderBottomWidth: 1, borderColor: colors.line }} {...addressPan.panHandlers}>
<Button label="‹" small onPress={() => refs.current.get(activeId)?.goBack()} style={!active?.canGoBack && { opacity: 0.4 }} />
<Button label="›" small onPress={() => refs.current.get(activeId)?.goForward()} style={!active?.canGoForward && { opacity: 0.4 }} />
<Button label="‹" a11yLabel="Back" small onPress={() => refs.current.get(activeId)?.goBack()} style={!active?.canGoBack && { opacity: 0.4 }} />
<Button label="›" a11yLabel="Forward" small onPress={() => refs.current.get(activeId)?.goForward()} style={!active?.canGoForward && { opacity: 0.4 }} />
<TextInput
style={[ui.input, { flex: 1, paddingVertical: 6 }]}
value={editing ? address : active?.url || ""}
Expand Down Expand Up @@ -432,7 +442,7 @@ function Browser({ services }) {
}}
/>
{/* Tap: tab list. Press and hold: new tab. */}
<Button label={String(tabs.length)} small onPress={() => setSheet("tabs")} onLongPress={() => addTab(HOME_URL)} />
<Button label={String(tabs.length)} a11yLabel={`${tabs.length} ${tabs.length === 1 ? "tab" : "tabs"}, open the tab list`} a11yHint="Press and hold for a new tab" small onPress={() => setSheet("tabs")} onLongPress={() => addTab(HOME_URL)} />
</View>
{editing ? (
<AddressSuggestions
Expand Down Expand Up @@ -484,7 +494,13 @@ function Browser({ services }) {
/>
{showMosaic && !isActive ? (
// Tap an inactive tile to make it the active tab; then it takes touches.
<Pressable style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }} onPress={() => switchTo(t.id)} />
<Pressable
style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }}
onPress={() => switchTo(t.id)}
accessibilityRole="button"
accessibilityLabel={`Mosaic tile ${tile + 1}: ${t.title || t.url || "new tab"}`}
accessibilityHint="Makes this the active tab"
/>
) : null}
</View>
);
Expand All @@ -494,10 +510,10 @@ function Browser({ services }) {
{/* Bottom bar */}
<View style={{ flexDirection: "row", gap: 6, padding: 8, borderTopWidth: 1, borderColor: colors.line, alignItems: "center" }}>
<Button label={`Suggest${state.results.length ? ` (${state.results.length})` : ""}`} active={state.busy} onPress={() => setSheet("suggest")} />
<Button label="Capture" onPress={capture} />
<Button label="Capture" a11yHint="Saves this page, the selected text and a thumbnail into the trail" onPress={capture} />
<Button label="Mosaic" active={mosaic} onPress={() => setMosaic(!mosaic)} />
<Button label="Trail" onPress={() => setHome(true)} />
<Button label="⚙︎" onPress={() => setSheet("settings")} />
<Button label="⚙︎" a11yLabel="Settings" onPress={() => setSheet("settings")} />
<Text style={[ui.help, { flex: 1 }]} numberOfLines={1}>
{state.busy ? "Searching…" : ""}
</Text>
Expand Down Expand Up @@ -538,8 +554,23 @@ function Browser({ services }) {
/>
<SettingsSheet visible={sheet === "settings"} onClose={() => setSheet(null)} services={services} />

{(active?.doi || active?.pmid) && !home && !sheet ? (
<View style={{ position: "absolute", right: 12, bottom: toast ? 128 : 70, zIndex: 39, elevation: 39 }}>
<Button label="Full text" active a11yHint="Finds a free copy of this paper" onPress={() => setSheet("fulltext")} />
</View>
) : null}
<FullTextSheet
visible={sheet === "fulltext"}
onClose={() => setSheet(null)}
doi={active?.doi}
pmid={active?.pmid}
services={services}
onOpenUrl={(url) => addTab(url)}
onOpenSettings={() => setSheet("settings")}
/>

{toast && !home ? (
<View style={{ position: "absolute", left: 12, right: 12, bottom: 70, zIndex: 40, elevation: 40, flexDirection: "row", alignItems: "center", gap: 8, backgroundColor: colors.bg3, borderWidth: 1, borderColor: colors.line, borderRadius: 10, padding: 10 }}>
<View accessibilityLiveRegion="polite" style={{ position: "absolute", left: 12, right: 12, bottom: 70, zIndex: 40, elevation: 40, flexDirection: "row", alignItems: "center", gap: 8, backgroundColor: colors.bg3, borderWidth: 1, borderColor: colors.line, borderRadius: 10, padding: 10 }}>
<Text style={{ color: colors.text, flex: 1 }} numberOfLines={2}>
{toast.text}
</Text>
Expand Down
3 changes: 3 additions & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Phase 5 groundwork. A React Native (Expo SDK 58, React Native 0.88) app that reu
- **Open a `.trail.md`** (CM3): Open file → the file opens read only, on Replay: the sender's note, Start here, the steps with captions (Back, Next, tap a step); the Canvas tab dims cards not reached yet and follows the step. Save as new trail makes an editable copy, "<name> · <sender>", and goes to its timeline. Open on desktop hands the file to the share sheet (AirDrop it to your Mac)
- **Share** (CM4, CM5): a note, Start here, the timeline on or off, your name; file name and size; "Share file…" opens the phone's share sheet with the `.trail.md`. Sign-in and payment pages are never put in the file
- **Capture** (M18, bottom bar): one tap saves the page into the trail being recorded: address, title, DOI or PMID if the page has one, the time with the time zone, the selected text (or the last text you selected on the page) and a thumbnail. It becomes a capture card linked "captured from" the page's card, as on desktop. A note says where it went, with Undo. Sign-in and payment pages are never captured. The card's preview shows the thumbnail and Open DOI / PubMed. Shared files carry the quote and the "Captured from … · DOI …" line, not the thumbnail
- **Full text for papers** (M19): when a page has a DOI or PMID (found on the phone, nothing sent), a Full text button shows above the bottom bar. It lists free, legal copies from Unpaywall and OpenAlex (PDFs and published versions first, with version, licence and where), plus "Through your library" (LibKey) and doi.org. Off until set up: Settings → Full text for papers takes your own email (Unpaywall) and optional OpenAlex key, both kept in the keychain, with a set-up tutorial and a "What NODE sends" disclaimer. Only the DOI with your email or key is sent, and only when you tap Full text
- **Screen readers** (M25): every button, tab, card, Mosaic tile, keyword and result has a spoken label and role; sheet titles and timeline sessions are headings; Timeline / Canvas / Replay are tabs. Gestures have screen-reader actions: next and previous trail on the switcher, hide on a keyword, close on a tab row. The capture note is announced. Every grid has a list: the Timeline for the canvas, the tab list for Mosaic
- Pages you read on the phone go into the current trail's timeline as phone visits (timeline only, as decided in doc 25); cards are placed on desktop. Old research sessions are copied into trails on the first start
- Tabs, each a WebView kept loaded; address bar with back and forward; tab switcher
- Mosaic on every screen (Mosaic button in the bottom bar): all tabs in a grid on tablets and phones in landscape; up to 4 tiles on a phone in portrait (the active tab plus the 3 most recent others)
Expand Down Expand Up @@ -62,6 +64,7 @@ npm run bundle:check # bundles for Android and iOS and compiles to Hermes byte
| `metro.config.js` | Lets the bundler include `../core` |
| `App.js` | Tabs, address bar, bottom bar, wiring to the controller |
| `src/services.js` | Storage (AsyncStorage), keys (SecureStore), search registry, search flow, trails (`core/trail.js`, device "phone") with the research store on top, history, site rules |
| `src/fulltext.js` | Full-text lookup (Unpaywall, OpenAlex, LibKey and doi.org links); tested with `npm test` |
| `src/capture.js` | Smart capture logic (DOI and PMID finding, the capture card, saving and Undo); tested with `npm test` |
| `src/trailModel.js` | The trail screens' logic with no React Native imports: timeline rows, card faces, canvas view maths, opening files, Replay, Save as new, the share file. Tested with `npm test` (in `mobile/`) |
| `src/bridge.js` | Scripts injected into pages: selection and scroll reports, page extraction, autoplay, mute |
Expand Down
2 changes: 1 addition & 1 deletion mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"ios": "expo start --ios",
"web": "expo start --web",
"bundle:check": "expo export --platform android --platform ios --output-dir dist-check",
"test": "node test/trail-flow.test.js && node test/capture.test.js"
"test": "node test/trail-flow.test.js && node test/capture.test.js && node test/fulltext.test.js"
},
"private": true
}
2 changes: 1 addition & 1 deletion mobile/src/components/AddressSuggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export default function AddressSuggestions({ query, history, bookmarks, getText,
{/* "handled": tapping a row does not first close the keyboard and the list. */}
<ScrollView keyboardShouldPersistTaps="handled">
{rows.map((r) => (
<Pressable key={r.key} onPress={() => onPick(r.url)} style={{ paddingHorizontal: 12, paddingVertical: 9, borderTopWidth: 1, borderColor: colors.line }}>
<Pressable key={r.key} onPress={() => onPick(r.url)} accessibilityRole="button" accessibilityLabel={`${r.history ? "From history: " : ""}${r.label}`} style={{ paddingHorizontal: 12, paddingVertical: 9, borderTopWidth: 1, borderColor: colors.line }}>
<Text style={{ color: colors.text, fontSize: 14 }} numberOfLines={1}>
{r.history ? "🕘 " : ""}
{r.label}
Expand Down
7 changes: 6 additions & 1 deletion mobile/src/components/Button.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Pressable, Text } from "react-native";
import { colors, ui } from "../theme";

export default function Button({ label, onPress, onLongPress, active, style, small }) {
// a11yLabel: what a screen reader says when the label is a symbol (‹, ⚙︎, +).
export default function Button({ label, onPress, onLongPress, active, style, small, a11yLabel, a11yHint }) {
return (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
accessibilityRole="button"
accessibilityLabel={a11yLabel || String(label)}
accessibilityHint={a11yHint}
accessibilityState={active !== undefined ? { selected: Boolean(active) } : undefined}
style={({ pressed }) => [ui.button, small && { paddingVertical: 4, paddingHorizontal: 8 }, active && { borderColor: colors.accent }, pressed && { opacity: 0.6 }, style]}
>
<Text style={[ui.buttonText, active && { color: colors.accent }, small && { fontSize: 12 }]}>{label}</Text>
Expand Down
Loading