Skip to content

Commit 38e47b3

Browse files
committed
Add live communication and production observability
1 parent 8529b32 commit 38e47b3

16 files changed

Lines changed: 235 additions & 13899 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
EXPO_PUBLIC_API_URL=http://localhost:3000
22
EXPO_PUBLIC_SENTRY_DSN=
3+
EXPO_PUBLIC_SENTRY_ENVIRONMENT=development

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ on:
88
jobs:
99
validate:
1010
runs-on: ubuntu-latest
11+
env:
12+
EXPO_PUBLIC_API_URL: https://backend.ruffl.thomaswhite.me
13+
EXPO_PUBLIC_SENTRY_ENVIRONMENT: test
1114
steps:
1215
- uses: actions/checkout@v4
1316
- uses: actions/setup-node@v4
@@ -18,3 +21,4 @@ jobs:
1821
- run: npm run typecheck
1922
- run: npm run lint
2023
- run: npm test
24+
- run: npm run export:web

README.md

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This guide assumes you have not used React Native, Expo, or a mobile emulator be
99
- **React Native** lets the project build Android and iOS interfaces from one codebase.
1010
- **Expo** supplies the development server and mobile tooling around React Native.
1111
- **Expo Go** is a phone app that can open Ruffl during early development without creating an app-store build.
12+
- **Expo development builds** contain Ruffl's own native libraries, including Sentry, and are the recommended production-like test path.
1213
- **Expo Router** turns files under `app/` into screens and navigation routes.
1314
- **TypeScript** is JavaScript with additional checks that catch many mistakes before the app runs.
1415
- **npm** downloads the libraries listed in `package.json` and runs the commands under `scripts`.
@@ -26,6 +27,7 @@ Ruffl-Frontend/
2627
|-- src/theme.ts Colours and shared visual values
2728
|-- test/ Automated tests
2829
|-- app.json Expo application configuration
30+
|-- eas.json Development, preview, and production cloud-build profiles
2931
|-- .env.example Example local settings
3032
`-- package.json Libraries and development commands
3133
```
@@ -76,6 +78,16 @@ The `.env` file is ignored by Git. Do not commit it.
7678

7779
Open `.env` and set `EXPO_PUBLIC_API_URL`.
7880

81+
The complete local file is:
82+
83+
```dotenv
84+
EXPO_PUBLIC_API_URL=http://localhost:3000
85+
EXPO_PUBLIC_SENTRY_DSN=
86+
EXPO_PUBLIC_SENTRY_ENVIRONMENT=development
87+
```
88+
89+
`EXPO_PUBLIC_*` values are compiled into the app and are not secret. Never put a Sentry auth token, database password, R2 secret, JWT secret, or Expo access token in them.
90+
7991
### Physical phone
8092

