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
24 changes: 24 additions & 0 deletions mobile/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 Down Expand Up @@ -279,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 @@ -545,6 +554,21 @@ 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 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}>
Expand Down
2 changes: 2 additions & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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
Expand Down Expand Up @@ -63,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
}
139 changes: 139 additions & 0 deletions mobile/src/components/FullTextSheet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// M19: the Full text sheet, the set-up tutorial and the disclaimer.
// The tutorial and disclaimer are shown in this sheet until full text is set
// up, and always in Settings → Full text for papers.
import { useEffect, useState } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { colors, ui } from "../theme";
import Button from "./Button";
import Sheet from "./Sheet";

const F = require("../fulltext");

// Set-up steps, in order.
export function FullTextTutorial() {
const steps = [
["Turn on Find free copies", "In Settings → Full text for papers."],
["Add your email", "Unpaywall asks every app to send a contact email with each lookup. Nothing else is needed; there is no account."],
["Optional: add an OpenAlex key", "OpenAlex finds copies Unpaywall misses. Make a free account at openalex.org, then copy your key from openalex.org/settings/api and paste it here. Lookups by DOI are free."],
["Tap Full text on a paper", "When a page has a DOI, a Full text button shows above the bottom bar. NODE looks the DOI up only when you tap it."],
];
return (
<View accessibilityRole="list">
<Text style={ui.kicker} accessibilityRole="header">
How to set it up
</Text>
{steps.map(([title, body], i) => (
<View key={title} style={{ flexDirection: "row", gap: 8, marginBottom: 8 }}>
<Text style={{ color: colors.accent, fontWeight: "700", width: 16 }}>{i + 1}</Text>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.text, fontWeight: "600" }}>{title}</Text>
<Text style={ui.help}>{body}</Text>
</View>
</View>
))}
</View>
);
}

// What is sent, to whom, and what NODE does not promise.
export function FullTextDisclaimer() {
return (
<View style={{ borderWidth: 1, borderColor: colors.line, borderRadius: 8, padding: 10, marginTop: 6 }}>
<Text style={[ui.kicker, { marginTop: 0 }]} accessibilityRole="header">
What NODE sends
</Text>
<Text style={ui.help}>
Only when you tap Full text: the paper's DOI and your email go to Unpaywall (api.unpaywall.org), and the DOI and your API key go to OpenAlex (api.openalex.org). NODE sends no page address, page text or trail. Your email and key stay in this phone's keychain.
</Text>
<Text style={[ui.help, { marginTop: 6 }]}>
NODE lists copies these services report as free to read. It does not check them itself. Check the licence before you reuse or share a paper. The library link goes to LibKey, which uses your library's sign-in if your library supports it.
</Text>
<Text style={[ui.help, { marginTop: 6 }]}>Unpaywall and OpenAlex are run by OurResearch, a nonprofit, under their own terms. You can turn this off in Settings at any time.</Text>
</View>
);
}

