diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 83c0f85..653908d 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, Platform, Switch, Linking, ActivityIndicator } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import { useTranslation } from 'react-i18next'; -import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw, Cloud, LogIn, LogOut } from 'lucide-react-native'; +import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw, Cloud, LogIn, LogOut, MessageSquarePlus } from 'lucide-react-native'; import Constants from 'expo-constants'; import { useLogto } from '@logto/rn'; @@ -14,6 +14,7 @@ import { initCalendar } from '@/src/services/calendar'; import { syncWidgetData } from '@/src/services/widgetSync'; import { fullSync, initialMerge, initialReset } from '@/src/services/syncClient'; import i18n from '@/src/i18n'; +import FeedbackModal from '@/src/components/FeedbackModal'; type ThemeMode = 'light' | 'dark' | 'system'; @@ -40,6 +41,7 @@ export default function SettingsScreen() { const [tagName, setTagName] = useState(''); const [tagColor, setTagColor] = useState(TAG_COLORS[0]); const [checkingUpdate, setCheckingUpdate] = useState(false); + const [showFeedback, setShowFeedback] = useState(false); const [isSyncing, setIsSyncing] = useState(false); const { signIn: logtoSignIn, signOut: logtoSignOut, getIdTokenClaims, isAuthenticated } = useLogto(); @@ -694,6 +696,12 @@ export default function SettingsScreen() { + setShowFeedback(false)} + isDark={isDark} + /> + {/* About Section */} + setShowFeedback(true)} + className={`flex-row items-center border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`} + > + + + {t('feedback.open')} + + Linking.openURL('mailto:lacompagniemaximus@protonmail.com')} className="flex-row items-center px-4 py-3.5" diff --git a/package.json b/package.json index c92a9e4..a342736 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "1.6.5", "scripts": { "start": "expo start", - "test": "node tests/smoke.test.cjs", + "test": "node tests/smoke.test.cjs && node --test --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON tests/feedback.test.mjs", "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", diff --git a/src/components/FeedbackModal.tsx b/src/components/FeedbackModal.tsx new file mode 100644 index 0000000..5d00d15 --- /dev/null +++ b/src/components/FeedbackModal.tsx @@ -0,0 +1,253 @@ +import { useEffect, useState } from 'react'; +import { + View, + Text, + Pressable, + Modal, + TextInput, + ActivityIndicator, + Dimensions, + Platform, +} from 'react-native'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; +import { usePathname } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { X, MessageSquarePlus, CheckCircle, AlertCircle, Check } from 'lucide-react-native'; +import Constants from 'expo-constants'; + +import { colors } from '@/src/theme/colors'; +import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { useFeedback } from '@/src/hooks/useFeedback'; +import { MAX_CONTENT_LENGTH, type FeedbackContext } from '@/src/services/feedback'; + +interface FeedbackModalProps { + visible: boolean; + onClose: () => void; + isDark: boolean; +} + +const AUTO_CLOSE_MS = 2000; + +export default function FeedbackModal({ visible, onClose, isDark }: FeedbackModalProps) { + const { t, i18n } = useTranslation(); + const pathname = usePathname(); + const { userId } = useSettingsStore(); + const { state, submit, reset } = useFeedback(); + + const [content, setContent] = useState(''); + const [includeContext, setIncludeContext] = useState(false); + const [identify, setIdentify] = useState(false); + + const trimmed = content.trim(); + const isSending = state.status === 'sending'; + const isSuccess = state.status === 'success'; + const canSubmit = trimmed.length > 0 && !isSending && !isSuccess; + + const textColor = isDark ? '#F5F5F5' : '#1A1A1A'; + const mutedColor = isDark ? '#A0A0A0' : '#6B6B6B'; + const borderColor = isDark ? '#3A3A3A' : '#E5E7EB'; + const successColor = colors.priority.low; + const errorColor = colors.terracotta.DEFAULT; + + // Reset both local and hook state each time the modal opens. + useEffect(() => { + if (visible) { + setContent(''); + setIncludeContext(false); + setIdentify(false); + reset(); + } + }, [visible, reset]); + + // Auto-close after a successful send; timer cleared on unmount / state change. + useEffect(() => { + if (!isSuccess) return; + const timer = setTimeout(() => { + onClose(); + reset(); + }, AUTO_CLOSE_MS); + return () => clearTimeout(timer); + }, [isSuccess, onClose, reset]); + + const handleClose = () => { + if (isSending) return; + onClose(); + }; + + const handleSubmit = async () => { + if (!canSubmit) return; + + let context: FeedbackContext | undefined; + if (includeContext) { + const { width, height } = Dimensions.get('window'); + context = { + page: pathname, + locale: i18n.language, + theme: isDark ? 'dark' : 'light', + viewport: `${Math.round(width)}x${Math.round(height)}`, + userAgent: `Simpl-Liste/${Constants.expoConfig?.version ?? '0.0.0'} (${Platform.OS})`, + timestamp: new Date().toISOString(), + }; + } + + const feedbackUserId = identify && userId ? userId : null; + await submit({ content, userId: feedbackUserId, context }); + }; + + const errorMessage = (() => { + if (state.status !== 'error' || !state.errorCode) return null; + switch (state.errorCode) { + case 'rate_limit': + return t('feedback.error.rateLimit'); + case 'invalid': + return t('feedback.error.invalid'); + default: + return t('feedback.error.generic'); + } + })(); + + return ( + + + + e.stopPropagation()} + className={`rounded-t-2xl px-4 pb-8 pt-4 ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`} + > + {/* Header */} + + + + + {t('feedback.title')} + + + + + + + + {isSuccess ? ( + + + + {t('feedback.success')} + + + ) : ( + <> + setContent(v.slice(0, MAX_CONTENT_LENGTH))} + placeholder={t('feedback.placeholder')} + placeholderTextColor={mutedColor} + editable={!isSending} + multiline + className="min-h-[120px] rounded-xl border px-3 py-2.5 text-base" + style={{ + fontFamily: 'Inter_400Regular', + color: textColor, + borderColor, + textAlignVertical: 'top', + }} + /> + + {content.length}/{MAX_CONTENT_LENGTH} + + + {/* Opt-in: navigation context */} + setIncludeContext((v) => !v)} + disabled={isSending} + className="mt-3 flex-row items-center" + > + + {includeContext && } + + + {t('feedback.includeContext')} + + + + {/* Opt-in: identify (only when signed in) */} + {!!userId && ( + setIdentify((v) => !v)} + disabled={isSending} + className="mt-2 flex-row items-center" + > + + {identify && } + + + {t('feedback.identify')} + + + )} + + {errorMessage && ( + + + + {errorMessage} + + + )} + + {/* Actions */} + + + + {t('feedback.cancel')} + + + + {isSending ? ( + + ) : ( + + {t('feedback.submit')} + + )} + + + + )} + + + + + ); +} diff --git a/src/hooks/useFeedback.ts b/src/hooks/useFeedback.ts new file mode 100644 index 0000000..88c0822 --- /dev/null +++ b/src/hooks/useFeedback.ts @@ -0,0 +1,55 @@ +import { useCallback, useReducer } from 'react'; + +import { + sendFeedback, + type FeedbackErrorCode, + type SendFeedbackInput, +} from '@/src/services/feedback'; + +export type FeedbackStatus = 'idle' | 'sending' | 'success' | 'error'; + +export interface FeedbackState { + status: FeedbackStatus; + errorCode: FeedbackErrorCode | null; +} + +type FeedbackAction = + | { type: 'SEND_START' } + | { type: 'SEND_SUCCESS' } + | { type: 'SEND_ERROR'; code: FeedbackErrorCode } + | { type: 'RESET' }; + +const initialState: FeedbackState = { status: 'idle', errorCode: null }; + +function reducer(_state: FeedbackState, action: FeedbackAction): FeedbackState { + switch (action.type) { + case 'SEND_START': + return { status: 'sending', errorCode: null }; + case 'SEND_SUCCESS': + return { status: 'success', errorCode: null }; + case 'SEND_ERROR': + return { status: 'error', errorCode: action.code }; + case 'RESET': + return initialState; + } +} + +// Feedback submission state machine (idle -> sending -> success | error). +// The network call never throws; the outcome is derived from its result. +export function useFeedback() { + const [state, dispatch] = useReducer(reducer, initialState); + + const submit = useCallback(async (args: SendFeedbackInput) => { + dispatch({ type: 'SEND_START' }); + const result = await sendFeedback(args); + if (result.ok) { + dispatch({ type: 'SEND_SUCCESS' }); + } else { + dispatch({ type: 'SEND_ERROR', code: result.code }); + } + }, []); + + const reset = useCallback(() => dispatch({ type: 'RESET' }), []); + + return { state, submit, reset }; +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 2531f3f..93df87d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -105,6 +105,21 @@ "download": "Download", "updateError": "Unable to check for updates" }, + "feedback": { + "open": "Send feedback", + "title": "Your feedback", + "placeholder": "Describe your suggestion or the issue you ran into...", + "cancel": "Cancel", + "submit": "Send", + "includeContext": "Include navigation context", + "identify": "Identify me (attach my account)", + "success": "Thanks for your feedback!", + "error": { + "rateLimit": "Too many submissions. Try again in a moment.", + "invalid": "Invalid message. Check the content.", + "generic": "Couldn't send. Try again later." + } + }, "notifications": { "title": "Notifications", "enabled": "Reminders enabled", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 845c8ad..ac6d78e 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -105,6 +105,21 @@ "download": "Télécharger", "updateError": "Impossible de vérifier les mises à jour" }, + "feedback": { + "open": "Envoyer un feedback", + "title": "Votre feedback", + "placeholder": "Décrivez votre suggestion ou le problème rencontré...", + "cancel": "Annuler", + "submit": "Envoyer", + "includeContext": "Inclure le contexte de navigation", + "identify": "M'identifier (joindre mon compte)", + "success": "Merci pour votre feedback !", + "error": { + "rateLimit": "Trop d'envois. Réessayez dans un moment.", + "invalid": "Message invalide. Vérifiez le contenu.", + "generic": "Envoi impossible. Réessayez plus tard." + } + }, "notifications": { "title": "Notifications", "enabled": "Rappels activés", diff --git a/src/services/feedback.ts b/src/services/feedback.ts new file mode 100644 index 0000000..dbfcbd2 --- /dev/null +++ b/src/services/feedback.ts @@ -0,0 +1,100 @@ +// Feedback Hub client — posts user feedback to the centralized micro-service +// (feedback.lacompagniemaximus.com). The endpoint is public (no auth) and +// re-sanitizes everything server-side; the mirror logic here keeps the payload +// minimal and predictable. +// +// IMPORTANT: this module is deliberately dependency-free (no RN/Expo imports, +// no `@/` path alias, only the global `fetch`/`console`). Its pure helpers are +// unit-tested with `node --test` via native TS type-stripping +// (tests/feedback.test.mjs), which cannot resolve the `@/` alias. Keep it +// import-free so that test path stays valid — the smoke test enforces this. + +export const FEEDBACK_HUB_URL = 'https://feedback.lacompagniemaximus.com'; +export const APP_ID = 'simpl-liste'; +export const MAX_CONTENT_LENGTH = 2000; +const MAX_CONTEXT_VALUE_LENGTH = 500; + +// Strict whitelist mirrored from the server's `sanitizeContext`. Any other key +// is dropped server-side anyway; we drop it here too. +export const CONTEXT_KEYS = [ + 'page', + 'locale', + 'theme', + 'viewport', + 'userAgent', + 'timestamp', +] as const; + +export type FeedbackContextKey = (typeof CONTEXT_KEYS)[number]; +export type FeedbackContext = Partial>; + +export type FeedbackErrorCode = + | 'invalid' + | 'rate_limit' + | 'server_error' + | 'network_error'; + +export type FeedbackResult = { ok: true } | { ok: false; code: FeedbackErrorCode }; + +export interface SendFeedbackInput { + content: string; + userId?: string | null; + context?: FeedbackContext; +} + +// Client-side cap on the raw text the user typed. Note: the server strips HTML +// *then* caps at 2000, so markup-heavy input may be shortened server-side. The +// server is authoritative; here we cap the raw string so the char counter and +// payload stay bounded. +export function capContent(content: string): string { + return content.trim().slice(0, MAX_CONTENT_LENGTH); +} + +// Keep only whitelisted string keys, each truncated to 500 chars. Returns +// undefined when nothing survives so the field is omitted from the payload. +export function sanitizeContext( + raw: FeedbackContext | null | undefined, +): FeedbackContext | undefined { + if (!raw) return undefined; + const out: FeedbackContext = {}; + for (const key of CONTEXT_KEYS) { + const value = raw[key]; + if (typeof value === 'string' && value.length > 0) { + out[key] = value.slice(0, MAX_CONTEXT_VALUE_LENGTH); + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +// Map a non-ok HTTP status to a stable error code. 400 = bad app_id/empty +// content, 429 = rate limit (5/hour/IP). Anything else (403/404/413/5xx) is a +// server_error so the UI never lands on an undefined state. +export function resolveErrorCode(status: number): FeedbackErrorCode { + if (status === 400) return 'invalid'; + if (status === 429) return 'rate_limit'; + return 'server_error'; +} + +export async function sendFeedback(input: SendFeedbackInput): Promise { + const context = sanitizeContext(input.context); + const body: Record = { + app_id: APP_ID, + content: capContent(input.content), + user_id: input.userId ?? null, + }; + if (context) body.context = context; + + try { + const res = await fetch(`${FEEDBACK_HUB_URL}/api/feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (res.ok) return { ok: true }; + console.warn(`[feedback] request failed with status ${res.status}`); + return { ok: false, code: resolveErrorCode(res.status) }; + } catch (err) { + console.warn('[feedback] network error:', err); + return { ok: false, code: 'network_error' }; + } +} diff --git a/tests/feedback.test.mjs b/tests/feedback.test.mjs new file mode 100644 index 0000000..4ba1842 --- /dev/null +++ b/tests/feedback.test.mjs @@ -0,0 +1,149 @@ +// Execution tests for the Feedback Hub client. Runs on plain node via the +// built-in test runner + native TS type-stripping (Node >= 22.18): +// +// node --test tests/feedback.test.mjs +// +// This EXECUTES the real helpers from src/services/feedback.ts (which is kept +// import-free so type-stripping can load it — the `@/` alias is unresolvable +// here). Static/import-graph guards live in smoke.test.cjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + capContent, + sanitizeContext, + resolveErrorCode, + sendFeedback, + APP_ID, + FEEDBACK_HUB_URL, + MAX_CONTENT_LENGTH, +} from '../src/services/feedback.ts'; + +test('APP_ID is the exact repo name', () => { + assert.equal(APP_ID, 'simpl-liste'); +}); + +test('capContent trims and caps at MAX_CONTENT_LENGTH', () => { + assert.equal(capContent(' hello '), 'hello'); + assert.equal(capContent('x'.repeat(3000)).length, MAX_CONTENT_LENGTH); + assert.equal(capContent(''), ''); +}); + +test('sanitizeContext keeps only whitelisted string keys', () => { + const out = sanitizeContext({ + page: '/task/1', + locale: 'fr', + theme: 'dark', + secret: 'nope', + }); + assert.deepEqual(out, { page: '/task/1', locale: 'fr', theme: 'dark' }); +}); + +test('sanitizeContext truncates values to 500 chars', () => { + const out = sanitizeContext({ page: 'a'.repeat(900) }); + assert.equal(out.page.length, 500); +}); + +test('sanitizeContext drops non-string values', () => { + const out = sanitizeContext({ page: '/x', viewport: 123 }); + assert.deepEqual(out, { page: '/x' }); +}); + +test('sanitizeContext returns undefined when empty or nullish', () => { + assert.equal(sanitizeContext(undefined), undefined); + assert.equal(sanitizeContext(null), undefined); + assert.equal(sanitizeContext({}), undefined); + assert.equal(sanitizeContext({ page: '' }), undefined); +}); + +test('resolveErrorCode maps statuses, defaulting to server_error', () => { + assert.equal(resolveErrorCode(400), 'invalid'); + assert.equal(resolveErrorCode(429), 'rate_limit'); + assert.equal(resolveErrorCode(500), 'server_error'); + assert.equal(resolveErrorCode(413), 'server_error'); + assert.equal(resolveErrorCode(404), 'server_error'); +}); + +test('sendFeedback POSTs to the exact endpoint with the right body', async () => { + const calls = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 201 }; + }; + try { + const result = await sendFeedback({ + content: ' Great app ', + userId: null, + context: { page: '/settings' }, + }); + assert.deepEqual(result, { ok: true }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, `${FEEDBACK_HUB_URL}/api/feedback`); + assert.equal(calls[0].opts.method, 'POST'); + assert.equal(calls[0].opts.headers['Content-Type'], 'application/json'); + const body = JSON.parse(calls[0].opts.body); + assert.equal(body.app_id, 'simpl-liste'); + assert.equal(body.content, 'Great app'); // trimmed by capContent + assert.equal(body.user_id, null); + assert.deepEqual(body.context, { page: '/settings' }); + } finally { + globalThis.fetch = original; + } +}); + +test('sendFeedback omits context when not opted in, and sends user_id when identified', async () => { + const captured = []; + const original = globalThis.fetch; + globalThis.fetch = async (_url, opts) => { + captured.push(JSON.parse(opts.body)); + return { ok: true, status: 201 }; + }; + try { + await sendFeedback({ content: 'hi' }); + assert.equal('context' in captured[0], false); + assert.equal(captured[0].user_id, null); + + await sendFeedback({ content: 'hi', userId: 'logto-sub-123' }); + assert.equal(captured[1].user_id, 'logto-sub-123'); + } finally { + globalThis.fetch = original; + } +}); + +test('sendFeedback maps error statuses to codes', async () => { + const original = globalThis.fetch; + const origWarn = console.warn; + console.warn = () => {}; + try { + for (const [status, code] of [ + [400, 'invalid'], + [429, 'rate_limit'], + [500, 'server_error'], + [413, 'server_error'], + ]) { + globalThis.fetch = async () => ({ ok: false, status }); + const result = await sendFeedback({ content: 'hi' }); + assert.deepEqual(result, { ok: false, code }); + } + } finally { + globalThis.fetch = original; + console.warn = origWarn; + } +}); + +test('sendFeedback maps thrown fetch to network_error', async () => { + const original = globalThis.fetch; + const origWarn = console.warn; + console.warn = () => {}; + globalThis.fetch = async () => { + throw new Error('offline'); + }; + try { + const result = await sendFeedback({ content: 'hi' }); + assert.deepEqual(result, { ok: false, code: 'network_error' }); + } finally { + globalThis.fetch = original; + console.warn = origWarn; + } +}); diff --git a/tests/smoke.test.cjs b/tests/smoke.test.cjs index fd5a11b..5073b9b 100644 --- a/tests/smoke.test.cjs +++ b/tests/smoke.test.cjs @@ -129,6 +129,45 @@ check('react-native-android-widget latency patch is wired up', () => { assert.equal(pkg.scripts.postinstall, 'patch-package', 'postinstall must run patch-package'); }); +// --- Feedback Hub client guards --- +// The feedback service is load-bearing on two axes the static check protects: +// (1) a wrong app_id/endpoint silently misroutes feedback in the shared hub, +// and (2) it MUST stay import-free — feedback.test.mjs loads it via node:test +// + TS type-stripping, which cannot resolve the `@/` alias or RN/Expo packages. +// Behavioral coverage (capContent/sanitizeContext/resolveErrorCode/sendFeedback) +// lives in tests/feedback.test.mjs; these guards lock the invariants that file +// can't assert about the source itself. + +const FEEDBACK_SRC = 'src/services/feedback.ts'; + +check('feedback.ts targets the exact feedback.lacompagniemaximus.com /api/feedback endpoint', () => { + const src = fs.readFileSync(path.join(__dirname, '..', FEEDBACK_SRC), 'utf8'); + assert.ok( + src.includes("'https://feedback.lacompagniemaximus.com'"), + 'feedback hub URL missing or changed' + ); + assert.ok(src.includes('/api/feedback'), 'endpoint path /api/feedback missing'); +}); + +check("feedback.ts pins app_id to the exact literal 'simpl-liste'", () => { + const src = fs.readFileSync(path.join(__dirname, '..', FEEDBACK_SRC), 'utf8'); + assert.match(src, /APP_ID\s*=\s*'simpl-liste'/, "APP_ID must be exactly 'simpl-liste'"); +}); + +check('feedback.ts caps content at 2000 chars', () => { + const src = fs.readFileSync(path.join(__dirname, '..', FEEDBACK_SRC), 'utf8'); + assert.match(src, /MAX_CONTENT_LENGTH\s*=\s*2000/, 'content cap must be 2000'); +}); + +check('feedback.ts stays import-free (protects type-stripping test path)', () => { + const imports = staticImportsOf(FEEDBACK_SRC); + assert.deepEqual( + imports, + [], + `feedback.ts must have zero imports, found: ${imports.join(', ')}` + ); +}); + if (failed === 0) { console.log('\nsmoke OK'); process.exit(0);