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
94 changes: 90 additions & 4 deletions mobile/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { StatusBar } from "expo-status-bar";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";

import { createServices } from "./src/services";
const Capture = require("./src/capture");
import { autoplayCall, CENTER_ON_TAP, extractCall, MUTE_ALL, PAUSE_FOR_LIMIT, readCall, RESUME, UNMUTE } from "./src/bridge";
import { colors, ui } from "./src/theme";
import TabView from "./src/components/TabView";
Expand All @@ -16,6 +17,8 @@ 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 { captureRef } from "react-native-view-shot";
import { Directory, File, Paths } from "expo-file-system";
import SettingsSheet from "./src/components/SettingsSheet";

const { SuggestController } = require("../core/controller");
Expand Down Expand Up @@ -92,6 +95,10 @@ function Browser({ services }) {
const refs = useRef(new Map()); // tab id -> WebView ref
const playback = useRef(new PlaybackLimiter()).current; // up to 4 tabs playing at once
const pending = useRef(new Map()); // extract request id -> { resolve, reject, timer }
const tileRefs = useRef(new Map()); // tab id -> the View around its WebView (for capture thumbnails)
const lastSelection = useRef({ tabId: null, url: "", text: "" }); // the last text selected, for Capture
const [toast, setToast] = useState(null); // { text, saved, trailId }
const toastTimer = useRef(null);
const tabsRef = useRef(tabs);
tabsRef.current = tabs;
const activeRef = useRef(activeId);
Expand Down Expand Up @@ -148,6 +155,14 @@ function Browser({ services }) {
} catch {
return;
}
if (msg.type === "capinfo") {
const p = pending.current.get(msg.id);
if (!p) return;
clearTimeout(p.timer);
pending.current.delete(msg.id);
p.resolve(msg);
return;
}
if (msg.type === "extract") {
const p = pending.current.get(msg.id);
if (!p) return;
Expand Down Expand Up @@ -175,7 +190,10 @@ function Browser({ services }) {
}
if (tabId !== activeRef.current) return;
const tab = withRule(tabById(tabId));
if (msg.type === "selection") controller.onPage(tab, "selection", msg.text);
if (msg.type === "selection") {
lastSelection.current = { tabId, url: tab?.url || "", text: msg.text };
controller.onPage(tab, "selection", msg.text);
}
if (msg.type === "scroll") controller.onPage(tab, "scroll");
},
[controller]
Expand All @@ -187,12 +205,70 @@ function Browser({ services }) {
// Single-page sites (m.youtube.com and similar) change address without a
// 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: "" };
services.history.record(nav.url, nav.title);
const tab = withRule({ ...before, url: nav.url });
refs.current.get(tabId)?.injectJavaScript(autoplayCall(tab.rule.autoplay));
}
}, []);