8193
`localhost` on a phone means the phone itself, not the development computer. Use the computer's local network address instead.
@@ -179,6 +191,7 @@ If `.env` changes, stop Expo with `Ctrl+C` and start it again. A normal hot relo
179191
| `npm run android` | Starts Expo and attempts to open an Android emulator/device |
180192
| `npm run ios` | Starts Expo and attempts to open the iOS Simulator |
181193
| `npm run web` | Runs the web-compatible version for quick layout checks |
194+
| `npm run export:web` | Creates a production static web bundle in `dist` |
182195
| `npm run typecheck` | Checks TypeScript without creating a build |
183196
| `npm run lint` | Checks code style and common programming mistakes |
184197
| `npm test` | Runs the automated tests once |
@@ -192,6 +205,7 @@ Run these before committing:
192205
npm run typecheck
193206
npm run lint
194207
npm test
208+
npm run export:web
195209
```
196210

197211
The mobile tests cover calculations, progress display, API errors, rate-limit messages, and account restriction propagation. Backend permissions and commission lifecycle tests live in `Ruffl-Backend`.
@@ -219,8 +233,11 @@ The interface update is not literally instantaneous. Its normal maximum delay is
219233
- Shipping and receipt confirmation
220234
- Reviews and dispute entry points
221235
- Commission, direct, dispute, and support conversation types
236+
- Direct “message maker” and “contact Ruffl support” actions with polling conversation screens
237+
- Activity notifications that can be acknowledged as read
222238
- Maker price and payout calculator
223239
- Warning, suspension, and deletion handling
240+
- Sentry JavaScript/native crash reporting when a mobile project DSN is configured
224241

225242
No real payment is taken. Every payment-related action is symbolic.
226243

@@ -287,19 +304,16 @@ Use `npx expo install <package-name>` for Expo-native packages because Expo choo
287304

288305
## Expo Go versus a development build
289306

290-
Expo Go is the easiest first development environment, but it only contains a fixed set of native libraries. A **development build** is a custom version of the Ruffl app containing its own native libraries. Use development builds before adding full native push notifications, monitoring, or app-store testing.
307+
Expo Go is useful for quick JavaScript/layout checks, but it contains a fixed native runtime and only supports the current Expo Go SDK. Ruffl is currently on supported Expo SDK 54, while the newest SDK is 57. Expo recommends incremental one-version-at-a-time upgrades and development builds for production apps. SDK 54 continues receiving critical fixes until the next Expo SDK release, expected in September or October 2026.
291308

292-
Install the development client:
309+
`expo-dev-client` and `eas.json` are already installed/configured. Link the repository to your Expo account once:
293310

294311
```powershell
295-
npx expo install expo-dev-client
312+
npx eas-cli@latest login
313+
npx eas-cli@latest init
296314
```
297315

298-
Configure Expo Application Services:
299-
300-
```powershell
301-
npx eas-cli@latest build:configure
302-
```
316+
`eas init` adds the real Expo project ID to app configuration. Review that change before committing it.
303317

304318
Create development builds:
305319

@@ -310,12 +324,24 @@ npx eas-cli@latest build --profile development --platform ios
310324

311325
EAS requires an Expo account. Apple Developer and Google Play accounts are required for store distribution.
312326

327+
Create preview and production builds only after configuring EAS environment variables:
328+
329+
```powershell
330+
npx eas-cli@latest env:create --environment preview --name EXPO_PUBLIC_API_URL --value https://backend.ruffl.thomaswhite.me
331+
npx eas-cli@latest env:create --environment production --name EXPO_PUBLIC_API_URL --value https://backend.ruffl.thomaswhite.me
332+
npx eas-cli@latest build --profile preview --platform android
333+
npx eas-cli@latest build --profile production --platform all
334+
```
335+
336+
Create a separate Sentry React Native project first, then add its DSN as `EXPO_PUBLIC_SENTRY_DSN` in preview/production. Configure `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT` as protected EAS build secrets for source-map upload; those three are not `EXPO_PUBLIC_*` values.
337+
313338
## Security and current limitations
314339

315340
- Authentication tokens are stored through Expo SecureStore.
316341
- Never place private server keys in `EXPO_PUBLIC_*` variables. Anything beginning with `EXPO_PUBLIC_` is included in the client application.
317-
- Media selection/upload UI, Expo Push registration, and Sentry initialisation still require production service integration.
318-
- The backend currently stores development data in memory, so restarting it resets accounts and commissions.
342+
- Media selection/upload UI and Expo Push registration still require production integration. Backend R2 upload signing is implemented.
343+
- Sentry is initialized, but the `ruffl-frontend` Sentry project/DSN and protected source-map credentials still need to be created.
344+
- Production backend data uses PostgreSQL. Local development without `DATABASE_URL` intentionally uses memory and resets on restart.
319345
- No payment processor is integrated.
320346

321-
As of 26 July 2026, `npm audit --omit=dev` reports high-severity advisories in transitive Expo/React Native build-tool dependencies (`brace-expansion` and `postcss`) with no compatible fix published for the SDK 54 dependency tree. Do not run `npm audit fix --force` blindly because that can move native packages outside Expo's supported versions.
347+
As of 29 July 2026, npm still reports high-severity advisories in transitive Expo/React Native tooling (`brace-expansion` and `postcss`) with no compatible fix in the SDK 54 tree, plus an indirect `uuid` advisory. Do not run `npm audit fix --force` blindly because that can move native packages outside Expo's supported versions. Recheck after each Expo patch and perform the SDK 54 → 55 → 56 → 57 upgrade incrementally in a dedicated change with native development builds on both platforms.

app.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
},
1818
"android": {
1919
"adaptiveIcon": {
20-
"backgroundColor": "#F8F4EC",
20+
"backgroundColor": "#F8F4EC",
2121
"foregroundImage": "./assets/images/android-icon-foreground.png",
2222
"backgroundImage": "./assets/images/android-icon-background.png",
2323
"monochromeImage": "./assets/images/android-icon-monochrome.png"
@@ -44,7 +44,8 @@
4444
"backgroundColor": "#1D2A24"
4545
}
4646
}
47-
]
47+
],
48+
"@sentry/react-native"
4849
],
4950
"experiments": {
5051
"typedRoutes": true,

app/(tabs)/inbox.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export default function InboxScreen() {
3939

4040
useEffect(() => {
4141
void load();
42+
const interval = setInterval(() => void load(), 5_000);
43+
return () => clearInterval(interval);
4244
}, [load]);
4345

4446
return (

app/(tabs)/index.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ export default function HomeScreen() {
4747
void load();
4848
}, [load]);
4949

50+
const markNotificationRead = async (notification: Notification) => {
51+
if (!token || notification.read) return;
52+
setNotifications((current) =>
53+
current.map((item) =>
54+
item.id === notification.id ? { ...item, read: true } : item,
55+
),
56+
);
57+
try {
58+
await api.readNotification(token, notification.id);
59+
} catch (caught) {
60+
setNotifications((current) =>
61+
current.map((item) =>
62+
item.id === notification.id ? { ...item, read: false } : item,
63+
),
64+
);
65+
setError(caught instanceof ApiError ? caught.message : 'Could not update this activity.');
66+
}
67+
};
68+
5069
const active = commissions.filter((commission) =>
5170
['pending', 'negotiating', 'price_proposed', 'accepted', 'active', 'shipping', 'disputed'].includes(
5271
commission.status,
@@ -170,15 +189,22 @@ export default function HomeScreen() {
170189
.reverse()
171190
.slice(0, 4)
172191
.map((notification, index) => (
173-
<View
192+
<Pressable
174193
key={notification.id}
194+
accessibilityRole="button"
195+
onPress={() => void markNotificationRead(notification)}
175196
style={[styles.activity, index > 0 && styles.activityBorder]}>
176-
<View style={styles.activityDot} />
197+
<View
198+
style={[
199+
styles.activityDot,
200+
notification.read && styles.activityDotRead,
201+
]}
202+
/>
177203
<View style={styles.flex}>
178204
<Text style={textStyles.label}>{notification.title}</Text>
179205
<Text style={textStyles.muted}>{notification.body}</Text>
180206
</View>
181-
</View>
207+
</Pressable>
182208
))
183209
)}
184210
</Card>
@@ -220,4 +246,5 @@ const styles = StyleSheet.create({
220246
marginTop: 6,
221247
width: 8,
222248
},
249+
activityDotRead: { backgroundColor: colours.line },
223250
});

app/(tabs)/profile.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Ionicons } from '@expo/vector-icons';
22
import { router } from 'expo-router';
3+
import { useState } from 'react';
34
import { Pressable, StyleSheet, Text, View } from 'react-native';
45

6+
import { api, ApiError } from '@/src/api/client';
57
import {
68
Avatar,
79
Button,
810
Card,
11+
ErrorNotice,
912
Eyebrow,
1013
Pill,
1114
Screen,
@@ -16,9 +19,25 @@ import { useSession } from '@/src/context/session';
1619
import { colours } from '@/src/theme';
1720

1821
export default function ProfileScreen() {
19-
const { signOut, user } = useSession();
22+
const { signOut, token, user } = useSession();
23+
const [supportError, setSupportError] = useState('');
24+
const [openingSupport, setOpeningSupport] = useState(false);
2025
if (!user) return null;
2126

27+
const openSupport = async () => {
28+
if (!token) return;
29+
setOpeningSupport(true);
30+
try {
31+
const { conversation } = await api.supportConversation(token);
32+
setSupportError('');
33+
router.push({ pathname: '/messages/[id]', params: { id: conversation.id } });
34+
} catch (caught) {
35+
setSupportError(caught instanceof ApiError ? caught.message : 'Could not contact support.');
36+
} finally {
37+
setOpeningSupport(false);
38+
}
39+
};
40+
2241
return (
2342
<Screen>
2443
<Eyebrow>Your Ruffl</Eyebrow>
@@ -54,6 +73,7 @@ export default function ProfileScreen() {
5473
</>
5574
) : null}
5675
<Text style={styles.section}>Trust and safety</Text>
76+
{supportError ? <ErrorNotice message={supportError} /> : null}
5777
<Card>
5878
<View style={styles.row}>
5979
<Ionicons color={colours.moss} name="shield-checkmark-outline" size={22} />
@@ -63,6 +83,12 @@ export default function ProfileScreen() {
6383
</View>
6484
</View>
6585
</Card>
86+
<Button
87+
disabled={openingSupport}
88+
label={openingSupport ? 'Opening support…' : 'Contact Ruffl support'}
89+
onPress={() => void openSupport()}
90+
variant="secondary"
91+
/>
6692
<Card tone="coral">
6793
<Text style={textStyles.label}>Payments are simulated</Text>
6894
<Text style={textStyles.muted}>

app/_layout.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Ionicons } from '@expo/vector-icons';
2+
import * as Sentry from '@sentry/react-native';
23
import { router, Stack } from 'expo-router';
34
import { StatusBar } from 'expo-status-bar';
45
import { useEffect } from 'react';
@@ -7,14 +8,27 @@ import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
78
import { SessionProvider, useSession } from '@/src/context/session';
89
import { colours } from '@/src/theme';
910

10-
export default function RootLayout() {
11+
if (process.env.EXPO_PUBLIC_SENTRY_DSN) {
12+
Sentry.init({
13+
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
14+
environment:
15+
process.env.EXPO_PUBLIC_SENTRY_ENVIRONMENT ??
16+
(__DEV__ ? 'development' : 'production'),
17+
sendDefaultPii: false,
18+
tracesSampleRate: 0.1,
19+
});
20+
}
21+
22+
function RootLayout() {
1123
return (
1224
<SessionProvider>
1325
<Navigation />
1426
</SessionProvider>
1527
);
1628
}
1729

30+
export default Sentry.wrap(RootLayout);
31+
1832
function Navigation() {
1933
const { dismissWarning, restriction, warning } = useSession();
2034

app/makers/[id].tsx

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default function MakerDetailScreen() {
2727
const [maker, setMaker] = useState<MakerResult | null>(null);
2828
const [error, setError] = useState('');
2929
const [waitlisted, setWaitlisted] = useState(false);
30+
const [startingChat, setStartingChat] = useState(false);
3031

3132
useEffect(() => {
3233
if (!token || !id) return;
@@ -47,6 +48,20 @@ export default function MakerDetailScreen() {
4748
}
4849
};
4950

51+
const startChat = async () => {
52+
if (!token || !id) return;
53+
setStartingChat(true);
54+
try {
55+
const { conversation } = await api.directConversation(token, id);
56+
setError('');
57+
router.push({ pathname: '/messages/[id]', params: { id: conversation.id } });
58+
} catch (caught) {
59+
setError(caught instanceof ApiError ? caught.message : 'Could not start this conversation.');
60+
} finally {
61+
setStartingChat(false);
62+
}
63+
};
64+
5065
if (!maker) {
5166
return (
5267
<Screen>
@@ -120,25 +135,33 @@ export default function MakerDetailScreen() {
120135
</View>
121136
</Card>
122137
{user?.role === 'commissioner' ? (
123-
profile.queueOpen ? (
124-
<Button
125-
label="Request a commission"
126-
onPress={() =>
127-
router.push({
128-
pathname: '/request/[makerId]',
129-
params: { makerId: maker.user.id },
130-
})
131-
}
132-
icon="sparkles-outline"
133-
/>
134-
) : (
138+
<>
139+
{profile.queueOpen ? (
140+
<Button
141+
label="Request a commission"
142+
onPress={() =>
143+
router.push({
144+
pathname: '/request/[makerId]',
145+
params: { makerId: maker.user.id },
146+
})
147+
}
148+
icon="sparkles-outline"
149+
/>
150+
) : (
151+
<Button
152+
disabled={waitlisted}
153+
label={waitlisted ? 'You are on the waitlist' : 'Join the waitlist'}
154+
onPress={() => void joinWaitlist()}
155+
variant="secondary"
156+
/>
157+
)}
135158
<Button
136-
disabled={waitlisted}
137-
label={waitlisted ? 'You are on the waitlist' : 'Join the waitlist'}
138-
onPress={() => void joinWaitlist()}
159+
disabled={startingChat}
160+
label={startingChat ? 'Opening conversation…' : 'Message maker'}
161+
onPress={() => void startChat()}
139162
variant="secondary"
140163
/>
141-
)
164+
</>
142165
) : null}
143166
<SectionTitle>Reviews</SectionTitle>
144167
{maker.reviews.length === 0 ? (

0 commit comments

Comments
 (0)