diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..f91a35e --- /dev/null +++ b/babel.config.js @@ -0,0 +1,16 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: [ + [ + 'babel-preset-expo', + { + // src/lib/install-message-event-polyfill.ts deep-requires a private + // react-native module on purpose (RN doesn't expose MessageEvent as + // a global) — silence the resulting dev-only deprecation warning. + disableDeepImportWarnings: true, + }, + ], + ], + }; +}; diff --git a/jest.config.js b/jest.config.js index 8d17119..8e66188 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,6 +3,12 @@ module.exports = { setupFiles: ['./jest.setup.ts'], moduleNameMapper: { '^@/(.*)$': '/src/$1', + // Under Jest's RN environment, lucide-react-native's package.json `exports` picks its ESM + // `.mjs` build (customExportConditions includes `react-native`), but Jest has no transform + // registered for `.mjs` (only `.[jt]sx?`) — `require()`-ing it raw hits `export` syntax and + // throws. Its CJS build is real CommonJS and needs no transform, so map straight to it. + '^lucide-react-native$': + '/node_modules/lucide-react-native/dist/cjs/lucide-react-native.js', }, // react-native-worklets (reanimated 4's underlying worklets runtime) ships a real native // module with no Jest mock; its own package provides this resolver, which steers Jest away diff --git a/package.json b/package.json index f880b50..a4364f4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "packageManager": "pnpm@11.15.1", "dependencies": { "@expo-google-fonts/plus-jakarta-sans": "^0.4.2", - "@expo/vector-icons": "^15.1.1", "@gorhom/bottom-sheet": "^5.2.14", "@react-native-async-storage/async-storage": "2.2.0", "@shopify/flash-list": "2.0.2", @@ -35,6 +34,7 @@ "expo-updates": "~57.0.19", "expo-web-browser": "~57.0.2", "i18next": "^26.3.6", + "lucide-react-native": "^1.35.0", "openapi-fetch": "^0.17.0", "partysocket": "^1.3.0", "react": "19.2.3", @@ -44,6 +44,7 @@ "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", "react-native-worklets": "0.10.1", "zustand": "^5.0.15" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19c25c1..c6810da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@expo-google-fonts/plus-jakarta-sans': specifier: ^0.4.2 version: 0.4.2 - '@expo/vector-icons': - specifier: ^15.1.1 - version: 15.1.1(expo-font@57.0.2)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) '@gorhom/bottom-sheet': specifier: ^5.2.14 version: 5.2.14(c57327f6cff0368afd765304bb73382e) @@ -98,6 +95,9 @@ importers: i18next: specifier: ^26.3.6 version: 26.3.6(typescript@6.0.3) + lucide-react-native: + specifier: ^1.35.0 + version: 1.35.0(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) openapi-fetch: specifier: ^0.17.0 version: 0.17.0 @@ -125,6 +125,9 @@ importers: react-native-screens: specifier: ~4.26.0 version: 4.26.2(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-native-svg: + specifier: 15.15.4 + version: 15.15.4(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) react-native-worklets: specifier: 0.10.1 version: 0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2) @@ -1021,13 +1024,6 @@ packages: react-native-worklets: optional: true - '@expo/vector-icons@15.1.1': - resolution: {integrity: sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==} - peerDependencies: - expo-font: '>=14.0.4' - react: '*' - react-native: '*' - '@expo/ws-tunnel@2.0.0': resolution: {integrity: sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==} peerDependencies: @@ -2103,6 +2099,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -2315,6 +2314,17 @@ packages: css-in-js-utils@3.1.0: resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + cssom@0.3.8: resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} @@ -2443,11 +2453,24 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domexception@4.0.0: resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} engines: {node: '>=12'} deprecated: Use your platform's native DOMException instead + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -2477,6 +2500,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -3863,6 +3890,13 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react-native@1.35.0: + resolution: {integrity: sha512-JMuFruu6QpILw0tMdgYmGfO6PVDd1HcoJfm9EGqiPTkcm64eeCxV8/mlehO+OscTy0RjL+1rm18/F0f7nQfsOQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: '*' + react-native-svg: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -3880,6 +3914,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -4081,6 +4118,9 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} @@ -4426,6 +4466,12 @@ packages: react: '*' react-native: '*' + react-native-svg@15.15.4: + resolution: {integrity: sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==} + peerDependencies: + react: '*' + react-native: '*' + react-native-web@0.21.2: resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} peerDependencies: @@ -6406,12 +6452,6 @@ snapshots: - '@types/react' - '@types/react-dom' - '@expo/vector-icons@15.1.1(expo-font@57.0.2)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)': - dependencies: - expo-font: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) - react: 19.2.3 - react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) - '@expo/ws-tunnel@2.0.0(ws@8.21.3)': dependencies: ws: 8.21.3 @@ -7701,6 +7741,8 @@ snapshots: big-integer@1.6.52: {} + boolbase@1.0.0: {} + bplist-creator@0.1.0: dependencies: stream-buffers: 2.2.0 @@ -7941,6 +7983,21 @@ snapshots: hyphenate-style-name: 1.1.0 optional: true + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + cssom@0.3.8: {} cssom@0.5.0: {} @@ -8044,10 +8101,28 @@ snapshots: dependencies: esutils: 2.0.3 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + domexception@4.0.0: dependencies: webidl-conversions: 7.0.0 + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@17.4.2: {} dunder-proto@1.0.1: @@ -8068,6 +8143,8 @@ snapshots: encodeurl@2.0.0: {} + entities@4.5.0: {} + entities@6.0.1: {} error-ex@1.3.4: @@ -9900,6 +9977,12 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react-native@1.35.0(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + react-native-svg: 15.15.4(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + make-dir@4.0.0: dependencies: semver: 7.8.5 @@ -9915,6 +9998,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.0.14: {} + memoize-one@5.2.1: {} memoize-one@6.0.0: @@ -10191,6 +10276,10 @@ snapshots: dependencies: path-key: 3.1.1 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nullthrows@1.1.1: {} nwsapi@2.2.24: {} @@ -10546,6 +10635,14 @@ snapshots: react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) warn-once: 0.1.1 + react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + warn-once: 0.1.1 + react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@babel/runtime': 7.29.7 diff --git a/src/app/(app)/(tabs)/_layout.tsx b/src/app/(app)/(tabs)/_layout.tsx index b1efe96..0d07619 100644 --- a/src/app/(app)/(tabs)/_layout.tsx +++ b/src/app/(app)/(tabs)/_layout.tsx @@ -11,10 +11,20 @@ import { useWorkspaceStore } from '@/stores/use-workspace-store'; import { useTheme } from '@/theme/use-theme'; /** react-navigation types tabBarIcon's `color` as `ColorValue` (includes opaque platform colors); - * our theme tokens are always plain hex strings, so this casts back for the Icon primitive. */ + * our theme tokens are always plain hex strings, so this casts back for the Icon primitive. + * `strokeWidth` bumps to 2.5 when focused — lucide icons are stroke-only, so this is how the + * active tab recovers the visual weight Ionicons' filled/outline pair used to convey. */ function tabIcon(name: IconName) { - function TabIcon({ color, size }: { color: ColorValue; size: number }) { - return ; + function TabIcon({ + color, + size, + focused, + }: { + color: ColorValue; + size: number; + focused: boolean; + }) { + return ; } return TabIcon; } @@ -52,7 +62,7 @@ export default function TabsLayout() { name="conversations/index" options={{ title: t('inbox.title'), - tabBarIcon: tabIcon('chatbubbles'), + tabBarIcon: tabIcon('messages-square'), // `useInboxUnreadCount` only counts CACHED list pages, not a true server-side unread // total (no such endpoint exists yet — see the hook's own doc comment). `undefined` // (not 0) hides the badge entirely when there's nothing unread. @@ -61,7 +71,7 @@ export default function TabsLayout() { /> state.currentWorkspaceId); const [keyword, setKeyword] = useState(''); + const [selectedContact, setSelectedContact] = useState(null); + const actionsSheetRef = useRef(null); + // `ContactActionsSheet` only mounts once `selectedContactDetail` resolves (see the render guard + // below), so `actionsSheetRef.current` is still null on the render triggered by `openActions` — + // calling `.expand()` synchronously there is a no-op on the first tap for any given contact. + // This ref defers the actual `.expand()` call to the effect below, which fires once the sheet + // has actually mounted. A ref (not state) so clearing it doesn't itself trigger another render. + const pendingOpenRef = useRef(false); const query = useContactsInfinite(workspaceId, keyword); const contacts = flattenContactPages(query.data?.pages); const normalizedError = query.error ? normalizeApiError(query.error) : null; + const blockContact = useBlockContact(workspaceId ?? ''); + const unblockContact = useUnblockContact(workspaceId ?? ''); + const deleteContact = useDeleteContact(workspaceId ?? ''); + const { data: selectedContactDetail } = useContactDetail( + workspaceId, + selectedContact?.id ?? null, + ); + + useEffect(() => { + if (pendingOpenRef.current && workspaceId && selectedContactDetail) { + actionsSheetRef.current?.expand(); + pendingOpenRef.current = false; + } + }, [workspaceId, selectedContactDetail]); + + const openActions = useCallback((contact: ContactListItem) => { + setSelectedContact(contact); + pendingOpenRef.current = true; + }, []); + + // Stable per-action handlers (not per-item inline arrows) so `ContactRow`'s `memo` can actually + // short-circuit — an inline arrow in `renderItem` would be a new function reference on every + // list render regardless of whether a given row's own data changed, defeating memo entirely. + const handlePress = useCallback( + (contact: ContactListItem) => router.push(`/(app)/contacts/${contact.id}`), + [router], + ); + + const handleToggleBlock = useCallback( + (contact: ContactListItem) => { + const wasBlocked = Boolean(contact.blockedAt); + const mutation = wasBlocked ? unblockContact : blockContact; + mutation.mutate(contact.id, { + onSuccess: () => { + showToast({ + message: wasBlocked ? t('contacts.unblockedSuccess') : t('contacts.blockedSuccess'), + tone: 'success', + }); + }, + onError: (error) => { + showToast({ message: describeApiError(error, t), tone: 'danger' }); + }, + }); + }, + [unblockContact, blockContact, showToast, t], + ); + + const handleDelete = useCallback( + (contact: ContactListItem) => { + deleteContact.mutate(contact.id, { + onSuccess: () => { + showToast({ message: t('contacts.deleted'), tone: 'success' }); + }, + onError: (error) => { + showToast({ message: describeApiError(error, t), tone: 'danger' }); + }, + }); + }, + [deleteContact, showToast, t], + ); + if (normalizedError?.kind === 'workspaceBlocked') { return ( @@ -63,7 +140,7 @@ export default function ContactsScreen() { ))} ) : contacts.length === 0 ? ( - + ) : ( router.push(`/(app)/contacts/${item.id}`)} + onPress={handlePress} + onOpenActions={openActions} + onToggleBlock={handleToggleBlock} + onDelete={handleDelete} /> )} onEndReachedThreshold={0.4} @@ -91,6 +171,15 @@ export default function ContactsScreen() { ListFooterComponent={} /> )} + + {workspaceId && selectedContactDetail ? ( + actionsSheetRef.current?.close()} + /> + ) : null} ); } diff --git a/src/app/(app)/(tabs)/conversations/index.tsx b/src/app/(app)/(tabs)/conversations/index.tsx index 0fd14f5..7f13ec5 100644 --- a/src/app/(app)/(tabs)/conversations/index.tsx +++ b/src/app/(app)/(tabs)/conversations/index.tsx @@ -1,4 +1,5 @@ import type BottomSheet from '@gorhom/bottom-sheet'; +import type { BottomSheetModal } from '@gorhom/bottom-sheet'; import { FlashList } from '@shopify/flash-list'; import { router } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -84,7 +85,7 @@ export default function ConversationsScreen() { ); const filterSheetRef = useRef(null); - const assignmentSheetRef = useRef(null); + const assignmentSheetRef = useRef(null); const workspaceSwitcherSheetRef = useRef(null); const { data: workspaces } = useWorkspaces(); const currentWorkspace = workspaces?.find((workspace) => workspace.id === workspaceId); @@ -106,7 +107,7 @@ export default function ConversationsScreen() { const openAssignmentSheet = useCallback((conversation: ConversationListItem) => { setAssigningConversation(conversation); - assignmentSheetRef.current?.expand(); + assignmentSheetRef.current?.present(); }, []); function handleAssign(userId: string | null) { @@ -131,6 +132,19 @@ export default function ConversationsScreen() { } function handleArchive(conversation: ConversationListItem) { + if (conversation.archivedAt) { + unarchiveConversations.mutate([conversation.id]); + showToast({ + message: t('conversations.archiveUndone'), + tone: 'neutral', + action: { + label: t('conversations.undo'), + onPress: () => archiveConversations.mutate([conversation.id]), + }, + }); + return; + } + archiveConversations.mutate([conversation.id]); showToast({ message: t('conversations.archive'), @@ -146,13 +160,13 @@ export default function ConversationsScreen() { if (filterCount > 0 || filtersSnapshot.keyword) { return ( ); } - return ; + return ; }, [filterCount, filtersSnapshot.keyword, resetFilters, t]); const normalizedError = query.error ? normalizeApiError(query.error) : null; @@ -184,7 +198,7 @@ export default function ConversationsScreen() { filterSheetRef.current?.expand()} diff --git a/src/app/(app)/(tabs)/settings/index.tsx b/src/app/(app)/(tabs)/settings/index.tsx index 57474fd..d15aed7 100644 --- a/src/app/(app)/(tabs)/settings/index.tsx +++ b/src/app/(app)/(tabs)/settings/index.tsx @@ -124,7 +124,6 @@ export default function SettingsScreen() { showChevron /> - @@ -152,7 +151,6 @@ export default function SettingsScreen() { showChevron /> - @@ -182,6 +180,8 @@ export default function SettingsScreen() { ) : null} + + ); diff --git a/src/app/(app)/settings/members.tsx b/src/app/(app)/settings/members.tsx index 15c78d1..a7004b3 100644 --- a/src/app/(app)/settings/members.tsx +++ b/src/app/(app)/settings/members.tsx @@ -49,7 +49,7 @@ export default function MembersScreen() { /> ) : members.length === 0 ? ( - + ) : ( members.map((member) => ( ) : teams.length === 0 ? ( - + ) : ( teams.map((team) => ( ) : ( - + )} ); diff --git a/src/app/(auth)/change-password.tsx b/src/app/(auth)/change-password.tsx index 9506147..d8accfa 100644 --- a/src/app/(auth)/change-password.tsx +++ b/src/app/(auth)/change-password.tsx @@ -82,7 +82,7 @@ export default function ChangePasswordScreen() { returnKeyType="next" blurOnSubmit={false} onSubmitEditing={() => newPasswordInputRef.current?.focus()} - trailingIcon={isCurrentPasswordVisible ? 'eye-off-outline' : 'eye-outline'} + trailingIcon={isCurrentPasswordVisible ? 'eye-off' : 'eye'} trailingIconAccessibilityLabel={ isCurrentPasswordVisible ? t('auth.hidePassword') : t('auth.showPassword') } @@ -99,7 +99,7 @@ export default function ChangePasswordScreen() { returnKeyType="done" blurOnSubmit onSubmitEditing={handleSubmit} - trailingIcon={isNewPasswordVisible ? 'eye-off-outline' : 'eye-outline'} + trailingIcon={isNewPasswordVisible ? 'eye-off' : 'eye'} trailingIconAccessibilityLabel={ isNewPasswordVisible ? t('auth.hidePassword') : t('auth.showPassword') } diff --git a/src/app/(auth)/forgot-password.tsx b/src/app/(auth)/forgot-password.tsx index f075db3..8f6ae5e 100644 --- a/src/app/(auth)/forgot-password.tsx +++ b/src/app/(auth)/forgot-password.tsx @@ -38,7 +38,7 @@ export default function ForgotPasswordScreen() { return ( router.back() }} diff --git a/src/app/(auth)/sign-in.tsx b/src/app/(auth)/sign-in.tsx index 81978c7..6c22210 100644 --- a/src/app/(auth)/sign-in.tsx +++ b/src/app/(auth)/sign-in.tsx @@ -80,7 +80,7 @@ export default function SignInScreen() { { backgroundColor: colors.brandSoft, borderRadius: LOGO_SIZE / 2 }, ]} > - + @@ -117,7 +117,7 @@ export default function SignInScreen() { returnKeyType="go" blurOnSubmit onSubmitEditing={handleSubmit} - trailingIcon={isPasswordVisible ? 'eye-off-outline' : 'eye-outline'} + trailingIcon={isPasswordVisible ? 'eye-off' : 'eye'} trailingIconAccessibilityLabel={ isPasswordVisible ? t('auth.hidePassword') : t('auth.showPassword') } diff --git a/src/app/+not-found.tsx b/src/app/+not-found.tsx index 4d543d1..1204e89 100644 --- a/src/app/+not-found.tsx +++ b/src/app/+not-found.tsx @@ -19,7 +19,7 @@ export default function NotFoundScreen() { return ( MAX_FILE_SIZE_BYTES) { setError(t('chat.attachmentTooLarge', { fileName: 'photo.jpg' })); return; @@ -83,7 +86,7 @@ export default function CameraScreen() { router.back(); } catch (err) { triggerHaptic('error'); - setError(err instanceof AttachmentTooLargeError ? err.message : t('chat.failedToSendPhoto')); + setError(describeApiError(err, t)); } finally { setIsCapturing(false); } @@ -100,7 +103,7 @@ export default function CameraScreen() { onPress={() => router.back()} style={styles.closeButton} > - + router.back()} style={[styles.closeButton, { top: insets.top + 8 }]} > - + {url ? : null} diff --git a/src/app/index.tsx b/src/app/index.tsx index 5603edd..9a85ba6 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -80,7 +80,7 @@ function LoadingScreen() { animatedStyle, ]} > - + diff --git a/src/components/ui/avatar.test.tsx b/src/components/ui/avatar.test.tsx new file mode 100644 index 0000000..3d35551 --- /dev/null +++ b/src/components/ui/avatar.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { initI18n } from '@/i18n'; + +import { Avatar } from './avatar'; + +beforeAll(() => { + initI18n(); +}); + +describe('Avatar', () => { + it('renders initials fallback when uri is absent', async () => { + await render(); + + expect(screen.getByText('MT')).toBeTruthy(); + }); + + it('renders the image with contentFit="contain" when uri is provided', async () => { + await render(); + + const image = screen.getByTestId('avatar-image'); + expect(image.props.contentFit).toBe('contain'); + }); + + it('renders the initials fallback after the image fires onError', async () => { + await render(); + + const image = screen.getByTestId('avatar-image'); + await fireEvent(image, 'onError', { nativeEvent: { error: 'load failed' } }); + + expect(screen.getByText('JD')).toBeTruthy(); + expect(screen.queryByTestId('avatar-image')).toBeNull(); + }); +}); diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index fd7b3a6..1c902d2 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -1,5 +1,6 @@ import { Image } from 'expo-image'; import type { ReactNode } from 'react'; +import { useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { withAlpha } from '@/theme/color-utils'; @@ -39,6 +40,7 @@ function hashString(value: string): number { export function Avatar({ uri, name, size = 40, badge, ring = 'none' }: AvatarProps) { const { colors } = useTheme(); + const [failedUri, setFailedUri] = useState(null); const dimensionStyle = { width: size, height: size, borderRadius: size / 2 }; const ringColor = ring === 'online' ? colors.success : ring === 'bot' ? colors.bubbleBotAccent : undefined; @@ -47,12 +49,16 @@ export function Avatar({ uri, name, size = 40, badge, ring = 'none' }: AvatarPro const paletteIndex = hashString(name) % colors.avatarPalette.length; const fallbackColor = colors.avatarPalette[paletteIndex]!; - const core = uri ? ( + const showImage = !!uri && uri !== failedUri; + + const core = showImage ? ( setFailedUri(uri)} /> ) : ( diff --git a/src/components/ui/brand-icons.tsx b/src/components/ui/brand-icons.tsx new file mode 100644 index 0000000..c16b4d6 --- /dev/null +++ b/src/components/ui/brand-icons.tsx @@ -0,0 +1,37 @@ +import { Path, Svg } from 'react-native-svg'; + +/** + * lucide has no brand/logo glyphs (Messenger, Instagram, WhatsApp, Zalo, Telegram, TikTok) — these + * are hand-copied path strings from the Simple Icons project (CC0 1.0, ~2 KB total). Do NOT add + * the `simple-icons` npm package (5 MB, ships every brand as a separate export) just for six + * paths. + */ +const BRAND_PATHS = { + messenger: + 'M12 0C5.373 0 0 4.974 0 11.111c0 3.498 1.744 6.614 4.469 8.652V24l4.088-2.242c1.092.301 2.246.464 3.443.464 6.627 0 12-4.975 12-11.111C24 4.974 18.627 0 12 0zm1.191 14.963l-3.055-3.26-5.963 3.26L10.732 8l3.131 3.259L19.752 8l-6.561 6.963z', + instagram: + 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z', + whatsapp: + 'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347zM12.05 21.785h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898 1.866 1.867 2.893 4.35 2.892 6.99-.003 5.45-4.437 9.888-9.884 9.888m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413', + zalo: 'M12 0C5.373 0 0 5.373 0 12c0 6.075 4.514 11.098 10.375 11.877V15.75H7.875v-3.375h2.5V9.75c0-2.475 1.475-3.85 3.738-3.85 1.083 0 2.213.194 2.213.194v2.437h-1.246c-1.229 0-1.612.762-1.612 1.544v1.85h2.744l-.438 3.375h-2.306v8.127C19.486 23.098 24 18.075 24 12c0-6.627-5.373-12-12-12z', + telegram: + 'M23.91 3.79L20.3 20.84c-.27 1.21-.98 1.51-1.99.94l-5.5-4.06-2.66 2.56c-.29.29-.54.54-1.11.54l.4-5.63L20.44 5.83c.46-.4-.1-.63-.72-.23L6.4 13.62l-5.42-1.7C-.16 11.65-.16 10.93 1.11 10.44l19.6-7.6c.86-.35 1.62.2 1.34 2.05z', + tiktok: + 'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.43 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z', +} as const; + +export type BrandIconName = keyof typeof BRAND_PATHS; + +interface BrandIconProps { + name: BrandIconName; + size?: number; + color: string; +} + +export function BrandIcon({ name, size = 24, color }: BrandIconProps) { + return ( + + + + ); +} diff --git a/src/components/ui/chip.tsx b/src/components/ui/chip.tsx index d73621e..279547e 100644 --- a/src/components/ui/chip.tsx +++ b/src/components/ui/chip.tsx @@ -84,7 +84,7 @@ export function Chip({ {onRemove ? ( = useMemo( () => ({ online: { - icon: 'checkmark-circle-outline', + icon: 'circle-check', background: colors.successSoft, textColor: colors.success, label: t('realtime.online', { defaultValue: 'Back online' }), }, connecting: { - icon: 'sync-outline', + icon: 'refresh-cw', background: colors.infoSoft, textColor: colors.info, label: t('realtime.connecting', { defaultValue: 'Connecting…' }), }, reconnecting: { - icon: 'sync-outline', + icon: 'refresh-cw', background: colors.warningSoft, textColor: colors.warning, label: t('realtime.reconnecting', { defaultValue: 'Reconnecting…' }), }, offline: { - icon: 'cloud-offline-outline', + icon: 'cloud-off', background: colors.dangerSoft, textColor: colors.danger, label: t('realtime.offline', { defaultValue: "You're offline" }), diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx index d7f324d..8817903 100644 --- a/src/components/ui/empty-state.tsx +++ b/src/components/ui/empty-state.tsx @@ -17,12 +17,7 @@ interface EmptyStateProps { const ICON_CIRCLE_SIZE = 72; -export function EmptyState({ - icon = 'file-tray-outline', - title, - description, - action, -}: EmptyStateProps) { +export function EmptyState({ icon = 'inbox', title, description, action }: EmptyStateProps) { const { colors, spacing } = useTheme(); const reducedMotion = useReducedMotion(); diff --git a/src/components/ui/error-banner.tsx b/src/components/ui/error-banner.tsx index 2c6521b..4f1f929 100644 --- a/src/components/ui/error-banner.tsx +++ b/src/components/ui/error-banner.tsx @@ -44,11 +44,7 @@ export function ErrorBanner({ message, actionLabel, onAction, tone = 'danger' }: }, ]} > - + {message} diff --git a/src/components/ui/feature-gated-list-item.tsx b/src/components/ui/feature-gated-list-item.tsx new file mode 100644 index 0000000..04fc8d0 --- /dev/null +++ b/src/components/ui/feature-gated-list-item.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next'; + +import { Badge } from '@/components/ui/badge'; +import { Icon, type IconName } from '@/components/ui/icon'; +import { ListItem } from '@/components/ui/list-item'; +import { useTheme } from '@/theme/use-theme'; + +interface FeatureGatedListItemProps { + title: string; + icon: IconName; + /** Whether the underlying feature flag is on. While `false`, the row shows a "Coming soon" + * badge and is disabled — still listed (not hidden entirely) so the feature stays discoverable + * before it ships. */ + enabled: boolean; + onPress: () => void; +} + +/** Destructive list-item row gated behind a feature flag — icon + danger tint + "Coming soon" + * badge while `enabled` is false. Shared by every block/delete row across the contact and + * conversation action sheets, which previously duplicated this exact `ListItem` + `Icon` + + * `Badge` + `disabled` shape verbatim. */ +export function FeatureGatedListItem({ title, icon, enabled, onPress }: FeatureGatedListItemProps) { + const { t } = useTranslation(); + const { colors } = useTheme(); + + return ( + } + trailing={enabled ? undefined : {t('common.comingSoon')}} + destructive + disabled={!enabled} + onPress={onPress} + /> + ); +} diff --git a/src/components/ui/icon-button.test.tsx b/src/components/ui/icon-button.test.tsx index 77ee7a4..bef33f0 100644 --- a/src/components/ui/icon-button.test.tsx +++ b/src/components/ui/icon-button.test.tsx @@ -11,7 +11,7 @@ beforeAll(() => { describe('IconButton', () => { it('exposes the required accessibilityLabel and button role', async () => { await render( - {}} />, + {}} />, ); const button = screen.getByRole('button', { name: 'Close conversation' }); @@ -20,9 +20,7 @@ describe('IconButton', () => { it('calls onPress when pressed', async () => { const onPress = jest.fn(); - await render( - , - ); + await render(); fireEvent.press(screen.getByRole('button', { name: 'Archive' })); @@ -30,7 +28,9 @@ describe('IconButton', () => { }); it('marks itself disabled via accessibilityState when loading', async () => { - await render( {}} />); + await render( + {}} />, + ); const button = screen.getByRole('button', { name: 'Send' }); expect(button.props.accessibilityState).toMatchObject({ disabled: true, busy: true }); @@ -39,7 +39,7 @@ describe('IconButton', () => { it('does not call onPress when disabled', async () => { const onPress = jest.fn(); await render( - , + , ); fireEvent.press(screen.getByRole('button', { name: 'Delete' })); diff --git a/src/components/ui/icon.test.tsx b/src/components/ui/icon.test.tsx new file mode 100644 index 0000000..16862d5 --- /dev/null +++ b/src/components/ui/icon.test.tsx @@ -0,0 +1,104 @@ +import { render, screen } from '@testing-library/react-native'; + +import { Icon, type IconName } from './icon'; + +const ICON_NAMES: IconName[] = [ + 'archive', + 'archive-x', + 'app-window', + 'bell', + 'bot', + 'bot-off', + 'building-2', + 'calendar-clock', + 'calendar-days', + 'camera', + 'check', + 'chevron-down', + 'chevron-left', + 'chevron-right', + 'circle', + 'circle-alert', + 'circle-check', + 'circle-plus', + 'circle-x', + 'clock', + 'cloud-off', + 'compass', + 'ellipsis-vertical', + 'eye', + 'eye-off', + 'file', + 'file-text', + 'globe', + 'hash', + 'heart', + 'image', + 'inbox', + 'info', + 'layers-2', + 'list-filter', + 'lock', + 'mail', + 'mail-open', + 'map-pin', + 'message-circle', + 'message-circle-more', + 'message-square-more', + 'message-square-text', + 'messages-square', + 'moon', + 'music', + 'paperclip', + 'pencil', + 'phone', + 'refresh-cw', + 'reply', + 'save', + 'search', + 'send-horizontal', + 'settings', + 'square', + 'square-check', + 'square-pen', + 'star', + 'star-off', + 'sun', + 'tag', + 'triangle-alert', + 'type', + 'user', + 'user-check', + 'user-lock', + 'user-minus', + 'user-plus', + 'user-round-x', + 'users', + 'users-round', + 'video', + 'webhook', + 'workflow', + 'x', +]; + +describe('Icon', () => { + it.each(ICON_NAMES)('renders "%s" without throwing', async (name) => { + await expect(render()).resolves.toBeTruthy(); + }); + + it('passes fill=color when filled is true', async () => { + await render(); + expect(JSON.stringify(screen.toJSON())).toContain('"fill":"#f00"'); + }); + + it('defaults to fill="none" when not filled', async () => { + await render(); + expect(JSON.stringify(screen.toJSON())).toContain('"fill":"none"'); + }); + + it('throws in dev mode for an unknown icon name', async () => { + await expect( + render(), + ).rejects.toThrow(); + }); +}); diff --git a/src/components/ui/icon.tsx b/src/components/ui/icon.tsx index 3de47a3..127b535 100644 --- a/src/components/ui/icon.tsx +++ b/src/components/ui/icon.tsx @@ -1,21 +1,208 @@ -import { Ionicons } from '@expo/vector-icons'; -import type { ComponentProps } from 'react'; +import { + Archive, + ArchiveX, + AppWindow, + ArrowDown, + Bell, + Bot, + BotOff, + Building2, + CalendarClock, + CalendarDays, + Camera, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + Circle, + CircleAlert, + CircleCheck, + CirclePlus, + CircleX, + Clock, + CloudOff, + Compass, + EllipsisVertical, + Eye, + EyeOff, + File, + FileText, + Globe, + Hash, + Heart, + Image, + Inbox, + Info, + Layers2, + ListFilter, + Lock, + Mail, + MailOpen, + MapPin, + MessageCircle, + MessageCircleMore, + MessageSquareMore, + MessageSquareText, + MessagesSquare, + Moon, + Music, + Paperclip, + Pencil, + Phone, + RefreshCw, + Reply, + Save, + Search, + SendHorizontal, + Settings, + Square, + SquareCheck, + SquarePen, + Star, + StarOff, + Sun, + Tag, + TriangleAlert, + Type, + User, + UserCheck, + UserLock, + UserMinus, + UserPlus, + UserRoundX, + Users, + UsersRound, + Video, + Webhook, + Workflow, + X, + type LucideIcon, +} from 'lucide-react-native'; import { I18nManager } from 'react-native'; -export type IconName = ComponentProps['name']; +/** + * Semantic icon registry — one lucide component per key. Keys mirror lucide's own kebab-case + * names (`bot`, `archive-x`, `user-lock`…) so this file and the web app's `lucide-react` icon + * usages are 1:1 greppable, with no second alias layer to keep in sync. Only registered icons are + * imported above, so unused lucide icons are tree-shaken. + */ +const ICONS = { + archive: Archive, + 'archive-x': ArchiveX, + 'app-window': AppWindow, + 'arrow-down': ArrowDown, + bell: Bell, + bot: Bot, + 'bot-off': BotOff, + 'building-2': Building2, + 'calendar-clock': CalendarClock, + 'calendar-days': CalendarDays, + camera: Camera, + check: Check, + 'chevron-down': ChevronDown, + 'chevron-left': ChevronLeft, + 'chevron-right': ChevronRight, + circle: Circle, + 'circle-alert': CircleAlert, + 'circle-check': CircleCheck, + 'circle-plus': CirclePlus, + 'circle-x': CircleX, + clock: Clock, + 'cloud-off': CloudOff, + compass: Compass, + 'ellipsis-vertical': EllipsisVertical, + eye: Eye, + 'eye-off': EyeOff, + file: File, + 'file-text': FileText, + globe: Globe, + hash: Hash, + heart: Heart, + image: Image, + inbox: Inbox, + info: Info, + 'layers-2': Layers2, + 'list-filter': ListFilter, + lock: Lock, + mail: Mail, + 'mail-open': MailOpen, + 'map-pin': MapPin, + 'message-circle': MessageCircle, + 'message-circle-more': MessageCircleMore, + 'message-square-more': MessageSquareMore, + 'message-square-text': MessageSquareText, + 'messages-square': MessagesSquare, + moon: Moon, + music: Music, + paperclip: Paperclip, + pencil: Pencil, + phone: Phone, + 'refresh-cw': RefreshCw, + reply: Reply, + save: Save, + search: Search, + 'send-horizontal': SendHorizontal, + settings: Settings, + square: Square, + 'square-check': SquareCheck, + 'square-pen': SquarePen, + star: Star, + 'star-off': StarOff, + sun: Sun, + tag: Tag, + 'triangle-alert': TriangleAlert, + type: Type, + user: User, + 'user-check': UserCheck, + 'user-lock': UserLock, + 'user-minus': UserMinus, + 'user-plus': UserPlus, + 'user-round-x': UserRoundX, + users: Users, + 'users-round': UsersRound, + video: Video, + webhook: Webhook, + workflow: Workflow, + x: X, +} satisfies Record; + +export type IconName = keyof typeof ICONS; interface IconProps { name: IconName; size?: number; color: string; + strokeWidth?: number; + /** Fills the glyph with `color` for "active" states (followed star, liked heart) — lucide icons + * are stroke-only by default, so there is no separate filled variant per icon. */ + filled?: boolean; /** Mirror this icon horizontally in RTL layouts (chevrons, send arrow, etc). `I18nManager.isRTL` * only changes on app reload, so reading it at render time is safe — no live-toggle case exists. */ flipRTL?: boolean; } -/** Single wrapper around the icon set in use (Ionicons) so swapping icon libraries later touches - * one file instead of every call site. */ -export function Icon({ name, size = 24, color, flipRTL = false }: IconProps) { +/** Single wrapper around the icon set in use (lucide-react-native) so swapping icon libraries + * later touches one file instead of every call site. */ +export function Icon({ + name, + size = 24, + color, + strokeWidth = 2, + filled = false, + flipRTL = false, +}: IconProps) { + const Component = ICONS[name]; + if (__DEV__ && !Component) { + throw new Error(`Icon: unknown icon name "${name}"`); + } const style = flipRTL && I18nManager.isRTL ? { transform: [{ scaleX: -1 }] } : undefined; - return ; + return ( + + ); } diff --git a/src/components/ui/list-item.tsx b/src/components/ui/list-item.tsx index 3fa9258..d1a0585 100644 --- a/src/components/ui/list-item.tsx +++ b/src/components/ui/list-item.tsx @@ -85,7 +85,7 @@ export function ListItem({ ) : null} {trailing} {showChevron ? ( - + ) : null} 0 ? ( - + {t('errors.workspaceBlockedTitle')} diff --git a/src/config/features.ts b/src/config/features.ts index 8ca5468..acfb294 100644 --- a/src/config/features.ts +++ b/src/config/features.ts @@ -6,7 +6,15 @@ * (`POST /workspaces/{workspaceId}/contacts/sequences`) doesn't exist yet — see * `src/features/sequences/api/use-enroll-contact-in-sequences.ts`. Flip to `true` once that route * ships and the hand-rolled fetch there is replaced with the generated client. + * + * `blockContact`/`deleteContact`: no session-auth routes exist yet — only the workspace-token + * `blockContactWorkspaceTokenAPI`/`unblockContactWorkspaceTokenAPI` do, and there's no delete + * route at all. See `use-contact-block.ts`/`use-delete-contact.ts`. Flip to `true` once + * `POST/DELETE /workspaces/{workspaceId}/contacts/{contactId}/block|unblock|` ship under + * bearer/session auth and `pnpm generate:api` has been re-run. */ export const FEATURES = { sendSequence: false, + blockContact: false, + deleteContact: false, } as const; diff --git a/src/dev/gallery-content.tsx b/src/dev/gallery-content.tsx index 6e5d057..b38cd88 100644 --- a/src/dev/gallery-content.tsx +++ b/src/dev/gallery-content.tsx @@ -71,7 +71,7 @@ export function GalleryContent() { trailing={ @@ -104,7 +104,7 @@ export function GalleryContent() {