// M18 smart capture: the page's address, title, DOI or PMID, the time with
// the time zone, the selected text and a thumbnail go into the trail being
// recorded as a capture card. No screen change; a note with Undo shows.
const showToast = (t) => {
clearTimeout(toastTimer.current);
setToast(t);
toastTimer.current = setTimeout(() => setToast(null), 6000);
};
const capture = async () => {
const id = activeRef.current;
const tab = tabById(id);
const view = refs.current.get(id);
if (!tab || !view) return;
const reqId = `cap${Date.now()}`;
const info = await new Promise((resolve) => {
const timer = setTimeout(() => {
pending.current.delete(reqId);
resolve({ error: "timeout" });
}, 3000);
pending.current.set(reqId, { resolve, reject: (e) => resolve({ error: String(e) }), timer });
view.injectJavaScript(Capture.captureInfoCall(reqId));
});
const at = Date.now();
const sel = lastSelection.current;
const built = Capture.buildCapture(info, { at, selection: sel.tabId === id && sel.url === info.url ? sel.text : "" });
if (built.error) return showToast({ text: built.error });
const card = built.card;
card.id = `c${at.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
try {
const tmp = await captureRef(tileRefs.current.get(id), { format: "jpg", quality: 0.6, width: 480, result: "tmpfile" });
const dir = new Directory(Paths.document, "captures");
if (!dir.exists) dir.create();
const dest = new File(dir, `${card.id}.jpg`);
await new File(tmp).move(dest);
Object.assign(card, { shot: dest.uri, captureKind: "shot" });
} catch (_) {
// No thumbnail (the page may not allow it); the capture still saves.
}
services.research.saveNow();
const trailId = services.research.current.id;
const saved = Capture.saveCapture(services.trails, trailId, card, { pageTitle: tab.title });
if (!saved.card) return showToast({ text: "Couldn't save the capture." });
setTrailVersion((v) => v + 1);
showToast({ text: Capture.capturedText(card, services.trails.get(trailId)?.trail.name), saved, trailId });
};
const undoCapture = () => {
if (!toast?.saved) return;
Capture.undoCapture(services.trails, toast.trailId, toast.saved);
if (toast.saved.card?.shot) {
try {
new File(toast.saved.card.shot).delete();
} catch (_) {}
}
setTrailVersion((v) => v + 1);
showToast({ text: "Capture removed." });
};

const onLoadEnd = useCallback(
(tabId) => {
const tab = withRule(tabById(tabId));
Expand Down Expand Up @@ -396,7 +472,7 @@ function Browser({ services }) {
}
: { position: "absolute", left: 0, top: 0, right: 0, bottom: 0, zIndex: onScreen ? 2 : 0 };
return (
<View key={t.id} style={style} pointerEvents={onScreen ? "auto" : "none"}>
<View key={t.id} ref={(r) => (r ? tileRefs.current.set(t.id, r) : tileRefs.current.delete(t.id))} collapsable={false} style={style} pointerEvents={onScreen ? "auto" : "none"}>
<TabView
ref={(r) => (r ? refs.current.set(t.id, r) : null)}
tab={t}
Expand All @@ -417,10 +493,11 @@ function Browser({ services }) {

{/* Bottom bar */}
<View style={{ flexDirection: "row", gap: 6, padding: 8, borderTopWidth: 1, borderColor: colors.line, alignItems: "center" }}>
<Button label={`Suggestions${state.results.length ? ` (${state.results.length})` : ""}`} active={state.busy} onPress={() => setSheet("suggest")} />
<Button label={`Suggest${state.results.length ? ` (${state.results.length})` : ""}`} active={state.busy} onPress={() => setSheet("suggest")} />
<Button label="Capture" onPress={capture} />
<Button label="Mosaic" active={mosaic} onPress={() => setMosaic(!mosaic)} />
<Button label="Trail" onPress={() => setHome(true)} />
<Button label="Settings" onPress={() => setSheet("settings")} />
<Button label="⚙︎" onPress={() => setSheet("settings")} />
<Text style={[ui.help, { flex: 1 }]} numberOfLines={1}>
{state.busy ? "Searching…" : ""}
</Text>
Expand Down Expand Up @@ -461,6 +538,15 @@ function Browser({ services }) {
/>
<SettingsSheet visible={sheet === "settings"} onClose={() => setSheet(null)} services={services} />

{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 }}>
<Text style={{ color: colors.text, flex: 1 }} numberOfLines={2}>
{toast.text}
</Text>
{toast.saved ? <Button label="Undo" small onPress={undoCapture} /> : null}
</View>
) : null}

{/* The trail, over the browser. The WebViews stay mounted underneath so
tabs keep their pages (and media) while the trail is shown. */}
{home ? (
Expand Down
3 changes: 3 additions & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Phase 5 groundwork. A React Native (Expo SDK 58, React Native 0.88) app that reu
- **Canvas tab** (CM2): the trail's canvas to look around in: drag, pinch, Fit, + and −, a minimap you can tap. Tap a card for its full preview and "Open page". Nothing is moved or edited on the phone
- **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
- 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 @@ -61,6 +62,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/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 |
| `src/components/` | TabView (WebView); TrailHome (start screen), TrailTimeline, TrailCanvas, ReplayPanel, CardPreview, ShareTrailSheet; Suggestions, Tabs and Settings sheets |
Expand All @@ -69,6 +71,7 @@ npm run bundle:check # bundles for Android and iOS and compiles to Hermes byte
## Status and known gaps

- Bundles for iOS and Android compile (Metro + Hermes). Not yet run on a device or simulator.
- Capture from the system share sheet or a long-press menu on selected text needs a development build (a share extension; a native text menu); for now select text, then tap Capture. Voice notes on captures are not built yet (expo-audio, microphone permission).
- Opening a `.trail.md` from Files, Mail or AirDrop straight into NODE (file association) needs a development build and an app.json document type; for now use Open file.
- Site rules use the built-in defaults; no rules editor yet.
- No Library (Shot, Save, Media), find in page, zoom, or history screen yet.
Expand Down
30 changes: 27 additions & 3 deletions mobile/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"react-native": "0.88.1",
"react-native-safe-area-context": "~5.11.1",
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "6.1.1",
"react-native-webview": "14.0.1"
},
"scripts": {
Expand All @@ -22,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"
"test": "node test/trail-flow.test.js && node test/capture.test.js"
},
"private": true
}
Loading
Loading