Skip to content

Commit 27b793e

Browse files
committed
Start working on bugs and add script for running locally
1 parent defc1bc commit 27b793e

9 files changed

Lines changed: 812 additions & 536 deletions

File tree

app/mobile/README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ npx expo run:ios --device
5151
npx expo run:android --device
5252
```
5353

54+
> **Note:** Local builds default to the `devtool` variant (`org.couchers.devtool.ios` / `org.couchers.devtool.android`), keeping them isolated from the production and staging apps. Production notifications won't open your local build.
55+
5456
On iOS: if you get issues about signing, try opening `app/mobile/ios` in Xcode and setting up app signing there.
5557

5658
> **Tip:** If you only work on JavaScript/TypeScript, you can skip the local
@@ -74,9 +76,30 @@ Scan the QR code with your phone's camera. Your JavaScript/TypeScript changes wi
7476

7577
### Testing Web App Changes on Mobile
7678

77-
If you need to test local web or backend changes on your phone, run everything locally and configure environment variables to point to your computer's IP address.
79+
If you need to test local web or backend changes on your phone, run the setup script — it auto-detects your IP and updates all config files at once:
80+
81+
```bash
82+
npm run setup:local
83+
```
84+
85+
Then restart everything from the repo root, each in a separate terminal:
86+
87+
```bash
88+
# Terminal 1 — backend (requires Docker)
89+
docker compose up --build
7890