export default function FullTextSheet({ visible, onClose, doi, pmid, services, onOpenUrl, onOpenSettings }) {
const ft = services.settings.fulltext || {};
const can = F.sources(ft);
const ready = can.unpaywall || can.openalex;
const [state, setState] = useState({ loading: false, result: null });

useEffect(() => {
if (!visible || !doi || !ready) return;
let live = true;
setState({ loading: true, result: null });
F.lookUp(doi, ft, services.net.getText).then((result) => live && setState({ loading: false, result }));
return () => {
live = false;
};
}, [visible, doi, ready]);

if (!visible) return null;
const links = doi ? F.links(doi) : null;
const r = state.result;
const open = (url) => {
onClose();
onOpenUrl(url);
};

return (
<Sheet visible onClose={onClose} title={r?.title || "Full text"} kicker={doi ? `DOI ${doi}` : pmid ? `PMID ${pmid}` : "Full text"}>
{ready && doi ? (
state.loading ? (
<View style={{ flexDirection: "row", gap: 8, alignItems: "center", marginVertical: 8 }}>
<ActivityIndicator color={colors.accent} />
<Text style={ui.help}>Looking for a free copy…</Text>
</View>
) : r ? (
<>
<Text style={{ color: colors.text, fontWeight: "600", marginTop: 4 }} accessibilityLiveRegion="polite">
{r.copies.length ? `${r.copies.length} free ${r.copies.length === 1 ? "copy" : "copies"} found` : F.STATUS_TEXT.closed}
{r.status && r.copies.length ? ` · ${F.STATUS_TEXT[r.status] || r.status}` : ""}
</Text>
{r.copies.map((c) => (
<Pressable key={c.url} onPress={() => open(c.url)} accessibilityRole="link" accessibilityLabel={`Open free copy: ${F.copyLine(c)}`} style={{ borderTopWidth: 1, borderColor: colors.line, paddingVertical: 9 }}>
<Text style={{ color: colors.accent }} numberOfLines={1}>
{c.where || c.url}
</Text>
<Text style={ui.help}>{F.copyLine(c)}</Text>
</Pressable>
))}
{r.notes.map((n) => (
<Text key={n} style={[ui.help, { marginTop: 4 }]}>
{n}
</Text>
))}
</>
) : null
) : null}

{links ? (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
<Button label="Through your library" a11yHint="Opens LibKey, which uses your library's sign-in" onPress={() => open(links.library)} />
<Button label="doi.org" a11yLabel="Open the DOI link" onPress={() => open(links.doi)} />
</View>
) : null}
{pmid ? (
<View style={{ flexDirection: "row", marginTop: 6 }}>
<Button label="PubMed" onPress={() => open(`https://pubmed.ncbi.nlm.nih.gov/${pmid}/`)} />
</View>
) : null}

{!ready ? (
<>
<Text style={[ui.help, { marginTop: 12 }]}>
{can.reason === "bad-email" ? "The email in Settings doesn't look right." : "To find free copies of papers, set up full text once."}
</Text>
<FullTextTutorial />
<FullTextDisclaimer />
<View style={{ flexDirection: "row", marginTop: 10 }}>
<Button label="Set up in Settings" active onPress={onOpenSettings} />
</View>
</>
) : (
<Text style={[ui.help, { marginTop: 12 }]}>Copies come from Unpaywall and OpenAlex, which report them as free to read. Check the licence before reusing. Settings → Full text for papers says what is sent.</Text>
)}
</Sheet>
);
}
22 changes: 21 additions & 1 deletion mobile/src/components/SettingsSheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { Switch, Text, TextInput, View } from "react-native";
import { colors, ui } from "../theme";
import Button from "./Button";
import Sheet from "./Sheet";
import { FullTextDisclaimer, FullTextTutorial } from "./FullTextSheet";

const F = require("../fulltext");

function Field({ label, value, onChange, secret, placeholder }) {
return (
Expand Down Expand Up @@ -36,11 +39,12 @@ export default function SettingsSheet({ visible, onClose, services }) {
const s = services.settings;
const [p, setP] = useState(() => JSON.parse(JSON.stringify(s.providers)));
const [ai, setAi] = useState(() => JSON.parse(JSON.stringify(s.ai)));
const [ft, setFt] = useState(() => ({ enabled: false, email: "", openalexKey: "", ...(s.fulltext || {}) }));
const [msg, setMsg] = useState("");
const setProv = (id, patch) => setP({ ...p, [id]: { ...p[id], ...patch } });

const save = async () => {
await services.saveSettings({ providers: p, ai });
await services.saveSettings({ providers: p, ai, fulltext: ft });
setMsg("Saved.");
};
const testAI = async () => {
Expand Down Expand Up @@ -81,6 +85,22 @@ export default function SettingsSheet({ visible, onClose, services }) {
<Field label="Model name" value={ai.grok.model} onChange={(v) => setAi({ ...ai, grok: { ...ai.grok, model: v } })} />
</>
)}

<Text style={ui.kicker} accessibilityRole="header">
Full text for papers
</Text>
<Text style={ui.help}>On a page with a DOI, Full text finds a free, legal copy of the paper and links it through your library.</Text>
<Toggle label="Find free copies (Unpaywall, OpenAlex)" value={ft.enabled} onChange={(v) => setFt({ ...ft, enabled: v })} />
{ft.enabled ? (
<>
<Field label="Your email (Unpaywall asks for it)" value={ft.email} onChange={(v) => setFt({ ...ft, email: v })} placeholder="you@example.org" />
{ft.email && !F.EMAIL_RE.test(ft.email.trim()) ? <Text style={[ui.help, { color: colors.bad }]}>This doesn't look like an email address.</Text> : null}
<Field label="OpenAlex API key (optional)" value={ft.openalexKey} onChange={(v) => setFt({ ...ft, openalexKey: v })} secret placeholder="From openalex.org/settings/api" />
</>
) : null}
<FullTextTutorial />
<FullTextDisclaimer />

<View style={{ flexDirection: "row", gap: 6, marginTop: 12 }}>
<Button label="Save" onPress={save} />
<Button label="Test AI" onPress={testAI} />
Expand Down
Loading