79-
**[Follow the local development guide](../../docs/run-local-app-on-mobile.md)** for detailed instructions.
91+
# Terminal 2 — web frontend
92+
cd app/web && yarn start
93+
94+
# Terminal 3 — Expo
95+
cd app/mobile && npx expo start
96+
```
97+
98+
When done, restore with:
99+
100+
```bash
101+
npm run setup:local:restore
102+
```
80103

81104
### Quick Development (Mobile Code Only)
82105

app/mobile/RELEASE_NOTES.txt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
Bug fixes and improvements:
2-
- Tapping a user avatar or name opens their profile in a bottom sheet to save your place
3-
- Language changes now apply immediately across all tabs
4-
- Samsung specific fixes for some interaction issues
5-
- Fixed community info edit page on mobile
2+
- Links from emails (account verification, password recovery, postal verification) now open correctly in the app
3+
- On Android, pressing back while viewing a member's profile now closes it instead of leaving the page
4+
- You can now swipe down on a member's profile card to dismiss it

app/mobile/app.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ const VARIANTS = {
7171
},
7272
};
7373

74-
const APP_VARIANT = process.env.APP_VARIANT || "production";
74+
const APP_VARIANT = process.env.APP_VARIANT || "devtool";
7575
const variant = VARIANTS[APP_VARIANT] ?? VARIANTS.production;
7676
const icons = ICON_SETS[variant.iconSet];
7777

app/mobile/package-lock.json

Lines changed: 510 additions & 494 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/mobile/package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
"main": "expo-router/entry",
44
"version": "1.1.20",
55
"scripts": {
6+
"setup:local": "python3 ../scripts/dev-mobile-setup.py",
7+
"setup:local:restore": "python3 ../scripts/dev-mobile-setup.py --restore",
68
"start": "expo start",
79
"start:devtool": "APP_VARIANT=devtool expo start --dev-client --scheme couchers-devtool",
810
"start:localhost": "expo start --localhost",
@@ -35,8 +37,8 @@
3537
"@react-native-async-storage/async-storage": "2.2.0",
3638
"@react-navigation/drawer": "^7.3.9",
3739
"@react-navigation/native": "^7.1.6",
38-
"@sentry/react-native": "^8.12.0",
39-
"expo": "~54.0.34",
40+
"@sentry/react-native": "~7.2.0",
41+
"expo": "~54.0.35",
4042
"expo-application": "~7.0.8",
4143
"expo-build-properties": "^1.0.10",
4244
"expo-clipboard": "~8.0.8",
@@ -47,11 +49,11 @@
4749
"expo-image-picker": "~17.0.11",
4850
"expo-linking": "~8.0.12",
4951
"expo-notifications": "~0.32.17",
50-
"expo-router": "~6.0.23",
52+
"expo-router": "~6.0.24",
5153
"expo-splash-screen": "~31.0.13",
5254
"expo-status-bar": "^3.0.9",
5355
"expo-system-ui": "~6.0.9",
54-
"expo-updates": "~29.0.17",
56+
"expo-updates": "~29.0.18",
5557
"google-protobuf": "^4.0.0",
5658
"grpc-web": "^2.0.1",
5759
"i18next": "^25.0.2",

app/scripts/dev-mobile-setup.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Configure the local dev environment for testing on a physical mobile device.
4+
5+
Updates API URLs and CORS settings across four config files so your phone can
6+
reach your dev machine over the local network.
7+
8+
Usage:
9+
python3 scripts/dev-mobile-setup.py # auto-detect IP
10+
python3 scripts/dev-mobile-setup.py 192.168.x.x # use a specific IP
11+
python3 scripts/dev-mobile-setup.py --restore # undo all changes
12+
13+
Files modified:
14+
app/proxy/envoy.yaml - adds IP to CORS allow list
15+
app/backend.dev.env - sets COOKIE_DOMAIN and media server URLs
16+
app/web/.env.localdev - sets API and media URLs
17+
app/mobile/.env - switches from staging to local dev mode
18+
19+
Note: do NOT commit the envoy.yaml change.
20+
"""
21+
22+
import argparse
23+
import re
24+
import socket
25+
import sys
26+
from pathlib import Path
27+
28+
APP = Path(__file__).resolve().parent.parent
29+
30+
ENVOY_YAML = APP / "proxy" / "envoy.yaml"
31+
BACKEND_ENV = APP / "backend.dev.env"
32+
WEB_ENV = APP / "web" / ".env.localdev"
33+
MOBILE_ENV = APP / "mobile" / ".env"
34+
35+
MOBILE_ENV_DEFAULT = """\
36+
# STAGE:
37+
EXPO_PUBLIC_COUCHERS_ENV=preview
38+
EXPO_PUBLIC_WEB_BASE_URL="https://next.couchershq.org"
39+
EXPO_PUBLIC_API_BASE_URL="https://dev-api.couchershq.org"
40+
41+
# LOCAL:
42+
# EXPO_PUBLIC_COUCHERS_ENV=dev
43+
# EXPO_PUBLIC_API_BASE_URL="http://[[YOUR_WEB_IP_ADDRESS]]:8888"
44+
# EXPO_PUBLIC_WEB_BASE_URL="http://[[YOUR_WEB_IP_ADDRESS]]:3000"
45+
"""
46+
47+
48+
def get_local_ip() -> str:
49+
# Connect to an external address to discover which interface the OS would
50+
# use — no data is actually sent.
51+
try:
52+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
53+
s.connect(("8.8.8.8", 80))
54+
return s.getsockname()[0]
55+
except Exception:
56+
return ""
57+
58+
59+
def setup(ip: str) -> None:
60+
print(f"Configuring local mobile dev with IP: {ip}\n")
61+
62+
# envoy.yaml — add IP to CORS allow list after the localhost:3000 line
63+
content = ENVOY_YAML.read_text()
64+
entry = f"- exact: http://{ip}:3000 # mobile-dev"
65+
if f"http://{ip}:3000" in content:
66+
print(" envoy.yaml already configured, skipping")
67+
else:
68+
content = content.replace(
69+
"- exact: http://localhost:3000",
70+
f"- exact: http://localhost:3000\n {entry}",
71+
)
72+
ENVOY_YAML.write_text(content)
73+
print(" envoy.yaml added IP to CORS allow list")
74+
75+
# backend.dev.env — COOKIE_DOMAIN + media server URLs
76+
content = BACKEND_ENV.read_text()
77+
content = re.sub(r"^COOKIE_DOMAIN=.*", f"COOKIE_DOMAIN={ip}", content, flags=re.MULTILINE)
78+
content = re.sub(r"^MEDIA_SERVER_BASE_URL=.*", f"MEDIA_SERVER_BASE_URL=http://{ip}:5001", content, flags=re.MULTILINE)
79+
content = re.sub(r"^MEDIA_SERVER_UPLOAD_BASE_URL=.*", f"MEDIA_SERVER_UPLOAD_BASE_URL=http://{ip}:5001", content, flags=re.MULTILINE)
80+
BACKEND_ENV.write_text(content)
81+
print(" backend.dev.env updated COOKIE_DOMAIN and media server URLs")
82+
83+
# web/.env.localdev — API + media URLs
84+
content = WEB_ENV.read_text()
85+
content = re.sub(r'^NEXT_PUBLIC_API_BASE_URL=.*', f'NEXT_PUBLIC_API_BASE_URL="http://{ip}:8888"', content, flags=re.MULTILINE)
86+
content = re.sub(r'^NEXT_PUBLIC_MEDIA_BASE_URL=.*', f'NEXT_PUBLIC_MEDIA_BASE_URL="http://{ip}:5001"', content, flags=re.MULTILINE)
87+
WEB_ENV.write_text(content)
88+
print(" web/.env.localdev updated API and media URLs")
89+
90+
# mobile/.env — comment out STAGE block, activate LOCAL block with real IP
91+
MOBILE_ENV.write_text(
92+
f"# STAGE:\n"
93+
f"# EXPO_PUBLIC_COUCHERS_ENV=preview\n"
94+
f"# EXPO_PUBLIC_WEB_BASE_URL=\"https://next.couchershq.org\"\n"
95+
f"# EXPO_PUBLIC_API_BASE_URL=\"https://dev-api.couchershq.org\"\n"
96+
f"\n"
97+
f"# LOCAL:\n"
98+
f"EXPO_PUBLIC_COUCHERS_ENV=dev\n"
99+
f"EXPO_PUBLIC_API_BASE_URL=\"http://{ip}:8888\"\n"
100+
f"EXPO_PUBLIC_WEB_BASE_URL=\"http://{ip}:3000\"\n"
101+
)
102+
print(" mobile/.env switched to local dev mode")
103+
104+
print(
105+
"\nDone! Next steps:\n"
106+
" 1. Restart the backend: docker compose up --build\n"
107+
" 2. Restart the frontend: cd app/web && yarn start\n"
108+
" 3. Start Expo: cd app/mobile && npx expo start\n"
109+
"\n"
110+
"To undo: python3 scripts/dev-mobile-setup.py --restore\n"
111+
"\n"
112+
"Reminder: do not commit the envoy.yaml change."
113+
)
114+
115+
116+
def restore() -> None:
117+
print("Restoring files to defaults...\n")
118+
119+
# envoy.yaml — remove the mobile-dev line
120+
content = ENVOY_YAML.read_text()
121+
lines = [line for line in content.splitlines(keepends=True) if "# mobile-dev" not in line]
122+
ENVOY_YAML.write_text("".join(lines))
123+
print(" envoy.yaml removed mobile dev CORS entry")
124+
125+
# backend.dev.env — back to localhost
126+
content = BACKEND_ENV.read_text()
127+
content = re.sub(r"^COOKIE_DOMAIN=.*", "COOKIE_DOMAIN=localhost", content, flags=re.MULTILINE)
128+
content = re.sub(r"^MEDIA_SERVER_BASE_URL=.*", "MEDIA_SERVER_BASE_URL=http://localhost:5001", content, flags=re.MULTILINE)
129+
content = re.sub(r"^MEDIA_SERVER_UPLOAD_BASE_URL=.*", "MEDIA_SERVER_UPLOAD_BASE_URL=http://localhost:5001", content, flags=re.MULTILINE)
130+
BACKEND_ENV.write_text(content)
131+
print(" backend.dev.env restored to localhost")
132+
133+
# web/.env.localdev — back to localhost
134+
content = WEB_ENV.read_text()
135+
content = re.sub(r'^NEXT_PUBLIC_API_BASE_URL=.*', 'NEXT_PUBLIC_API_BASE_URL="http://localhost:8888"', content, flags=re.MULTILINE)
136+
content = re.sub(r'^NEXT_PUBLIC_MEDIA_BASE_URL=.*', 'NEXT_PUBLIC_MEDIA_BASE_URL="http://localhost:5001"', content, flags=re.MULTILINE)
137+
WEB_ENV.write_text(content)
138+
print(" web/.env.localdev restored to localhost")
139+
140+
# mobile/.env — back to staging mode
141+
MOBILE_ENV.write_text(MOBILE_ENV_DEFAULT)
142+
print(" mobile/.env restored to staging mode")
143+
144+
print("\nDone. Restart backend and frontend to apply.")
145+
146+
147+
def main() -> None:
148+
parser = argparse.ArgumentParser(
149+
description="Set up local mobile dev environment for physical device testing.",
150+
formatter_class=argparse.RawDescriptionHelpFormatter,
151+
epilog=(
152+
"examples:\n"
153+
" python3 scripts/dev-mobile-setup.py\n"
154+
" python3 scripts/dev-mobile-setup.py 192.168.1.42\n"
155+
" python3 scripts/dev-mobile-setup.py --restore"
156+
),
157+
)
158+
parser.add_argument("ip", nargs="?", help="local IP address (auto-detected if omitted)")
159+
parser.add_argument("--restore", action="store_true", help="revert all changes to defaults")
160+
args = parser.parse_args()
161+
162+
if args.restore:
163+
restore()
164+
return
165+
166+
ip = args.ip or get_local_ip()
167+
if not ip:
168+
print("Error: could not detect local IP. Pass it explicitly:")
169+
print(" python3 scripts/dev-mobile-setup.py 192.168.x.x")
170+
sys.exit(1)
171+
172+
setup(ip)
173+
174+
175+
if __name__ == "__main__":
176+
main()

app/web/components/ProfileLink/ProfileLink.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useProfileSheet } from "features/profile/ProfileSheetContext";
44
import { MouseEvent, ReactNode } from "react";
55
import { routeToUser } from "routes";
66
import { useIsNativeEmbed } from "utils/nativeLink";
7+
import useIsScreenSizeOrSmaller from "utils/useIsScreenSizeOrSmaller";
78

89
interface ProfileLinkProps {
910
userId?: number;
@@ -25,9 +26,10 @@ export default function ProfileLink({
2526
"aria-label": ariaLabel,
2627
}: ProfileLinkProps) {
2728
const isNativeEmbed = useIsNativeEmbed();
29+
const isMobile = useIsScreenSizeOrSmaller("mobile");
2830
const { openProfileSheet } = useProfileSheet();
2931

30-
if (isNativeEmbed && userId !== undefined) {
32+
if ((isNativeEmbed || isMobile) && userId !== undefined) {
3133
return (
3234
<ButtonBase
3335
component="span"

app/web/features/profile/ProfileSheet.tsx

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ArrowBack, Close } from "@mui/icons-material";
2-
import { Collapse, Drawer, IconButton, styled } from "@mui/material";
2+
import { Collapse, IconButton, styled, SwipeableDrawer } from "@mui/material";
33
import CenteredSpinner from "components/CenteredSpinner/CenteredSpinner";
44
import Snackbar from "components/Snackbar";
55
import GroupChatView from "features/messages/groupchats/GroupChatView";
@@ -13,12 +13,16 @@ import { GLOBAL, PROFILE } from "i18n/namespaces";
1313
import { useRouter } from "next/router";
1414
import { useEffect, useLayoutEffect, useRef, useState } from "react";
1515
import { UserTab } from "routes";
16-
import { useIsNativeEmbed } from "utils/nativeLink";
16+
import useIsScreenSizeOrSmaller from "utils/useIsScreenSizeOrSmaller";
1717

1818
import { ProfileUserProvider } from "./hooks/useProfileUser";
1919
import { useProfileSheet } from "./ProfileSheetContext";
2020

21-
const StyledDrawer = styled(Drawer)({
21+
const iOS =
22+
typeof navigator !== "undefined" &&
23+
/iPad|iPhone|iPod/.test(navigator.userAgent);
24+
25+
const StyledDrawer = styled(SwipeableDrawer)({
2226
"& .MuiDrawer-paper": {
2327
borderTopLeftRadius: 16,
2428
borderTopRightRadius: 16,
@@ -30,11 +34,22 @@ const StyledDrawer = styled(Drawer)({
3034
},
3135
});
3236

37+
const Puller = styled("div")({
38+
width: 30,
39+
height: 6,
40+
backgroundColor: "var(--mui-palette-grey-300)",
41+
borderRadius: 3,
42+
position: "absolute",
43+
top: 8,
44+
left: "calc(50% - 15px)",
45+
});
46+
3347
const SheetHeader = styled("div")(({ theme }) => ({
3448
display: "flex",
3549
justifyContent: "flex-end",
3650
padding: theme.spacing(1, 1, 0),
3751
flexShrink: 0,
52+
position: "relative",
3853
backgroundColor: "var(--mui-palette-background-paper)",
3954
}));
4055

@@ -47,7 +62,7 @@ const ScrollContent = styled("div")({
4762
const REQUEST_ID = "request";
4863

4964
export default function ProfileSheet() {
50-
const isNativeEmbed = useIsNativeEmbed();
65+
const isMobile = useIsScreenSizeOrSmaller("mobile");
5166
const {
5267
openProfileUserId,
5368
closeProfileSheet,
@@ -77,22 +92,47 @@ export default function ProfileSheet() {
7792
return () => router.events.off("routeChangeStart", closeProfileSheet);
7893
}, [router.events, closeProfileSheet]);
7994

95+
// On Android, the hardware back gesture triggers webview.goBack() in the native
96+
// layer. Push a history entry when the sheet opens so goBack() fires popstate
97+
// here instead of routing to the previous page.
98+
useEffect(() => {
99+
if (!isMobile || openProfileUserId === null) return;
100+
101+
window.history.pushState({ profileSheetOpen: true }, "");
102+
103+
const handlePopState = () => closeProfileSheet();
104+
window.addEventListener("popstate", handlePopState);
105+
106+
return () => {
107+
window.removeEventListener("popstate", handlePopState);
108+
// Sheet closed via button — our pushed state is still on top, pop it.
109+
if (window.history.state?.profileSheetOpen) {
110+
window.history.back();
111+
}
112+
};
113+
}, [openProfileUserId, closeProfileSheet, isMobile]);
114+
80115
useLayoutEffect(() => {
81116
if (isRequesting || isMessaging) {
82117
const el = scrollRef.current?.querySelector(`#${REQUEST_ID}`);
83118
el?.scrollIntoView();
84119
}
85120
}, [isRequesting, isMessaging]);
86121

87-
if (!isNativeEmbed) return null;
122+
if (!isMobile) return null;
88123

89124
return (
90125
<StyledDrawer
91126
anchor="bottom"
92127
open={openProfileUserId !== null}
93128
onClose={closeProfileSheet}
129+
onOpen={() => {}}
130+
disableSwipeToOpen
131+
disableBackdropTransition={!iOS}
132+
disableDiscovery={iOS}
94133
>
95134
<SheetHeader>
135+
<Puller />
96136
<IconButton
97137
onClick={openGroupChatId ? closeGroupChat : closeProfileSheet}
98138
aria-label={openGroupChatId ? t("global:back") : t("global:close")}

0 commit comments

Comments
 (0)