From b908148bbddebdad1762952f095a7593daeca84c Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 1 Sep 2025 13:26:23 +0200 Subject: [PATCH 001/519] bump react-native-lottie --- ios/Podfile.lock | 10 +++--- package-lock.json | 33 ++++++++++++++----- package.json | 3 +- ...react-native+6.5.1+003+support-RN-77.patch | 24 -------------- 4 files changed, 32 insertions(+), 38 deletions(-) delete mode 100644 patches/lottie-react-native+6.5.1+003+support-RN-77.patch diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9cb3cd9f3556..6bf8c020fa63 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -347,12 +347,12 @@ PODS: - libwebp/sharpyuv (1.3.2) - libwebp/webp (1.3.2): - libwebp/sharpyuv - - lottie-ios (4.3.4) - - lottie-react-native (6.5.1): + - lottie-ios (4.5.0) + - lottie-react-native (7.3.3): - DoubleConversion - glog - hermes-engine - - lottie-ios (~> 4.3.3) + - lottie-ios (= 4.5.0) - RCT-Folly (= 2024.11.18.00) - RCTRequired - RCTTypeSafety @@ -3798,8 +3798,8 @@ SPEC CHECKSUMS: libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 - lottie-ios: 3d98679b41fa6fd6aff2352b3953dbd3df8a397e - lottie-react-native: 539be2007394ec90451fd09ee7eeacbb61da4153 + lottie-ios: a881093fab623c467d3bce374367755c272bdd59 + lottie-react-native: 56923b267e4d0037443bf4b26a29cb06c6358f2f MapboxCommon: 873b75dd0e8c5d7029e0c849437eba365f4887e5 MapboxCoreMaps: 35685edba03e44468aed57c3dfd7f8795edafda8 MapboxMaps: 05822ab0ee74f7d626e6471572439afe35c1c116 diff --git a/package-lock.json b/package-lock.json index ba44bd3fa851..8b7c6d10e359 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@fullstory/react-native": "^1.7.6", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", + "@lottiefiles/dotlottie-react": "^0.13.5", "@onfido/react-native-sdk": "10.6.0", "@pusher/pusher-websocket-react-native": "^1.3.1", "@react-native-camera-roll/camera-roll": "7.4.0", @@ -75,7 +76,7 @@ "idb-keyval": "^6.2.1", "jszip": "^3.10.1", "lodash-es": "4.17.21", - "lottie-react-native": "6.5.1", + "lottie-react-native": "7.3.3", "mapbox-gl": "^2.15.0", "onfido-sdk-ui": "14.42.0", "pako": "^2.1.0", @@ -7719,6 +7720,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@lottiefiles/dotlottie-react": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz", + "integrity": "sha512-4U5okwjRqDPkjB572hfZtLXJ/LGfCo6vDwUB2KIPEUoSgqbIlw+UrbnaqVp3GS+dRvhMD27F2JObpHpYRlpF0Q==", + "license": "MIT", + "dependencies": { + "@lottiefiles/dotlottie-web": "0.44.0" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19" + } + }, + "node_modules/@lottiefiles/dotlottie-web": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.44.0.tgz", + "integrity": "sha512-IUWKVciDJI/BMWDWnh7j0Ngd0N8q9ySRAwm84aDqIE07qpmdZ7x1rkIpBaU1yHSNqNYHeh1Rxsl+LC3CY4f0KA==", + "license": "MIT" + }, "node_modules/@lwc/eslint-plugin-lwc": { "version": "1.7.2", "dev": true, @@ -27740,20 +27759,18 @@ } }, "node_modules/lottie-react-native": { - "version": "6.5.1", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-7.3.3.tgz", + "integrity": "sha512-NKGQZllI6rrxF0Mg1amCGwmfFJftI+7v54f48BfWpHH0tTl0RNRj1dujIMvhKGKPt485B41AfGOrFXC1k7/i7w==", "license": "Apache-2.0", "peerDependencies": { - "@dotlottie/react-player": "^1.6.1", - "@lottiefiles/react-lottie-player": "^3.5.3", + "@lottiefiles/dotlottie-react": "^0.13.5", "react": "*", "react-native": ">=0.46", "react-native-windows": ">=0.63.x" }, "peerDependenciesMeta": { - "@dotlottie/react-player": { - "optional": true - }, - "@lottiefiles/react-lottie-player": { + "@lottiefiles/dotlottie-react": { "optional": true }, "react-native-windows": { diff --git a/package.json b/package.json index 2297eb9aacb3..c54a3d7903e2 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "@fullstory/react-native": "^1.7.6", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", + "@lottiefiles/dotlottie-react": "^0.13.5", "@onfido/react-native-sdk": "10.6.0", "@pusher/pusher-websocket-react-native": "^1.3.1", "@react-native-camera-roll/camera-roll": "7.4.0", @@ -145,7 +146,7 @@ "idb-keyval": "^6.2.1", "jszip": "^3.10.1", "lodash-es": "4.17.21", - "lottie-react-native": "6.5.1", + "lottie-react-native": "7.3.3", "mapbox-gl": "^2.15.0", "onfido-sdk-ui": "14.42.0", "pako": "^2.1.0", diff --git a/patches/lottie-react-native+6.5.1+003+support-RN-77.patch b/patches/lottie-react-native+6.5.1+003+support-RN-77.patch deleted file mode 100644 index 46d033857bb1..000000000000 --- a/patches/lottie-react-native+6.5.1+003+support-RN-77.patch +++ /dev/null @@ -1,24 +0,0 @@ -diff --git a/node_modules/lottie-react-native/android/src/main/java/com/airbnb/android/react/lottie/LottieAnimationViewPropertyManager.kt b/node_modules/lottie-react-native/android/src/main/java/com/airbnb/android/react/lottie/LottieAnimationViewPropertyManager.kt -index e4bfb4a..851aec6 100644 ---- a/node_modules/lottie-react-native/android/src/main/java/com/airbnb/android/react/lottie/LottieAnimationViewPropertyManager.kt -+++ b/node_modules/lottie-react-native/android/src/main/java/com/airbnb/android/react/lottie/LottieAnimationViewPropertyManager.kt -@@ -102,8 +102,8 @@ class LottieAnimationViewPropertyManager(view: LottieAnimationView) { - textFilters?.let { - if (it.size() > 0) { - val textDelegate = TextDelegate(view) -- for (i in 0 until textFilters!!.size()) { -- val current = textFilters!!.getMap(i) -+ for (i in 0 until it.size()) { -+ val current = it.getMap(i) ?: continue - val searchText = current.getString("find") - val replacementText = current.getString("replace") - textDelegate.setText(searchText, replacementText) -@@ -213,7 +213,7 @@ class LottieAnimationViewPropertyManager(view: LottieAnimationView) { - colorFilters?.let { colorFilters -> - if (colorFilters.size() > 0) { - for (i in 0 until colorFilters.size()) { -- val current = colorFilters.getMap(i) -+ val current = colorFilters.getMap(i) ?: continue - parseColorFilter(current, view) - } - } From b8d7692ac31ed4c44046f95bbe2403f281d81b54 Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 1 Sep 2025 13:38:52 +0200 Subject: [PATCH 002/519] rename patches --- ...ycling.patch => lottie-react-native+7.3.3+001+recycling.patch} | 0 ...eless.patch => lottie-react-native+7.3.3+002+bridgeless.patch} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename patches/{lottie-react-native+6.5.1+001+recycling.patch => lottie-react-native+7.3.3+001+recycling.patch} (100%) rename patches/{lottie-react-native+6.5.1+002+bridgeless.patch => lottie-react-native+7.3.3+002+bridgeless.patch} (100%) diff --git a/patches/lottie-react-native+6.5.1+001+recycling.patch b/patches/lottie-react-native+7.3.3+001+recycling.patch similarity index 100% rename from patches/lottie-react-native+6.5.1+001+recycling.patch rename to patches/lottie-react-native+7.3.3+001+recycling.patch diff --git a/patches/lottie-react-native+6.5.1+002+bridgeless.patch b/patches/lottie-react-native+7.3.3+002+bridgeless.patch similarity index 100% rename from patches/lottie-react-native+6.5.1+002+bridgeless.patch rename to patches/lottie-react-native+7.3.3+002+bridgeless.patch From d7135f7aefd6c1fc8fb22e967cb1c2e71cc3d001 Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 5 Sep 2025 11:08:04 +0200 Subject: [PATCH 003/519] add dotlottie-web patch fixing cors policy issue --- ...iefiles+dotlottie-web+0.44.0+001+fix-cors.patch | 13 +++++++++++++ patches/@lottiefiles/dotlottie-web/details.md | 14 ++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch create mode 100644 patches/@lottiefiles/dotlottie-web/details.md diff --git a/patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch b/patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch new file mode 100644 index 000000000000..404bfbcb1159 --- /dev/null +++ b/patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/@lottiefiles/dotlottie-web/dist/index.js b/node_modules/@lottiefiles/dotlottie-web/dist/index.js +index 1ea13c9..5b55100 100644 +--- a/node_modules/@lottiefiles/dotlottie-web/dist/index.js ++++ b/node_modules/@lottiefiles/dotlottie-web/dist/index.js +@@ -14,7 +14,7 @@ return ret; + `,s+=e[f].mc;return o+=` var rv = ${n===1?"new func":"func.call"}(${u.join(", ")}); + `,i.Nc||(c.push("emval_returnValue"),l.push(e3),o+=` return emval_returnValue(retType, destructorsRef, rv); + `),c.push(o+`}; +-`),t=X2(c)(...l),n=`methodCaller<(${e.map(p=>p.name).join(", ")}) => ${i.name}>`,C0(r1(n,t))},Ta:t=>{9{var e=c1(t);i2(e),u2(t);},F:(t,e)=>(t=w1(t,"_emval_take_value"),t=t.readValueFromPointer(e),y1(t)),oa:(t,e)=>{if(E1[t]&&(clearTimeout(E1[t].id),delete E1[t]),!e)return 0;var n=setTimeout(()=>{delete E1[t],L0(()=>s3(t,performance.now()));},e);return E1[t]={id:n,Uc:e},0},pa:(t,e,n,i)=>{var o=new Date().getFullYear(),s=new Date(o,0,1).getTimezoneOffset();o=new Date(o,6,1).getTimezoneOffset(),F[t>>2]=60*Math.max(s,o),t1[e>>2]=+(s!=o),e=u=>{var c=Math.abs(u);return `UTC${0<=u?"-":"+"}${String(Math.floor(c/60)).padStart(2,"0")}${String(c%60).padStart(2,"0")}`},t=e(s),e=e(o),o{var e=k.length;if(t>>>=0,2147483648=n;n*=2){var i=e*(1+.2/n);i=Math.min(i,t+100663296);e:{i=(Math.min(2147483648,65536*Math.ceil(Math.max(t,i)/65536))-x1.buffer.byteLength+65535)/65536|0;try{x1.grow(i),F2();var o=1;break e}catch(s){}o=void 0;}if(o)return !0}return !1},Ca:(t,e)=>{var n=0;return r3().forEach((i,o)=>{var s=e+n;for(o=F[t+4*o>>2]=s,s=0;s{var n=r3();F[t>>2]=n.length;var i=0;return n.forEach(o=>i+=o.length+1),F[e>>2]=i,0},za:()=>52,xa:()=>52,U:(t,e,n,i)=>{for(var o=0,s=0;s>2],c=F[e+4>>2];e+=8;for(var l=0;l>2]=o,0},Ia:Q0,n:D0,$:j0,La:J0,g:x0,u:O0,Na:A0,G:z0,J:N0,f:R0,_:q0,h:$0,Ma:B0,k:U0,R:H0,t:W0,V:a4,W:o4,Xa:I4,bb:C4,ha:l4,ka:d4,la:c4,fa:p4,db:g4,I:s4,a:S0,B:Z0,E:k0,X:r4,c:I0,Ka:G0,Ha:e4,e:T0,Y:t4,Q:i4,j:F0,y:n4,i:V0,p:Y0,s:K0,Z:X0,Wa:S4,Za:E4,Ya:P4,ab:b4,$a:L4,_a:M4,cb:w4,ia:h4,ga:f4,Va:T4,fb:_4,ea:v4,gb:m4,ja:u4,Ua:F4,eb:y4,q:t=>t,Ba:n3,sa:(t,e)=>(i3(k.subarray(t,t+e)),0)},w=function(){var n;function t(i){var o;return w=i.exports,x1=w.hb,F2(),P=w.mb,R2.unshift(w.ib),n1--,(o=d.monitorRunDependencies)==null||o.call(d,n1),n1==0&&(p1&&(i=p1,p1=null,i())),w}n1++,(n=d.monitorRunDependencies)==null||n.call(d,n1);var e={a:P0};if(d.instantiateWasm)try{return d.instantiateWasm(e,t)}catch(i){K(`Module.instantiateWasm callback failed with error: ${i}`),T(i);}return v1!=null||(v1=A2("DotLottiePlayer.wasm")?"DotLottiePlayer.wasm":d.locateFile?d.locateFile("DotLottiePlayer.wasm",U):U+"DotLottiePlayer.wasm"),Z3(e,function(i){t(i.instance);}).catch(T),{}}(),v2=t=>(v2=w.jb)(t),a3=t=>(a3=w.kb)(t),Q=t=>(Q=w.lb)(t),s3=(t,e)=>(s3=w.nb)(t,e),m=(t,e)=>(m=w.ob)(t,e),P1=t=>(P1=w.pb)(t),_=t=>(_=w.qb)(t),y=()=>(y=w.rb)(),c3=t=>(c3=w.sb)(t),d3=t=>(d3=w.tb)(t),u3=(t,e,n)=>(u3=w.ub)(t,e,n),h3=t=>(h3=w.vb)(t),l3=d.dynCall_ji=(t,e)=>(l3=d.dynCall_ji=w.wb)(t,e),f3=d.dynCall_viji=(t,e,n,i,o)=>(f3=d.dynCall_viji=w.xb)(t,e,n,i,o),p3=d.dynCall_jii=(t,e,n)=>(p3=d.dynCall_jii=w.yb)(t,e,n);d.dynCall_iijj=(t,e,n,i,o,s)=>(d.dynCall_iijj=w.zb)(t,e,n,i,o,s),d.dynCall_vijj=(t,e,n,i,o,s)=>(d.dynCall_vijj=w.Ab)(t,e,n,i,o,s);var v3=d.dynCall_vjiii=(t,e,n,i,o,s)=>(v3=d.dynCall_vjiii=w.Bb)(t,e,n,i,o,s),m3=d.dynCall_vij=(t,e,n,i)=>(m3=d.dynCall_vij=w.Cb)(t,e,n,i),_3=d.dynCall_viijii=(t,e,n,i,o,s,u)=>(_3=d.dynCall_viijii=w.Db)(t,e,n,i,o,s,u),y3=d.dynCall_jjji=(t,e,n,i,o,s)=>(y3=d.dynCall_jjji=w.Eb)(t,e,n,i,o,s),g3=d.dynCall_viijj=(t,e,n,i,o,s,u)=>(g3=d.dynCall_viijj=w.Fb)(t,e,n,i,o,s,u),w3=d.dynCall_viijji=(t,e,n,i,o,s,u,c)=>(w3=d.dynCall_viijji=w.Gb)(t,e,n,i,o,s,u,c),C3=d.dynCall_viij=(t,e,n,i,o)=>(C3=d.dynCall_viij=w.Hb)(t,e,n,i,o),b3=d.dynCall_iiiijj=(t,e,n,i,o,s,u,c)=>(b3=d.dynCall_iiiijj=w.Ib)(t,e,n,i,o,s,u,c),L3=d.dynCall_viiij=(t,e,n,i,o,s)=>(L3=d.dynCall_viiij=w.Jb)(t,e,n,i,o,s),M3=d.dynCall_viiji=(t,e,n,i,o,s)=>(M3=d.dynCall_viiji=w.Kb)(t,e,n,i,o,s),E3=d.dynCall_jiii=(t,e,n,i)=>(E3=d.dynCall_jiii=w.Lb)(t,e,n,i),P3=d.dynCall_viiiji=(t,e,n,i,o,s,u)=>(P3=d.dynCall_viiiji=w.Mb)(t,e,n,i,o,s,u),I3=d.dynCall_viiijj=(t,e,n,i,o,s,u,c)=>(I3=d.dynCall_viiijj=w.Nb)(t,e,n,i,o,s,u,c),S3=d.dynCall_viiiijjiiiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S)=>(S3=d.dynCall_viiiijjiiiiii=w.Ob)(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S),T3=d.dynCall_viiiijjiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L)=>(T3=d.dynCall_viiiijjiiii=w.Pb)(t,e,n,i,o,s,u,c,l,f,p,C,L),F3=d.dynCall_iiiiiijjii=(t,e,n,i,o,s,u,c,l,f,p,C)=>(F3=d.dynCall_iiiiiijjii=w.Qb)(t,e,n,i,o,s,u,c,l,f,p,C),x3=d.dynCall_viiiijjii=(t,e,n,i,o,s,u,c,l,f,p)=>(x3=d.dynCall_viiiijjii=w.Rb)(t,e,n,i,o,s,u,c,l,f,p),R3=d.dynCall_viijiii=(t,e,n,i,o,s,u,c)=>(R3=d.dynCall_viijiii=w.Sb)(t,e,n,i,o,s,u,c),j3=d.dynCall_iji=(t,e,n,i)=>(j3=d.dynCall_iji=w.Tb)(t,e,n,i),A3=d.dynCall_vijjjj=(t,e,n,i,o,s,u,c,l,f)=>(A3=d.dynCall_vijjjj=w.Ub)(t,e,n,i,o,s,u,c,l,f);d.dynCall_vjii=(t,e,n,i,o)=>(d.dynCall_vjii=w.Vb)(t,e,n,i,o),d.dynCall_vjfii=(t,e,n,i,o,s)=>(d.dynCall_vjfii=w.Wb)(t,e,n,i,o,s),d.dynCall_vj=(t,e,n)=>(d.dynCall_vj=w.Xb)(t,e,n),d.dynCall_vjiiiii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiiiii=w.Yb)(t,e,n,i,o,s,u,c),d.dynCall_vjiffii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiffii=w.Zb)(t,e,n,i,o,s,u,c),d.dynCall_vjiiii=(t,e,n,i,o,s,u)=>(d.dynCall_vjiiii=w._b)(t,e,n,i,o,s,u),d.dynCall_iiiiij=(t,e,n,i,o,s,u)=>(d.dynCall_iiiiij=w.$b)(t,e,n,i,o,s,u),d.dynCall_iiiiijj=(t,e,n,i,o,s,u,c,l)=>(d.dynCall_iiiiijj=w.ac)(t,e,n,i,o,s,u,c,l),d.dynCall_iiiiiijj=(t,e,n,i,o,s,u,c,l,f)=>(d.dynCall_iiiiiijj=w.bc)(t,e,n,i,o,s,u,c,l,f);function I0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function S0(t,e){var n=y();try{P.get(t)(e);}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function T0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function F0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function x0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function R0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function j0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function A0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function k0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function D0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function O0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function $0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function W0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function z0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function U0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function B0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function H0(t,e,n,i,o,s,u){var c=y();try{return P.get(t)(e,n,i,o,s,u)}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function N0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function V0(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function J0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function G0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function Y0(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function K0(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function X0(t,e,n,i,o,s,u,c,l){var f=y();try{P.get(t)(e,n,i,o,s,u,c,l);}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function Z0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function Q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function e4(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function t4(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function n4(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function r4(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function i4(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function o4(t,e,n,i,o,s,u,c,l){var f=y();try{return P.get(t)(e,n,i,o,s,u,c,l)}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function a4(t,e,n,i,o,s,u,c){var l=y();try{return P.get(t)(e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function s4(t){var e=y();try{P.get(t)();}catch(n){if(_(e),n!==n+0)throw n;m(1,0);}}function c4(t,e,n){var i=y();try{return p3(t,e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function d4(t,e){var n=y();try{return l3(t,e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function u4(t,e,n,i,o){var s=y();try{f3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function h4(t,e,n,i,o,s){var u=y();try{M3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function l4(t,e,n,i){var o=y();try{return j3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function f4(t,e,n,i,o,s,u){var c=y();try{_3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function p4(t,e,n,i){var o=y();try{return E3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function v4(t,e,n,i,o,s,u,c){var l=y();try{w3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function m4(t,e,n,i){var o=y();try{m3(t,e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function _4(t,e,n,i,o,s,u){var c=y();try{g3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function y4(t,e,n,i,o,s){var u=y();try{v3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function g4(t,e,n,i,o,s){var u=y();try{return y3(t,e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function w4(t,e,n,i,o){var s=y();try{C3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function C4(t,e,n,i,o,s,u,c){var l=y();try{return b3(t,e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function b4(t,e,n,i,o,s){var u=y();try{L3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function L4(t,e,n,i,o,s,u){var c=y();try{P3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function M4(t,e,n,i,o,s,u,c){var l=y();try{I3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function E4(t,e,n,i,o,s,u,c,l,f,p,C,L){var I=y();try{T3(t,e,n,i,o,s,u,c,l,f,p,C,L);}catch(S){if(_(I),S!==S+0)throw S;m(1,0);}}function P4(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S){var B=y();try{S3(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S);}catch(V){if(_(B),V!==V+0)throw V;m(1,0);}}function I4(t,e,n,i,o,s,u,c,l,f,p,C){var L=y();try{return F3(t,e,n,i,o,s,u,c,l,f,p,C)}catch(I){if(_(L),I!==I+0)throw I;m(1,0);}}function S4(t,e,n,i,o,s,u,c,l,f,p){var C=y();try{x3(t,e,n,i,o,s,u,c,l,f,p);}catch(L){if(_(C),L!==L+0)throw L;m(1,0);}}function T4(t,e,n,i,o,s,u,c){var l=y();try{R3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function F4(t,e,n,i,o,s,u,c,l,f){var p=y();try{A3(t,e,n,i,o,s,u,c,l,f);}catch(C){if(_(p),C!==C+0)throw C;m(1,0);}}var H1;p1=function t(){H1||k3(),H1||(p1=t);};function k3(){function t(){var n;if(!H1&&(H1=!0,d.calledRun=!0,!R1)){if(Z1(R2),g(d),(n=d.onRuntimeInitialized)==null||n.call(d),d.postRun)for(typeof d.postRun=="function"&&(d.postRun=[d.postRun]);d.postRun.length;){var e=d.postRun.shift();j2.unshift(e);}Z1(j2);}}if(!(0{setTimeout(()=>d.setStatus(""),1),t();},1)):t());}}if(d.preInit)for(typeof d.preInit=="function"&&(d.preInit=[d.preInit]);0r})})}static _loadWithBackup(){return E(this,null,function*(){return this._ModulePromise||(this._ModulePromise=this._tryLoad(this._wasmURL).catch(r=>E(this,null,function*(){let a=`https://unpkg.com/${C2}@${w2}/dist/dotlottie-player.wasm`;console.warn(`Primary WASM load failed from ${this._wasmURL}. Error: ${r.message}`),console.warn(`Attempting to load WASM from backup URL: ${a}`);try{return yield this._tryLoad(a)}catch(h){throw console.error(`Primary WASM URL failed: ${r.message}`),console.error(`Backup WASM URL failed: ${h.message}`),new Error("WASM loading failed from all sources.")}}))),this._ModulePromise})}static load(){return E(this,null,function*(){return this._loadWithBackup()})}static setWasmUrl(r){r!==this._wasmURL&&(this._wasmURL=r,this._ModulePromise=null);}};b(o1,"_ModulePromise",null),b(o1,"_wasmURL",`https://cdn.jsdelivr.net/npm/${C2}@${w2}/dist/dotlottie-player.wasm`);var h1=class{constructor(){b(this,"_eventListeners",new Map);}addEventListener(r,a){let h=this._eventListeners.get(r);h||(h=new Set,this._eventListeners.set(r,h)),h.add(a);}removeEventListener(r,a){let h=this._eventListeners.get(r);h&&(a?(h.delete(a),h.size===0&&this._eventListeners.delete(r)):this._eventListeners.delete(r));}dispatch(r){let a=this._eventListeners.get(r.type);a==null||a.forEach(h=>h(r));}removeAllEventListeners(){this._eventListeners.clear();}};var H=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);d&&(h.isIntersecting?d.unfreeze():d.freeze());});};this._observer=new IntersectionObserver(r,{threshold:0});}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,a),(h=this._observer)==null||h.observe(r));}static unobserve(r){var a,h;(a=this._observer)==null||a.unobserve(r),this._observedCanvases.delete(r),this._observedCanvases.size===0&&((h=this._observer)==null||h.disconnect(),this._observer=null);}};b(H,"_observer",null),b(H,"_observedCanvases",new Map);var N=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);if(!d)return;let[g,T]=d;clearTimeout(T);let R=setTimeout(()=>{g.resize();},100);this._observedCanvases.set(h.target,[g,R]);});};this._observer=new ResizeObserver(r);}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,[a,0]),(h=this._observer)==null||h.observe(r));}static unobserve(r){var h;let a=this._observedCanvases.get(r);if(a){let d=a[1];d&&clearTimeout(d);}(h=this._observer)==null||h.unobserve(r),this._observedCanvases.delete(r),!this._observedCanvases.size&&this._observer&&(this._observer.disconnect(),this._observer=null);}};b(N,"_observer",null),b(N,"_observedCanvases",new Map);function k4(v){return /^#([\da-f]{6}|[\da-f]{8})$/iu.test(v)}function N3(v){if(!k4(v))return 0;let r=v.replace("#","");return r=r.length===6?`${r}ff`:r,parseInt(r,16)}function b2(v){if(v.byteLength<4)return !1;let r=new Uint8Array(v.slice(0,J1.byteLength));for(let a=0;aObject.prototype.hasOwnProperty.call(v,r))}function L2(v){if(typeof v=="string")try{return H3(JSON.parse(v))}catch(r){return !1}else return H3(v)}function e1(){return 1+((O?window.devicePixelRatio:1)-1)*U3}function G1(v){let r=v.getBoundingClientRect();return r.top>=0&&r.left>=0&&r.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&r.right<=(window.innerWidth||document.documentElement.clientWidth)}var M2=(v,r)=>v==="reverse"?r.Mode.Reverse:v==="bounce"?r.Mode.Bounce:v==="reverse-bounce"?r.Mode.ReverseBounce:r.Mode.Forward,D4=(v,r)=>v==="contain"?r.Fit.Contain:v==="cover"?r.Fit.Cover:v==="fill"?r.Fit.Fill:v==="fit-height"?r.Fit.FitHeight:v==="fit-width"?r.Fit.FitWidth:r.Fit.None,O4=(v,r)=>{let a=new r.VectorFloat;return a.push_back(v[0]),a.push_back(v[1]),a},E2=(v,r)=>{let a=new r.VectorFloat;return v.length!==2||(a.push_back(v[0]),a.push_back(v[1])),a},P2=(v,r)=>{var a,h;return v?{align:O4((a=v.align)!=null?a:[.5,.5],r),fit:D4((h=v.fit)!=null?h:"contain",r)}:r.createDefaultLayout()},x=class x{constructor(r){b(this,"_canvas");b(this,"_context",null);b(this,"_eventManager");b(this,"_animationFrameId",null);b(this,"_frameManager");b(this,"_dotLottieCore",null);b(this,"_renderConfig",{});b(this,"_isFrozen",!1);b(this,"_backgroundColor",null);b(this,"_pointerUpMethod");b(this,"_pointerDownMethod");b(this,"_pointerMoveMethod");b(this,"_pointerEnterMethod");b(this,"_pointerExitMethod");var a,h,d;this._canvas=r.canvas,this._eventManager=new h1,this._frameManager=new q1,this._renderConfig=z(D({},r.renderConfig),{devicePixelRatio:((a=r.renderConfig)==null?void 0:a.devicePixelRatio)||e1(),freezeOnOffscreen:(d=(h=r.renderConfig)==null?void 0:h.freezeOnOffscreen)!=null?d:!0}),o1.load().then(g=>{var T,R,j,$,U,l1,T1,K;x._wasmModule=g,this._dotLottieCore=new g.DotLottiePlayer({themeId:(T=r.themeId)!=null?T:"",stateMachineId:"",autoplay:(R=r.autoplay)!=null?R:!1,backgroundColor:0,loopAnimation:(j=r.loop)!=null?j:!1,mode:M2(($=r.mode)!=null?$:"forward",g),segment:E2((U=r.segment)!=null?U:[],g),speed:(l1=r.speed)!=null?l1:1,useFrameInterpolation:(T1=r.useFrameInterpolation)!=null?T1:!0,marker:(K=r.marker)!=null?K:"",layout:P2(r.layout,g)}),this._eventManager.dispatch({type:"ready"}),r.data?this._loadFromData(r.data):r.src&&this._loadFromSrc(r.src),r.backgroundColor&&this.setBackgroundColor(r.backgroundColor);}).catch(g=>{this._eventManager.dispatch({type:"loadError",error:new Error(`Failed to load wasm module: ${g}`)});}),this._pointerUpMethod=this._onPointerUp.bind(this),this._pointerDownMethod=this._onPointerDown.bind(this),this._pointerMoveMethod=this._onPointerMove.bind(this),this._pointerEnterMethod=this._onPointerEnter.bind(this),this._pointerExitMethod=this._onPointerLeave.bind(this);}_dispatchError(r){console.error(r),this._eventManager.dispatch({type:"loadError",error:new Error(r)});}_fetchData(r){return E(this,null,function*(){let a=yield fetch(r);if(!a.ok)throw new Error(`Failed to fetch animation data from URL: ${r}. ${a.status}: ${a.statusText}`);let h=yield a.arrayBuffer();return b2(h)?h:new TextDecoder().decode(h)})}_loadFromData(r){if(this._dotLottieCore===null)return;let a=this._canvas.width,h=this._canvas.height,d=!1;if(typeof r=="string"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON string: The provided string does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(r,a,h);}else if(r instanceof ArrayBuffer){if(!b2(r)){this._dispatchError("Invalid dotLottie ArrayBuffer: The provided ArrayBuffer does not conform to the dotLottie format.");return}d=this._dotLottieCore.loadDotLottieData(r,a,h);}else if(typeof r=="object"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON object: The provided object does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(JSON.stringify(r),a,h);}else {this._dispatchError(`Unsupported data type for animation data. Expected: ++`),t=X2(c)(...l),n=`methodCaller<(${e.map(p=>p.name).join(", ")}) => ${i.name}>`,C0(r1(n,t))},Ta:t=>{9{var e=c1(t);i2(e),u2(t);},F:(t,e)=>(t=w1(t,"_emval_take_value"),t=t.readValueFromPointer(e),y1(t)),oa:(t,e)=>{if(E1[t]&&(clearTimeout(E1[t].id),delete E1[t]),!e)return 0;var n=setTimeout(()=>{delete E1[t],L0(()=>s3(t,performance.now()));},e);return E1[t]={id:n,Uc:e},0},pa:(t,e,n,i)=>{var o=new Date().getFullYear(),s=new Date(o,0,1).getTimezoneOffset();o=new Date(o,6,1).getTimezoneOffset(),F[t>>2]=60*Math.max(s,o),t1[e>>2]=+(s!=o),e=u=>{var c=Math.abs(u);return `UTC${0<=u?"-":"+"}${String(Math.floor(c/60)).padStart(2,"0")}${String(c%60).padStart(2,"0")}`},t=e(s),e=e(o),o{var e=k.length;if(t>>>=0,2147483648=n;n*=2){var i=e*(1+.2/n);i=Math.min(i,t+100663296);e:{i=(Math.min(2147483648,65536*Math.ceil(Math.max(t,i)/65536))-x1.buffer.byteLength+65535)/65536|0;try{x1.grow(i),F2();var o=1;break e}catch(s){}o=void 0;}if(o)return !0}return !1},Ca:(t,e)=>{var n=0;return r3().forEach((i,o)=>{var s=e+n;for(o=F[t+4*o>>2]=s,s=0;s{var n=r3();F[t>>2]=n.length;var i=0;return n.forEach(o=>i+=o.length+1),F[e>>2]=i,0},za:()=>52,xa:()=>52,U:(t,e,n,i)=>{for(var o=0,s=0;s>2],c=F[e+4>>2];e+=8;for(var l=0;l>2]=o,0},Ia:Q0,n:D0,$:j0,La:J0,g:x0,u:O0,Na:A0,G:z0,J:N0,f:R0,_:q0,h:$0,Ma:B0,k:U0,R:H0,t:W0,V:a4,W:o4,Xa:I4,bb:C4,ha:l4,ka:d4,la:c4,fa:p4,db:g4,I:s4,a:S0,B:Z0,E:k0,X:r4,c:I0,Ka:G0,Ha:e4,e:T0,Y:t4,Q:i4,j:F0,y:n4,i:V0,p:Y0,s:K0,Z:X0,Wa:S4,Za:E4,Ya:P4,ab:b4,$a:L4,_a:M4,cb:w4,ia:h4,ga:f4,Va:T4,fb:_4,ea:v4,gb:m4,ja:u4,Ua:F4,eb:y4,q:t=>t,Ba:n3,sa:(t,e)=>(i3(k.subarray(t,t+e)),0)},w=function(){var n;function t(i){var o;return w=i.exports,x1=w.hb,F2(),P=w.mb,R2.unshift(w.ib),n1--,(o=d.monitorRunDependencies)==null||o.call(d,n1),n1==0&&(p1&&(i=p1,p1=null,i())),w}n1++,(n=d.monitorRunDependencies)==null||n.call(d,n1);var e={a:P0};if(d.instantiateWasm)try{return d.instantiateWasm(e,t)}catch(i){K(`Module.instantiateWasm callback failed with error: ${i}`),T(i);}return v1!=null||(v1=A2("DotLottiePlayer.wasm")?"DotLottiePlayer.wasm":d.locateFile?d.locateFile("DotLottiePlayer.wasm",U):U+"DotLottiePlayer.wasm"),Z3(e,function(i){t(i.instance);}).catch(T),{}}(),v2=t=>(v2=w.jb)(t),a3=t=>(a3=w.kb)(t),Q=t=>(Q=w.lb)(t),s3=(t,e)=>(s3=w.nb)(t,e),m=(t,e)=>(m=w.ob)(t,e),P1=t=>(P1=w.pb)(t),_=t=>(_=w.qb)(t),y=()=>(y=w.rb)(),c3=t=>(c3=w.sb)(t),d3=t=>(d3=w.tb)(t),u3=(t,e,n)=>(u3=w.ub)(t,e,n),h3=t=>(h3=w.vb)(t),l3=d.dynCall_ji=(t,e)=>(l3=d.dynCall_ji=w.wb)(t,e),f3=d.dynCall_viji=(t,e,n,i,o)=>(f3=d.dynCall_viji=w.xb)(t,e,n,i,o),p3=d.dynCall_jii=(t,e,n)=>(p3=d.dynCall_jii=w.yb)(t,e,n);d.dynCall_iijj=(t,e,n,i,o,s)=>(d.dynCall_iijj=w.zb)(t,e,n,i,o,s),d.dynCall_vijj=(t,e,n,i,o,s)=>(d.dynCall_vijj=w.Ab)(t,e,n,i,o,s);var v3=d.dynCall_vjiii=(t,e,n,i,o,s)=>(v3=d.dynCall_vjiii=w.Bb)(t,e,n,i,o,s),m3=d.dynCall_vij=(t,e,n,i)=>(m3=d.dynCall_vij=w.Cb)(t,e,n,i),_3=d.dynCall_viijii=(t,e,n,i,o,s,u)=>(_3=d.dynCall_viijii=w.Db)(t,e,n,i,o,s,u),y3=d.dynCall_jjji=(t,e,n,i,o,s)=>(y3=d.dynCall_jjji=w.Eb)(t,e,n,i,o,s),g3=d.dynCall_viijj=(t,e,n,i,o,s,u)=>(g3=d.dynCall_viijj=w.Fb)(t,e,n,i,o,s,u),w3=d.dynCall_viijji=(t,e,n,i,o,s,u,c)=>(w3=d.dynCall_viijji=w.Gb)(t,e,n,i,o,s,u,c),C3=d.dynCall_viij=(t,e,n,i,o)=>(C3=d.dynCall_viij=w.Hb)(t,e,n,i,o),b3=d.dynCall_iiiijj=(t,e,n,i,o,s,u,c)=>(b3=d.dynCall_iiiijj=w.Ib)(t,e,n,i,o,s,u,c),L3=d.dynCall_viiij=(t,e,n,i,o,s)=>(L3=d.dynCall_viiij=w.Jb)(t,e,n,i,o,s),M3=d.dynCall_viiji=(t,e,n,i,o,s)=>(M3=d.dynCall_viiji=w.Kb)(t,e,n,i,o,s),E3=d.dynCall_jiii=(t,e,n,i)=>(E3=d.dynCall_jiii=w.Lb)(t,e,n,i),P3=d.dynCall_viiiji=(t,e,n,i,o,s,u)=>(P3=d.dynCall_viiiji=w.Mb)(t,e,n,i,o,s,u),I3=d.dynCall_viiijj=(t,e,n,i,o,s,u,c)=>(I3=d.dynCall_viiijj=w.Nb)(t,e,n,i,o,s,u,c),S3=d.dynCall_viiiijjiiiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S)=>(S3=d.dynCall_viiiijjiiiiii=w.Ob)(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S),T3=d.dynCall_viiiijjiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L)=>(T3=d.dynCall_viiiijjiiii=w.Pb)(t,e,n,i,o,s,u,c,l,f,p,C,L),F3=d.dynCall_iiiiiijjii=(t,e,n,i,o,s,u,c,l,f,p,C)=>(F3=d.dynCall_iiiiiijjii=w.Qb)(t,e,n,i,o,s,u,c,l,f,p,C),x3=d.dynCall_viiiijjii=(t,e,n,i,o,s,u,c,l,f,p)=>(x3=d.dynCall_viiiijjii=w.Rb)(t,e,n,i,o,s,u,c,l,f,p),R3=d.dynCall_viijiii=(t,e,n,i,o,s,u,c)=>(R3=d.dynCall_viijiii=w.Sb)(t,e,n,i,o,s,u,c),j3=d.dynCall_iji=(t,e,n,i)=>(j3=d.dynCall_iji=w.Tb)(t,e,n,i),A3=d.dynCall_vijjjj=(t,e,n,i,o,s,u,c,l,f)=>(A3=d.dynCall_vijjjj=w.Ub)(t,e,n,i,o,s,u,c,l,f);d.dynCall_vjii=(t,e,n,i,o)=>(d.dynCall_vjii=w.Vb)(t,e,n,i,o),d.dynCall_vjfii=(t,e,n,i,o,s)=>(d.dynCall_vjfii=w.Wb)(t,e,n,i,o,s),d.dynCall_vj=(t,e,n)=>(d.dynCall_vj=w.Xb)(t,e,n),d.dynCall_vjiiiii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiiiii=w.Yb)(t,e,n,i,o,s,u,c),d.dynCall_vjiffii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiffii=w.Zb)(t,e,n,i,o,s,u,c),d.dynCall_vjiiii=(t,e,n,i,o,s,u)=>(d.dynCall_vjiiii=w._b)(t,e,n,i,o,s,u),d.dynCall_iiiiij=(t,e,n,i,o,s,u)=>(d.dynCall_iiiiij=w.$b)(t,e,n,i,o,s,u),d.dynCall_iiiiijj=(t,e,n,i,o,s,u,c,l)=>(d.dynCall_iiiiijj=w.ac)(t,e,n,i,o,s,u,c,l),d.dynCall_iiiiiijj=(t,e,n,i,o,s,u,c,l,f)=>(d.dynCall_iiiiiijj=w.bc)(t,e,n,i,o,s,u,c,l,f);function I0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function S0(t,e){var n=y();try{P.get(t)(e);}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function T0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function F0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function x0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function R0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function j0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function A0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function k0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function D0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function O0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function $0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function W0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function z0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function U0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function B0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function H0(t,e,n,i,o,s,u){var c=y();try{return P.get(t)(e,n,i,o,s,u)}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function N0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function V0(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function J0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function G0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function Y0(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function K0(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function X0(t,e,n,i,o,s,u,c,l){var f=y();try{P.get(t)(e,n,i,o,s,u,c,l);}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function Z0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function Q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function e4(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function t4(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function n4(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function r4(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function i4(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function o4(t,e,n,i,o,s,u,c,l){var f=y();try{return P.get(t)(e,n,i,o,s,u,c,l)}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function a4(t,e,n,i,o,s,u,c){var l=y();try{return P.get(t)(e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function s4(t){var e=y();try{P.get(t)();}catch(n){if(_(e),n!==n+0)throw n;m(1,0);}}function c4(t,e,n){var i=y();try{return p3(t,e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function d4(t,e){var n=y();try{return l3(t,e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function u4(t,e,n,i,o){var s=y();try{f3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function h4(t,e,n,i,o,s){var u=y();try{M3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function l4(t,e,n,i){var o=y();try{return j3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function f4(t,e,n,i,o,s,u){var c=y();try{_3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function p4(t,e,n,i){var o=y();try{return E3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function v4(t,e,n,i,o,s,u,c){var l=y();try{w3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function m4(t,e,n,i){var o=y();try{m3(t,e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function _4(t,e,n,i,o,s,u){var c=y();try{g3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function y4(t,e,n,i,o,s){var u=y();try{v3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function g4(t,e,n,i,o,s){var u=y();try{return y3(t,e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function w4(t,e,n,i,o){var s=y();try{C3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function C4(t,e,n,i,o,s,u,c){var l=y();try{return b3(t,e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function b4(t,e,n,i,o,s){var u=y();try{L3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function L4(t,e,n,i,o,s,u){var c=y();try{P3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function M4(t,e,n,i,o,s,u,c){var l=y();try{I3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function E4(t,e,n,i,o,s,u,c,l,f,p,C,L){var I=y();try{T3(t,e,n,i,o,s,u,c,l,f,p,C,L);}catch(S){if(_(I),S!==S+0)throw S;m(1,0);}}function P4(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S){var B=y();try{S3(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S);}catch(V){if(_(B),V!==V+0)throw V;m(1,0);}}function I4(t,e,n,i,o,s,u,c,l,f,p,C){var L=y();try{return F3(t,e,n,i,o,s,u,c,l,f,p,C)}catch(I){if(_(L),I!==I+0)throw I;m(1,0);}}function S4(t,e,n,i,o,s,u,c,l,f,p){var C=y();try{x3(t,e,n,i,o,s,u,c,l,f,p);}catch(L){if(_(C),L!==L+0)throw L;m(1,0);}}function T4(t,e,n,i,o,s,u,c){var l=y();try{R3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function F4(t,e,n,i,o,s,u,c,l,f){var p=y();try{A3(t,e,n,i,o,s,u,c,l,f);}catch(C){if(_(p),C!==C+0)throw C;m(1,0);}}var H1;p1=function t(){H1||k3(),H1||(p1=t);};function k3(){function t(){var n;if(!H1&&(H1=!0,d.calledRun=!0,!R1)){if(Z1(R2),g(d),(n=d.onRuntimeInitialized)==null||n.call(d),d.postRun)for(typeof d.postRun=="function"&&(d.postRun=[d.postRun]);d.postRun.length;){var e=d.postRun.shift();j2.unshift(e);}Z1(j2);}}if(!(0{setTimeout(()=>d.setStatus(""),1),t();},1)):t());}}if(d.preInit)for(typeof d.preInit=="function"&&(d.preInit=[d.preInit]);0r})})}static _loadWithBackup(){return E(this,null,function*(){return this._ModulePromise||(this._ModulePromise=this._tryLoad(this._wasmURL).catch(r=>E(this,null,function*(){let a=`https://unpkg.com/${C2}@${w2}/dist/dotlottie-player.wasm`;console.warn(`Primary WASM load failed from ${this._wasmURL}. Error: ${r.message}`),console.warn(`Attempting to load WASM from backup URL: ${a}`);try{return yield this._tryLoad(a)}catch(h){throw console.error(`Primary WASM URL failed: ${r.message}`),console.error(`Backup WASM URL failed: ${h.message}`),new Error("WASM loading failed from all sources.")}}))),this._ModulePromise})}static load(){return E(this,null,function*(){return this._loadWithBackup()})}static setWasmUrl(r){r!==this._wasmURL&&(this._wasmURL=r,this._ModulePromise=null);}};b(o1,"_ModulePromise",null),b(o1,"_wasmURL",`https://cdn.expensify.com/cdn.jsdelivr.net/npm/${C2}@${w2}/dist/dotlottie-player.wasm`);var h1=class{constructor(){b(this,"_eventListeners",new Map);}addEventListener(r,a){let h=this._eventListeners.get(r);h||(h=new Set,this._eventListeners.set(r,h)),h.add(a);}removeEventListener(r,a){let h=this._eventListeners.get(r);h&&(a?(h.delete(a),h.size===0&&this._eventListeners.delete(r)):this._eventListeners.delete(r));}dispatch(r){let a=this._eventListeners.get(r.type);a==null||a.forEach(h=>h(r));}removeAllEventListeners(){this._eventListeners.clear();}};var H=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);d&&(h.isIntersecting?d.unfreeze():d.freeze());});};this._observer=new IntersectionObserver(r,{threshold:0});}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,a),(h=this._observer)==null||h.observe(r));}static unobserve(r){var a,h;(a=this._observer)==null||a.unobserve(r),this._observedCanvases.delete(r),this._observedCanvases.size===0&&((h=this._observer)==null||h.disconnect(),this._observer=null);}};b(H,"_observer",null),b(H,"_observedCanvases",new Map);var N=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);if(!d)return;let[g,T]=d;clearTimeout(T);let R=setTimeout(()=>{g.resize();},100);this._observedCanvases.set(h.target,[g,R]);});};this._observer=new ResizeObserver(r);}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,[a,0]),(h=this._observer)==null||h.observe(r));}static unobserve(r){var h;let a=this._observedCanvases.get(r);if(a){let d=a[1];d&&clearTimeout(d);}(h=this._observer)==null||h.unobserve(r),this._observedCanvases.delete(r),!this._observedCanvases.size&&this._observer&&(this._observer.disconnect(),this._observer=null);}};b(N,"_observer",null),b(N,"_observedCanvases",new Map);function k4(v){return /^#([\da-f]{6}|[\da-f]{8})$/iu.test(v)}function N3(v){if(!k4(v))return 0;let r=v.replace("#","");return r=r.length===6?`${r}ff`:r,parseInt(r,16)}function b2(v){if(v.byteLength<4)return !1;let r=new Uint8Array(v.slice(0,J1.byteLength));for(let a=0;aObject.prototype.hasOwnProperty.call(v,r))}function L2(v){if(typeof v=="string")try{return H3(JSON.parse(v))}catch(r){return !1}else return H3(v)}function e1(){return 1+((O?window.devicePixelRatio:1)-1)*U3}function G1(v){let r=v.getBoundingClientRect();return r.top>=0&&r.left>=0&&r.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&r.right<=(window.innerWidth||document.documentElement.clientWidth)}var M2=(v,r)=>v==="reverse"?r.Mode.Reverse:v==="bounce"?r.Mode.Bounce:v==="reverse-bounce"?r.Mode.ReverseBounce:r.Mode.Forward,D4=(v,r)=>v==="contain"?r.Fit.Contain:v==="cover"?r.Fit.Cover:v==="fill"?r.Fit.Fill:v==="fit-height"?r.Fit.FitHeight:v==="fit-width"?r.Fit.FitWidth:r.Fit.None,O4=(v,r)=>{let a=new r.VectorFloat;return a.push_back(v[0]),a.push_back(v[1]),a},E2=(v,r)=>{let a=new r.VectorFloat;return v.length!==2||(a.push_back(v[0]),a.push_back(v[1])),a},P2=(v,r)=>{var a,h;return v?{align:O4((a=v.align)!=null?a:[.5,.5],r),fit:D4((h=v.fit)!=null?h:"contain",r)}:r.createDefaultLayout()},x=class x{constructor(r){b(this,"_canvas");b(this,"_context",null);b(this,"_eventManager");b(this,"_animationFrameId",null);b(this,"_frameManager");b(this,"_dotLottieCore",null);b(this,"_renderConfig",{});b(this,"_isFrozen",!1);b(this,"_backgroundColor",null);b(this,"_pointerUpMethod");b(this,"_pointerDownMethod");b(this,"_pointerMoveMethod");b(this,"_pointerEnterMethod");b(this,"_pointerExitMethod");var a,h,d;this._canvas=r.canvas,this._eventManager=new h1,this._frameManager=new q1,this._renderConfig=z(D({},r.renderConfig),{devicePixelRatio:((a=r.renderConfig)==null?void 0:a.devicePixelRatio)||e1(),freezeOnOffscreen:(d=(h=r.renderConfig)==null?void 0:h.freezeOnOffscreen)!=null?d:!0}),o1.load().then(g=>{var T,R,j,$,U,l1,T1,K;x._wasmModule=g,this._dotLottieCore=new g.DotLottiePlayer({themeId:(T=r.themeId)!=null?T:"",stateMachineId:"",autoplay:(R=r.autoplay)!=null?R:!1,backgroundColor:0,loopAnimation:(j=r.loop)!=null?j:!1,mode:M2(($=r.mode)!=null?$:"forward",g),segment:E2((U=r.segment)!=null?U:[],g),speed:(l1=r.speed)!=null?l1:1,useFrameInterpolation:(T1=r.useFrameInterpolation)!=null?T1:!0,marker:(K=r.marker)!=null?K:"",layout:P2(r.layout,g)}),this._eventManager.dispatch({type:"ready"}),r.data?this._loadFromData(r.data):r.src&&this._loadFromSrc(r.src),r.backgroundColor&&this.setBackgroundColor(r.backgroundColor);}).catch(g=>{this._eventManager.dispatch({type:"loadError",error:new Error(`Failed to load wasm module: ${g}`)});}),this._pointerUpMethod=this._onPointerUp.bind(this),this._pointerDownMethod=this._onPointerDown.bind(this),this._pointerMoveMethod=this._onPointerMove.bind(this),this._pointerEnterMethod=this._onPointerEnter.bind(this),this._pointerExitMethod=this._onPointerLeave.bind(this);}_dispatchError(r){console.error(r),this._eventManager.dispatch({type:"loadError",error:new Error(r)});}_fetchData(r){return E(this,null,function*(){let a=yield fetch(r);if(!a.ok)throw new Error(`Failed to fetch animation data from URL: ${r}. ${a.status}: ${a.statusText}`);let h=yield a.arrayBuffer();return b2(h)?h:new TextDecoder().decode(h)})}_loadFromData(r){if(this._dotLottieCore===null)return;let a=this._canvas.width,h=this._canvas.height,d=!1;if(typeof r=="string"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON string: The provided string does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(r,a,h);}else if(r instanceof ArrayBuffer){if(!b2(r)){this._dispatchError("Invalid dotLottie ArrayBuffer: The provided ArrayBuffer does not conform to the dotLottie format.");return}d=this._dotLottieCore.loadDotLottieData(r,a,h);}else if(typeof r=="object"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON object: The provided object does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(JSON.stringify(r),a,h);}else {this._dispatchError(`Unsupported data type for animation data. Expected: + - string (Lottie JSON), + - ArrayBuffer (dotLottie), + - object (Lottie JSON). diff --git a/patches/@lottiefiles/dotlottie-web/details.md b/patches/@lottiefiles/dotlottie-web/details.md new file mode 100644 index 000000000000..a1426eb1794c --- /dev/null +++ b/patches/@lottiefiles/dotlottie-web/details.md @@ -0,0 +1,14 @@ +# `@lottiefiles/dotlottie-web` patches + +### [@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch](@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch) + +- Reason: + + ``` + Patch adds `https://cdn.expensify.com/` prefix to `cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.44.0/dist/dotlottie-player.wasm` + to fix CORS policy issue preventing lottie animations to load on web + ``` + +- Upstream PR/issue: +- E/App issue: +- PR introducing patch: https://github.com/Expensify/App/pull/69597 From 241afc698f42a5dbd2bf562fed9e939e865306a2 Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 5 Sep 2025 12:35:48 +0200 Subject: [PATCH 004/519] update patch --- ...efiles+dotlottie-web+0.44.0+001+fix-cors.patch | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch b/patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch index 404bfbcb1159..210a71762387 100644 --- a/patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch +++ b/patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch @@ -1,5 +1,18 @@ +diff --git a/node_modules/@lottiefiles/dotlottie-web/dist/index.cjs b/node_modules/@lottiefiles/dotlottie-web/dist/index.cjs +index 283ae2e..08e1aea 100644 +--- a/node_modules/@lottiefiles/dotlottie-web/dist/index.cjs ++++ b/node_modules/@lottiefiles/dotlottie-web/dist/index.cjs +@@ -16,7 +16,7 @@ return ret; + `,s+=e[f].mc;return o+=` var rv = ${n===1?"new func":"func.call"}(${u.join(", ")}); + `,i.Nc||(c.push("emval_returnValue"),l.push(e3),o+=` return emval_returnValue(retType, destructorsRef, rv); + `),c.push(o+`}; +-`),t=X2(c)(...l),n=`methodCaller<(${e.map(p=>p.name).join(", ")}) => ${i.name}>`,C0(r1(n,t))},Ta:t=>{9{var e=c1(t);i2(e),u2(t);},F:(t,e)=>(t=w1(t,"_emval_take_value"),t=t.readValueFromPointer(e),y1(t)),oa:(t,e)=>{if(E1[t]&&(clearTimeout(E1[t].id),delete E1[t]),!e)return 0;var n=setTimeout(()=>{delete E1[t],L0(()=>s3(t,performance.now()));},e);return E1[t]={id:n,Uc:e},0},pa:(t,e,n,i)=>{var o=new Date().getFullYear(),s=new Date(o,0,1).getTimezoneOffset();o=new Date(o,6,1).getTimezoneOffset(),F[t>>2]=60*Math.max(s,o),t1[e>>2]=+(s!=o),e=u=>{var c=Math.abs(u);return `UTC${0<=u?"-":"+"}${String(Math.floor(c/60)).padStart(2,"0")}${String(c%60).padStart(2,"0")}`},t=e(s),e=e(o),o{var e=k.length;if(t>>>=0,2147483648=n;n*=2){var i=e*(1+.2/n);i=Math.min(i,t+100663296);e:{i=(Math.min(2147483648,65536*Math.ceil(Math.max(t,i)/65536))-x1.buffer.byteLength+65535)/65536|0;try{x1.grow(i),F2();var o=1;break e}catch(s){}o=void 0;}if(o)return !0}return !1},Ca:(t,e)=>{var n=0;return r3().forEach((i,o)=>{var s=e+n;for(o=F[t+4*o>>2]=s,s=0;s{var n=r3();F[t>>2]=n.length;var i=0;return n.forEach(o=>i+=o.length+1),F[e>>2]=i,0},za:()=>52,xa:()=>52,U:(t,e,n,i)=>{for(var o=0,s=0;s>2],c=F[e+4>>2];e+=8;for(var l=0;l>2]=o,0},Ia:Q0,n:D0,$:j0,La:J0,g:x0,u:O0,Na:A0,G:z0,J:N0,f:R0,_:q0,h:$0,Ma:B0,k:U0,R:H0,t:W0,V:a4,W:o4,Xa:I4,bb:C4,ha:l4,ka:d4,la:c4,fa:p4,db:g4,I:s4,a:S0,B:Z0,E:k0,X:r4,c:I0,Ka:G0,Ha:e4,e:T0,Y:t4,Q:i4,j:F0,y:n4,i:V0,p:Y0,s:K0,Z:X0,Wa:S4,Za:E4,Ya:P4,ab:b4,$a:L4,_a:M4,cb:w4,ia:h4,ga:f4,Va:T4,fb:_4,ea:v4,gb:m4,ja:u4,Ua:F4,eb:y4,q:t=>t,Ba:n3,sa:(t,e)=>(i3(k.subarray(t,t+e)),0)},w=function(){var n;function t(i){var o;return w=i.exports,x1=w.hb,F2(),P=w.mb,R2.unshift(w.ib),n1--,(o=d.monitorRunDependencies)==null||o.call(d,n1),n1==0&&(p1&&(i=p1,p1=null,i())),w}n1++,(n=d.monitorRunDependencies)==null||n.call(d,n1);var e={a:P0};if(d.instantiateWasm)try{return d.instantiateWasm(e,t)}catch(i){K(`Module.instantiateWasm callback failed with error: ${i}`),T(i);}return v1!=null||(v1=A2("DotLottiePlayer.wasm")?"DotLottiePlayer.wasm":d.locateFile?d.locateFile("DotLottiePlayer.wasm",U):U+"DotLottiePlayer.wasm"),Z3(e,function(i){t(i.instance);}).catch(T),{}}(),v2=t=>(v2=w.jb)(t),a3=t=>(a3=w.kb)(t),Q=t=>(Q=w.lb)(t),s3=(t,e)=>(s3=w.nb)(t,e),m=(t,e)=>(m=w.ob)(t,e),P1=t=>(P1=w.pb)(t),_=t=>(_=w.qb)(t),y=()=>(y=w.rb)(),c3=t=>(c3=w.sb)(t),d3=t=>(d3=w.tb)(t),u3=(t,e,n)=>(u3=w.ub)(t,e,n),h3=t=>(h3=w.vb)(t),l3=d.dynCall_ji=(t,e)=>(l3=d.dynCall_ji=w.wb)(t,e),f3=d.dynCall_viji=(t,e,n,i,o)=>(f3=d.dynCall_viji=w.xb)(t,e,n,i,o),p3=d.dynCall_jii=(t,e,n)=>(p3=d.dynCall_jii=w.yb)(t,e,n);d.dynCall_iijj=(t,e,n,i,o,s)=>(d.dynCall_iijj=w.zb)(t,e,n,i,o,s),d.dynCall_vijj=(t,e,n,i,o,s)=>(d.dynCall_vijj=w.Ab)(t,e,n,i,o,s);var v3=d.dynCall_vjiii=(t,e,n,i,o,s)=>(v3=d.dynCall_vjiii=w.Bb)(t,e,n,i,o,s),m3=d.dynCall_vij=(t,e,n,i)=>(m3=d.dynCall_vij=w.Cb)(t,e,n,i),_3=d.dynCall_viijii=(t,e,n,i,o,s,u)=>(_3=d.dynCall_viijii=w.Db)(t,e,n,i,o,s,u),y3=d.dynCall_jjji=(t,e,n,i,o,s)=>(y3=d.dynCall_jjji=w.Eb)(t,e,n,i,o,s),g3=d.dynCall_viijj=(t,e,n,i,o,s,u)=>(g3=d.dynCall_viijj=w.Fb)(t,e,n,i,o,s,u),w3=d.dynCall_viijji=(t,e,n,i,o,s,u,c)=>(w3=d.dynCall_viijji=w.Gb)(t,e,n,i,o,s,u,c),C3=d.dynCall_viij=(t,e,n,i,o)=>(C3=d.dynCall_viij=w.Hb)(t,e,n,i,o),b3=d.dynCall_iiiijj=(t,e,n,i,o,s,u,c)=>(b3=d.dynCall_iiiijj=w.Ib)(t,e,n,i,o,s,u,c),L3=d.dynCall_viiij=(t,e,n,i,o,s)=>(L3=d.dynCall_viiij=w.Jb)(t,e,n,i,o,s),M3=d.dynCall_viiji=(t,e,n,i,o,s)=>(M3=d.dynCall_viiji=w.Kb)(t,e,n,i,o,s),E3=d.dynCall_jiii=(t,e,n,i)=>(E3=d.dynCall_jiii=w.Lb)(t,e,n,i),P3=d.dynCall_viiiji=(t,e,n,i,o,s,u)=>(P3=d.dynCall_viiiji=w.Mb)(t,e,n,i,o,s,u),I3=d.dynCall_viiijj=(t,e,n,i,o,s,u,c)=>(I3=d.dynCall_viiijj=w.Nb)(t,e,n,i,o,s,u,c),S3=d.dynCall_viiiijjiiiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S)=>(S3=d.dynCall_viiiijjiiiiii=w.Ob)(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S),T3=d.dynCall_viiiijjiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L)=>(T3=d.dynCall_viiiijjiiii=w.Pb)(t,e,n,i,o,s,u,c,l,f,p,C,L),F3=d.dynCall_iiiiiijjii=(t,e,n,i,o,s,u,c,l,f,p,C)=>(F3=d.dynCall_iiiiiijjii=w.Qb)(t,e,n,i,o,s,u,c,l,f,p,C),x3=d.dynCall_viiiijjii=(t,e,n,i,o,s,u,c,l,f,p)=>(x3=d.dynCall_viiiijjii=w.Rb)(t,e,n,i,o,s,u,c,l,f,p),R3=d.dynCall_viijiii=(t,e,n,i,o,s,u,c)=>(R3=d.dynCall_viijiii=w.Sb)(t,e,n,i,o,s,u,c),j3=d.dynCall_iji=(t,e,n,i)=>(j3=d.dynCall_iji=w.Tb)(t,e,n,i),A3=d.dynCall_vijjjj=(t,e,n,i,o,s,u,c,l,f)=>(A3=d.dynCall_vijjjj=w.Ub)(t,e,n,i,o,s,u,c,l,f);d.dynCall_vjii=(t,e,n,i,o)=>(d.dynCall_vjii=w.Vb)(t,e,n,i,o),d.dynCall_vjfii=(t,e,n,i,o,s)=>(d.dynCall_vjfii=w.Wb)(t,e,n,i,o,s),d.dynCall_vj=(t,e,n)=>(d.dynCall_vj=w.Xb)(t,e,n),d.dynCall_vjiiiii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiiiii=w.Yb)(t,e,n,i,o,s,u,c),d.dynCall_vjiffii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiffii=w.Zb)(t,e,n,i,o,s,u,c),d.dynCall_vjiiii=(t,e,n,i,o,s,u)=>(d.dynCall_vjiiii=w._b)(t,e,n,i,o,s,u),d.dynCall_iiiiij=(t,e,n,i,o,s,u)=>(d.dynCall_iiiiij=w.$b)(t,e,n,i,o,s,u),d.dynCall_iiiiijj=(t,e,n,i,o,s,u,c,l)=>(d.dynCall_iiiiijj=w.ac)(t,e,n,i,o,s,u,c,l),d.dynCall_iiiiiijj=(t,e,n,i,o,s,u,c,l,f)=>(d.dynCall_iiiiiijj=w.bc)(t,e,n,i,o,s,u,c,l,f);function I0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function S0(t,e){var n=y();try{P.get(t)(e);}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function T0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function F0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function x0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function R0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function j0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function A0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function k0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function D0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function O0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function $0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function W0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function z0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function U0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function B0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function H0(t,e,n,i,o,s,u){var c=y();try{return P.get(t)(e,n,i,o,s,u)}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function N0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function V0(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function J0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function G0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function Y0(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function K0(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function X0(t,e,n,i,o,s,u,c,l){var f=y();try{P.get(t)(e,n,i,o,s,u,c,l);}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function Z0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function Q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function e4(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function t4(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function n4(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function r4(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function i4(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function o4(t,e,n,i,o,s,u,c,l){var f=y();try{return P.get(t)(e,n,i,o,s,u,c,l)}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function a4(t,e,n,i,o,s,u,c){var l=y();try{return P.get(t)(e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function s4(t){var e=y();try{P.get(t)();}catch(n){if(_(e),n!==n+0)throw n;m(1,0);}}function c4(t,e,n){var i=y();try{return p3(t,e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function d4(t,e){var n=y();try{return l3(t,e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function u4(t,e,n,i,o){var s=y();try{f3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function h4(t,e,n,i,o,s){var u=y();try{M3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function l4(t,e,n,i){var o=y();try{return j3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function f4(t,e,n,i,o,s,u){var c=y();try{_3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function p4(t,e,n,i){var o=y();try{return E3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function v4(t,e,n,i,o,s,u,c){var l=y();try{w3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function m4(t,e,n,i){var o=y();try{m3(t,e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function _4(t,e,n,i,o,s,u){var c=y();try{g3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function y4(t,e,n,i,o,s){var u=y();try{v3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function g4(t,e,n,i,o,s){var u=y();try{return y3(t,e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function w4(t,e,n,i,o){var s=y();try{C3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function C4(t,e,n,i,o,s,u,c){var l=y();try{return b3(t,e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function b4(t,e,n,i,o,s){var u=y();try{L3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function L4(t,e,n,i,o,s,u){var c=y();try{P3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function M4(t,e,n,i,o,s,u,c){var l=y();try{I3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function E4(t,e,n,i,o,s,u,c,l,f,p,C,L){var I=y();try{T3(t,e,n,i,o,s,u,c,l,f,p,C,L);}catch(S){if(_(I),S!==S+0)throw S;m(1,0);}}function P4(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S){var B=y();try{S3(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S);}catch(V){if(_(B),V!==V+0)throw V;m(1,0);}}function I4(t,e,n,i,o,s,u,c,l,f,p,C){var L=y();try{return F3(t,e,n,i,o,s,u,c,l,f,p,C)}catch(I){if(_(L),I!==I+0)throw I;m(1,0);}}function S4(t,e,n,i,o,s,u,c,l,f,p){var C=y();try{x3(t,e,n,i,o,s,u,c,l,f,p);}catch(L){if(_(C),L!==L+0)throw L;m(1,0);}}function T4(t,e,n,i,o,s,u,c){var l=y();try{R3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function F4(t,e,n,i,o,s,u,c,l,f){var p=y();try{A3(t,e,n,i,o,s,u,c,l,f);}catch(C){if(_(p),C!==C+0)throw C;m(1,0);}}var H1;p1=function t(){H1||k3(),H1||(p1=t);};function k3(){function t(){var n;if(!H1&&(H1=!0,d.calledRun=!0,!R1)){if(Z1(R2),g(d),(n=d.onRuntimeInitialized)==null||n.call(d),d.postRun)for(typeof d.postRun=="function"&&(d.postRun=[d.postRun]);d.postRun.length;){var e=d.postRun.shift();j2.unshift(e);}Z1(j2);}}if(!(0{setTimeout(()=>d.setStatus(""),1),t();},1)):t());}}if(d.preInit)for(typeof d.preInit=="function"&&(d.preInit=[d.preInit]);0r})})}static _loadWithBackup(){return E(this,null,function*(){return this._ModulePromise||(this._ModulePromise=this._tryLoad(this._wasmURL).catch(r=>E(this,null,function*(){let a=`https://unpkg.com/${C2}@${w2}/dist/dotlottie-player.wasm`;console.warn(`Primary WASM load failed from ${this._wasmURL}. Error: ${r.message}`),console.warn(`Attempting to load WASM from backup URL: ${a}`);try{return yield this._tryLoad(a)}catch(h){throw console.error(`Primary WASM URL failed: ${r.message}`),console.error(`Backup WASM URL failed: ${h.message}`),new Error("WASM loading failed from all sources.")}}))),this._ModulePromise})}static load(){return E(this,null,function*(){return this._loadWithBackup()})}static setWasmUrl(r){r!==this._wasmURL&&(this._wasmURL=r,this._ModulePromise=null);}};b(o1,"_ModulePromise",null),b(o1,"_wasmURL",`https://cdn.jsdelivr.net/npm/${C2}@${w2}/dist/dotlottie-player.wasm`);var h1=class{constructor(){b(this,"_eventListeners",new Map);}addEventListener(r,a){let h=this._eventListeners.get(r);h||(h=new Set,this._eventListeners.set(r,h)),h.add(a);}removeEventListener(r,a){let h=this._eventListeners.get(r);h&&(a?(h.delete(a),h.size===0&&this._eventListeners.delete(r)):this._eventListeners.delete(r));}dispatch(r){let a=this._eventListeners.get(r.type);a==null||a.forEach(h=>h(r));}removeAllEventListeners(){this._eventListeners.clear();}};var H=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);d&&(h.isIntersecting?d.unfreeze():d.freeze());});};this._observer=new IntersectionObserver(r,{threshold:0});}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,a),(h=this._observer)==null||h.observe(r));}static unobserve(r){var a,h;(a=this._observer)==null||a.unobserve(r),this._observedCanvases.delete(r),this._observedCanvases.size===0&&((h=this._observer)==null||h.disconnect(),this._observer=null);}};b(H,"_observer",null),b(H,"_observedCanvases",new Map);var N=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);if(!d)return;let[g,T]=d;clearTimeout(T);let R=setTimeout(()=>{g.resize();},100);this._observedCanvases.set(h.target,[g,R]);});};this._observer=new ResizeObserver(r);}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,[a,0]),(h=this._observer)==null||h.observe(r));}static unobserve(r){var h;let a=this._observedCanvases.get(r);if(a){let d=a[1];d&&clearTimeout(d);}(h=this._observer)==null||h.unobserve(r),this._observedCanvases.delete(r),!this._observedCanvases.size&&this._observer&&(this._observer.disconnect(),this._observer=null);}};b(N,"_observer",null),b(N,"_observedCanvases",new Map);function k4(v){return /^#([\da-f]{6}|[\da-f]{8})$/iu.test(v)}function N3(v){if(!k4(v))return 0;let r=v.replace("#","");return r=r.length===6?`${r}ff`:r,parseInt(r,16)}function b2(v){if(v.byteLength<4)return !1;let r=new Uint8Array(v.slice(0,J1.byteLength));for(let a=0;aObject.prototype.hasOwnProperty.call(v,r))}function L2(v){if(typeof v=="string")try{return H3(JSON.parse(v))}catch(r){return !1}else return H3(v)}function e1(){return 1+((O?window.devicePixelRatio:1)-1)*U3}function G1(v){let r=v.getBoundingClientRect();return r.top>=0&&r.left>=0&&r.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&r.right<=(window.innerWidth||document.documentElement.clientWidth)}var M2=(v,r)=>v==="reverse"?r.Mode.Reverse:v==="bounce"?r.Mode.Bounce:v==="reverse-bounce"?r.Mode.ReverseBounce:r.Mode.Forward,D4=(v,r)=>v==="contain"?r.Fit.Contain:v==="cover"?r.Fit.Cover:v==="fill"?r.Fit.Fill:v==="fit-height"?r.Fit.FitHeight:v==="fit-width"?r.Fit.FitWidth:r.Fit.None,O4=(v,r)=>{let a=new r.VectorFloat;return a.push_back(v[0]),a.push_back(v[1]),a},E2=(v,r)=>{let a=new r.VectorFloat;return v.length!==2||(a.push_back(v[0]),a.push_back(v[1])),a},P2=(v,r)=>{var a,h;return v?{align:O4((a=v.align)!=null?a:[.5,.5],r),fit:D4((h=v.fit)!=null?h:"contain",r)}:r.createDefaultLayout()},x=class x{constructor(r){b(this,"_canvas");b(this,"_context",null);b(this,"_eventManager");b(this,"_animationFrameId",null);b(this,"_frameManager");b(this,"_dotLottieCore",null);b(this,"_renderConfig",{});b(this,"_isFrozen",!1);b(this,"_backgroundColor",null);b(this,"_pointerUpMethod");b(this,"_pointerDownMethod");b(this,"_pointerMoveMethod");b(this,"_pointerEnterMethod");b(this,"_pointerExitMethod");var a,h,d;this._canvas=r.canvas,this._eventManager=new h1,this._frameManager=new q1,this._renderConfig=z(D({},r.renderConfig),{devicePixelRatio:((a=r.renderConfig)==null?void 0:a.devicePixelRatio)||e1(),freezeOnOffscreen:(d=(h=r.renderConfig)==null?void 0:h.freezeOnOffscreen)!=null?d:!0}),o1.load().then(g=>{var T,R,j,$,U,l1,T1,K;x._wasmModule=g,this._dotLottieCore=new g.DotLottiePlayer({themeId:(T=r.themeId)!=null?T:"",stateMachineId:"",autoplay:(R=r.autoplay)!=null?R:!1,backgroundColor:0,loopAnimation:(j=r.loop)!=null?j:!1,mode:M2(($=r.mode)!=null?$:"forward",g),segment:E2((U=r.segment)!=null?U:[],g),speed:(l1=r.speed)!=null?l1:1,useFrameInterpolation:(T1=r.useFrameInterpolation)!=null?T1:!0,marker:(K=r.marker)!=null?K:"",layout:P2(r.layout,g)}),this._eventManager.dispatch({type:"ready"}),r.data?this._loadFromData(r.data):r.src&&this._loadFromSrc(r.src),r.backgroundColor&&this.setBackgroundColor(r.backgroundColor);}).catch(g=>{this._eventManager.dispatch({type:"loadError",error:new Error(`Failed to load wasm module: ${g}`)});}),this._pointerUpMethod=this._onPointerUp.bind(this),this._pointerDownMethod=this._onPointerDown.bind(this),this._pointerMoveMethod=this._onPointerMove.bind(this),this._pointerEnterMethod=this._onPointerEnter.bind(this),this._pointerExitMethod=this._onPointerLeave.bind(this);}_dispatchError(r){console.error(r),this._eventManager.dispatch({type:"loadError",error:new Error(r)});}_fetchData(r){return E(this,null,function*(){let a=yield fetch(r);if(!a.ok)throw new Error(`Failed to fetch animation data from URL: ${r}. ${a.status}: ${a.statusText}`);let h=yield a.arrayBuffer();return b2(h)?h:new TextDecoder().decode(h)})}_loadFromData(r){if(this._dotLottieCore===null)return;let a=this._canvas.width,h=this._canvas.height,d=!1;if(typeof r=="string"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON string: The provided string does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(r,a,h);}else if(r instanceof ArrayBuffer){if(!b2(r)){this._dispatchError("Invalid dotLottie ArrayBuffer: The provided ArrayBuffer does not conform to the dotLottie format.");return}d=this._dotLottieCore.loadDotLottieData(r,a,h);}else if(typeof r=="object"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON object: The provided object does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(JSON.stringify(r),a,h);}else {this._dispatchError(`Unsupported data type for animation data. Expected: ++`),t=X2(c)(...l),n=`methodCaller<(${e.map(p=>p.name).join(", ")}) => ${i.name}>`,C0(r1(n,t))},Ta:t=>{9{var e=c1(t);i2(e),u2(t);},F:(t,e)=>(t=w1(t,"_emval_take_value"),t=t.readValueFromPointer(e),y1(t)),oa:(t,e)=>{if(E1[t]&&(clearTimeout(E1[t].id),delete E1[t]),!e)return 0;var n=setTimeout(()=>{delete E1[t],L0(()=>s3(t,performance.now()));},e);return E1[t]={id:n,Uc:e},0},pa:(t,e,n,i)=>{var o=new Date().getFullYear(),s=new Date(o,0,1).getTimezoneOffset();o=new Date(o,6,1).getTimezoneOffset(),F[t>>2]=60*Math.max(s,o),t1[e>>2]=+(s!=o),e=u=>{var c=Math.abs(u);return `UTC${0<=u?"-":"+"}${String(Math.floor(c/60)).padStart(2,"0")}${String(c%60).padStart(2,"0")}`},t=e(s),e=e(o),o{var e=k.length;if(t>>>=0,2147483648=n;n*=2){var i=e*(1+.2/n);i=Math.min(i,t+100663296);e:{i=(Math.min(2147483648,65536*Math.ceil(Math.max(t,i)/65536))-x1.buffer.byteLength+65535)/65536|0;try{x1.grow(i),F2();var o=1;break e}catch(s){}o=void 0;}if(o)return !0}return !1},Ca:(t,e)=>{var n=0;return r3().forEach((i,o)=>{var s=e+n;for(o=F[t+4*o>>2]=s,s=0;s{var n=r3();F[t>>2]=n.length;var i=0;return n.forEach(o=>i+=o.length+1),F[e>>2]=i,0},za:()=>52,xa:()=>52,U:(t,e,n,i)=>{for(var o=0,s=0;s>2],c=F[e+4>>2];e+=8;for(var l=0;l>2]=o,0},Ia:Q0,n:D0,$:j0,La:J0,g:x0,u:O0,Na:A0,G:z0,J:N0,f:R0,_:q0,h:$0,Ma:B0,k:U0,R:H0,t:W0,V:a4,W:o4,Xa:I4,bb:C4,ha:l4,ka:d4,la:c4,fa:p4,db:g4,I:s4,a:S0,B:Z0,E:k0,X:r4,c:I0,Ka:G0,Ha:e4,e:T0,Y:t4,Q:i4,j:F0,y:n4,i:V0,p:Y0,s:K0,Z:X0,Wa:S4,Za:E4,Ya:P4,ab:b4,$a:L4,_a:M4,cb:w4,ia:h4,ga:f4,Va:T4,fb:_4,ea:v4,gb:m4,ja:u4,Ua:F4,eb:y4,q:t=>t,Ba:n3,sa:(t,e)=>(i3(k.subarray(t,t+e)),0)},w=function(){var n;function t(i){var o;return w=i.exports,x1=w.hb,F2(),P=w.mb,R2.unshift(w.ib),n1--,(o=d.monitorRunDependencies)==null||o.call(d,n1),n1==0&&(p1&&(i=p1,p1=null,i())),w}n1++,(n=d.monitorRunDependencies)==null||n.call(d,n1);var e={a:P0};if(d.instantiateWasm)try{return d.instantiateWasm(e,t)}catch(i){K(`Module.instantiateWasm callback failed with error: ${i}`),T(i);}return v1!=null||(v1=A2("DotLottiePlayer.wasm")?"DotLottiePlayer.wasm":d.locateFile?d.locateFile("DotLottiePlayer.wasm",U):U+"DotLottiePlayer.wasm"),Z3(e,function(i){t(i.instance);}).catch(T),{}}(),v2=t=>(v2=w.jb)(t),a3=t=>(a3=w.kb)(t),Q=t=>(Q=w.lb)(t),s3=(t,e)=>(s3=w.nb)(t,e),m=(t,e)=>(m=w.ob)(t,e),P1=t=>(P1=w.pb)(t),_=t=>(_=w.qb)(t),y=()=>(y=w.rb)(),c3=t=>(c3=w.sb)(t),d3=t=>(d3=w.tb)(t),u3=(t,e,n)=>(u3=w.ub)(t,e,n),h3=t=>(h3=w.vb)(t),l3=d.dynCall_ji=(t,e)=>(l3=d.dynCall_ji=w.wb)(t,e),f3=d.dynCall_viji=(t,e,n,i,o)=>(f3=d.dynCall_viji=w.xb)(t,e,n,i,o),p3=d.dynCall_jii=(t,e,n)=>(p3=d.dynCall_jii=w.yb)(t,e,n);d.dynCall_iijj=(t,e,n,i,o,s)=>(d.dynCall_iijj=w.zb)(t,e,n,i,o,s),d.dynCall_vijj=(t,e,n,i,o,s)=>(d.dynCall_vijj=w.Ab)(t,e,n,i,o,s);var v3=d.dynCall_vjiii=(t,e,n,i,o,s)=>(v3=d.dynCall_vjiii=w.Bb)(t,e,n,i,o,s),m3=d.dynCall_vij=(t,e,n,i)=>(m3=d.dynCall_vij=w.Cb)(t,e,n,i),_3=d.dynCall_viijii=(t,e,n,i,o,s,u)=>(_3=d.dynCall_viijii=w.Db)(t,e,n,i,o,s,u),y3=d.dynCall_jjji=(t,e,n,i,o,s)=>(y3=d.dynCall_jjji=w.Eb)(t,e,n,i,o,s),g3=d.dynCall_viijj=(t,e,n,i,o,s,u)=>(g3=d.dynCall_viijj=w.Fb)(t,e,n,i,o,s,u),w3=d.dynCall_viijji=(t,e,n,i,o,s,u,c)=>(w3=d.dynCall_viijji=w.Gb)(t,e,n,i,o,s,u,c),C3=d.dynCall_viij=(t,e,n,i,o)=>(C3=d.dynCall_viij=w.Hb)(t,e,n,i,o),b3=d.dynCall_iiiijj=(t,e,n,i,o,s,u,c)=>(b3=d.dynCall_iiiijj=w.Ib)(t,e,n,i,o,s,u,c),L3=d.dynCall_viiij=(t,e,n,i,o,s)=>(L3=d.dynCall_viiij=w.Jb)(t,e,n,i,o,s),M3=d.dynCall_viiji=(t,e,n,i,o,s)=>(M3=d.dynCall_viiji=w.Kb)(t,e,n,i,o,s),E3=d.dynCall_jiii=(t,e,n,i)=>(E3=d.dynCall_jiii=w.Lb)(t,e,n,i),P3=d.dynCall_viiiji=(t,e,n,i,o,s,u)=>(P3=d.dynCall_viiiji=w.Mb)(t,e,n,i,o,s,u),I3=d.dynCall_viiijj=(t,e,n,i,o,s,u,c)=>(I3=d.dynCall_viiijj=w.Nb)(t,e,n,i,o,s,u,c),S3=d.dynCall_viiiijjiiiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S)=>(S3=d.dynCall_viiiijjiiiiii=w.Ob)(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S),T3=d.dynCall_viiiijjiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L)=>(T3=d.dynCall_viiiijjiiii=w.Pb)(t,e,n,i,o,s,u,c,l,f,p,C,L),F3=d.dynCall_iiiiiijjii=(t,e,n,i,o,s,u,c,l,f,p,C)=>(F3=d.dynCall_iiiiiijjii=w.Qb)(t,e,n,i,o,s,u,c,l,f,p,C),x3=d.dynCall_viiiijjii=(t,e,n,i,o,s,u,c,l,f,p)=>(x3=d.dynCall_viiiijjii=w.Rb)(t,e,n,i,o,s,u,c,l,f,p),R3=d.dynCall_viijiii=(t,e,n,i,o,s,u,c)=>(R3=d.dynCall_viijiii=w.Sb)(t,e,n,i,o,s,u,c),j3=d.dynCall_iji=(t,e,n,i)=>(j3=d.dynCall_iji=w.Tb)(t,e,n,i),A3=d.dynCall_vijjjj=(t,e,n,i,o,s,u,c,l,f)=>(A3=d.dynCall_vijjjj=w.Ub)(t,e,n,i,o,s,u,c,l,f);d.dynCall_vjii=(t,e,n,i,o)=>(d.dynCall_vjii=w.Vb)(t,e,n,i,o),d.dynCall_vjfii=(t,e,n,i,o,s)=>(d.dynCall_vjfii=w.Wb)(t,e,n,i,o,s),d.dynCall_vj=(t,e,n)=>(d.dynCall_vj=w.Xb)(t,e,n),d.dynCall_vjiiiii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiiiii=w.Yb)(t,e,n,i,o,s,u,c),d.dynCall_vjiffii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiffii=w.Zb)(t,e,n,i,o,s,u,c),d.dynCall_vjiiii=(t,e,n,i,o,s,u)=>(d.dynCall_vjiiii=w._b)(t,e,n,i,o,s,u),d.dynCall_iiiiij=(t,e,n,i,o,s,u)=>(d.dynCall_iiiiij=w.$b)(t,e,n,i,o,s,u),d.dynCall_iiiiijj=(t,e,n,i,o,s,u,c,l)=>(d.dynCall_iiiiijj=w.ac)(t,e,n,i,o,s,u,c,l),d.dynCall_iiiiiijj=(t,e,n,i,o,s,u,c,l,f)=>(d.dynCall_iiiiiijj=w.bc)(t,e,n,i,o,s,u,c,l,f);function I0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function S0(t,e){var n=y();try{P.get(t)(e);}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function T0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function F0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function x0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function R0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function j0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function A0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function k0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function D0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function O0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function $0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function W0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function z0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function U0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function B0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function H0(t,e,n,i,o,s,u){var c=y();try{return P.get(t)(e,n,i,o,s,u)}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function N0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function V0(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function J0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function G0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function Y0(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function K0(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function X0(t,e,n,i,o,s,u,c,l){var f=y();try{P.get(t)(e,n,i,o,s,u,c,l);}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function Z0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function Q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function e4(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function t4(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function n4(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function r4(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function i4(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function o4(t,e,n,i,o,s,u,c,l){var f=y();try{return P.get(t)(e,n,i,o,s,u,c,l)}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function a4(t,e,n,i,o,s,u,c){var l=y();try{return P.get(t)(e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function s4(t){var e=y();try{P.get(t)();}catch(n){if(_(e),n!==n+0)throw n;m(1,0);}}function c4(t,e,n){var i=y();try{return p3(t,e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function d4(t,e){var n=y();try{return l3(t,e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function u4(t,e,n,i,o){var s=y();try{f3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function h4(t,e,n,i,o,s){var u=y();try{M3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function l4(t,e,n,i){var o=y();try{return j3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function f4(t,e,n,i,o,s,u){var c=y();try{_3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function p4(t,e,n,i){var o=y();try{return E3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function v4(t,e,n,i,o,s,u,c){var l=y();try{w3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function m4(t,e,n,i){var o=y();try{m3(t,e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function _4(t,e,n,i,o,s,u){var c=y();try{g3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function y4(t,e,n,i,o,s){var u=y();try{v3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function g4(t,e,n,i,o,s){var u=y();try{return y3(t,e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function w4(t,e,n,i,o){var s=y();try{C3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function C4(t,e,n,i,o,s,u,c){var l=y();try{return b3(t,e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function b4(t,e,n,i,o,s){var u=y();try{L3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function L4(t,e,n,i,o,s,u){var c=y();try{P3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function M4(t,e,n,i,o,s,u,c){var l=y();try{I3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function E4(t,e,n,i,o,s,u,c,l,f,p,C,L){var I=y();try{T3(t,e,n,i,o,s,u,c,l,f,p,C,L);}catch(S){if(_(I),S!==S+0)throw S;m(1,0);}}function P4(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S){var B=y();try{S3(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S);}catch(V){if(_(B),V!==V+0)throw V;m(1,0);}}function I4(t,e,n,i,o,s,u,c,l,f,p,C){var L=y();try{return F3(t,e,n,i,o,s,u,c,l,f,p,C)}catch(I){if(_(L),I!==I+0)throw I;m(1,0);}}function S4(t,e,n,i,o,s,u,c,l,f,p){var C=y();try{x3(t,e,n,i,o,s,u,c,l,f,p);}catch(L){if(_(C),L!==L+0)throw L;m(1,0);}}function T4(t,e,n,i,o,s,u,c){var l=y();try{R3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function F4(t,e,n,i,o,s,u,c,l,f){var p=y();try{A3(t,e,n,i,o,s,u,c,l,f);}catch(C){if(_(p),C!==C+0)throw C;m(1,0);}}var H1;p1=function t(){H1||k3(),H1||(p1=t);};function k3(){function t(){var n;if(!H1&&(H1=!0,d.calledRun=!0,!R1)){if(Z1(R2),g(d),(n=d.onRuntimeInitialized)==null||n.call(d),d.postRun)for(typeof d.postRun=="function"&&(d.postRun=[d.postRun]);d.postRun.length;){var e=d.postRun.shift();j2.unshift(e);}Z1(j2);}}if(!(0{setTimeout(()=>d.setStatus(""),1),t();},1)):t());}}if(d.preInit)for(typeof d.preInit=="function"&&(d.preInit=[d.preInit]);0r})})}static _loadWithBackup(){return E(this,null,function*(){return this._ModulePromise||(this._ModulePromise=this._tryLoad(this._wasmURL).catch(r=>E(this,null,function*(){let a=`https://unpkg.com/${C2}@${w2}/dist/dotlottie-player.wasm`;console.warn(`Primary WASM load failed from ${this._wasmURL}. Error: ${r.message}`),console.warn(`Attempting to load WASM from backup URL: ${a}`);try{return yield this._tryLoad(a)}catch(h){throw console.error(`Primary WASM URL failed: ${r.message}`),console.error(`Backup WASM URL failed: ${h.message}`),new Error("WASM loading failed from all sources.")}}))),this._ModulePromise})}static load(){return E(this,null,function*(){return this._loadWithBackup()})}static setWasmUrl(r){r!==this._wasmURL&&(this._wasmURL=r,this._ModulePromise=null);}};b(o1,"_ModulePromise",null),b(o1,"_wasmURL",`https://cdn.expensify.com/cdn.jsdelivr.net/npm/${C2}@${w2}/dist/dotlottie-player.wasm`);var h1=class{constructor(){b(this,"_eventListeners",new Map);}addEventListener(r,a){let h=this._eventListeners.get(r);h||(h=new Set,this._eventListeners.set(r,h)),h.add(a);}removeEventListener(r,a){let h=this._eventListeners.get(r);h&&(a?(h.delete(a),h.size===0&&this._eventListeners.delete(r)):this._eventListeners.delete(r));}dispatch(r){let a=this._eventListeners.get(r.type);a==null||a.forEach(h=>h(r));}removeAllEventListeners(){this._eventListeners.clear();}};var H=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);d&&(h.isIntersecting?d.unfreeze():d.freeze());});};this._observer=new IntersectionObserver(r,{threshold:0});}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,a),(h=this._observer)==null||h.observe(r));}static unobserve(r){var a,h;(a=this._observer)==null||a.unobserve(r),this._observedCanvases.delete(r),this._observedCanvases.size===0&&((h=this._observer)==null||h.disconnect(),this._observer=null);}};b(H,"_observer",null),b(H,"_observedCanvases",new Map);var N=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);if(!d)return;let[g,T]=d;clearTimeout(T);let R=setTimeout(()=>{g.resize();},100);this._observedCanvases.set(h.target,[g,R]);});};this._observer=new ResizeObserver(r);}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,[a,0]),(h=this._observer)==null||h.observe(r));}static unobserve(r){var h;let a=this._observedCanvases.get(r);if(a){let d=a[1];d&&clearTimeout(d);}(h=this._observer)==null||h.unobserve(r),this._observedCanvases.delete(r),!this._observedCanvases.size&&this._observer&&(this._observer.disconnect(),this._observer=null);}};b(N,"_observer",null),b(N,"_observedCanvases",new Map);function k4(v){return /^#([\da-f]{6}|[\da-f]{8})$/iu.test(v)}function N3(v){if(!k4(v))return 0;let r=v.replace("#","");return r=r.length===6?`${r}ff`:r,parseInt(r,16)}function b2(v){if(v.byteLength<4)return !1;let r=new Uint8Array(v.slice(0,J1.byteLength));for(let a=0;aObject.prototype.hasOwnProperty.call(v,r))}function L2(v){if(typeof v=="string")try{return H3(JSON.parse(v))}catch(r){return !1}else return H3(v)}function e1(){return 1+((O?window.devicePixelRatio:1)-1)*U3}function G1(v){let r=v.getBoundingClientRect();return r.top>=0&&r.left>=0&&r.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&r.right<=(window.innerWidth||document.documentElement.clientWidth)}var M2=(v,r)=>v==="reverse"?r.Mode.Reverse:v==="bounce"?r.Mode.Bounce:v==="reverse-bounce"?r.Mode.ReverseBounce:r.Mode.Forward,D4=(v,r)=>v==="contain"?r.Fit.Contain:v==="cover"?r.Fit.Cover:v==="fill"?r.Fit.Fill:v==="fit-height"?r.Fit.FitHeight:v==="fit-width"?r.Fit.FitWidth:r.Fit.None,O4=(v,r)=>{let a=new r.VectorFloat;return a.push_back(v[0]),a.push_back(v[1]),a},E2=(v,r)=>{let a=new r.VectorFloat;return v.length!==2||(a.push_back(v[0]),a.push_back(v[1])),a},P2=(v,r)=>{var a,h;return v?{align:O4((a=v.align)!=null?a:[.5,.5],r),fit:D4((h=v.fit)!=null?h:"contain",r)}:r.createDefaultLayout()},x=class x{constructor(r){b(this,"_canvas");b(this,"_context",null);b(this,"_eventManager");b(this,"_animationFrameId",null);b(this,"_frameManager");b(this,"_dotLottieCore",null);b(this,"_renderConfig",{});b(this,"_isFrozen",!1);b(this,"_backgroundColor",null);b(this,"_pointerUpMethod");b(this,"_pointerDownMethod");b(this,"_pointerMoveMethod");b(this,"_pointerEnterMethod");b(this,"_pointerExitMethod");var a,h,d;this._canvas=r.canvas,this._eventManager=new h1,this._frameManager=new q1,this._renderConfig=z(D({},r.renderConfig),{devicePixelRatio:((a=r.renderConfig)==null?void 0:a.devicePixelRatio)||e1(),freezeOnOffscreen:(d=(h=r.renderConfig)==null?void 0:h.freezeOnOffscreen)!=null?d:!0}),o1.load().then(g=>{var T,R,j,$,U,l1,T1,K;x._wasmModule=g,this._dotLottieCore=new g.DotLottiePlayer({themeId:(T=r.themeId)!=null?T:"",stateMachineId:"",autoplay:(R=r.autoplay)!=null?R:!1,backgroundColor:0,loopAnimation:(j=r.loop)!=null?j:!1,mode:M2(($=r.mode)!=null?$:"forward",g),segment:E2((U=r.segment)!=null?U:[],g),speed:(l1=r.speed)!=null?l1:1,useFrameInterpolation:(T1=r.useFrameInterpolation)!=null?T1:!0,marker:(K=r.marker)!=null?K:"",layout:P2(r.layout,g)}),this._eventManager.dispatch({type:"ready"}),r.data?this._loadFromData(r.data):r.src&&this._loadFromSrc(r.src),r.backgroundColor&&this.setBackgroundColor(r.backgroundColor);}).catch(g=>{this._eventManager.dispatch({type:"loadError",error:new Error(`Failed to load wasm module: ${g}`)});}),this._pointerUpMethod=this._onPointerUp.bind(this),this._pointerDownMethod=this._onPointerDown.bind(this),this._pointerMoveMethod=this._onPointerMove.bind(this),this._pointerEnterMethod=this._onPointerEnter.bind(this),this._pointerExitMethod=this._onPointerLeave.bind(this);}_dispatchError(r){console.error(r),this._eventManager.dispatch({type:"loadError",error:new Error(r)});}_fetchData(r){return E(this,null,function*(){let a=yield fetch(r);if(!a.ok)throw new Error(`Failed to fetch animation data from URL: ${r}. ${a.status}: ${a.statusText}`);let h=yield a.arrayBuffer();return b2(h)?h:new TextDecoder().decode(h)})}_loadFromData(r){if(this._dotLottieCore===null)return;let a=this._canvas.width,h=this._canvas.height,d=!1;if(typeof r=="string"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON string: The provided string does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(r,a,h);}else if(r instanceof ArrayBuffer){if(!b2(r)){this._dispatchError("Invalid dotLottie ArrayBuffer: The provided ArrayBuffer does not conform to the dotLottie format.");return}d=this._dotLottieCore.loadDotLottieData(r,a,h);}else if(typeof r=="object"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON object: The provided object does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(JSON.stringify(r),a,h);}else {this._dispatchError(`Unsupported data type for animation data. Expected: + - string (Lottie JSON), + - ArrayBuffer (dotLottie), + - object (Lottie JSON). diff --git a/node_modules/@lottiefiles/dotlottie-web/dist/index.js b/node_modules/@lottiefiles/dotlottie-web/dist/index.js -index 1ea13c9..5b55100 100644 +index 1ea13c9..3cd41fa 100644 --- a/node_modules/@lottiefiles/dotlottie-web/dist/index.js +++ b/node_modules/@lottiefiles/dotlottie-web/dist/index.js @@ -14,7 +14,7 @@ return ret; From 4a382629701f62c6135fbb84ce07bcd9133f4605 Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 18 Sep 2025 16:51:46 +0200 Subject: [PATCH 005/519] update use setWasmUrl to fix lottie animations --- ...es+dotlottie-web+0.44.0+001+fix-cors.patch | 26 ------------------- patches/@lottiefiles/dotlottie-web/details.md | 14 ---------- src/App.tsx | 3 +++ 3 files changed, 3 insertions(+), 40 deletions(-) delete mode 100644 patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch delete mode 100644 patches/@lottiefiles/dotlottie-web/details.md diff --git a/patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch b/patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch deleted file mode 100644 index 210a71762387..000000000000 --- a/patches/@lottiefiles/dotlottie-web/@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch +++ /dev/null @@ -1,26 +0,0 @@ -diff --git a/node_modules/@lottiefiles/dotlottie-web/dist/index.cjs b/node_modules/@lottiefiles/dotlottie-web/dist/index.cjs -index 283ae2e..08e1aea 100644 ---- a/node_modules/@lottiefiles/dotlottie-web/dist/index.cjs -+++ b/node_modules/@lottiefiles/dotlottie-web/dist/index.cjs -@@ -16,7 +16,7 @@ return ret; - `,s+=e[f].mc;return o+=` var rv = ${n===1?"new func":"func.call"}(${u.join(", ")}); - `,i.Nc||(c.push("emval_returnValue"),l.push(e3),o+=` return emval_returnValue(retType, destructorsRef, rv); - `),c.push(o+`}; --`),t=X2(c)(...l),n=`methodCaller<(${e.map(p=>p.name).join(", ")}) => ${i.name}>`,C0(r1(n,t))},Ta:t=>{9{var e=c1(t);i2(e),u2(t);},F:(t,e)=>(t=w1(t,"_emval_take_value"),t=t.readValueFromPointer(e),y1(t)),oa:(t,e)=>{if(E1[t]&&(clearTimeout(E1[t].id),delete E1[t]),!e)return 0;var n=setTimeout(()=>{delete E1[t],L0(()=>s3(t,performance.now()));},e);return E1[t]={id:n,Uc:e},0},pa:(t,e,n,i)=>{var o=new Date().getFullYear(),s=new Date(o,0,1).getTimezoneOffset();o=new Date(o,6,1).getTimezoneOffset(),F[t>>2]=60*Math.max(s,o),t1[e>>2]=+(s!=o),e=u=>{var c=Math.abs(u);return `UTC${0<=u?"-":"+"}${String(Math.floor(c/60)).padStart(2,"0")}${String(c%60).padStart(2,"0")}`},t=e(s),e=e(o),o{var e=k.length;if(t>>>=0,2147483648=n;n*=2){var i=e*(1+.2/n);i=Math.min(i,t+100663296);e:{i=(Math.min(2147483648,65536*Math.ceil(Math.max(t,i)/65536))-x1.buffer.byteLength+65535)/65536|0;try{x1.grow(i),F2();var o=1;break e}catch(s){}o=void 0;}if(o)return !0}return !1},Ca:(t,e)=>{var n=0;return r3().forEach((i,o)=>{var s=e+n;for(o=F[t+4*o>>2]=s,s=0;s{var n=r3();F[t>>2]=n.length;var i=0;return n.forEach(o=>i+=o.length+1),F[e>>2]=i,0},za:()=>52,xa:()=>52,U:(t,e,n,i)=>{for(var o=0,s=0;s>2],c=F[e+4>>2];e+=8;for(var l=0;l>2]=o,0},Ia:Q0,n:D0,$:j0,La:J0,g:x0,u:O0,Na:A0,G:z0,J:N0,f:R0,_:q0,h:$0,Ma:B0,k:U0,R:H0,t:W0,V:a4,W:o4,Xa:I4,bb:C4,ha:l4,ka:d4,la:c4,fa:p4,db:g4,I:s4,a:S0,B:Z0,E:k0,X:r4,c:I0,Ka:G0,Ha:e4,e:T0,Y:t4,Q:i4,j:F0,y:n4,i:V0,p:Y0,s:K0,Z:X0,Wa:S4,Za:E4,Ya:P4,ab:b4,$a:L4,_a:M4,cb:w4,ia:h4,ga:f4,Va:T4,fb:_4,ea:v4,gb:m4,ja:u4,Ua:F4,eb:y4,q:t=>t,Ba:n3,sa:(t,e)=>(i3(k.subarray(t,t+e)),0)},w=function(){var n;function t(i){var o;return w=i.exports,x1=w.hb,F2(),P=w.mb,R2.unshift(w.ib),n1--,(o=d.monitorRunDependencies)==null||o.call(d,n1),n1==0&&(p1&&(i=p1,p1=null,i())),w}n1++,(n=d.monitorRunDependencies)==null||n.call(d,n1);var e={a:P0};if(d.instantiateWasm)try{return d.instantiateWasm(e,t)}catch(i){K(`Module.instantiateWasm callback failed with error: ${i}`),T(i);}return v1!=null||(v1=A2("DotLottiePlayer.wasm")?"DotLottiePlayer.wasm":d.locateFile?d.locateFile("DotLottiePlayer.wasm",U):U+"DotLottiePlayer.wasm"),Z3(e,function(i){t(i.instance);}).catch(T),{}}(),v2=t=>(v2=w.jb)(t),a3=t=>(a3=w.kb)(t),Q=t=>(Q=w.lb)(t),s3=(t,e)=>(s3=w.nb)(t,e),m=(t,e)=>(m=w.ob)(t,e),P1=t=>(P1=w.pb)(t),_=t=>(_=w.qb)(t),y=()=>(y=w.rb)(),c3=t=>(c3=w.sb)(t),d3=t=>(d3=w.tb)(t),u3=(t,e,n)=>(u3=w.ub)(t,e,n),h3=t=>(h3=w.vb)(t),l3=d.dynCall_ji=(t,e)=>(l3=d.dynCall_ji=w.wb)(t,e),f3=d.dynCall_viji=(t,e,n,i,o)=>(f3=d.dynCall_viji=w.xb)(t,e,n,i,o),p3=d.dynCall_jii=(t,e,n)=>(p3=d.dynCall_jii=w.yb)(t,e,n);d.dynCall_iijj=(t,e,n,i,o,s)=>(d.dynCall_iijj=w.zb)(t,e,n,i,o,s),d.dynCall_vijj=(t,e,n,i,o,s)=>(d.dynCall_vijj=w.Ab)(t,e,n,i,o,s);var v3=d.dynCall_vjiii=(t,e,n,i,o,s)=>(v3=d.dynCall_vjiii=w.Bb)(t,e,n,i,o,s),m3=d.dynCall_vij=(t,e,n,i)=>(m3=d.dynCall_vij=w.Cb)(t,e,n,i),_3=d.dynCall_viijii=(t,e,n,i,o,s,u)=>(_3=d.dynCall_viijii=w.Db)(t,e,n,i,o,s,u),y3=d.dynCall_jjji=(t,e,n,i,o,s)=>(y3=d.dynCall_jjji=w.Eb)(t,e,n,i,o,s),g3=d.dynCall_viijj=(t,e,n,i,o,s,u)=>(g3=d.dynCall_viijj=w.Fb)(t,e,n,i,o,s,u),w3=d.dynCall_viijji=(t,e,n,i,o,s,u,c)=>(w3=d.dynCall_viijji=w.Gb)(t,e,n,i,o,s,u,c),C3=d.dynCall_viij=(t,e,n,i,o)=>(C3=d.dynCall_viij=w.Hb)(t,e,n,i,o),b3=d.dynCall_iiiijj=(t,e,n,i,o,s,u,c)=>(b3=d.dynCall_iiiijj=w.Ib)(t,e,n,i,o,s,u,c),L3=d.dynCall_viiij=(t,e,n,i,o,s)=>(L3=d.dynCall_viiij=w.Jb)(t,e,n,i,o,s),M3=d.dynCall_viiji=(t,e,n,i,o,s)=>(M3=d.dynCall_viiji=w.Kb)(t,e,n,i,o,s),E3=d.dynCall_jiii=(t,e,n,i)=>(E3=d.dynCall_jiii=w.Lb)(t,e,n,i),P3=d.dynCall_viiiji=(t,e,n,i,o,s,u)=>(P3=d.dynCall_viiiji=w.Mb)(t,e,n,i,o,s,u),I3=d.dynCall_viiijj=(t,e,n,i,o,s,u,c)=>(I3=d.dynCall_viiijj=w.Nb)(t,e,n,i,o,s,u,c),S3=d.dynCall_viiiijjiiiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S)=>(S3=d.dynCall_viiiijjiiiiii=w.Ob)(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S),T3=d.dynCall_viiiijjiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L)=>(T3=d.dynCall_viiiijjiiii=w.Pb)(t,e,n,i,o,s,u,c,l,f,p,C,L),F3=d.dynCall_iiiiiijjii=(t,e,n,i,o,s,u,c,l,f,p,C)=>(F3=d.dynCall_iiiiiijjii=w.Qb)(t,e,n,i,o,s,u,c,l,f,p,C),x3=d.dynCall_viiiijjii=(t,e,n,i,o,s,u,c,l,f,p)=>(x3=d.dynCall_viiiijjii=w.Rb)(t,e,n,i,o,s,u,c,l,f,p),R3=d.dynCall_viijiii=(t,e,n,i,o,s,u,c)=>(R3=d.dynCall_viijiii=w.Sb)(t,e,n,i,o,s,u,c),j3=d.dynCall_iji=(t,e,n,i)=>(j3=d.dynCall_iji=w.Tb)(t,e,n,i),A3=d.dynCall_vijjjj=(t,e,n,i,o,s,u,c,l,f)=>(A3=d.dynCall_vijjjj=w.Ub)(t,e,n,i,o,s,u,c,l,f);d.dynCall_vjii=(t,e,n,i,o)=>(d.dynCall_vjii=w.Vb)(t,e,n,i,o),d.dynCall_vjfii=(t,e,n,i,o,s)=>(d.dynCall_vjfii=w.Wb)(t,e,n,i,o,s),d.dynCall_vj=(t,e,n)=>(d.dynCall_vj=w.Xb)(t,e,n),d.dynCall_vjiiiii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiiiii=w.Yb)(t,e,n,i,o,s,u,c),d.dynCall_vjiffii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiffii=w.Zb)(t,e,n,i,o,s,u,c),d.dynCall_vjiiii=(t,e,n,i,o,s,u)=>(d.dynCall_vjiiii=w._b)(t,e,n,i,o,s,u),d.dynCall_iiiiij=(t,e,n,i,o,s,u)=>(d.dynCall_iiiiij=w.$b)(t,e,n,i,o,s,u),d.dynCall_iiiiijj=(t,e,n,i,o,s,u,c,l)=>(d.dynCall_iiiiijj=w.ac)(t,e,n,i,o,s,u,c,l),d.dynCall_iiiiiijj=(t,e,n,i,o,s,u,c,l,f)=>(d.dynCall_iiiiiijj=w.bc)(t,e,n,i,o,s,u,c,l,f);function I0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function S0(t,e){var n=y();try{P.get(t)(e);}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function T0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function F0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function x0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function R0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function j0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function A0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function k0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function D0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function O0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function $0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function W0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function z0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function U0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function B0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function H0(t,e,n,i,o,s,u){var c=y();try{return P.get(t)(e,n,i,o,s,u)}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function N0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function V0(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function J0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function G0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function Y0(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function K0(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function X0(t,e,n,i,o,s,u,c,l){var f=y();try{P.get(t)(e,n,i,o,s,u,c,l);}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function Z0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function Q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function e4(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function t4(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function n4(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function r4(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function i4(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function o4(t,e,n,i,o,s,u,c,l){var f=y();try{return P.get(t)(e,n,i,o,s,u,c,l)}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function a4(t,e,n,i,o,s,u,c){var l=y();try{return P.get(t)(e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function s4(t){var e=y();try{P.get(t)();}catch(n){if(_(e),n!==n+0)throw n;m(1,0);}}function c4(t,e,n){var i=y();try{return p3(t,e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function d4(t,e){var n=y();try{return l3(t,e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function u4(t,e,n,i,o){var s=y();try{f3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function h4(t,e,n,i,o,s){var u=y();try{M3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function l4(t,e,n,i){var o=y();try{return j3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function f4(t,e,n,i,o,s,u){var c=y();try{_3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function p4(t,e,n,i){var o=y();try{return E3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function v4(t,e,n,i,o,s,u,c){var l=y();try{w3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function m4(t,e,n,i){var o=y();try{m3(t,e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function _4(t,e,n,i,o,s,u){var c=y();try{g3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function y4(t,e,n,i,o,s){var u=y();try{v3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function g4(t,e,n,i,o,s){var u=y();try{return y3(t,e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function w4(t,e,n,i,o){var s=y();try{C3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function C4(t,e,n,i,o,s,u,c){var l=y();try{return b3(t,e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function b4(t,e,n,i,o,s){var u=y();try{L3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function L4(t,e,n,i,o,s,u){var c=y();try{P3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function M4(t,e,n,i,o,s,u,c){var l=y();try{I3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function E4(t,e,n,i,o,s,u,c,l,f,p,C,L){var I=y();try{T3(t,e,n,i,o,s,u,c,l,f,p,C,L);}catch(S){if(_(I),S!==S+0)throw S;m(1,0);}}function P4(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S){var B=y();try{S3(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S);}catch(V){if(_(B),V!==V+0)throw V;m(1,0);}}function I4(t,e,n,i,o,s,u,c,l,f,p,C){var L=y();try{return F3(t,e,n,i,o,s,u,c,l,f,p,C)}catch(I){if(_(L),I!==I+0)throw I;m(1,0);}}function S4(t,e,n,i,o,s,u,c,l,f,p){var C=y();try{x3(t,e,n,i,o,s,u,c,l,f,p);}catch(L){if(_(C),L!==L+0)throw L;m(1,0);}}function T4(t,e,n,i,o,s,u,c){var l=y();try{R3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function F4(t,e,n,i,o,s,u,c,l,f){var p=y();try{A3(t,e,n,i,o,s,u,c,l,f);}catch(C){if(_(p),C!==C+0)throw C;m(1,0);}}var H1;p1=function t(){H1||k3(),H1||(p1=t);};function k3(){function t(){var n;if(!H1&&(H1=!0,d.calledRun=!0,!R1)){if(Z1(R2),g(d),(n=d.onRuntimeInitialized)==null||n.call(d),d.postRun)for(typeof d.postRun=="function"&&(d.postRun=[d.postRun]);d.postRun.length;){var e=d.postRun.shift();j2.unshift(e);}Z1(j2);}}if(!(0{setTimeout(()=>d.setStatus(""),1),t();},1)):t());}}if(d.preInit)for(typeof d.preInit=="function"&&(d.preInit=[d.preInit]);0r})})}static _loadWithBackup(){return E(this,null,function*(){return this._ModulePromise||(this._ModulePromise=this._tryLoad(this._wasmURL).catch(r=>E(this,null,function*(){let a=`https://unpkg.com/${C2}@${w2}/dist/dotlottie-player.wasm`;console.warn(`Primary WASM load failed from ${this._wasmURL}. Error: ${r.message}`),console.warn(`Attempting to load WASM from backup URL: ${a}`);try{return yield this._tryLoad(a)}catch(h){throw console.error(`Primary WASM URL failed: ${r.message}`),console.error(`Backup WASM URL failed: ${h.message}`),new Error("WASM loading failed from all sources.")}}))),this._ModulePromise})}static load(){return E(this,null,function*(){return this._loadWithBackup()})}static setWasmUrl(r){r!==this._wasmURL&&(this._wasmURL=r,this._ModulePromise=null);}};b(o1,"_ModulePromise",null),b(o1,"_wasmURL",`https://cdn.jsdelivr.net/npm/${C2}@${w2}/dist/dotlottie-player.wasm`);var h1=class{constructor(){b(this,"_eventListeners",new Map);}addEventListener(r,a){let h=this._eventListeners.get(r);h||(h=new Set,this._eventListeners.set(r,h)),h.add(a);}removeEventListener(r,a){let h=this._eventListeners.get(r);h&&(a?(h.delete(a),h.size===0&&this._eventListeners.delete(r)):this._eventListeners.delete(r));}dispatch(r){let a=this._eventListeners.get(r.type);a==null||a.forEach(h=>h(r));}removeAllEventListeners(){this._eventListeners.clear();}};var H=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);d&&(h.isIntersecting?d.unfreeze():d.freeze());});};this._observer=new IntersectionObserver(r,{threshold:0});}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,a),(h=this._observer)==null||h.observe(r));}static unobserve(r){var a,h;(a=this._observer)==null||a.unobserve(r),this._observedCanvases.delete(r),this._observedCanvases.size===0&&((h=this._observer)==null||h.disconnect(),this._observer=null);}};b(H,"_observer",null),b(H,"_observedCanvases",new Map);var N=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);if(!d)return;let[g,T]=d;clearTimeout(T);let R=setTimeout(()=>{g.resize();},100);this._observedCanvases.set(h.target,[g,R]);});};this._observer=new ResizeObserver(r);}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,[a,0]),(h=this._observer)==null||h.observe(r));}static unobserve(r){var h;let a=this._observedCanvases.get(r);if(a){let d=a[1];d&&clearTimeout(d);}(h=this._observer)==null||h.unobserve(r),this._observedCanvases.delete(r),!this._observedCanvases.size&&this._observer&&(this._observer.disconnect(),this._observer=null);}};b(N,"_observer",null),b(N,"_observedCanvases",new Map);function k4(v){return /^#([\da-f]{6}|[\da-f]{8})$/iu.test(v)}function N3(v){if(!k4(v))return 0;let r=v.replace("#","");return r=r.length===6?`${r}ff`:r,parseInt(r,16)}function b2(v){if(v.byteLength<4)return !1;let r=new Uint8Array(v.slice(0,J1.byteLength));for(let a=0;aObject.prototype.hasOwnProperty.call(v,r))}function L2(v){if(typeof v=="string")try{return H3(JSON.parse(v))}catch(r){return !1}else return H3(v)}function e1(){return 1+((O?window.devicePixelRatio:1)-1)*U3}function G1(v){let r=v.getBoundingClientRect();return r.top>=0&&r.left>=0&&r.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&r.right<=(window.innerWidth||document.documentElement.clientWidth)}var M2=(v,r)=>v==="reverse"?r.Mode.Reverse:v==="bounce"?r.Mode.Bounce:v==="reverse-bounce"?r.Mode.ReverseBounce:r.Mode.Forward,D4=(v,r)=>v==="contain"?r.Fit.Contain:v==="cover"?r.Fit.Cover:v==="fill"?r.Fit.Fill:v==="fit-height"?r.Fit.FitHeight:v==="fit-width"?r.Fit.FitWidth:r.Fit.None,O4=(v,r)=>{let a=new r.VectorFloat;return a.push_back(v[0]),a.push_back(v[1]),a},E2=(v,r)=>{let a=new r.VectorFloat;return v.length!==2||(a.push_back(v[0]),a.push_back(v[1])),a},P2=(v,r)=>{var a,h;return v?{align:O4((a=v.align)!=null?a:[.5,.5],r),fit:D4((h=v.fit)!=null?h:"contain",r)}:r.createDefaultLayout()},x=class x{constructor(r){b(this,"_canvas");b(this,"_context",null);b(this,"_eventManager");b(this,"_animationFrameId",null);b(this,"_frameManager");b(this,"_dotLottieCore",null);b(this,"_renderConfig",{});b(this,"_isFrozen",!1);b(this,"_backgroundColor",null);b(this,"_pointerUpMethod");b(this,"_pointerDownMethod");b(this,"_pointerMoveMethod");b(this,"_pointerEnterMethod");b(this,"_pointerExitMethod");var a,h,d;this._canvas=r.canvas,this._eventManager=new h1,this._frameManager=new q1,this._renderConfig=z(D({},r.renderConfig),{devicePixelRatio:((a=r.renderConfig)==null?void 0:a.devicePixelRatio)||e1(),freezeOnOffscreen:(d=(h=r.renderConfig)==null?void 0:h.freezeOnOffscreen)!=null?d:!0}),o1.load().then(g=>{var T,R,j,$,U,l1,T1,K;x._wasmModule=g,this._dotLottieCore=new g.DotLottiePlayer({themeId:(T=r.themeId)!=null?T:"",stateMachineId:"",autoplay:(R=r.autoplay)!=null?R:!1,backgroundColor:0,loopAnimation:(j=r.loop)!=null?j:!1,mode:M2(($=r.mode)!=null?$:"forward",g),segment:E2((U=r.segment)!=null?U:[],g),speed:(l1=r.speed)!=null?l1:1,useFrameInterpolation:(T1=r.useFrameInterpolation)!=null?T1:!0,marker:(K=r.marker)!=null?K:"",layout:P2(r.layout,g)}),this._eventManager.dispatch({type:"ready"}),r.data?this._loadFromData(r.data):r.src&&this._loadFromSrc(r.src),r.backgroundColor&&this.setBackgroundColor(r.backgroundColor);}).catch(g=>{this._eventManager.dispatch({type:"loadError",error:new Error(`Failed to load wasm module: ${g}`)});}),this._pointerUpMethod=this._onPointerUp.bind(this),this._pointerDownMethod=this._onPointerDown.bind(this),this._pointerMoveMethod=this._onPointerMove.bind(this),this._pointerEnterMethod=this._onPointerEnter.bind(this),this._pointerExitMethod=this._onPointerLeave.bind(this);}_dispatchError(r){console.error(r),this._eventManager.dispatch({type:"loadError",error:new Error(r)});}_fetchData(r){return E(this,null,function*(){let a=yield fetch(r);if(!a.ok)throw new Error(`Failed to fetch animation data from URL: ${r}. ${a.status}: ${a.statusText}`);let h=yield a.arrayBuffer();return b2(h)?h:new TextDecoder().decode(h)})}_loadFromData(r){if(this._dotLottieCore===null)return;let a=this._canvas.width,h=this._canvas.height,d=!1;if(typeof r=="string"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON string: The provided string does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(r,a,h);}else if(r instanceof ArrayBuffer){if(!b2(r)){this._dispatchError("Invalid dotLottie ArrayBuffer: The provided ArrayBuffer does not conform to the dotLottie format.");return}d=this._dotLottieCore.loadDotLottieData(r,a,h);}else if(typeof r=="object"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON object: The provided object does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(JSON.stringify(r),a,h);}else {this._dispatchError(`Unsupported data type for animation data. Expected: -+`),t=X2(c)(...l),n=`methodCaller<(${e.map(p=>p.name).join(", ")}) => ${i.name}>`,C0(r1(n,t))},Ta:t=>{9{var e=c1(t);i2(e),u2(t);},F:(t,e)=>(t=w1(t,"_emval_take_value"),t=t.readValueFromPointer(e),y1(t)),oa:(t,e)=>{if(E1[t]&&(clearTimeout(E1[t].id),delete E1[t]),!e)return 0;var n=setTimeout(()=>{delete E1[t],L0(()=>s3(t,performance.now()));},e);return E1[t]={id:n,Uc:e},0},pa:(t,e,n,i)=>{var o=new Date().getFullYear(),s=new Date(o,0,1).getTimezoneOffset();o=new Date(o,6,1).getTimezoneOffset(),F[t>>2]=60*Math.max(s,o),t1[e>>2]=+(s!=o),e=u=>{var c=Math.abs(u);return `UTC${0<=u?"-":"+"}${String(Math.floor(c/60)).padStart(2,"0")}${String(c%60).padStart(2,"0")}`},t=e(s),e=e(o),o{var e=k.length;if(t>>>=0,2147483648=n;n*=2){var i=e*(1+.2/n);i=Math.min(i,t+100663296);e:{i=(Math.min(2147483648,65536*Math.ceil(Math.max(t,i)/65536))-x1.buffer.byteLength+65535)/65536|0;try{x1.grow(i),F2();var o=1;break e}catch(s){}o=void 0;}if(o)return !0}return !1},Ca:(t,e)=>{var n=0;return r3().forEach((i,o)=>{var s=e+n;for(o=F[t+4*o>>2]=s,s=0;s{var n=r3();F[t>>2]=n.length;var i=0;return n.forEach(o=>i+=o.length+1),F[e>>2]=i,0},za:()=>52,xa:()=>52,U:(t,e,n,i)=>{for(var o=0,s=0;s>2],c=F[e+4>>2];e+=8;for(var l=0;l>2]=o,0},Ia:Q0,n:D0,$:j0,La:J0,g:x0,u:O0,Na:A0,G:z0,J:N0,f:R0,_:q0,h:$0,Ma:B0,k:U0,R:H0,t:W0,V:a4,W:o4,Xa:I4,bb:C4,ha:l4,ka:d4,la:c4,fa:p4,db:g4,I:s4,a:S0,B:Z0,E:k0,X:r4,c:I0,Ka:G0,Ha:e4,e:T0,Y:t4,Q:i4,j:F0,y:n4,i:V0,p:Y0,s:K0,Z:X0,Wa:S4,Za:E4,Ya:P4,ab:b4,$a:L4,_a:M4,cb:w4,ia:h4,ga:f4,Va:T4,fb:_4,ea:v4,gb:m4,ja:u4,Ua:F4,eb:y4,q:t=>t,Ba:n3,sa:(t,e)=>(i3(k.subarray(t,t+e)),0)},w=function(){var n;function t(i){var o;return w=i.exports,x1=w.hb,F2(),P=w.mb,R2.unshift(w.ib),n1--,(o=d.monitorRunDependencies)==null||o.call(d,n1),n1==0&&(p1&&(i=p1,p1=null,i())),w}n1++,(n=d.monitorRunDependencies)==null||n.call(d,n1);var e={a:P0};if(d.instantiateWasm)try{return d.instantiateWasm(e,t)}catch(i){K(`Module.instantiateWasm callback failed with error: ${i}`),T(i);}return v1!=null||(v1=A2("DotLottiePlayer.wasm")?"DotLottiePlayer.wasm":d.locateFile?d.locateFile("DotLottiePlayer.wasm",U):U+"DotLottiePlayer.wasm"),Z3(e,function(i){t(i.instance);}).catch(T),{}}(),v2=t=>(v2=w.jb)(t),a3=t=>(a3=w.kb)(t),Q=t=>(Q=w.lb)(t),s3=(t,e)=>(s3=w.nb)(t,e),m=(t,e)=>(m=w.ob)(t,e),P1=t=>(P1=w.pb)(t),_=t=>(_=w.qb)(t),y=()=>(y=w.rb)(),c3=t=>(c3=w.sb)(t),d3=t=>(d3=w.tb)(t),u3=(t,e,n)=>(u3=w.ub)(t,e,n),h3=t=>(h3=w.vb)(t),l3=d.dynCall_ji=(t,e)=>(l3=d.dynCall_ji=w.wb)(t,e),f3=d.dynCall_viji=(t,e,n,i,o)=>(f3=d.dynCall_viji=w.xb)(t,e,n,i,o),p3=d.dynCall_jii=(t,e,n)=>(p3=d.dynCall_jii=w.yb)(t,e,n);d.dynCall_iijj=(t,e,n,i,o,s)=>(d.dynCall_iijj=w.zb)(t,e,n,i,o,s),d.dynCall_vijj=(t,e,n,i,o,s)=>(d.dynCall_vijj=w.Ab)(t,e,n,i,o,s);var v3=d.dynCall_vjiii=(t,e,n,i,o,s)=>(v3=d.dynCall_vjiii=w.Bb)(t,e,n,i,o,s),m3=d.dynCall_vij=(t,e,n,i)=>(m3=d.dynCall_vij=w.Cb)(t,e,n,i),_3=d.dynCall_viijii=(t,e,n,i,o,s,u)=>(_3=d.dynCall_viijii=w.Db)(t,e,n,i,o,s,u),y3=d.dynCall_jjji=(t,e,n,i,o,s)=>(y3=d.dynCall_jjji=w.Eb)(t,e,n,i,o,s),g3=d.dynCall_viijj=(t,e,n,i,o,s,u)=>(g3=d.dynCall_viijj=w.Fb)(t,e,n,i,o,s,u),w3=d.dynCall_viijji=(t,e,n,i,o,s,u,c)=>(w3=d.dynCall_viijji=w.Gb)(t,e,n,i,o,s,u,c),C3=d.dynCall_viij=(t,e,n,i,o)=>(C3=d.dynCall_viij=w.Hb)(t,e,n,i,o),b3=d.dynCall_iiiijj=(t,e,n,i,o,s,u,c)=>(b3=d.dynCall_iiiijj=w.Ib)(t,e,n,i,o,s,u,c),L3=d.dynCall_viiij=(t,e,n,i,o,s)=>(L3=d.dynCall_viiij=w.Jb)(t,e,n,i,o,s),M3=d.dynCall_viiji=(t,e,n,i,o,s)=>(M3=d.dynCall_viiji=w.Kb)(t,e,n,i,o,s),E3=d.dynCall_jiii=(t,e,n,i)=>(E3=d.dynCall_jiii=w.Lb)(t,e,n,i),P3=d.dynCall_viiiji=(t,e,n,i,o,s,u)=>(P3=d.dynCall_viiiji=w.Mb)(t,e,n,i,o,s,u),I3=d.dynCall_viiijj=(t,e,n,i,o,s,u,c)=>(I3=d.dynCall_viiijj=w.Nb)(t,e,n,i,o,s,u,c),S3=d.dynCall_viiiijjiiiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S)=>(S3=d.dynCall_viiiijjiiiiii=w.Ob)(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S),T3=d.dynCall_viiiijjiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L)=>(T3=d.dynCall_viiiijjiiii=w.Pb)(t,e,n,i,o,s,u,c,l,f,p,C,L),F3=d.dynCall_iiiiiijjii=(t,e,n,i,o,s,u,c,l,f,p,C)=>(F3=d.dynCall_iiiiiijjii=w.Qb)(t,e,n,i,o,s,u,c,l,f,p,C),x3=d.dynCall_viiiijjii=(t,e,n,i,o,s,u,c,l,f,p)=>(x3=d.dynCall_viiiijjii=w.Rb)(t,e,n,i,o,s,u,c,l,f,p),R3=d.dynCall_viijiii=(t,e,n,i,o,s,u,c)=>(R3=d.dynCall_viijiii=w.Sb)(t,e,n,i,o,s,u,c),j3=d.dynCall_iji=(t,e,n,i)=>(j3=d.dynCall_iji=w.Tb)(t,e,n,i),A3=d.dynCall_vijjjj=(t,e,n,i,o,s,u,c,l,f)=>(A3=d.dynCall_vijjjj=w.Ub)(t,e,n,i,o,s,u,c,l,f);d.dynCall_vjii=(t,e,n,i,o)=>(d.dynCall_vjii=w.Vb)(t,e,n,i,o),d.dynCall_vjfii=(t,e,n,i,o,s)=>(d.dynCall_vjfii=w.Wb)(t,e,n,i,o,s),d.dynCall_vj=(t,e,n)=>(d.dynCall_vj=w.Xb)(t,e,n),d.dynCall_vjiiiii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiiiii=w.Yb)(t,e,n,i,o,s,u,c),d.dynCall_vjiffii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiffii=w.Zb)(t,e,n,i,o,s,u,c),d.dynCall_vjiiii=(t,e,n,i,o,s,u)=>(d.dynCall_vjiiii=w._b)(t,e,n,i,o,s,u),d.dynCall_iiiiij=(t,e,n,i,o,s,u)=>(d.dynCall_iiiiij=w.$b)(t,e,n,i,o,s,u),d.dynCall_iiiiijj=(t,e,n,i,o,s,u,c,l)=>(d.dynCall_iiiiijj=w.ac)(t,e,n,i,o,s,u,c,l),d.dynCall_iiiiiijj=(t,e,n,i,o,s,u,c,l,f)=>(d.dynCall_iiiiiijj=w.bc)(t,e,n,i,o,s,u,c,l,f);function I0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function S0(t,e){var n=y();try{P.get(t)(e);}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function T0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function F0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function x0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function R0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function j0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function A0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function k0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function D0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function O0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function $0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function W0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function z0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function U0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function B0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function H0(t,e,n,i,o,s,u){var c=y();try{return P.get(t)(e,n,i,o,s,u)}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function N0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function V0(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function J0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function G0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function Y0(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function K0(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function X0(t,e,n,i,o,s,u,c,l){var f=y();try{P.get(t)(e,n,i,o,s,u,c,l);}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function Z0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function Q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function e4(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function t4(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function n4(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function r4(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function i4(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function o4(t,e,n,i,o,s,u,c,l){var f=y();try{return P.get(t)(e,n,i,o,s,u,c,l)}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function a4(t,e,n,i,o,s,u,c){var l=y();try{return P.get(t)(e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function s4(t){var e=y();try{P.get(t)();}catch(n){if(_(e),n!==n+0)throw n;m(1,0);}}function c4(t,e,n){var i=y();try{return p3(t,e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function d4(t,e){var n=y();try{return l3(t,e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function u4(t,e,n,i,o){var s=y();try{f3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function h4(t,e,n,i,o,s){var u=y();try{M3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function l4(t,e,n,i){var o=y();try{return j3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function f4(t,e,n,i,o,s,u){var c=y();try{_3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function p4(t,e,n,i){var o=y();try{return E3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function v4(t,e,n,i,o,s,u,c){var l=y();try{w3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function m4(t,e,n,i){var o=y();try{m3(t,e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function _4(t,e,n,i,o,s,u){var c=y();try{g3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function y4(t,e,n,i,o,s){var u=y();try{v3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function g4(t,e,n,i,o,s){var u=y();try{return y3(t,e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function w4(t,e,n,i,o){var s=y();try{C3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function C4(t,e,n,i,o,s,u,c){var l=y();try{return b3(t,e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function b4(t,e,n,i,o,s){var u=y();try{L3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function L4(t,e,n,i,o,s,u){var c=y();try{P3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function M4(t,e,n,i,o,s,u,c){var l=y();try{I3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function E4(t,e,n,i,o,s,u,c,l,f,p,C,L){var I=y();try{T3(t,e,n,i,o,s,u,c,l,f,p,C,L);}catch(S){if(_(I),S!==S+0)throw S;m(1,0);}}function P4(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S){var B=y();try{S3(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S);}catch(V){if(_(B),V!==V+0)throw V;m(1,0);}}function I4(t,e,n,i,o,s,u,c,l,f,p,C){var L=y();try{return F3(t,e,n,i,o,s,u,c,l,f,p,C)}catch(I){if(_(L),I!==I+0)throw I;m(1,0);}}function S4(t,e,n,i,o,s,u,c,l,f,p){var C=y();try{x3(t,e,n,i,o,s,u,c,l,f,p);}catch(L){if(_(C),L!==L+0)throw L;m(1,0);}}function T4(t,e,n,i,o,s,u,c){var l=y();try{R3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function F4(t,e,n,i,o,s,u,c,l,f){var p=y();try{A3(t,e,n,i,o,s,u,c,l,f);}catch(C){if(_(p),C!==C+0)throw C;m(1,0);}}var H1;p1=function t(){H1||k3(),H1||(p1=t);};function k3(){function t(){var n;if(!H1&&(H1=!0,d.calledRun=!0,!R1)){if(Z1(R2),g(d),(n=d.onRuntimeInitialized)==null||n.call(d),d.postRun)for(typeof d.postRun=="function"&&(d.postRun=[d.postRun]);d.postRun.length;){var e=d.postRun.shift();j2.unshift(e);}Z1(j2);}}if(!(0{setTimeout(()=>d.setStatus(""),1),t();},1)):t());}}if(d.preInit)for(typeof d.preInit=="function"&&(d.preInit=[d.preInit]);0r})})}static _loadWithBackup(){return E(this,null,function*(){return this._ModulePromise||(this._ModulePromise=this._tryLoad(this._wasmURL).catch(r=>E(this,null,function*(){let a=`https://unpkg.com/${C2}@${w2}/dist/dotlottie-player.wasm`;console.warn(`Primary WASM load failed from ${this._wasmURL}. Error: ${r.message}`),console.warn(`Attempting to load WASM from backup URL: ${a}`);try{return yield this._tryLoad(a)}catch(h){throw console.error(`Primary WASM URL failed: ${r.message}`),console.error(`Backup WASM URL failed: ${h.message}`),new Error("WASM loading failed from all sources.")}}))),this._ModulePromise})}static load(){return E(this,null,function*(){return this._loadWithBackup()})}static setWasmUrl(r){r!==this._wasmURL&&(this._wasmURL=r,this._ModulePromise=null);}};b(o1,"_ModulePromise",null),b(o1,"_wasmURL",`https://cdn.expensify.com/cdn.jsdelivr.net/npm/${C2}@${w2}/dist/dotlottie-player.wasm`);var h1=class{constructor(){b(this,"_eventListeners",new Map);}addEventListener(r,a){let h=this._eventListeners.get(r);h||(h=new Set,this._eventListeners.set(r,h)),h.add(a);}removeEventListener(r,a){let h=this._eventListeners.get(r);h&&(a?(h.delete(a),h.size===0&&this._eventListeners.delete(r)):this._eventListeners.delete(r));}dispatch(r){let a=this._eventListeners.get(r.type);a==null||a.forEach(h=>h(r));}removeAllEventListeners(){this._eventListeners.clear();}};var H=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);d&&(h.isIntersecting?d.unfreeze():d.freeze());});};this._observer=new IntersectionObserver(r,{threshold:0});}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,a),(h=this._observer)==null||h.observe(r));}static unobserve(r){var a,h;(a=this._observer)==null||a.unobserve(r),this._observedCanvases.delete(r),this._observedCanvases.size===0&&((h=this._observer)==null||h.disconnect(),this._observer=null);}};b(H,"_observer",null),b(H,"_observedCanvases",new Map);var N=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);if(!d)return;let[g,T]=d;clearTimeout(T);let R=setTimeout(()=>{g.resize();},100);this._observedCanvases.set(h.target,[g,R]);});};this._observer=new ResizeObserver(r);}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,[a,0]),(h=this._observer)==null||h.observe(r));}static unobserve(r){var h;let a=this._observedCanvases.get(r);if(a){let d=a[1];d&&clearTimeout(d);}(h=this._observer)==null||h.unobserve(r),this._observedCanvases.delete(r),!this._observedCanvases.size&&this._observer&&(this._observer.disconnect(),this._observer=null);}};b(N,"_observer",null),b(N,"_observedCanvases",new Map);function k4(v){return /^#([\da-f]{6}|[\da-f]{8})$/iu.test(v)}function N3(v){if(!k4(v))return 0;let r=v.replace("#","");return r=r.length===6?`${r}ff`:r,parseInt(r,16)}function b2(v){if(v.byteLength<4)return !1;let r=new Uint8Array(v.slice(0,J1.byteLength));for(let a=0;aObject.prototype.hasOwnProperty.call(v,r))}function L2(v){if(typeof v=="string")try{return H3(JSON.parse(v))}catch(r){return !1}else return H3(v)}function e1(){return 1+((O?window.devicePixelRatio:1)-1)*U3}function G1(v){let r=v.getBoundingClientRect();return r.top>=0&&r.left>=0&&r.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&r.right<=(window.innerWidth||document.documentElement.clientWidth)}var M2=(v,r)=>v==="reverse"?r.Mode.Reverse:v==="bounce"?r.Mode.Bounce:v==="reverse-bounce"?r.Mode.ReverseBounce:r.Mode.Forward,D4=(v,r)=>v==="contain"?r.Fit.Contain:v==="cover"?r.Fit.Cover:v==="fill"?r.Fit.Fill:v==="fit-height"?r.Fit.FitHeight:v==="fit-width"?r.Fit.FitWidth:r.Fit.None,O4=(v,r)=>{let a=new r.VectorFloat;return a.push_back(v[0]),a.push_back(v[1]),a},E2=(v,r)=>{let a=new r.VectorFloat;return v.length!==2||(a.push_back(v[0]),a.push_back(v[1])),a},P2=(v,r)=>{var a,h;return v?{align:O4((a=v.align)!=null?a:[.5,.5],r),fit:D4((h=v.fit)!=null?h:"contain",r)}:r.createDefaultLayout()},x=class x{constructor(r){b(this,"_canvas");b(this,"_context",null);b(this,"_eventManager");b(this,"_animationFrameId",null);b(this,"_frameManager");b(this,"_dotLottieCore",null);b(this,"_renderConfig",{});b(this,"_isFrozen",!1);b(this,"_backgroundColor",null);b(this,"_pointerUpMethod");b(this,"_pointerDownMethod");b(this,"_pointerMoveMethod");b(this,"_pointerEnterMethod");b(this,"_pointerExitMethod");var a,h,d;this._canvas=r.canvas,this._eventManager=new h1,this._frameManager=new q1,this._renderConfig=z(D({},r.renderConfig),{devicePixelRatio:((a=r.renderConfig)==null?void 0:a.devicePixelRatio)||e1(),freezeOnOffscreen:(d=(h=r.renderConfig)==null?void 0:h.freezeOnOffscreen)!=null?d:!0}),o1.load().then(g=>{var T,R,j,$,U,l1,T1,K;x._wasmModule=g,this._dotLottieCore=new g.DotLottiePlayer({themeId:(T=r.themeId)!=null?T:"",stateMachineId:"",autoplay:(R=r.autoplay)!=null?R:!1,backgroundColor:0,loopAnimation:(j=r.loop)!=null?j:!1,mode:M2(($=r.mode)!=null?$:"forward",g),segment:E2((U=r.segment)!=null?U:[],g),speed:(l1=r.speed)!=null?l1:1,useFrameInterpolation:(T1=r.useFrameInterpolation)!=null?T1:!0,marker:(K=r.marker)!=null?K:"",layout:P2(r.layout,g)}),this._eventManager.dispatch({type:"ready"}),r.data?this._loadFromData(r.data):r.src&&this._loadFromSrc(r.src),r.backgroundColor&&this.setBackgroundColor(r.backgroundColor);}).catch(g=>{this._eventManager.dispatch({type:"loadError",error:new Error(`Failed to load wasm module: ${g}`)});}),this._pointerUpMethod=this._onPointerUp.bind(this),this._pointerDownMethod=this._onPointerDown.bind(this),this._pointerMoveMethod=this._onPointerMove.bind(this),this._pointerEnterMethod=this._onPointerEnter.bind(this),this._pointerExitMethod=this._onPointerLeave.bind(this);}_dispatchError(r){console.error(r),this._eventManager.dispatch({type:"loadError",error:new Error(r)});}_fetchData(r){return E(this,null,function*(){let a=yield fetch(r);if(!a.ok)throw new Error(`Failed to fetch animation data from URL: ${r}. ${a.status}: ${a.statusText}`);let h=yield a.arrayBuffer();return b2(h)?h:new TextDecoder().decode(h)})}_loadFromData(r){if(this._dotLottieCore===null)return;let a=this._canvas.width,h=this._canvas.height,d=!1;if(typeof r=="string"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON string: The provided string does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(r,a,h);}else if(r instanceof ArrayBuffer){if(!b2(r)){this._dispatchError("Invalid dotLottie ArrayBuffer: The provided ArrayBuffer does not conform to the dotLottie format.");return}d=this._dotLottieCore.loadDotLottieData(r,a,h);}else if(typeof r=="object"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON object: The provided object does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(JSON.stringify(r),a,h);}else {this._dispatchError(`Unsupported data type for animation data. Expected: - - string (Lottie JSON), - - ArrayBuffer (dotLottie), - - object (Lottie JSON). -diff --git a/node_modules/@lottiefiles/dotlottie-web/dist/index.js b/node_modules/@lottiefiles/dotlottie-web/dist/index.js -index 1ea13c9..3cd41fa 100644 ---- a/node_modules/@lottiefiles/dotlottie-web/dist/index.js -+++ b/node_modules/@lottiefiles/dotlottie-web/dist/index.js -@@ -14,7 +14,7 @@ return ret; - `,s+=e[f].mc;return o+=` var rv = ${n===1?"new func":"func.call"}(${u.join(", ")}); - `,i.Nc||(c.push("emval_returnValue"),l.push(e3),o+=` return emval_returnValue(retType, destructorsRef, rv); - `),c.push(o+`}; --`),t=X2(c)(...l),n=`methodCaller<(${e.map(p=>p.name).join(", ")}) => ${i.name}>`,C0(r1(n,t))},Ta:t=>{9{var e=c1(t);i2(e),u2(t);},F:(t,e)=>(t=w1(t,"_emval_take_value"),t=t.readValueFromPointer(e),y1(t)),oa:(t,e)=>{if(E1[t]&&(clearTimeout(E1[t].id),delete E1[t]),!e)return 0;var n=setTimeout(()=>{delete E1[t],L0(()=>s3(t,performance.now()));},e);return E1[t]={id:n,Uc:e},0},pa:(t,e,n,i)=>{var o=new Date().getFullYear(),s=new Date(o,0,1).getTimezoneOffset();o=new Date(o,6,1).getTimezoneOffset(),F[t>>2]=60*Math.max(s,o),t1[e>>2]=+(s!=o),e=u=>{var c=Math.abs(u);return `UTC${0<=u?"-":"+"}${String(Math.floor(c/60)).padStart(2,"0")}${String(c%60).padStart(2,"0")}`},t=e(s),e=e(o),o{var e=k.length;if(t>>>=0,2147483648=n;n*=2){var i=e*(1+.2/n);i=Math.min(i,t+100663296);e:{i=(Math.min(2147483648,65536*Math.ceil(Math.max(t,i)/65536))-x1.buffer.byteLength+65535)/65536|0;try{x1.grow(i),F2();var o=1;break e}catch(s){}o=void 0;}if(o)return !0}return !1},Ca:(t,e)=>{var n=0;return r3().forEach((i,o)=>{var s=e+n;for(o=F[t+4*o>>2]=s,s=0;s{var n=r3();F[t>>2]=n.length;var i=0;return n.forEach(o=>i+=o.length+1),F[e>>2]=i,0},za:()=>52,xa:()=>52,U:(t,e,n,i)=>{for(var o=0,s=0;s>2],c=F[e+4>>2];e+=8;for(var l=0;l>2]=o,0},Ia:Q0,n:D0,$:j0,La:J0,g:x0,u:O0,Na:A0,G:z0,J:N0,f:R0,_:q0,h:$0,Ma:B0,k:U0,R:H0,t:W0,V:a4,W:o4,Xa:I4,bb:C4,ha:l4,ka:d4,la:c4,fa:p4,db:g4,I:s4,a:S0,B:Z0,E:k0,X:r4,c:I0,Ka:G0,Ha:e4,e:T0,Y:t4,Q:i4,j:F0,y:n4,i:V0,p:Y0,s:K0,Z:X0,Wa:S4,Za:E4,Ya:P4,ab:b4,$a:L4,_a:M4,cb:w4,ia:h4,ga:f4,Va:T4,fb:_4,ea:v4,gb:m4,ja:u4,Ua:F4,eb:y4,q:t=>t,Ba:n3,sa:(t,e)=>(i3(k.subarray(t,t+e)),0)},w=function(){var n;function t(i){var o;return w=i.exports,x1=w.hb,F2(),P=w.mb,R2.unshift(w.ib),n1--,(o=d.monitorRunDependencies)==null||o.call(d,n1),n1==0&&(p1&&(i=p1,p1=null,i())),w}n1++,(n=d.monitorRunDependencies)==null||n.call(d,n1);var e={a:P0};if(d.instantiateWasm)try{return d.instantiateWasm(e,t)}catch(i){K(`Module.instantiateWasm callback failed with error: ${i}`),T(i);}return v1!=null||(v1=A2("DotLottiePlayer.wasm")?"DotLottiePlayer.wasm":d.locateFile?d.locateFile("DotLottiePlayer.wasm",U):U+"DotLottiePlayer.wasm"),Z3(e,function(i){t(i.instance);}).catch(T),{}}(),v2=t=>(v2=w.jb)(t),a3=t=>(a3=w.kb)(t),Q=t=>(Q=w.lb)(t),s3=(t,e)=>(s3=w.nb)(t,e),m=(t,e)=>(m=w.ob)(t,e),P1=t=>(P1=w.pb)(t),_=t=>(_=w.qb)(t),y=()=>(y=w.rb)(),c3=t=>(c3=w.sb)(t),d3=t=>(d3=w.tb)(t),u3=(t,e,n)=>(u3=w.ub)(t,e,n),h3=t=>(h3=w.vb)(t),l3=d.dynCall_ji=(t,e)=>(l3=d.dynCall_ji=w.wb)(t,e),f3=d.dynCall_viji=(t,e,n,i,o)=>(f3=d.dynCall_viji=w.xb)(t,e,n,i,o),p3=d.dynCall_jii=(t,e,n)=>(p3=d.dynCall_jii=w.yb)(t,e,n);d.dynCall_iijj=(t,e,n,i,o,s)=>(d.dynCall_iijj=w.zb)(t,e,n,i,o,s),d.dynCall_vijj=(t,e,n,i,o,s)=>(d.dynCall_vijj=w.Ab)(t,e,n,i,o,s);var v3=d.dynCall_vjiii=(t,e,n,i,o,s)=>(v3=d.dynCall_vjiii=w.Bb)(t,e,n,i,o,s),m3=d.dynCall_vij=(t,e,n,i)=>(m3=d.dynCall_vij=w.Cb)(t,e,n,i),_3=d.dynCall_viijii=(t,e,n,i,o,s,u)=>(_3=d.dynCall_viijii=w.Db)(t,e,n,i,o,s,u),y3=d.dynCall_jjji=(t,e,n,i,o,s)=>(y3=d.dynCall_jjji=w.Eb)(t,e,n,i,o,s),g3=d.dynCall_viijj=(t,e,n,i,o,s,u)=>(g3=d.dynCall_viijj=w.Fb)(t,e,n,i,o,s,u),w3=d.dynCall_viijji=(t,e,n,i,o,s,u,c)=>(w3=d.dynCall_viijji=w.Gb)(t,e,n,i,o,s,u,c),C3=d.dynCall_viij=(t,e,n,i,o)=>(C3=d.dynCall_viij=w.Hb)(t,e,n,i,o),b3=d.dynCall_iiiijj=(t,e,n,i,o,s,u,c)=>(b3=d.dynCall_iiiijj=w.Ib)(t,e,n,i,o,s,u,c),L3=d.dynCall_viiij=(t,e,n,i,o,s)=>(L3=d.dynCall_viiij=w.Jb)(t,e,n,i,o,s),M3=d.dynCall_viiji=(t,e,n,i,o,s)=>(M3=d.dynCall_viiji=w.Kb)(t,e,n,i,o,s),E3=d.dynCall_jiii=(t,e,n,i)=>(E3=d.dynCall_jiii=w.Lb)(t,e,n,i),P3=d.dynCall_viiiji=(t,e,n,i,o,s,u)=>(P3=d.dynCall_viiiji=w.Mb)(t,e,n,i,o,s,u),I3=d.dynCall_viiijj=(t,e,n,i,o,s,u,c)=>(I3=d.dynCall_viiijj=w.Nb)(t,e,n,i,o,s,u,c),S3=d.dynCall_viiiijjiiiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S)=>(S3=d.dynCall_viiiijjiiiiii=w.Ob)(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S),T3=d.dynCall_viiiijjiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L)=>(T3=d.dynCall_viiiijjiiii=w.Pb)(t,e,n,i,o,s,u,c,l,f,p,C,L),F3=d.dynCall_iiiiiijjii=(t,e,n,i,o,s,u,c,l,f,p,C)=>(F3=d.dynCall_iiiiiijjii=w.Qb)(t,e,n,i,o,s,u,c,l,f,p,C),x3=d.dynCall_viiiijjii=(t,e,n,i,o,s,u,c,l,f,p)=>(x3=d.dynCall_viiiijjii=w.Rb)(t,e,n,i,o,s,u,c,l,f,p),R3=d.dynCall_viijiii=(t,e,n,i,o,s,u,c)=>(R3=d.dynCall_viijiii=w.Sb)(t,e,n,i,o,s,u,c),j3=d.dynCall_iji=(t,e,n,i)=>(j3=d.dynCall_iji=w.Tb)(t,e,n,i),A3=d.dynCall_vijjjj=(t,e,n,i,o,s,u,c,l,f)=>(A3=d.dynCall_vijjjj=w.Ub)(t,e,n,i,o,s,u,c,l,f);d.dynCall_vjii=(t,e,n,i,o)=>(d.dynCall_vjii=w.Vb)(t,e,n,i,o),d.dynCall_vjfii=(t,e,n,i,o,s)=>(d.dynCall_vjfii=w.Wb)(t,e,n,i,o,s),d.dynCall_vj=(t,e,n)=>(d.dynCall_vj=w.Xb)(t,e,n),d.dynCall_vjiiiii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiiiii=w.Yb)(t,e,n,i,o,s,u,c),d.dynCall_vjiffii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiffii=w.Zb)(t,e,n,i,o,s,u,c),d.dynCall_vjiiii=(t,e,n,i,o,s,u)=>(d.dynCall_vjiiii=w._b)(t,e,n,i,o,s,u),d.dynCall_iiiiij=(t,e,n,i,o,s,u)=>(d.dynCall_iiiiij=w.$b)(t,e,n,i,o,s,u),d.dynCall_iiiiijj=(t,e,n,i,o,s,u,c,l)=>(d.dynCall_iiiiijj=w.ac)(t,e,n,i,o,s,u,c,l),d.dynCall_iiiiiijj=(t,e,n,i,o,s,u,c,l,f)=>(d.dynCall_iiiiiijj=w.bc)(t,e,n,i,o,s,u,c,l,f);function I0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function S0(t,e){var n=y();try{P.get(t)(e);}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function T0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function F0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function x0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function R0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function j0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function A0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function k0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function D0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function O0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function $0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function W0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function z0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function U0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function B0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function H0(t,e,n,i,o,s,u){var c=y();try{return P.get(t)(e,n,i,o,s,u)}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function N0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function V0(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function J0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function G0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function Y0(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function K0(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function X0(t,e,n,i,o,s,u,c,l){var f=y();try{P.get(t)(e,n,i,o,s,u,c,l);}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function Z0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function Q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function e4(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function t4(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function n4(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function r4(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function i4(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function o4(t,e,n,i,o,s,u,c,l){var f=y();try{return P.get(t)(e,n,i,o,s,u,c,l)}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function a4(t,e,n,i,o,s,u,c){var l=y();try{return P.get(t)(e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function s4(t){var e=y();try{P.get(t)();}catch(n){if(_(e),n!==n+0)throw n;m(1,0);}}function c4(t,e,n){var i=y();try{return p3(t,e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function d4(t,e){var n=y();try{return l3(t,e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function u4(t,e,n,i,o){var s=y();try{f3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function h4(t,e,n,i,o,s){var u=y();try{M3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function l4(t,e,n,i){var o=y();try{return j3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function f4(t,e,n,i,o,s,u){var c=y();try{_3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function p4(t,e,n,i){var o=y();try{return E3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function v4(t,e,n,i,o,s,u,c){var l=y();try{w3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function m4(t,e,n,i){var o=y();try{m3(t,e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function _4(t,e,n,i,o,s,u){var c=y();try{g3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function y4(t,e,n,i,o,s){var u=y();try{v3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function g4(t,e,n,i,o,s){var u=y();try{return y3(t,e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function w4(t,e,n,i,o){var s=y();try{C3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function C4(t,e,n,i,o,s,u,c){var l=y();try{return b3(t,e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function b4(t,e,n,i,o,s){var u=y();try{L3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function L4(t,e,n,i,o,s,u){var c=y();try{P3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function M4(t,e,n,i,o,s,u,c){var l=y();try{I3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function E4(t,e,n,i,o,s,u,c,l,f,p,C,L){var I=y();try{T3(t,e,n,i,o,s,u,c,l,f,p,C,L);}catch(S){if(_(I),S!==S+0)throw S;m(1,0);}}function P4(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S){var B=y();try{S3(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S);}catch(V){if(_(B),V!==V+0)throw V;m(1,0);}}function I4(t,e,n,i,o,s,u,c,l,f,p,C){var L=y();try{return F3(t,e,n,i,o,s,u,c,l,f,p,C)}catch(I){if(_(L),I!==I+0)throw I;m(1,0);}}function S4(t,e,n,i,o,s,u,c,l,f,p){var C=y();try{x3(t,e,n,i,o,s,u,c,l,f,p);}catch(L){if(_(C),L!==L+0)throw L;m(1,0);}}function T4(t,e,n,i,o,s,u,c){var l=y();try{R3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function F4(t,e,n,i,o,s,u,c,l,f){var p=y();try{A3(t,e,n,i,o,s,u,c,l,f);}catch(C){if(_(p),C!==C+0)throw C;m(1,0);}}var H1;p1=function t(){H1||k3(),H1||(p1=t);};function k3(){function t(){var n;if(!H1&&(H1=!0,d.calledRun=!0,!R1)){if(Z1(R2),g(d),(n=d.onRuntimeInitialized)==null||n.call(d),d.postRun)for(typeof d.postRun=="function"&&(d.postRun=[d.postRun]);d.postRun.length;){var e=d.postRun.shift();j2.unshift(e);}Z1(j2);}}if(!(0{setTimeout(()=>d.setStatus(""),1),t();},1)):t());}}if(d.preInit)for(typeof d.preInit=="function"&&(d.preInit=[d.preInit]);0r})})}static _loadWithBackup(){return E(this,null,function*(){return this._ModulePromise||(this._ModulePromise=this._tryLoad(this._wasmURL).catch(r=>E(this,null,function*(){let a=`https://unpkg.com/${C2}@${w2}/dist/dotlottie-player.wasm`;console.warn(`Primary WASM load failed from ${this._wasmURL}. Error: ${r.message}`),console.warn(`Attempting to load WASM from backup URL: ${a}`);try{return yield this._tryLoad(a)}catch(h){throw console.error(`Primary WASM URL failed: ${r.message}`),console.error(`Backup WASM URL failed: ${h.message}`),new Error("WASM loading failed from all sources.")}}))),this._ModulePromise})}static load(){return E(this,null,function*(){return this._loadWithBackup()})}static setWasmUrl(r){r!==this._wasmURL&&(this._wasmURL=r,this._ModulePromise=null);}};b(o1,"_ModulePromise",null),b(o1,"_wasmURL",`https://cdn.jsdelivr.net/npm/${C2}@${w2}/dist/dotlottie-player.wasm`);var h1=class{constructor(){b(this,"_eventListeners",new Map);}addEventListener(r,a){let h=this._eventListeners.get(r);h||(h=new Set,this._eventListeners.set(r,h)),h.add(a);}removeEventListener(r,a){let h=this._eventListeners.get(r);h&&(a?(h.delete(a),h.size===0&&this._eventListeners.delete(r)):this._eventListeners.delete(r));}dispatch(r){let a=this._eventListeners.get(r.type);a==null||a.forEach(h=>h(r));}removeAllEventListeners(){this._eventListeners.clear();}};var H=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);d&&(h.isIntersecting?d.unfreeze():d.freeze());});};this._observer=new IntersectionObserver(r,{threshold:0});}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,a),(h=this._observer)==null||h.observe(r));}static unobserve(r){var a,h;(a=this._observer)==null||a.unobserve(r),this._observedCanvases.delete(r),this._observedCanvases.size===0&&((h=this._observer)==null||h.disconnect(),this._observer=null);}};b(H,"_observer",null),b(H,"_observedCanvases",new Map);var N=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);if(!d)return;let[g,T]=d;clearTimeout(T);let R=setTimeout(()=>{g.resize();},100);this._observedCanvases.set(h.target,[g,R]);});};this._observer=new ResizeObserver(r);}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,[a,0]),(h=this._observer)==null||h.observe(r));}static unobserve(r){var h;let a=this._observedCanvases.get(r);if(a){let d=a[1];d&&clearTimeout(d);}(h=this._observer)==null||h.unobserve(r),this._observedCanvases.delete(r),!this._observedCanvases.size&&this._observer&&(this._observer.disconnect(),this._observer=null);}};b(N,"_observer",null),b(N,"_observedCanvases",new Map);function k4(v){return /^#([\da-f]{6}|[\da-f]{8})$/iu.test(v)}function N3(v){if(!k4(v))return 0;let r=v.replace("#","");return r=r.length===6?`${r}ff`:r,parseInt(r,16)}function b2(v){if(v.byteLength<4)return !1;let r=new Uint8Array(v.slice(0,J1.byteLength));for(let a=0;aObject.prototype.hasOwnProperty.call(v,r))}function L2(v){if(typeof v=="string")try{return H3(JSON.parse(v))}catch(r){return !1}else return H3(v)}function e1(){return 1+((O?window.devicePixelRatio:1)-1)*U3}function G1(v){let r=v.getBoundingClientRect();return r.top>=0&&r.left>=0&&r.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&r.right<=(window.innerWidth||document.documentElement.clientWidth)}var M2=(v,r)=>v==="reverse"?r.Mode.Reverse:v==="bounce"?r.Mode.Bounce:v==="reverse-bounce"?r.Mode.ReverseBounce:r.Mode.Forward,D4=(v,r)=>v==="contain"?r.Fit.Contain:v==="cover"?r.Fit.Cover:v==="fill"?r.Fit.Fill:v==="fit-height"?r.Fit.FitHeight:v==="fit-width"?r.Fit.FitWidth:r.Fit.None,O4=(v,r)=>{let a=new r.VectorFloat;return a.push_back(v[0]),a.push_back(v[1]),a},E2=(v,r)=>{let a=new r.VectorFloat;return v.length!==2||(a.push_back(v[0]),a.push_back(v[1])),a},P2=(v,r)=>{var a,h;return v?{align:O4((a=v.align)!=null?a:[.5,.5],r),fit:D4((h=v.fit)!=null?h:"contain",r)}:r.createDefaultLayout()},x=class x{constructor(r){b(this,"_canvas");b(this,"_context",null);b(this,"_eventManager");b(this,"_animationFrameId",null);b(this,"_frameManager");b(this,"_dotLottieCore",null);b(this,"_renderConfig",{});b(this,"_isFrozen",!1);b(this,"_backgroundColor",null);b(this,"_pointerUpMethod");b(this,"_pointerDownMethod");b(this,"_pointerMoveMethod");b(this,"_pointerEnterMethod");b(this,"_pointerExitMethod");var a,h,d;this._canvas=r.canvas,this._eventManager=new h1,this._frameManager=new q1,this._renderConfig=z(D({},r.renderConfig),{devicePixelRatio:((a=r.renderConfig)==null?void 0:a.devicePixelRatio)||e1(),freezeOnOffscreen:(d=(h=r.renderConfig)==null?void 0:h.freezeOnOffscreen)!=null?d:!0}),o1.load().then(g=>{var T,R,j,$,U,l1,T1,K;x._wasmModule=g,this._dotLottieCore=new g.DotLottiePlayer({themeId:(T=r.themeId)!=null?T:"",stateMachineId:"",autoplay:(R=r.autoplay)!=null?R:!1,backgroundColor:0,loopAnimation:(j=r.loop)!=null?j:!1,mode:M2(($=r.mode)!=null?$:"forward",g),segment:E2((U=r.segment)!=null?U:[],g),speed:(l1=r.speed)!=null?l1:1,useFrameInterpolation:(T1=r.useFrameInterpolation)!=null?T1:!0,marker:(K=r.marker)!=null?K:"",layout:P2(r.layout,g)}),this._eventManager.dispatch({type:"ready"}),r.data?this._loadFromData(r.data):r.src&&this._loadFromSrc(r.src),r.backgroundColor&&this.setBackgroundColor(r.backgroundColor);}).catch(g=>{this._eventManager.dispatch({type:"loadError",error:new Error(`Failed to load wasm module: ${g}`)});}),this._pointerUpMethod=this._onPointerUp.bind(this),this._pointerDownMethod=this._onPointerDown.bind(this),this._pointerMoveMethod=this._onPointerMove.bind(this),this._pointerEnterMethod=this._onPointerEnter.bind(this),this._pointerExitMethod=this._onPointerLeave.bind(this);}_dispatchError(r){console.error(r),this._eventManager.dispatch({type:"loadError",error:new Error(r)});}_fetchData(r){return E(this,null,function*(){let a=yield fetch(r);if(!a.ok)throw new Error(`Failed to fetch animation data from URL: ${r}. ${a.status}: ${a.statusText}`);let h=yield a.arrayBuffer();return b2(h)?h:new TextDecoder().decode(h)})}_loadFromData(r){if(this._dotLottieCore===null)return;let a=this._canvas.width,h=this._canvas.height,d=!1;if(typeof r=="string"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON string: The provided string does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(r,a,h);}else if(r instanceof ArrayBuffer){if(!b2(r)){this._dispatchError("Invalid dotLottie ArrayBuffer: The provided ArrayBuffer does not conform to the dotLottie format.");return}d=this._dotLottieCore.loadDotLottieData(r,a,h);}else if(typeof r=="object"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON object: The provided object does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(JSON.stringify(r),a,h);}else {this._dispatchError(`Unsupported data type for animation data. Expected: -+`),t=X2(c)(...l),n=`methodCaller<(${e.map(p=>p.name).join(", ")}) => ${i.name}>`,C0(r1(n,t))},Ta:t=>{9{var e=c1(t);i2(e),u2(t);},F:(t,e)=>(t=w1(t,"_emval_take_value"),t=t.readValueFromPointer(e),y1(t)),oa:(t,e)=>{if(E1[t]&&(clearTimeout(E1[t].id),delete E1[t]),!e)return 0;var n=setTimeout(()=>{delete E1[t],L0(()=>s3(t,performance.now()));},e);return E1[t]={id:n,Uc:e},0},pa:(t,e,n,i)=>{var o=new Date().getFullYear(),s=new Date(o,0,1).getTimezoneOffset();o=new Date(o,6,1).getTimezoneOffset(),F[t>>2]=60*Math.max(s,o),t1[e>>2]=+(s!=o),e=u=>{var c=Math.abs(u);return `UTC${0<=u?"-":"+"}${String(Math.floor(c/60)).padStart(2,"0")}${String(c%60).padStart(2,"0")}`},t=e(s),e=e(o),o{var e=k.length;if(t>>>=0,2147483648=n;n*=2){var i=e*(1+.2/n);i=Math.min(i,t+100663296);e:{i=(Math.min(2147483648,65536*Math.ceil(Math.max(t,i)/65536))-x1.buffer.byteLength+65535)/65536|0;try{x1.grow(i),F2();var o=1;break e}catch(s){}o=void 0;}if(o)return !0}return !1},Ca:(t,e)=>{var n=0;return r3().forEach((i,o)=>{var s=e+n;for(o=F[t+4*o>>2]=s,s=0;s{var n=r3();F[t>>2]=n.length;var i=0;return n.forEach(o=>i+=o.length+1),F[e>>2]=i,0},za:()=>52,xa:()=>52,U:(t,e,n,i)=>{for(var o=0,s=0;s>2],c=F[e+4>>2];e+=8;for(var l=0;l>2]=o,0},Ia:Q0,n:D0,$:j0,La:J0,g:x0,u:O0,Na:A0,G:z0,J:N0,f:R0,_:q0,h:$0,Ma:B0,k:U0,R:H0,t:W0,V:a4,W:o4,Xa:I4,bb:C4,ha:l4,ka:d4,la:c4,fa:p4,db:g4,I:s4,a:S0,B:Z0,E:k0,X:r4,c:I0,Ka:G0,Ha:e4,e:T0,Y:t4,Q:i4,j:F0,y:n4,i:V0,p:Y0,s:K0,Z:X0,Wa:S4,Za:E4,Ya:P4,ab:b4,$a:L4,_a:M4,cb:w4,ia:h4,ga:f4,Va:T4,fb:_4,ea:v4,gb:m4,ja:u4,Ua:F4,eb:y4,q:t=>t,Ba:n3,sa:(t,e)=>(i3(k.subarray(t,t+e)),0)},w=function(){var n;function t(i){var o;return w=i.exports,x1=w.hb,F2(),P=w.mb,R2.unshift(w.ib),n1--,(o=d.monitorRunDependencies)==null||o.call(d,n1),n1==0&&(p1&&(i=p1,p1=null,i())),w}n1++,(n=d.monitorRunDependencies)==null||n.call(d,n1);var e={a:P0};if(d.instantiateWasm)try{return d.instantiateWasm(e,t)}catch(i){K(`Module.instantiateWasm callback failed with error: ${i}`),T(i);}return v1!=null||(v1=A2("DotLottiePlayer.wasm")?"DotLottiePlayer.wasm":d.locateFile?d.locateFile("DotLottiePlayer.wasm",U):U+"DotLottiePlayer.wasm"),Z3(e,function(i){t(i.instance);}).catch(T),{}}(),v2=t=>(v2=w.jb)(t),a3=t=>(a3=w.kb)(t),Q=t=>(Q=w.lb)(t),s3=(t,e)=>(s3=w.nb)(t,e),m=(t,e)=>(m=w.ob)(t,e),P1=t=>(P1=w.pb)(t),_=t=>(_=w.qb)(t),y=()=>(y=w.rb)(),c3=t=>(c3=w.sb)(t),d3=t=>(d3=w.tb)(t),u3=(t,e,n)=>(u3=w.ub)(t,e,n),h3=t=>(h3=w.vb)(t),l3=d.dynCall_ji=(t,e)=>(l3=d.dynCall_ji=w.wb)(t,e),f3=d.dynCall_viji=(t,e,n,i,o)=>(f3=d.dynCall_viji=w.xb)(t,e,n,i,o),p3=d.dynCall_jii=(t,e,n)=>(p3=d.dynCall_jii=w.yb)(t,e,n);d.dynCall_iijj=(t,e,n,i,o,s)=>(d.dynCall_iijj=w.zb)(t,e,n,i,o,s),d.dynCall_vijj=(t,e,n,i,o,s)=>(d.dynCall_vijj=w.Ab)(t,e,n,i,o,s);var v3=d.dynCall_vjiii=(t,e,n,i,o,s)=>(v3=d.dynCall_vjiii=w.Bb)(t,e,n,i,o,s),m3=d.dynCall_vij=(t,e,n,i)=>(m3=d.dynCall_vij=w.Cb)(t,e,n,i),_3=d.dynCall_viijii=(t,e,n,i,o,s,u)=>(_3=d.dynCall_viijii=w.Db)(t,e,n,i,o,s,u),y3=d.dynCall_jjji=(t,e,n,i,o,s)=>(y3=d.dynCall_jjji=w.Eb)(t,e,n,i,o,s),g3=d.dynCall_viijj=(t,e,n,i,o,s,u)=>(g3=d.dynCall_viijj=w.Fb)(t,e,n,i,o,s,u),w3=d.dynCall_viijji=(t,e,n,i,o,s,u,c)=>(w3=d.dynCall_viijji=w.Gb)(t,e,n,i,o,s,u,c),C3=d.dynCall_viij=(t,e,n,i,o)=>(C3=d.dynCall_viij=w.Hb)(t,e,n,i,o),b3=d.dynCall_iiiijj=(t,e,n,i,o,s,u,c)=>(b3=d.dynCall_iiiijj=w.Ib)(t,e,n,i,o,s,u,c),L3=d.dynCall_viiij=(t,e,n,i,o,s)=>(L3=d.dynCall_viiij=w.Jb)(t,e,n,i,o,s),M3=d.dynCall_viiji=(t,e,n,i,o,s)=>(M3=d.dynCall_viiji=w.Kb)(t,e,n,i,o,s),E3=d.dynCall_jiii=(t,e,n,i)=>(E3=d.dynCall_jiii=w.Lb)(t,e,n,i),P3=d.dynCall_viiiji=(t,e,n,i,o,s,u)=>(P3=d.dynCall_viiiji=w.Mb)(t,e,n,i,o,s,u),I3=d.dynCall_viiijj=(t,e,n,i,o,s,u,c)=>(I3=d.dynCall_viiijj=w.Nb)(t,e,n,i,o,s,u,c),S3=d.dynCall_viiiijjiiiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S)=>(S3=d.dynCall_viiiijjiiiiii=w.Ob)(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S),T3=d.dynCall_viiiijjiiii=(t,e,n,i,o,s,u,c,l,f,p,C,L)=>(T3=d.dynCall_viiiijjiiii=w.Pb)(t,e,n,i,o,s,u,c,l,f,p,C,L),F3=d.dynCall_iiiiiijjii=(t,e,n,i,o,s,u,c,l,f,p,C)=>(F3=d.dynCall_iiiiiijjii=w.Qb)(t,e,n,i,o,s,u,c,l,f,p,C),x3=d.dynCall_viiiijjii=(t,e,n,i,o,s,u,c,l,f,p)=>(x3=d.dynCall_viiiijjii=w.Rb)(t,e,n,i,o,s,u,c,l,f,p),R3=d.dynCall_viijiii=(t,e,n,i,o,s,u,c)=>(R3=d.dynCall_viijiii=w.Sb)(t,e,n,i,o,s,u,c),j3=d.dynCall_iji=(t,e,n,i)=>(j3=d.dynCall_iji=w.Tb)(t,e,n,i),A3=d.dynCall_vijjjj=(t,e,n,i,o,s,u,c,l,f)=>(A3=d.dynCall_vijjjj=w.Ub)(t,e,n,i,o,s,u,c,l,f);d.dynCall_vjii=(t,e,n,i,o)=>(d.dynCall_vjii=w.Vb)(t,e,n,i,o),d.dynCall_vjfii=(t,e,n,i,o,s)=>(d.dynCall_vjfii=w.Wb)(t,e,n,i,o,s),d.dynCall_vj=(t,e,n)=>(d.dynCall_vj=w.Xb)(t,e,n),d.dynCall_vjiiiii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiiiii=w.Yb)(t,e,n,i,o,s,u,c),d.dynCall_vjiffii=(t,e,n,i,o,s,u,c)=>(d.dynCall_vjiffii=w.Zb)(t,e,n,i,o,s,u,c),d.dynCall_vjiiii=(t,e,n,i,o,s,u)=>(d.dynCall_vjiiii=w._b)(t,e,n,i,o,s,u),d.dynCall_iiiiij=(t,e,n,i,o,s,u)=>(d.dynCall_iiiiij=w.$b)(t,e,n,i,o,s,u),d.dynCall_iiiiijj=(t,e,n,i,o,s,u,c,l)=>(d.dynCall_iiiiijj=w.ac)(t,e,n,i,o,s,u,c,l),d.dynCall_iiiiiijj=(t,e,n,i,o,s,u,c,l,f)=>(d.dynCall_iiiiiijj=w.bc)(t,e,n,i,o,s,u,c,l,f);function I0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function S0(t,e){var n=y();try{P.get(t)(e);}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function T0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function F0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function x0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function R0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function j0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function A0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function k0(t,e,n){var i=y();try{P.get(t)(e,n);}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function D0(t,e){var n=y();try{return P.get(t)(e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function O0(t,e,n){var i=y();try{return P.get(t)(e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function $0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function W0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function z0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function U0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function B0(t,e,n,i,o){var s=y();try{return P.get(t)(e,n,i,o)}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function H0(t,e,n,i,o,s,u){var c=y();try{return P.get(t)(e,n,i,o,s,u)}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function N0(t,e,n,i,o,s){var u=y();try{return P.get(t)(e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function V0(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function J0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function G0(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function Y0(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function K0(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function X0(t,e,n,i,o,s,u,c,l){var f=y();try{P.get(t)(e,n,i,o,s,u,c,l);}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function Z0(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function Q0(t,e,n,i){var o=y();try{return P.get(t)(e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function e4(t,e,n,i,o,s,u){var c=y();try{P.get(t)(e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function t4(t,e,n,i,o){var s=y();try{P.get(t)(e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function n4(t,e,n,i,o,s,u,c){var l=y();try{P.get(t)(e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function r4(t,e,n,i){var o=y();try{P.get(t)(e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function i4(t,e,n,i,o,s){var u=y();try{P.get(t)(e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function o4(t,e,n,i,o,s,u,c,l){var f=y();try{return P.get(t)(e,n,i,o,s,u,c,l)}catch(p){if(_(f),p!==p+0)throw p;m(1,0);}}function a4(t,e,n,i,o,s,u,c){var l=y();try{return P.get(t)(e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function s4(t){var e=y();try{P.get(t)();}catch(n){if(_(e),n!==n+0)throw n;m(1,0);}}function c4(t,e,n){var i=y();try{return p3(t,e,n)}catch(o){if(_(i),o!==o+0)throw o;m(1,0);}}function d4(t,e){var n=y();try{return l3(t,e)}catch(i){if(_(n),i!==i+0)throw i;m(1,0);}}function u4(t,e,n,i,o){var s=y();try{f3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function h4(t,e,n,i,o,s){var u=y();try{M3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function l4(t,e,n,i){var o=y();try{return j3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function f4(t,e,n,i,o,s,u){var c=y();try{_3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function p4(t,e,n,i){var o=y();try{return E3(t,e,n,i)}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function v4(t,e,n,i,o,s,u,c){var l=y();try{w3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function m4(t,e,n,i){var o=y();try{m3(t,e,n,i);}catch(s){if(_(o),s!==s+0)throw s;m(1,0);}}function _4(t,e,n,i,o,s,u){var c=y();try{g3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function y4(t,e,n,i,o,s){var u=y();try{v3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function g4(t,e,n,i,o,s){var u=y();try{return y3(t,e,n,i,o,s)}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function w4(t,e,n,i,o){var s=y();try{C3(t,e,n,i,o);}catch(u){if(_(s),u!==u+0)throw u;m(1,0);}}function C4(t,e,n,i,o,s,u,c){var l=y();try{return b3(t,e,n,i,o,s,u,c)}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function b4(t,e,n,i,o,s){var u=y();try{L3(t,e,n,i,o,s);}catch(c){if(_(u),c!==c+0)throw c;m(1,0);}}function L4(t,e,n,i,o,s,u){var c=y();try{P3(t,e,n,i,o,s,u);}catch(l){if(_(c),l!==l+0)throw l;m(1,0);}}function M4(t,e,n,i,o,s,u,c){var l=y();try{I3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function E4(t,e,n,i,o,s,u,c,l,f,p,C,L){var I=y();try{T3(t,e,n,i,o,s,u,c,l,f,p,C,L);}catch(S){if(_(I),S!==S+0)throw S;m(1,0);}}function P4(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S){var B=y();try{S3(t,e,n,i,o,s,u,c,l,f,p,C,L,I,S);}catch(V){if(_(B),V!==V+0)throw V;m(1,0);}}function I4(t,e,n,i,o,s,u,c,l,f,p,C){var L=y();try{return F3(t,e,n,i,o,s,u,c,l,f,p,C)}catch(I){if(_(L),I!==I+0)throw I;m(1,0);}}function S4(t,e,n,i,o,s,u,c,l,f,p){var C=y();try{x3(t,e,n,i,o,s,u,c,l,f,p);}catch(L){if(_(C),L!==L+0)throw L;m(1,0);}}function T4(t,e,n,i,o,s,u,c){var l=y();try{R3(t,e,n,i,o,s,u,c);}catch(f){if(_(l),f!==f+0)throw f;m(1,0);}}function F4(t,e,n,i,o,s,u,c,l,f){var p=y();try{A3(t,e,n,i,o,s,u,c,l,f);}catch(C){if(_(p),C!==C+0)throw C;m(1,0);}}var H1;p1=function t(){H1||k3(),H1||(p1=t);};function k3(){function t(){var n;if(!H1&&(H1=!0,d.calledRun=!0,!R1)){if(Z1(R2),g(d),(n=d.onRuntimeInitialized)==null||n.call(d),d.postRun)for(typeof d.postRun=="function"&&(d.postRun=[d.postRun]);d.postRun.length;){var e=d.postRun.shift();j2.unshift(e);}Z1(j2);}}if(!(0{setTimeout(()=>d.setStatus(""),1),t();},1)):t());}}if(d.preInit)for(typeof d.preInit=="function"&&(d.preInit=[d.preInit]);0r})})}static _loadWithBackup(){return E(this,null,function*(){return this._ModulePromise||(this._ModulePromise=this._tryLoad(this._wasmURL).catch(r=>E(this,null,function*(){let a=`https://unpkg.com/${C2}@${w2}/dist/dotlottie-player.wasm`;console.warn(`Primary WASM load failed from ${this._wasmURL}. Error: ${r.message}`),console.warn(`Attempting to load WASM from backup URL: ${a}`);try{return yield this._tryLoad(a)}catch(h){throw console.error(`Primary WASM URL failed: ${r.message}`),console.error(`Backup WASM URL failed: ${h.message}`),new Error("WASM loading failed from all sources.")}}))),this._ModulePromise})}static load(){return E(this,null,function*(){return this._loadWithBackup()})}static setWasmUrl(r){r!==this._wasmURL&&(this._wasmURL=r,this._ModulePromise=null);}};b(o1,"_ModulePromise",null),b(o1,"_wasmURL",`https://cdn.expensify.com/cdn.jsdelivr.net/npm/${C2}@${w2}/dist/dotlottie-player.wasm`);var h1=class{constructor(){b(this,"_eventListeners",new Map);}addEventListener(r,a){let h=this._eventListeners.get(r);h||(h=new Set,this._eventListeners.set(r,h)),h.add(a);}removeEventListener(r,a){let h=this._eventListeners.get(r);h&&(a?(h.delete(a),h.size===0&&this._eventListeners.delete(r)):this._eventListeners.delete(r));}dispatch(r){let a=this._eventListeners.get(r.type);a==null||a.forEach(h=>h(r));}removeAllEventListeners(){this._eventListeners.clear();}};var H=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);d&&(h.isIntersecting?d.unfreeze():d.freeze());});};this._observer=new IntersectionObserver(r,{threshold:0});}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,a),(h=this._observer)==null||h.observe(r));}static unobserve(r){var a,h;(a=this._observer)==null||a.unobserve(r),this._observedCanvases.delete(r),this._observedCanvases.size===0&&((h=this._observer)==null||h.disconnect(),this._observer=null);}};b(H,"_observer",null),b(H,"_observedCanvases",new Map);var N=class{static _initializeObserver(){if(this._observer)return;let r=a=>{a.forEach(h=>{let d=this._observedCanvases.get(h.target);if(!d)return;let[g,T]=d;clearTimeout(T);let R=setTimeout(()=>{g.resize();},100);this._observedCanvases.set(h.target,[g,R]);});};this._observer=new ResizeObserver(r);}static observe(r,a){var h;this._initializeObserver(),!this._observedCanvases.has(r)&&(this._observedCanvases.set(r,[a,0]),(h=this._observer)==null||h.observe(r));}static unobserve(r){var h;let a=this._observedCanvases.get(r);if(a){let d=a[1];d&&clearTimeout(d);}(h=this._observer)==null||h.unobserve(r),this._observedCanvases.delete(r),!this._observedCanvases.size&&this._observer&&(this._observer.disconnect(),this._observer=null);}};b(N,"_observer",null),b(N,"_observedCanvases",new Map);function k4(v){return /^#([\da-f]{6}|[\da-f]{8})$/iu.test(v)}function N3(v){if(!k4(v))return 0;let r=v.replace("#","");return r=r.length===6?`${r}ff`:r,parseInt(r,16)}function b2(v){if(v.byteLength<4)return !1;let r=new Uint8Array(v.slice(0,J1.byteLength));for(let a=0;aObject.prototype.hasOwnProperty.call(v,r))}function L2(v){if(typeof v=="string")try{return H3(JSON.parse(v))}catch(r){return !1}else return H3(v)}function e1(){return 1+((O?window.devicePixelRatio:1)-1)*U3}function G1(v){let r=v.getBoundingClientRect();return r.top>=0&&r.left>=0&&r.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&r.right<=(window.innerWidth||document.documentElement.clientWidth)}var M2=(v,r)=>v==="reverse"?r.Mode.Reverse:v==="bounce"?r.Mode.Bounce:v==="reverse-bounce"?r.Mode.ReverseBounce:r.Mode.Forward,D4=(v,r)=>v==="contain"?r.Fit.Contain:v==="cover"?r.Fit.Cover:v==="fill"?r.Fit.Fill:v==="fit-height"?r.Fit.FitHeight:v==="fit-width"?r.Fit.FitWidth:r.Fit.None,O4=(v,r)=>{let a=new r.VectorFloat;return a.push_back(v[0]),a.push_back(v[1]),a},E2=(v,r)=>{let a=new r.VectorFloat;return v.length!==2||(a.push_back(v[0]),a.push_back(v[1])),a},P2=(v,r)=>{var a,h;return v?{align:O4((a=v.align)!=null?a:[.5,.5],r),fit:D4((h=v.fit)!=null?h:"contain",r)}:r.createDefaultLayout()},x=class x{constructor(r){b(this,"_canvas");b(this,"_context",null);b(this,"_eventManager");b(this,"_animationFrameId",null);b(this,"_frameManager");b(this,"_dotLottieCore",null);b(this,"_renderConfig",{});b(this,"_isFrozen",!1);b(this,"_backgroundColor",null);b(this,"_pointerUpMethod");b(this,"_pointerDownMethod");b(this,"_pointerMoveMethod");b(this,"_pointerEnterMethod");b(this,"_pointerExitMethod");var a,h,d;this._canvas=r.canvas,this._eventManager=new h1,this._frameManager=new q1,this._renderConfig=z(D({},r.renderConfig),{devicePixelRatio:((a=r.renderConfig)==null?void 0:a.devicePixelRatio)||e1(),freezeOnOffscreen:(d=(h=r.renderConfig)==null?void 0:h.freezeOnOffscreen)!=null?d:!0}),o1.load().then(g=>{var T,R,j,$,U,l1,T1,K;x._wasmModule=g,this._dotLottieCore=new g.DotLottiePlayer({themeId:(T=r.themeId)!=null?T:"",stateMachineId:"",autoplay:(R=r.autoplay)!=null?R:!1,backgroundColor:0,loopAnimation:(j=r.loop)!=null?j:!1,mode:M2(($=r.mode)!=null?$:"forward",g),segment:E2((U=r.segment)!=null?U:[],g),speed:(l1=r.speed)!=null?l1:1,useFrameInterpolation:(T1=r.useFrameInterpolation)!=null?T1:!0,marker:(K=r.marker)!=null?K:"",layout:P2(r.layout,g)}),this._eventManager.dispatch({type:"ready"}),r.data?this._loadFromData(r.data):r.src&&this._loadFromSrc(r.src),r.backgroundColor&&this.setBackgroundColor(r.backgroundColor);}).catch(g=>{this._eventManager.dispatch({type:"loadError",error:new Error(`Failed to load wasm module: ${g}`)});}),this._pointerUpMethod=this._onPointerUp.bind(this),this._pointerDownMethod=this._onPointerDown.bind(this),this._pointerMoveMethod=this._onPointerMove.bind(this),this._pointerEnterMethod=this._onPointerEnter.bind(this),this._pointerExitMethod=this._onPointerLeave.bind(this);}_dispatchError(r){console.error(r),this._eventManager.dispatch({type:"loadError",error:new Error(r)});}_fetchData(r){return E(this,null,function*(){let a=yield fetch(r);if(!a.ok)throw new Error(`Failed to fetch animation data from URL: ${r}. ${a.status}: ${a.statusText}`);let h=yield a.arrayBuffer();return b2(h)?h:new TextDecoder().decode(h)})}_loadFromData(r){if(this._dotLottieCore===null)return;let a=this._canvas.width,h=this._canvas.height,d=!1;if(typeof r=="string"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON string: The provided string does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(r,a,h);}else if(r instanceof ArrayBuffer){if(!b2(r)){this._dispatchError("Invalid dotLottie ArrayBuffer: The provided ArrayBuffer does not conform to the dotLottie format.");return}d=this._dotLottieCore.loadDotLottieData(r,a,h);}else if(typeof r=="object"){if(!L2(r)){this._dispatchError("Invalid Lottie JSON object: The provided object does not conform to the Lottie JSON format.");return}d=this._dotLottieCore.loadAnimationData(JSON.stringify(r),a,h);}else {this._dispatchError(`Unsupported data type for animation data. Expected: - - string (Lottie JSON), - - ArrayBuffer (dotLottie), - - object (Lottie JSON). diff --git a/patches/@lottiefiles/dotlottie-web/details.md b/patches/@lottiefiles/dotlottie-web/details.md deleted file mode 100644 index a1426eb1794c..000000000000 --- a/patches/@lottiefiles/dotlottie-web/details.md +++ /dev/null @@ -1,14 +0,0 @@ -# `@lottiefiles/dotlottie-web` patches - -### [@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch](@lottiefiles+dotlottie-web+0.44.0+001+fix-cors.patch) - -- Reason: - - ``` - Patch adds `https://cdn.expensify.com/` prefix to `cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.44.0/dist/dotlottie-player.wasm` - to fix CORS policy issue preventing lottie animations to load on web - ``` - -- Upstream PR/issue: -- E/App issue: -- PR introducing patch: https://github.com/Expensify/App/pull/69597 diff --git a/src/App.tsx b/src/App.tsx index d5875b790e7c..631e5037ebaa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,7 @@ import './setup/backgroundTask'; import './setup/fraudProtection'; import './setup/hybridApp'; import {SplashScreenStateContextProvider} from './SplashScreenStateContext'; +import {setWasmUrl} from '@lottiefiles/dotlottie-react'; LogBox.ignoreLogs([ // Basically it means that if the app goes in the background and back to foreground on Android, @@ -67,6 +68,8 @@ function App() { useDefaultDragAndDrop(); OnyxUpdateManager(); + setWasmUrl("https://cdn.expensify.com/cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.44.0/dist/dotlottie-player.wasm"); + return ( From 1445b97d52cc1128c2bdfca2a54bde48dc471699 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Wed, 11 Feb 2026 09:21:20 +0700 Subject: [PATCH 006/519] fix: New workflow is not added via Import Spreadsheet in a control WS --- src/libs/actions/Policy/Member.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 51cd06172972..21f91ab36143 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -153,8 +153,8 @@ function buildRoomMembersOnyxData( /** * Updates the import spreadsheet data according to the result of the import */ -function updateImportSpreadsheetData(addedMembersLength: number, updatedMembersLength: number): OnyxData { - const onyxData: OnyxData = { +function updateImportSpreadsheetData(addedMembersLength: number, updatedMembersLength: number): OnyxData { + const onyxData: OnyxData = { successData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1014,6 +1014,17 @@ function importPolicyMembers(policy: OnyxEntry, members: PolicyMember[]) ); const onyxData = updateImportSpreadsheetData(added, updated); + const shouldUpdateApprovalMode = members.some((member) => !!member.submitsTo || !!member.forwardsTo); + if (shouldUpdateApprovalMode) { + onyxData.successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + value: { + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }, + }); + } + const parameters = { policyID: policy.id, employees: JSON.stringify(members.map((member) => ({email: member.email, role: member.role, submitsTo: member.submitsTo, forwardsTo: member.forwardsTo}))), From 91edc6ae1946aef81dc7beb66984e46d46fad796 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Wed, 11 Feb 2026 09:22:12 +0700 Subject: [PATCH 007/519] only apply for control policy --- src/libs/actions/Policy/Member.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 21f91ab36143..1935e49b7fb5 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -22,7 +22,7 @@ import Parser from '@libs/Parser'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; -import {getDefaultApprover, isPolicyAdmin} from '@libs/PolicyUtils'; +import {getDefaultApprover, isControlPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as FormActions from '@userActions/FormActions'; @@ -1014,7 +1014,7 @@ function importPolicyMembers(policy: OnyxEntry, members: PolicyMember[]) ); const onyxData = updateImportSpreadsheetData(added, updated); - const shouldUpdateApprovalMode = members.some((member) => !!member.submitsTo || !!member.forwardsTo); + const shouldUpdateApprovalMode = members.some((member) => !!member.submitsTo || !!member.forwardsTo) && isControlPolicy(policy); if (shouldUpdateApprovalMode) { onyxData.successData?.push({ onyxMethod: Onyx.METHOD.MERGE, From 7293bb4a459e2aa2fc19c07f7354a2a4d9d3302f Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 2 Apr 2026 09:27:29 +0500 Subject: [PATCH 008/519] Add transactionAutoSelections parameter to policy deletion APIs and implement auto-selection logic --- .../DeletePolicyDistanceRatesParams.ts | 2 + .../API/parameters/DeletePolicyTagsParams.ts | 2 + .../API/parameters/DeletePolicyTaxesParams.ts | 2 + .../DeleteWorkspaceCategoriesParams.ts | 5 + src/libs/ReportUtils.ts | 158 ++++++++++++++++-- src/libs/actions/Policy/Category.ts | 12 +- src/libs/actions/Policy/DistanceRate.ts | 32 +++- src/libs/actions/Policy/Tag.ts | 5 +- src/libs/actions/TaxRate.ts | 61 ++++--- .../PolicyDistanceRateDetailsPage.tsx | 21 ++- .../distanceRates/PolicyDistanceRatesPage.tsx | 16 +- .../workspace/taxes/WorkspaceEditTaxPage.tsx | 4 +- .../workspace/taxes/WorkspaceTaxesPage.tsx | 6 +- tests/actions/PolicyTaxTest.ts | 17 +- tests/unit/DistanceRateTest.ts | 8 +- 15 files changed, 286 insertions(+), 65 deletions(-) diff --git a/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts b/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts index d4f972ff9757..bd0c9a353445 100644 --- a/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts +++ b/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts @@ -2,6 +2,8 @@ type DeletePolicyDistanceRatesParams = { policyID: string; customUnitID: string; customUnitRateID: string[]; + /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ + transactionAutoSelections?: string; }; export default DeletePolicyDistanceRatesParams; diff --git a/src/libs/API/parameters/DeletePolicyTagsParams.ts b/src/libs/API/parameters/DeletePolicyTagsParams.ts index 0094ce318390..961bcfd4096f 100644 --- a/src/libs/API/parameters/DeletePolicyTagsParams.ts +++ b/src/libs/API/parameters/DeletePolicyTagsParams.ts @@ -5,6 +5,8 @@ type DeletePolicyTagsParams = { * Array */ tags: string; + /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ + transactionAutoSelections?: string; }; export default DeletePolicyTagsParams; diff --git a/src/libs/API/parameters/DeletePolicyTaxesParams.ts b/src/libs/API/parameters/DeletePolicyTaxesParams.ts index 9e0963cdcb28..b444fc8d9d3e 100644 --- a/src/libs/API/parameters/DeletePolicyTaxesParams.ts +++ b/src/libs/API/parameters/DeletePolicyTaxesParams.ts @@ -6,6 +6,8 @@ type DeletePolicyTaxesParams = { * Each element is a tax name */ taxNames: string; + /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ + transactionAutoSelections?: string; }; export default DeletePolicyTaxesParams; diff --git a/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts b/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts index 07a8103a9b06..d884f8c58651 100644 --- a/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts +++ b/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts @@ -5,6 +5,11 @@ type DeleteWorkspaceCategoriesParams = { * Each element in the array is a string that specifies the name of a category. */ categories: string; + /** + * JSON-encoded array of auto-selected transaction updates when only one valid value remains. + * Each element: {transactionID, category?, tag?, taxCode?} + */ + transactionAutoSelections?: string; }; export default DeleteWorkspaceCategoriesParams; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7be23bade49a..ffa6ef315ec8 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -162,6 +162,7 @@ import { getPolicyNameByID, getPolicyRole, getRuleApprovers, + getSortedTagKeys, getSubmitToAccountID, hasDependentTags as hasDependentTagsPolicyUtils, hasDynamicExternalWorkflow, @@ -290,6 +291,7 @@ import { getRecentTransactions, getReimbursable, getTag, + getTagArrayFromName, getTaxAmount, getTaxCode, getTaxName, @@ -2075,27 +2077,31 @@ function isAwaitingFirstLevelApproval(report: OnyxEntry): boolean { */ function pushTransactionViolationsOnyxData( onyxData: OnyxData< - typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES | typeof ONYXKEYS.COLLECTION.POLICY | typeof ONYXKEYS.COLLECTION.POLICY_TAGS | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES + | typeof ONYXKEYS.COLLECTION.POLICY + | typeof ONYXKEYS.COLLECTION.POLICY_TAGS + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.TRANSACTION >, policyData: PolicyData, policyUpdate: Partial = {}, categoriesUpdate: Record> = {}, tagListsUpdate: Record> = {}, ) { - const nonInvoiceReportTransactionsAndViolations = policyData.reports.reduce((acc, report) => { + const nonInvoiceReportItems = policyData.reports.reduce>((acc, report) => { // Skipping invoice reports since they should not have any category or tag violations if (isInvoiceReport(report)) { return acc; } const reportTransactionsAndViolations = policyData.transactionsAndViolations[report.reportID]; if (!isEmptyObject(reportTransactionsAndViolations) && !isEmptyObject(reportTransactionsAndViolations.transactions)) { - acc.push(reportTransactionsAndViolations); + acc.push({report, transactionsAndViolations: reportTransactionsAndViolations}); } return acc; }, []); - if (nonInvoiceReportTransactionsAndViolations.length === 0) { - return; + if (nonInvoiceReportItems.length === 0) { + return []; } const updatedTagListsNames = Object.keys(tagListsUpdate); @@ -2106,7 +2112,7 @@ function pushTransactionViolationsOnyxData( const isTagListsUpdateEmpty = updatedTagListsNames.length === 0; const isCategoriesUpdateEmpty = updatedCategoriesNames.length === 0; if (isPolicyUpdateEmpty && isTagListsUpdateEmpty && isCategoriesUpdateEmpty) { - return; + return []; } // Merge the existing policy with the optimistic updates @@ -2166,19 +2172,149 @@ function pushTransactionViolationsOnyxData( }, {}), }; - const hasDependentTags = hasDependentTagsPolicyUtils(optimisticPolicy, optimisticTagLists); + const hasDependentTagsValue = hasDependentTagsPolicyUtils(optimisticPolicy, optimisticTagLists); + + // Compute sole remaining values for auto-selection when a policy value is deleted + const enabledCategoryKeys = Object.entries(optimisticCategories) + .filter(([, cat]) => cat.enabled && cat.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key); + const singleRemainingCategory = enabledCategoryKeys.length === 1 ? enabledCategoryKeys.at(0) : undefined; + + const tagListKeys = Object.keys(optimisticTagLists); + + // Single-level tag auto-selection + let singleRemainingTag: string | undefined; + if (tagListKeys.length === 1) { + const tagListName = tagListKeys.at(0) ?? ''; + const enabledTagKeys = Object.entries(optimisticTagLists[tagListName]?.tags ?? {}) + .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key); + singleRemainingTag = enabledTagKeys.length === 1 ? enabledTagKeys.at(0) : undefined; + } + + // Multi-level tag auto-selection (per level) + let perLevelSingleTag: Array = []; + if (tagListKeys.length > 1) { + const sortedTagKeys = getSortedTagKeys(optimisticTagLists); + perLevelSingleTag = sortedTagKeys.map((key) => { + const tags = optimisticTagLists[key]?.tags ?? {}; + const enabledKeys = Object.entries(tags) + .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([k]) => k); + return enabledKeys.length === 1 ? enabledKeys.at(0) : undefined; + }); + } + + // Tax auto-selection + const optimisticTaxes = optimisticPolicy.taxRates?.taxes ?? {}; + const enabledTaxKeys = Object.entries(optimisticTaxes) + .filter(([, tax]) => !tax.isDisabled && tax.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key); + const singleRemainingTaxCode = enabledTaxKeys.length === 1 ? enabledTaxKeys.at(0) : undefined; + + // Collect auto-selected transaction updates to return to callers for the API request + const autoSelections: Array<{transactionID: string; category?: string; tag?: string; taxCode?: string}> = []; // Iterate through all policy reports to find transactions that need optimistic violations - for (const {transactions, violations} of nonInvoiceReportTransactionsAndViolations) { + for (const { + report, + transactionsAndViolations: {transactions, violations}, + } of nonInvoiceReportItems) { + const isEligibleForAutoSelect = isOpenOrProcessingReport(report); + for (const transaction of Object.values(transactions)) { + let modifiedTransaction = transaction; + const transactionUpdates: Partial = {}; + const transactionRollback: Partial = {}; + + if (isEligibleForAutoSelect) { + // Category auto-select: if the transaction's category is out of policy and only one enabled category remains + if (singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { + transactionUpdates.category = singleRemainingCategory; + transactionRollback.category = transaction.category; + } + + // Single-level tag auto-select + if (tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { + const tagListName = tagListKeys.at(0) ?? ''; + const isTagInPolicy = !!optimisticTagLists[tagListName]?.tags?.[transaction.tag]?.enabled; + if (!isTagInPolicy) { + transactionUpdates.tag = singleRemainingTag; + transactionRollback.tag = transaction.tag; + } + } + + // Multi-level tag auto-select + if (tagListKeys.length > 1 && transaction.tag) { + const sortedTagKeys = getSortedTagKeys(optimisticTagLists); + const currentTags = getTagArrayFromName(transaction.tag); + let anyTagChanged = false; + const newTags = [...currentTags]; + + for (let i = 0; i < sortedTagKeys.length; i++) { + const currentTag = currentTags.at(i); + if (!currentTag) { + continue; + } + const sortedTagKey = sortedTagKeys.at(i) ?? ''; + const levelTags = optimisticTagLists[sortedTagKey]?.tags ?? {}; + const isInPolicy = !!levelTags[currentTag]?.enabled; + const singleTag = perLevelSingleTag.at(i); + if (!isInPolicy && singleTag) { + newTags[i] = singleTag; + anyTagChanged = true; + } + } + + if (anyTagChanged) { + transactionUpdates.tag = newTags.join(CONST.COLON); + transactionRollback.tag = transaction.tag; + } + } + + // Tax auto-select: if the transaction's tax code is out of policy and only one enabled tax remains + if (singleRemainingTaxCode && transaction.taxCode) { + const isTaxInPolicy = !!optimisticTaxes[transaction.taxCode] && !optimisticTaxes[transaction.taxCode].isDisabled; + if (!isTaxInPolicy) { + transactionUpdates.taxCode = singleRemainingTaxCode; + transactionRollback.taxCode = transaction.taxCode; + } + } + } + + // If auto-selection modified the transaction, push optimistic transaction updates + if (Object.keys(transactionUpdates).length > 0) { + modifiedTransaction = {...transaction, ...transactionUpdates}; + + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionUpdates, + }); + + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionRollback, + }); + + // Collect auto-selection data for the API request + autoSelections.push({ + transactionID: transaction.transactionID, + ...(transactionUpdates.category !== undefined && {category: transactionUpdates.category}), + ...(transactionUpdates.tag !== undefined && {tag: transactionUpdates.tag}), + ...(transactionUpdates.taxCode !== undefined && {taxCode: transactionUpdates.taxCode}), + }); + } + const existingViolations = violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; const optimisticViolations = ViolationsUtils.getViolationsOnyxData( - transaction, + modifiedTransaction, existingViolations ?? [], optimisticPolicy, optimisticTagLists, optimisticCategories, - hasDependentTags, + hasDependentTagsValue, false, ); @@ -2192,6 +2328,8 @@ function pushTransactionViolationsOnyxData( } } } + + return autoSelections; } /** diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 7223a9a81863..a7a1001741a2 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -78,7 +78,12 @@ type SetWorkspaceCategoryEnabledParams = { function appendSetupCategoriesOnboardingData( onyxData: OnyxData< - typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES + | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS >, setupCategoryTaskReport: OnyxEntry, setupCategoryTaskParentReport: OnyxEntry, @@ -1348,7 +1353,7 @@ function deleteWorkspaceCategories( } : {}; - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1382,7 +1387,7 @@ function deleteWorkspaceCategories( ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); appendSetupCategoriesOnboardingData( onyxData, setupCategoryTaskReport, @@ -1396,6 +1401,7 @@ function deleteWorkspaceCategories( const parameters = { policyID, categories: JSON.stringify(categoryNamesToDelete), + ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES, parameters, onyxData); diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 762e0030cf7a..a662659b90b8 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -394,7 +394,7 @@ function deletePolicyDistanceRates( policyID: string, customUnit: CustomUnit, rateIDsToDelete: string[], - transactionIDsAffected: string[], + transactionsAffected: Array<{transactionID: string; customUnitRateID: string}>, transactionViolations: OnyxCollection, ) { const currentRates = customUnit.rates; @@ -413,7 +413,13 @@ function deletePolicyDistanceRates( }; } - const optimisticData: Array> = [ + // Check if there's exactly one remaining enabled rate for auto-selection + const remainingEnabledRateIDs = Object.entries(currentRates) + .filter(([rateID, rate]) => !rateIDsToDelete.includes(rateID) && rate.enabled) + .map(([rateID]) => rateID); + const singleRemainingRateID = remainingEnabledRateIDs.length === 1 ? remainingEnabledRateIDs.at(0) : undefined; + + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -427,7 +433,7 @@ function deletePolicyDistanceRates( }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -443,13 +449,30 @@ function deletePolicyDistanceRates( const optimisticTransactionsViolations: Array> = []; const failureTransactionsViolations: Array> = []; + const distanceRateAutoSelections: Array<{transactionID: string; customUnitRateID: string}> = []; - for (const transactionID of transactionIDsAffected) { + for (const {transactionID, customUnitRateID} of transactionsAffected) { const currentTransactionViolations = transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; if (currentTransactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY)) { return; } + // If there's exactly one remaining enabled rate, auto-select it instead of adding a violation + if (singleRemainingRateID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: {comment: {customUnit: {customUnitRateID: singleRemainingRateID}}}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: {comment: {customUnit: {customUnitRateID}}}, + }); + distanceRateAutoSelections.push({transactionID, customUnitRateID: singleRemainingRateID}); + continue; + } + optimisticTransactionsViolations.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, @@ -477,6 +500,7 @@ function deletePolicyDistanceRates( policyID, customUnitID: customUnit.customUnitID, customUnitRateID: rateIDsToDelete, + ...(distanceRateAutoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(distanceRateAutoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES, params, {optimisticData, failureData}); diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 85d11f28327e..cf12333e4a2c 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -473,7 +473,7 @@ function deletePolicyTags(policyData: PolicyData, tagsToDelete: string[]) { }, }; - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -519,11 +519,12 @@ function deletePolicyTags(policyData: PolicyData, tagsToDelete: string[]) { ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); + const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); const parameters = { policyID, tags: JSON.stringify(tagsToDelete), + ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_POLICY_TAGS, parameters, onyxData); diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index 07f9a4b942b2..3ef983fa4463 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -2,6 +2,7 @@ import type {NullishDeep, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {FormOnyxValues} from '@components/Form/types'; import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; +import type PolicyData from '@hooks/usePolicyData/types'; import * as API from '@libs/API'; import type { CreatePolicyTaxParams, @@ -13,6 +14,7 @@ import type { } from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; +import {pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; import {getFieldRequiredErrors, isExistingTaxCode, isExistingTaxName, isValidPercentage} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@src/libs/ErrorUtils'; @@ -282,7 +284,8 @@ type TaxRateDeleteMap = Record< | null >; -function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], localeCompare: LocaleContextProps['localeCompare']) { +function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], localeCompare: LocaleContextProps['localeCompare']) { + const policy = policyData.policy; const policyTaxRates = policy?.taxRates?.taxes; const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault; const firstTaxID = Object.keys(policyTaxRates ?? {}) @@ -328,11 +331,15 @@ function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], l }; } - const onyxData: OnyxData = { + const customUnitsOptimistic = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: optimisticRates}}} : {}; + const customUnitsSuccess = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: successRates}}} : {}; + const customUnitsFailure = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: failureRates}}} : {}; + + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: isForeignTaxRemoved ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null}, @@ -342,20 +349,14 @@ function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], l return acc; }, {}), }, - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - customUnits: distanceRateCustomUnit && - customUnitID && { - [customUnitID]: { - rates: optimisticRates, - }, - }, + ...customUnitsOptimistic, }, }, ], successData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: null}, @@ -364,20 +365,14 @@ function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], l return acc; }, {}), }, - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - customUnits: distanceRateCustomUnit && - customUnitID && { - [customUnitID]: { - rates: successRates, - }, - }, + ...customUnitsSuccess, }, }, ], failureData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: null}, @@ -390,21 +385,33 @@ function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], l return acc; }, {}), }, - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - customUnits: distanceRateCustomUnit && - customUnitID && { - [customUnitID]: { - rates: failureRates, - }, - }, + ...customUnitsFailure, }, }, ], }; + // Build the optimistic policy update for tax deletion to pass to violation calculation + const policyTaxUpdate = { + taxRates: { + ...policy?.taxRates, + foreignTaxDefault: (isForeignTaxRemoved ? firstTaxID : foreignTaxDefault) ?? '', + taxes: { + ...policyTaxRates, + ...taxesToDelete.reduce>((acc, taxID) => { + acc[taxID] = {...policyTaxRates[taxID], pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, isDisabled: true}; + return acc; + }, {}), + }, + }, + } as Partial; + + const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, policyTaxUpdate); + const parameters = { - policyID: policy.id, + policyID: policy?.id, taxNames: JSON.stringify(taxesToDelete.map((taxID) => policyTaxRates[taxID].name)), + ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), } satisfies DeletePolicyTaxesParams; API.write(WRITE_COMMANDS.DELETE_POLICY_TAXES, parameters, onyxData); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index 89463fce9951..c0ea5bc22643 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -63,7 +63,11 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro const transactionsSelector = useCallback( (transactions: OnyxCollection) => { - return Object.values(transactions ?? {}).reduce((transactionIDs, transaction) => { + const result: {transactionIDs: Set; transactionsAffected: Array<{transactionID: string; customUnitRateID: string}>} = { + transactionIDs: new Set(), + transactionsAffected: [], + }; + for (const transaction of Object.values(transactions ?? {})) { if ( transaction && transaction.reportID && @@ -73,18 +77,23 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro transaction?.comment?.customUnit?.customUnitRateID && transaction?.comment?.customUnit?.customUnitRateID === rateID ) { - transactionIDs.add(transaction?.transactionID); + result.transactionIDs.add(transaction.transactionID); + result.transactionsAffected.push({ + transactionID: transaction.transactionID, + customUnitRateID: transaction.comment.customUnit.customUnitRateID, + }); } - return transactionIDs; - }, new Set()); + } + return result; }, [customUnitID, rateID, policyReports], ); - const [eligibleTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + const [eligibleTransactionsData] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector: transactionsSelector, }); + const eligibleTransactionIDs = eligibleTransactionsData?.transactionIDs; const transactionViolations = useTransactionViolation(eligibleTransactionIDs); const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); @@ -130,7 +139,7 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro const deleteRate = () => { Navigation.goBack(); - deletePolicyDistanceRates(policyID, customUnit, [rateID], Array.from(eligibleTransactionIDs ?? []), transactionViolations); + deletePolicyDistanceRates(policyID, customUnit, [rateID], eligibleTransactionsData?.transactionsAffected ?? [], transactionViolations); setIsDeleteModalVisible(false); }; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index e34cefbd4e01..f8e571484520 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -121,16 +121,20 @@ function PolicyDistanceRatesPage({ transaction?.comment?.customUnit?.customUnitRateID && rateIDs.has(transaction?.comment?.customUnit?.customUnitRateID) ) { + const rateID = transaction.comment.customUnit.customUnitRateID; transactionsData.transactionIDs.add(transaction.transactionID); - if (!transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]) { + if (!transactionsData.rateIDToTransactionsMap[rateID]) { // eslint-disable-next-line no-param-reassign - transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID] = []; + transactionsData.rateIDToTransactionsMap[rateID] = []; } - transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]?.push(transaction?.transactionID); + transactionsData.rateIDToTransactionsMap[rateID]?.push({ + transactionID: transaction.transactionID, + customUnitRateID: rateID, + }); } return transactionsData; }, - {transactionIDs: new Set(), rateIDToTransactionIDsMap: {} as Record}, + {transactionIDs: new Set(), rateIDToTransactionsMap: {} as Record>}, ); }, [customUnit?.customUnitID, rateIDs, policyReports], @@ -311,9 +315,9 @@ function PolicyDistanceRatesPage({ return; } - const transactionIDsAffected = selectedDistanceRates.flatMap((rateID) => eligibleTransactionsData?.rateIDToTransactionIDsMap?.[rateID] ?? []); + const transactionsAffected = selectedDistanceRates.flatMap((rateID) => eligibleTransactionsData?.rateIDToTransactionsMap?.[rateID] ?? []); - deletePolicyDistanceRates(policyID, customUnit, selectedDistanceRates, transactionIDsAffected, transactionViolations); + deletePolicyDistanceRates(policyID, customUnit, selectedDistanceRates, transactionsAffected, transactionViolations); setIsDeleteModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated diff --git a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx index 40e54ffcb228..61b61a4deda7 100644 --- a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx @@ -11,6 +11,7 @@ import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearTaxRateFieldError, deletePolicyTaxes, setPolicyTaxesEnabled} from '@libs/actions/TaxRate'; import {getLatestErrorField} from '@libs/ErrorUtils'; @@ -36,6 +37,7 @@ function WorkspaceEditTaxPage({ }: WorkspaceEditTaxPageBaseProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); + const policyData = usePolicyData(policyID); const currentTaxID = getCurrentTaxID(policy, taxID); const currentTaxRate = currentTaxID && policy?.taxRates?.taxes?.[currentTaxID]; const {showConfirmModal} = useConfirmModal(); @@ -62,7 +64,7 @@ function WorkspaceEditTaxPage({ if (!policyID) { return; } - deletePolicyTaxes(policy, [taxID], localeCompare); + deletePolicyTaxes(policyData, [taxID], localeCompare); Navigation.goBack(); }; diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index 04b9671a29e4..a3e65dccec6f 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -22,6 +22,7 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePolicyData from '@hooks/usePolicyData'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; @@ -63,6 +64,7 @@ function WorkspaceTaxesPage({ }, }: WorkspaceTaxesPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.taxes'); + const policyData = usePolicyData(policyID); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -246,13 +248,13 @@ function WorkspaceTaxesPage({ if (!policy?.id) { return; } - deletePolicyTaxes(policy, selectedTaxesIDs, localeCompare); + deletePolicyTaxes(policyData, selectedTaxesIDs, localeCompare); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { setSelectedTaxesIDs([]); }); - }, [policy, selectedTaxesIDs, localeCompare]); + }, [policy?.id, policyData, selectedTaxesIDs, localeCompare]); const toggleTaxes = useCallback( (isEnabled: boolean) => { diff --git a/tests/actions/PolicyTaxTest.ts b/tests/actions/PolicyTaxTest.ts index afebe7674f4e..59d3671e9f8f 100644 --- a/tests/actions/PolicyTaxTest.ts +++ b/tests/actions/PolicyTaxTest.ts @@ -1,4 +1,5 @@ import Onyx from 'react-native-onyx'; +import type PolicyData from '@hooks/usePolicyData/types'; import {createPolicyTax, deletePolicyTaxes, renamePolicyTax, setPolicyTaxCode, setPolicyTaxesEnabled, updatePolicyTaxValue} from '@libs/actions/TaxRate'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; @@ -33,6 +34,16 @@ describe('actions/PolicyTax', () => { }, }, }; + function createPolicyData(policy: PolicyType): PolicyData { + return { + policy, + tags: {}, + categories: {}, + reports: [], + transactionsAndViolations: {}, + }; + } + beforeAll(() => { Onyx.init({ keys: ONYXKEYS, @@ -704,7 +715,7 @@ describe('actions/PolicyTax', () => { const taxID = 'id_TAX_RATE_1'; mockFetch?.pause?.(); - deletePolicyTaxes(fakePolicy, [taxID], TestHelper.localeCompare); + deletePolicyTaxes(createPolicyData(fakePolicy), [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => @@ -758,7 +769,7 @@ describe('actions/PolicyTax', () => { }, }; mockFetch?.pause?.(); - deletePolicyTaxes(fakePolicyWithForeignTaxDefault, [taxID], TestHelper.localeCompare); + deletePolicyTaxes(createPolicyData(fakePolicyWithForeignTaxDefault), [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => @@ -805,7 +816,7 @@ describe('actions/PolicyTax', () => { const taxID = 'id_TAX_RATE_1'; mockFetch?.pause?.(); - deletePolicyTaxes(fakePolicy, [taxID], TestHelper.localeCompare); + deletePolicyTaxes(createPolicyData(fakePolicy), [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => diff --git a/tests/unit/DistanceRateTest.ts b/tests/unit/DistanceRateTest.ts index fa2970093db0..134ba7d372b7 100644 --- a/tests/unit/DistanceRateTest.ts +++ b/tests/unit/DistanceRateTest.ts @@ -78,7 +78,13 @@ describe('DistanceRate', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); if (policy.customUnits) { - deletePolicyDistanceRates(policy.id, policy.customUnits[customUnitID], [customUnitRateID1], [transaction1.transactionID], undefined); + deletePolicyDistanceRates( + policy.id, + policy.customUnits[customUnitID], + [customUnitRateID1], + [{transactionID: transaction1.transactionID, customUnitRateID: customUnitRateID1}], + undefined, + ); } await waitForBatchedUpdates(); const transactionViolations = await new Promise>((resolve) => { From 9c97f2adb8c6442b10ab9163bdfbebb8bca4a77f Mon Sep 17 00:00:00 2001 From: Mukher Date: Sat, 4 Apr 2026 10:55:40 +0500 Subject: [PATCH 009/519] Revert "Add transactionAutoSelections parameter to policy deletion APIs and implement auto-selection logic" --- src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts | 2 -- src/libs/API/parameters/DeletePolicyTagsParams.ts | 2 -- src/libs/API/parameters/DeletePolicyTaxesParams.ts | 2 -- src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts | 5 ----- src/libs/actions/Policy/Category.ts | 3 +-- src/libs/actions/Policy/DistanceRate.ts | 3 --- src/libs/actions/Policy/Tag.ts | 3 +-- src/libs/actions/TaxRate.ts | 3 +-- 8 files changed, 3 insertions(+), 20 deletions(-) diff --git a/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts b/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts index bd0c9a353445..d4f972ff9757 100644 --- a/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts +++ b/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts @@ -2,8 +2,6 @@ type DeletePolicyDistanceRatesParams = { policyID: string; customUnitID: string; customUnitRateID: string[]; - /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ - transactionAutoSelections?: string; }; export default DeletePolicyDistanceRatesParams; diff --git a/src/libs/API/parameters/DeletePolicyTagsParams.ts b/src/libs/API/parameters/DeletePolicyTagsParams.ts index 961bcfd4096f..0094ce318390 100644 --- a/src/libs/API/parameters/DeletePolicyTagsParams.ts +++ b/src/libs/API/parameters/DeletePolicyTagsParams.ts @@ -5,8 +5,6 @@ type DeletePolicyTagsParams = { * Array */ tags: string; - /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ - transactionAutoSelections?: string; }; export default DeletePolicyTagsParams; diff --git a/src/libs/API/parameters/DeletePolicyTaxesParams.ts b/src/libs/API/parameters/DeletePolicyTaxesParams.ts index b444fc8d9d3e..9e0963cdcb28 100644 --- a/src/libs/API/parameters/DeletePolicyTaxesParams.ts +++ b/src/libs/API/parameters/DeletePolicyTaxesParams.ts @@ -6,8 +6,6 @@ type DeletePolicyTaxesParams = { * Each element is a tax name */ taxNames: string; - /** JSON-encoded array of auto-selected transaction updates when only one valid value remains. */ - transactionAutoSelections?: string; }; export default DeletePolicyTaxesParams; diff --git a/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts b/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts index d884f8c58651..07a8103a9b06 100644 --- a/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts +++ b/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts @@ -5,11 +5,6 @@ type DeleteWorkspaceCategoriesParams = { * Each element in the array is a string that specifies the name of a category. */ categories: string; - /** - * JSON-encoded array of auto-selected transaction updates when only one valid value remains. - * Each element: {transactionID, category?, tag?, taxCode?} - */ - transactionAutoSelections?: string; }; export default DeleteWorkspaceCategoriesParams; diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index a7a1001741a2..4fb7e9bd1222 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -1387,7 +1387,7 @@ function deleteWorkspaceCategories( ], }; - const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); appendSetupCategoriesOnboardingData( onyxData, setupCategoryTaskReport, @@ -1401,7 +1401,6 @@ function deleteWorkspaceCategories( const parameters = { policyID, categories: JSON.stringify(categoryNamesToDelete), - ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES, parameters, onyxData); diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index a662659b90b8..d611d28c0f69 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -449,7 +449,6 @@ function deletePolicyDistanceRates( const optimisticTransactionsViolations: Array> = []; const failureTransactionsViolations: Array> = []; - const distanceRateAutoSelections: Array<{transactionID: string; customUnitRateID: string}> = []; for (const {transactionID, customUnitRateID} of transactionsAffected) { const currentTransactionViolations = transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; @@ -469,7 +468,6 @@ function deletePolicyDistanceRates( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, value: {comment: {customUnit: {customUnitRateID}}}, }); - distanceRateAutoSelections.push({transactionID, customUnitRateID: singleRemainingRateID}); continue; } @@ -500,7 +498,6 @@ function deletePolicyDistanceRates( policyID, customUnitID: customUnit.customUnitID, customUnitRateID: rateIDsToDelete, - ...(distanceRateAutoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(distanceRateAutoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES, params, {optimisticData, failureData}); diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index cf12333e4a2c..9ace92663a75 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -519,12 +519,11 @@ function deletePolicyTags(policyData: PolicyData, tagsToDelete: string[]) { ], }; - const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, policyTagsOptimisticData); const parameters = { policyID, tags: JSON.stringify(tagsToDelete), - ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), }; API.write(WRITE_COMMANDS.DELETE_POLICY_TAGS, parameters, onyxData); diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index 3ef983fa4463..841feb98bdbe 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -406,12 +406,11 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca }, } as Partial; - const autoSelections = pushTransactionViolationsOnyxData(onyxData, policyData, policyTaxUpdate); + pushTransactionViolationsOnyxData(onyxData, policyData, policyTaxUpdate); const parameters = { policyID: policy?.id, taxNames: JSON.stringify(taxesToDelete.map((taxID) => policyTaxRates[taxID].name)), - ...(autoSelections.length > 0 && {transactionAutoSelections: JSON.stringify(autoSelections)}), } satisfies DeletePolicyTaxesParams; API.write(WRITE_COMMANDS.DELETE_POLICY_TAXES, parameters, onyxData); From 84e8ff6d48b507d13adf3e7e4d8dd0b7f6120cf1 Mon Sep 17 00:00:00 2001 From: Mukher Date: Mon, 27 Apr 2026 08:51:04 +0500 Subject: [PATCH 010/519] removed tax/distance rate and fixed related tests --- src/libs/ReportUtils.ts | 33 +--------- src/libs/actions/Policy/DistanceRate.ts | 29 ++------- src/libs/actions/TaxRate.ts | 65 +++++++++---------- .../PolicyDistanceRateDetailsPage.tsx | 21 ++---- .../distanceRates/PolicyDistanceRatesPage.tsx | 16 ++--- .../workspace/taxes/WorkspaceEditTaxPage.tsx | 4 +- .../workspace/taxes/WorkspaceTaxesPage.tsx | 6 +- tests/actions/PolicyTaxTest.ts | 17 +---- tests/unit/DistanceRateTest.ts | 8 +-- 9 files changed, 56 insertions(+), 143 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ea25e92eae29..9c17d841acbd 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2067,7 +2067,7 @@ function pushTransactionViolationsOnyxData( }, []); if (nonInvoiceReportItems.length === 0) { - return []; + return; } const updatedTagListsNames = Object.keys(tagListsUpdate); @@ -2078,7 +2078,7 @@ function pushTransactionViolationsOnyxData( const isTagListsUpdateEmpty = updatedTagListsNames.length === 0; const isCategoriesUpdateEmpty = updatedCategoriesNames.length === 0; if (isPolicyUpdateEmpty && isTagListsUpdateEmpty && isCategoriesUpdateEmpty) { - return []; + return; } // Merge the existing policy with the optimistic updates @@ -2171,16 +2171,6 @@ function pushTransactionViolationsOnyxData( }); } - // Tax auto-selection - const optimisticTaxes = optimisticPolicy.taxRates?.taxes ?? {}; - const enabledTaxKeys = Object.entries(optimisticTaxes) - .filter(([, tax]) => !tax.isDisabled && tax.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map(([key]) => key); - const singleRemainingTaxCode = enabledTaxKeys.length === 1 ? enabledTaxKeys.at(0) : undefined; - - // Collect auto-selected transaction updates to return to callers for the API request - const autoSelections: Array<{transactionID: string; category?: string; tag?: string; taxCode?: string}> = []; - // Iterate through all policy reports to find transactions that need optimistic violations for (const { report, @@ -2237,15 +2227,6 @@ function pushTransactionViolationsOnyxData( transactionRollback.tag = transaction.tag; } } - - // Tax auto-select: if the transaction's tax code is out of policy and only one enabled tax remains - if (singleRemainingTaxCode && transaction.taxCode) { - const isTaxInPolicy = !!optimisticTaxes[transaction.taxCode] && !optimisticTaxes[transaction.taxCode].isDisabled; - if (!isTaxInPolicy) { - transactionUpdates.taxCode = singleRemainingTaxCode; - transactionRollback.taxCode = transaction.taxCode; - } - } } // If auto-selection modified the transaction, push optimistic transaction updates @@ -2263,14 +2244,6 @@ function pushTransactionViolationsOnyxData( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: transactionRollback, }); - - // Collect auto-selection data for the API request - autoSelections.push({ - transactionID: transaction.transactionID, - ...(transactionUpdates.category !== undefined && {category: transactionUpdates.category}), - ...(transactionUpdates.tag !== undefined && {tag: transactionUpdates.tag}), - ...(transactionUpdates.taxCode !== undefined && {taxCode: transactionUpdates.taxCode}), - }); } const existingViolations = violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; @@ -2294,8 +2267,6 @@ function pushTransactionViolationsOnyxData( } } } - - return autoSelections; } /** diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index d611d28c0f69..762e0030cf7a 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -394,7 +394,7 @@ function deletePolicyDistanceRates( policyID: string, customUnit: CustomUnit, rateIDsToDelete: string[], - transactionsAffected: Array<{transactionID: string; customUnitRateID: string}>, + transactionIDsAffected: string[], transactionViolations: OnyxCollection, ) { const currentRates = customUnit.rates; @@ -413,13 +413,7 @@ function deletePolicyDistanceRates( }; } - // Check if there's exactly one remaining enabled rate for auto-selection - const remainingEnabledRateIDs = Object.entries(currentRates) - .filter(([rateID, rate]) => !rateIDsToDelete.includes(rateID) && rate.enabled) - .map(([rateID]) => rateID); - const singleRemainingRateID = remainingEnabledRateIDs.length === 1 ? remainingEnabledRateIDs.at(0) : undefined; - - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -433,7 +427,7 @@ function deletePolicyDistanceRates( }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -450,27 +444,12 @@ function deletePolicyDistanceRates( const optimisticTransactionsViolations: Array> = []; const failureTransactionsViolations: Array> = []; - for (const {transactionID, customUnitRateID} of transactionsAffected) { + for (const transactionID of transactionIDsAffected) { const currentTransactionViolations = transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; if (currentTransactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY)) { return; } - // If there's exactly one remaining enabled rate, auto-select it instead of adding a violation - if (singleRemainingRateID) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: {comment: {customUnit: {customUnitRateID: singleRemainingRateID}}}, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: {comment: {customUnit: {customUnitRateID}}}, - }); - continue; - } - optimisticTransactionsViolations.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index 841feb98bdbe..f60085d146db 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -2,7 +2,6 @@ import type {NullishDeep, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {FormOnyxValues} from '@components/Form/types'; import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; -import type PolicyData from '@hooks/usePolicyData/types'; import * as API from '@libs/API'; import type { CreatePolicyTaxParams, @@ -14,7 +13,6 @@ import type { } from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; -import {pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; import {getFieldRequiredErrors, isExistingTaxCode, isExistingTaxName, isValidPercentage} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@src/libs/ErrorUtils'; @@ -284,8 +282,7 @@ type TaxRateDeleteMap = Record< | null >; -function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], localeCompare: LocaleContextProps['localeCompare']) { - const policy = policyData.policy; +function deletePolicyTaxes(policy: OnyxEntry, taxesToDelete: string[], localeCompare: LocaleContextProps['localeCompare']) { const policyTaxRates = policy?.taxRates?.taxes; const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault; const firstTaxID = Object.keys(policyTaxRates ?? {}) @@ -297,7 +294,7 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca (rate) => !!rate.attributes?.taxRateExternalID && taxesToDelete.includes(rate.attributes?.taxRateExternalID), ); - if (!policyTaxRates) { + if (!policy || !policyTaxRates) { console.debug('Policy or tax rates not found'); return; } @@ -331,15 +328,11 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca }; } - const customUnitsOptimistic = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: optimisticRates}}} : {}; - const customUnitsSuccess = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: successRates}}} : {}; - const customUnitsFailure = distanceRateCustomUnit && customUnitID ? {customUnits: {[customUnitID]: {rates: failureRates}}} : {}; - - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: isForeignTaxRemoved ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null}, @@ -349,14 +342,21 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca return acc; }, {}), }, - ...customUnitsOptimistic, + customUnits: + distanceRateCustomUnit && customUnitID + ? { + [customUnitID]: { + rates: optimisticRates, + }, + } + : undefined, }, }, ], successData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: null}, @@ -365,14 +365,21 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca return acc; }, {}), }, - ...customUnitsSuccess, + customUnits: + distanceRateCustomUnit && customUnitID + ? { + [customUnitID]: { + rates: successRates, + }, + } + : undefined, }, }, ], failureData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, value: { taxRates: { pendingFields: {foreignTaxDefault: null}, @@ -385,31 +392,21 @@ function deletePolicyTaxes(policyData: PolicyData, taxesToDelete: string[], loca return acc; }, {}), }, - ...customUnitsFailure, + customUnits: + distanceRateCustomUnit && customUnitID + ? { + [customUnitID]: { + rates: failureRates, + }, + } + : undefined, }, }, ], }; - // Build the optimistic policy update for tax deletion to pass to violation calculation - const policyTaxUpdate = { - taxRates: { - ...policy?.taxRates, - foreignTaxDefault: (isForeignTaxRemoved ? firstTaxID : foreignTaxDefault) ?? '', - taxes: { - ...policyTaxRates, - ...taxesToDelete.reduce>((acc, taxID) => { - acc[taxID] = {...policyTaxRates[taxID], pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, isDisabled: true}; - return acc; - }, {}), - }, - }, - } as Partial; - - pushTransactionViolationsOnyxData(onyxData, policyData, policyTaxUpdate); - const parameters = { - policyID: policy?.id, + policyID: policy.id, taxNames: JSON.stringify(taxesToDelete.map((taxID) => policyTaxRates[taxID].name)), } satisfies DeletePolicyTaxesParams; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index c91717a5dd8f..51dd73fb631d 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -63,11 +63,7 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro const transactionsSelector = useCallback( (transactions: OnyxCollection) => { - const result: {transactionIDs: Set; transactionsAffected: Array<{transactionID: string; customUnitRateID: string}>} = { - transactionIDs: new Set(), - transactionsAffected: [], - }; - for (const transaction of Object.values(transactions ?? {})) { + return Object.values(transactions ?? {}).reduce((transactionIDs, transaction) => { if ( transaction?.reportID && policyReports?.has(transaction.reportID) && @@ -76,23 +72,18 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro transaction?.comment?.customUnit?.customUnitRateID && transaction?.comment?.customUnit?.customUnitRateID === rateID ) { - result.transactionIDs.add(transaction.transactionID); - result.transactionsAffected.push({ - transactionID: transaction.transactionID, - customUnitRateID: transaction.comment.customUnit.customUnitRateID, - }); + transactionIDs.add(transaction?.transactionID); } - } - return result; + return transactionIDs; + }, new Set()); }, [customUnitID, rateID, policyReports], ); - const [eligibleTransactionsData] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + const [eligibleTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector: transactionsSelector, }); - const eligibleTransactionIDs = eligibleTransactionsData?.transactionIDs; const transactionViolations = useTransactionViolation(eligibleTransactionIDs); const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); @@ -146,7 +137,7 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro }; const deleteRate = () => { - deletePolicyDistanceRates(policyID, customUnit, [rateID], eligibleTransactionsData?.transactionsAffected ?? [], transactionViolations); + deletePolicyDistanceRates(policyID, customUnit, [rateID], Array.from(eligibleTransactionIDs ?? []), transactionViolations); Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack()); }; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 04a05dd84a4c..32f7272d68ee 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -120,20 +120,16 @@ function PolicyDistanceRatesPage({ transaction?.comment?.customUnit?.customUnitRateID && rateIDs.has(transaction?.comment?.customUnit?.customUnitRateID) ) { - const rateID = transaction.comment.customUnit.customUnitRateID; transactionsData.transactionIDs.add(transaction.transactionID); - if (!transactionsData.rateIDToTransactionsMap[rateID]) { + if (!transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]) { // eslint-disable-next-line no-param-reassign - transactionsData.rateIDToTransactionsMap[rateID] = []; + transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID] = []; } - transactionsData.rateIDToTransactionsMap[rateID]?.push({ - transactionID: transaction.transactionID, - customUnitRateID: rateID, - }); + transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]?.push(transaction?.transactionID); } return transactionsData; }, - {transactionIDs: new Set(), rateIDToTransactionsMap: {} as Record>}, + {transactionIDs: new Set(), rateIDToTransactionIDsMap: {} as Record}, ); }, [customUnit?.customUnitID, rateIDs, policyReports], @@ -323,9 +319,9 @@ function PolicyDistanceRatesPage({ return; } - const transactionsAffected = selectedDistanceRates.flatMap((rateID) => eligibleTransactionsData?.rateIDToTransactionsMap?.[rateID] ?? []); + const transactionIDsAffected = selectedDistanceRates.flatMap((rateID) => eligibleTransactionsData?.rateIDToTransactionIDsMap?.[rateID] ?? []); - deletePolicyDistanceRates(policyID, customUnit, selectedDistanceRates, transactionsAffected, transactionViolations); + deletePolicyDistanceRates(policyID, customUnit, selectedDistanceRates, transactionIDsAffected, transactionViolations); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { diff --git a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx index 33aec4c62670..9070572b6062 100644 --- a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx @@ -11,7 +11,6 @@ import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearTaxRateFieldError, deletePolicyTaxes, setPolicyTaxesEnabled} from '@libs/actions/TaxRate'; import {getLatestErrorField} from '@libs/ErrorUtils'; @@ -37,7 +36,6 @@ function WorkspaceEditTaxPage({ }: WorkspaceEditTaxPageBaseProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); - const policyData = usePolicyData(policyID); const currentTaxID = getCurrentTaxID(policy, taxID); const currentTaxRate = currentTaxID && policy?.taxRates?.taxes?.[currentTaxID]; const {showConfirmModal} = useConfirmModal(); @@ -71,7 +69,7 @@ function WorkspaceEditTaxPage({ if (!policyID) { return; } - deletePolicyTaxes(policyData, [taxID], localeCompare); + deletePolicyTaxes(policy, [taxID], localeCompare); Navigation.goBack(); }; diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index bc9fa89e5475..586fd75e2d28 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -22,7 +22,6 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePolicyData from '@hooks/usePolicyData'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; @@ -64,7 +63,6 @@ function WorkspaceTaxesPage({ }, }: WorkspaceTaxesPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.taxes'); - const policyData = usePolicyData(policyID); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -249,13 +247,13 @@ function WorkspaceTaxesPage({ if (!policy?.id) { return; } - deletePolicyTaxes(policyData, selectedTaxesIDs, localeCompare); + deletePolicyTaxes(policy, selectedTaxesIDs, localeCompare); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { setSelectedTaxesIDs([]); }); - }, [policy?.id, policyData, selectedTaxesIDs, localeCompare]); + }, [policy, selectedTaxesIDs, localeCompare]); const toggleTaxes = useCallback( (isEnabled: boolean) => { diff --git a/tests/actions/PolicyTaxTest.ts b/tests/actions/PolicyTaxTest.ts index 59d3671e9f8f..afebe7674f4e 100644 --- a/tests/actions/PolicyTaxTest.ts +++ b/tests/actions/PolicyTaxTest.ts @@ -1,5 +1,4 @@ import Onyx from 'react-native-onyx'; -import type PolicyData from '@hooks/usePolicyData/types'; import {createPolicyTax, deletePolicyTaxes, renamePolicyTax, setPolicyTaxCode, setPolicyTaxesEnabled, updatePolicyTaxValue} from '@libs/actions/TaxRate'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; @@ -34,16 +33,6 @@ describe('actions/PolicyTax', () => { }, }, }; - function createPolicyData(policy: PolicyType): PolicyData { - return { - policy, - tags: {}, - categories: {}, - reports: [], - transactionsAndViolations: {}, - }; - } - beforeAll(() => { Onyx.init({ keys: ONYXKEYS, @@ -715,7 +704,7 @@ describe('actions/PolicyTax', () => { const taxID = 'id_TAX_RATE_1'; mockFetch?.pause?.(); - deletePolicyTaxes(createPolicyData(fakePolicy), [taxID], TestHelper.localeCompare); + deletePolicyTaxes(fakePolicy, [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => @@ -769,7 +758,7 @@ describe('actions/PolicyTax', () => { }, }; mockFetch?.pause?.(); - deletePolicyTaxes(createPolicyData(fakePolicyWithForeignTaxDefault), [taxID], TestHelper.localeCompare); + deletePolicyTaxes(fakePolicyWithForeignTaxDefault, [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => @@ -816,7 +805,7 @@ describe('actions/PolicyTax', () => { const taxID = 'id_TAX_RATE_1'; mockFetch?.pause?.(); - deletePolicyTaxes(createPolicyData(fakePolicy), [taxID], TestHelper.localeCompare); + deletePolicyTaxes(fakePolicy, [taxID], TestHelper.localeCompare); return waitForBatchedUpdates() .then( () => diff --git a/tests/unit/DistanceRateTest.ts b/tests/unit/DistanceRateTest.ts index f5c0a6a89302..13a549e20181 100644 --- a/tests/unit/DistanceRateTest.ts +++ b/tests/unit/DistanceRateTest.ts @@ -78,13 +78,7 @@ describe('DistanceRate', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); if (policy.customUnits) { - deletePolicyDistanceRates( - policy.id, - policy.customUnits[customUnitID], - [customUnitRateID1], - [{transactionID: transaction1.transactionID, customUnitRateID: customUnitRateID1}], - undefined, - ); + deletePolicyDistanceRates(policy.id, policy.customUnits[customUnitID], [customUnitRateID1], [transaction1.transactionID], undefined); } await waitForBatchedUpdates(); const transactionViolations = await new Promise>((resolve) => { From b43341ad2fa5919b42ab6c26d892c3d8da390cc1 Mon Sep 17 00:00:00 2001 From: Mukher Date: Mon, 27 Apr 2026 11:06:03 +0500 Subject: [PATCH 011/519] fixed ai reviews --- src/libs/ReportUtils.ts | 34 ++- tests/unit/ReportUtilsTest.ts | 383 ++++++++++++++++++++++++++++++++++ 2 files changed, 399 insertions(+), 18 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 9c17d841acbd..f0bbb8eaf849 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2159,17 +2159,14 @@ function pushTransactionViolationsOnyxData( } // Multi-level tag auto-selection (per level) - let perLevelSingleTag: Array = []; - if (tagListKeys.length > 1) { - const sortedTagKeys = getSortedTagKeys(optimisticTagLists); - perLevelSingleTag = sortedTagKeys.map((key) => { - const tags = optimisticTagLists[key]?.tags ?? {}; - const enabledKeys = Object.entries(tags) - .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map(([k]) => k); - return enabledKeys.length === 1 ? enabledKeys.at(0) : undefined; - }); - } + const sortedTagKeys = tagListKeys.length > 1 ? getSortedTagKeys(optimisticTagLists) : []; + const perLevelSingleTag: Array = sortedTagKeys.map((key) => { + const tags = optimisticTagLists[key]?.tags ?? {}; + const enabledKeys = Object.entries(tags) + .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([k]) => k); + return enabledKeys.length === 1 ? enabledKeys.at(0) : undefined; + }); // Iterate through all policy reports to find transactions that need optimistic violations for (const { @@ -2184,14 +2181,15 @@ function pushTransactionViolationsOnyxData( const transactionRollback: Partial = {}; if (isEligibleForAutoSelect) { - // Category auto-select: if the transaction's category is out of policy and only one enabled category remains - if (singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { + // Category auto-select: gated to calls that actually mutate categories so toggling unrelated + // policy settings doesn't rewrite transaction category values. + if (!isCategoriesUpdateEmpty && singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { transactionUpdates.category = singleRemainingCategory; transactionRollback.category = transaction.category; } - // Single-level tag auto-select - if (tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { + // Single-level tag auto-select: gated to tag mutations for the same reason. + if (!isTagListsUpdateEmpty && tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { const tagListName = tagListKeys.at(0) ?? ''; const isTagInPolicy = !!optimisticTagLists[tagListName]?.tags?.[transaction.tag]?.enabled; if (!isTagInPolicy) { @@ -2200,9 +2198,9 @@ function pushTransactionViolationsOnyxData( } } - // Multi-level tag auto-select - if (tagListKeys.length > 1 && transaction.tag) { - const sortedTagKeys = getSortedTagKeys(optimisticTagLists); + // Multi-level tag auto-select. Skipped for dependent-tag policies because the per-level + // sole-remaining check ignores parent-tag filtering and could write an invalid combination. + if (!isTagListsUpdateEmpty && !hasDependentTagsValue && tagListKeys.length > 1 && transaction.tag) { const currentTags = getTagArrayFromName(transaction.tag); let anyTagChanged = false; const newTags = [...currentTags]; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 09363d8f5213..d34a5305495b 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -172,6 +172,8 @@ import type { Policy, PolicyEmployeeList, PolicyTag, + PolicyTagLists, + PolicyTags, Report, ReportAction, ReportActions, @@ -9675,6 +9677,387 @@ describe('ReportUtils', () => { expect(onyxData).toMatchObject(expectedOnyxData); }); + + it('should auto-select the sole remaining category for transactions on open reports instead of pushing a violation', async () => { + // Given a policy with 2 enabled categories, where the first one is being deleted + const fakePolicyCategories = createRandomPolicyCategories(2); + for (const cat of Object.values(fakePolicyCategories)) { + // eslint-disable-next-line no-param-reassign + cat.enabled = true; + } + const categoryNames = Object.keys(fakePolicyCategories); + const categoryToDelete = categoryNames.at(0) ?? ''; + const remainingCategory = categoryNames.at(1) ?? ''; + const fakePolicyCategoriesUpdate = { + [categoryToDelete]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + enabled: false, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + areCategoriesEnabled: true, + }; + + // Given an OPEN report (eligible for auto-selection) + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + category: categoryToDelete, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + // The optimistic data should contain a transaction merge auto-selecting the remaining category + expect(onyxData.optimisticData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {category: remainingCategory}, + }); + // The failure data should restore the original category + expect(onyxData.failureData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {category: categoryToDelete}, + }); + }); + + it('should auto-select the sole remaining single-level tag for transactions on open reports', async () => { + // Given a policy with 2 tags, where the first one is being deleted + const fakePolicyTagListName = 'Tag List'; + const fakePolicyTagsLists = createRandomPolicyTags(fakePolicyTagListName, 2); + const tagNames = Object.keys(fakePolicyTagsLists?.[fakePolicyTagListName]?.tags ?? {}); + const tagToDelete = tagNames.at(0) ?? ''; + const remainingTag = tagNames.at(1) ?? ''; + const fakePolicyTagListsUpdate: Record>>> = { + [fakePolicyTagListName]: { + tags: { + [tagToDelete]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false}, + }, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresTag: true, + areTagsEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicyID}`]: fakePolicyTagsLists, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + tag: tagToDelete, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + + expect(onyxData.optimisticData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {tag: remainingTag}, + }); + expect(onyxData.failureData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {tag: tagToDelete}, + }); + }); + + it('should not auto-select when more than one enabled category remains after deletion', async () => { + // Given a policy with 3 enabled categories, where the first one is being deleted (2 remain — auto-select should NOT trigger) + const fakePolicyCategories = createRandomPolicyCategories(3); + for (const cat of Object.values(fakePolicyCategories)) { + // eslint-disable-next-line no-param-reassign + cat.enabled = true; + } + const categoryToDelete = Object.keys(fakePolicyCategories).at(0) ?? ''; + const fakePolicyCategoriesUpdate = { + [categoryToDelete]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + enabled: false, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + areCategoriesEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + category: categoryToDelete, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + // No transaction merge should be present — only the violation push + const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); + expect(hasTransactionMerge).toBe(false); + // A categoryOutOfPolicy violation should still be created + expect(onyxData.optimisticData).toContainEqual( + expect.objectContaining({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, + value: expect.arrayContaining([expect.objectContaining({name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY})]), + }), + ); + }); + + it('should not auto-select for transactions on approved reports (only open or processing)', async () => { + // Given a sole remaining enabled category but the report is already APPROVED (not eligible for auto-select) + const fakePolicyCategories = createRandomPolicyCategories(2); + for (const cat of Object.values(fakePolicyCategories)) { + // eslint-disable-next-line no-param-reassign + cat.enabled = true; + } + const categoryToDelete = Object.keys(fakePolicyCategories).at(0) ?? ''; + const fakePolicyCategoriesUpdate = { + [categoryToDelete]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + enabled: false, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + areCategoriesEnabled: true, + }; + + // Report is APPROVED — auto-select should be skipped + const approvedReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${approvedReport.reportID}`]: approvedReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: approvedReport.reportID, + policyID: fakePolicyID, + category: categoryToDelete, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + // No transaction merge should be present + const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); + expect(hasTransactionMerge).toBe(false); + }); + + it('should not auto-select category when the call only updates policy settings (no categoriesUpdate)', async () => { + // Given a sole remaining enabled category, but the caller only passes a policyUpdate + // (e.g. toggling an unrelated workspace setting). Auto-select must NOT mutate transactions. + const fakePolicyCategories = createRandomPolicyCategories(2); + for (const cat of Object.values(fakePolicyCategories)) { + // eslint-disable-next-line no-param-reassign + cat.enabled = true; + } + const categoryNames = Object.keys(fakePolicyCategories); + const transactionCategory = categoryNames.at(0) ?? ''; + // Mark this category disabled in Onyx so it is "out of policy" without any pending DELETE update + fakePolicyCategories[transactionCategory].enabled = false; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + areCategoriesEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + category: transactionCategory, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + // policyUpdate-only call (simulates setPolicyRulesEnabled and similar) + pushTransactionViolationsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); + + const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); + expect(hasTransactionMerge).toBe(false); + }); + + it('should not auto-select multi-level tags when the policy has dependent tags', async () => { + // Given a multi-level tag policy with dependent tags, where one level has a sole remaining tag. + // Auto-select should be skipped because the per-level sole-remaining check ignores parent filtering. + const level1Tags: PolicyTags = { + Engineering: {name: 'Engineering', enabled: true, rules: {parentTagsFilter: ''}}, + }; + const level2Tags: PolicyTags = { + Q1: {name: 'Q1', enabled: true, rules: {parentTagsFilter: 'Engineering'}}, + }; + const fakePolicyTagsLists: PolicyTagLists = { + Department: {name: 'Department', orderWeight: 0, required: false, tags: level1Tags}, + Quarter: {name: 'Quarter', orderWeight: 1, required: false, tags: level2Tags}, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresTag: true, + areTagsEnabled: true, + hasMultipleTagLists: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicyID}`]: fakePolicyTagsLists, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + // Out-of-policy combination — neither value matches enabled tags above + tag: `Sales${CONST.COLON}Q4`, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + // tagListsUpdate is non-empty so we exercise the multi-level branch's dependent-tag guard + const tagsToDelete: Record>> = { + Sales: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false}, + }; + const fakePolicyTagListsUpdate: Record>>> = { + Department: {tags: tagsToDelete}, + }; + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + + const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); + expect(hasTransactionMerge).toBe(false); + }); }); describe('canLeaveChat', () => { From 227f710e8a4dc2ac0be870cf1ea529caf6860e03 Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 29 Apr 2026 07:19:40 +0500 Subject: [PATCH 012/519] added tests for disabling tag/category case --- tests/unit/ReportUtilsTest.ts | 132 ++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index d34a5305495b..7904759939c8 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -9813,6 +9813,138 @@ describe('ReportUtils', () => { }); }); + it('should auto-select the sole remaining category when a category is disabled (not deleted)', async () => { + // Given a policy with 2 enabled categories, where the first one is being disabled (UPDATE, not DELETE) + const fakePolicyCategories = createRandomPolicyCategories(2); + for (const cat of Object.values(fakePolicyCategories)) { + // eslint-disable-next-line no-param-reassign + cat.enabled = true; + } + const categoryNames = Object.keys(fakePolicyCategories); + const categoryToDisable = categoryNames.at(0) ?? ''; + const remainingCategory = categoryNames.at(1) ?? ''; + const fakePolicyCategoriesUpdate = { + [categoryToDisable]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + enabled: false, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresCategory: true, + areCategoriesEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicyID}`]: fakePolicyCategories, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + category: categoryToDisable, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + + expect(onyxData.optimisticData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {category: remainingCategory}, + }); + expect(onyxData.failureData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {category: categoryToDisable}, + }); + }); + + it('should auto-select the sole remaining single-level tag when a tag is disabled (not deleted)', async () => { + // Given a policy with 2 tags, where the first one is being disabled (UPDATE, not DELETE) + const fakePolicyTagListName = 'Tag List'; + const fakePolicyTagsLists = createRandomPolicyTags(fakePolicyTagListName, 2); + const tagNames = Object.keys(fakePolicyTagsLists?.[fakePolicyTagListName]?.tags ?? {}); + const tagToDisable = tagNames.at(0) ?? ''; + const remainingTag = tagNames.at(1) ?? ''; + const fakePolicyTagListsUpdate: Record>>> = { + [fakePolicyTagListName]: { + tags: { + [tagToDisable]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, enabled: false}, + }, + }, + }; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresTag: true, + areTagsEnabled: true, + }; + + const openReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const fakePolicyReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [`${ONYXKEYS.COLLECTION.REPORT}${openReport.reportID}`]: openReport, + }; + + await Onyx.multiSet({ + ...fakePolicyReports, + [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicyID}`]: fakePolicyTagsLists, + [`${ONYXKEYS.COLLECTION.POLICY}${fakePolicyID}`]: fakePolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`]: { + ...mockTransaction, + reportID: openReport.reportID, + policyID: fakePolicyID, + tag: tagToDisable, + }, + }); + + await waitForBatchedUpdates(); + const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + const onyxData = {optimisticData: [], failureData: []}; + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + + expect(onyxData.optimisticData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {tag: remainingTag}, + }); + expect(onyxData.failureData).toContainEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, + value: {tag: tagToDisable}, + }); + }); + it('should not auto-select when more than one enabled category remains after deletion', async () => { // Given a policy with 3 enabled categories, where the first one is being deleted (2 remain — auto-select should NOT trigger) const fakePolicyCategories = createRandomPolicyCategories(3); From c3b694b79feb954bd0a5be5ddcaeb93edbb56d93 Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 30 Apr 2026 16:02:32 +0500 Subject: [PATCH 013/519] Extracted private helper getOptimisticPolicyState --- src/libs/ReportUtils.ts | 282 ++++++++++++++++++---------- src/libs/actions/Policy/Category.ts | 30 ++- src/libs/actions/Policy/Policy.ts | 6 +- src/libs/actions/Policy/Tag.ts | 35 +++- src/libs/actions/TaxRate.ts | 2 +- tests/unit/ReportUtilsTest.ts | 20 +- 6 files changed, 249 insertions(+), 126 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f0bbb8eaf849..8578d522253d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2032,30 +2032,19 @@ function isAwaitingFirstLevelApproval(report: OnyxEntry): boolean { return isProcessingReport(report) && submitsToAccountID === report.managerID; } +type PolicyOptimisticOnyxData = OnyxData< + | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES + | typeof ONYXKEYS.COLLECTION.POLICY + | typeof ONYXKEYS.COLLECTION.POLICY_TAGS + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.TRANSACTION +>; + /** - * Updates optimistic transaction violations to OnyxData for the given policy and categories onyx update. - * - * @param onyxData - The OnyxData object to push updates to - * @param policyData - The current policy Data - * @param policyUpdate - Changed policy properties, if none pass empty object - * @param categoriesUpdate - Changed categories properties, if none pass empty object - * @param tagListsUpdate - Changed tag properties, if none pass empty object + * Returns the list of policy reports (excluding invoice reports) that have transactions to evaluate. */ -function pushTransactionViolationsOnyxData( - onyxData: OnyxData< - | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES - | typeof ONYXKEYS.COLLECTION.POLICY - | typeof ONYXKEYS.COLLECTION.POLICY_TAGS - | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS - | typeof ONYXKEYS.COLLECTION.TRANSACTION - >, - policyData: PolicyData, - policyUpdate: Partial = {}, - categoriesUpdate: Record> = {}, - tagListsUpdate: Record> = {}, -) { - const nonInvoiceReportItems = policyData.reports.reduce>((acc, report) => { - // Skipping invoice reports since they should not have any category or tag violations +function getNonInvoiceReportItemsForPolicy(policyData: PolicyData): Array<{report: Report; transactionsAndViolations: ReportTransactionsAndViolations}> { + return policyData.reports.reduce>((acc, report) => { if (isInvoiceReport(report)) { return acc; } @@ -2065,26 +2054,24 @@ function pushTransactionViolationsOnyxData( } return acc; }, []); +} - if (nonInvoiceReportItems.length === 0) { - return; - } - - const updatedTagListsNames = Object.keys(tagListsUpdate); - const updatedCategoriesNames = Object.keys(categoriesUpdate); - - // If there are no updates to policy, categories or tags, return early +/** + * Merges the existing policy data with the optimistic update payloads to produce the post-update view + * used by both auto-selection and violation calculation. + */ +function getOptimisticPolicyState( + policyData: PolicyData, + policyUpdate: Partial, + categoriesUpdate: Record>, + tagListsUpdate: Record>, +) { const isPolicyUpdateEmpty = isEmptyObject(policyUpdate); - const isTagListsUpdateEmpty = updatedTagListsNames.length === 0; - const isCategoriesUpdateEmpty = updatedCategoriesNames.length === 0; - if (isPolicyUpdateEmpty && isTagListsUpdateEmpty && isCategoriesUpdateEmpty) { - return; - } + const isCategoriesUpdateEmpty = isEmptyObject(categoriesUpdate); + const isTagListsUpdateEmpty = isEmptyObject(tagListsUpdate); - // Merge the existing policy with the optimistic updates const optimisticPolicy = isPolicyUpdateEmpty ? policyData.policy : {...policyData.policy, ...policyUpdate}; - // Merge the existing categories with the optimistic updates const optimisticCategories = isCategoriesUpdateEmpty ? policyData.categories : { @@ -2101,7 +2088,6 @@ function pushTransactionViolationsOnyxData( }, {}), }; - // Merge the existing tag lists with the optimistic updates const optimisticTagLists = isTagListsUpdateEmpty ? policyData.tags : { @@ -2140,7 +2126,65 @@ function pushTransactionViolationsOnyxData( const hasDependentTagsValue = hasDependentTagsPolicyUtils(optimisticPolicy, optimisticTagLists); - // Compute sole remaining values for auto-selection when a policy value is deleted + return { + optimisticPolicy, + optimisticCategories, + optimisticTagLists, + hasDependentTagsValue, + isPolicyUpdateEmpty, + isCategoriesUpdateEmpty, + isTagListsUpdateEmpty, + }; +} + +/** + * Reads any pending transaction merges already pushed to the OnyxData object so callers running + * after `pushTransactionAutoSelectionsOnyxData` see the auto-selected category/tag values when + * computing violations. + */ +function getPendingTransactionUpdate(onyxData: PolicyOptimisticOnyxData, transactionID: string): Partial { + const targetKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; + const merges: Partial = {}; + for (const update of onyxData.optimisticData ?? []) { + if (update.onyxMethod === Onyx.METHOD.MERGE && update.key === targetKey) { + Object.assign(merges, update.value as Partial); + } + } + return merges; +} + +/** + * Auto-selects the sole remaining enabled category/tag for transactions on open or processing reports + * when a category/tag is being deleted or disabled. Pushes optimistic transaction merges and matching + * failure rollbacks to the provided OnyxData object. + * + * Call this BEFORE `pushTransactionViolationsOnyxData` so that violations are recomputed against + * the auto-selected values (suppressing the violation that would otherwise be created). + */ +function pushTransactionAutoSelectionsOnyxData( + onyxData: PolicyOptimisticOnyxData, + policyData: PolicyData, + policyUpdate: Partial = {}, + categoriesUpdate: Record> = {}, + tagListsUpdate: Record> = {}, +) { + // Auto-selection is only meaningful when categories or tag lists are being updated + if (isEmptyObject(categoriesUpdate) && isEmptyObject(tagListsUpdate)) { + return; + } + + const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); + if (nonInvoiceReportItems.length === 0) { + return; + } + + const {optimisticCategories, optimisticTagLists, hasDependentTagsValue, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState( + policyData, + policyUpdate, + categoriesUpdate, + tagListsUpdate, + ); + const enabledCategoryKeys = Object.entries(optimisticCategories) .filter(([, cat]) => cat.enabled && cat.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map(([key]) => key); @@ -2148,7 +2192,6 @@ function pushTransactionViolationsOnyxData( const tagListKeys = Object.keys(optimisticTagLists); - // Single-level tag auto-selection let singleRemainingTag: string | undefined; if (tagListKeys.length === 1) { const tagListName = tagListKeys.at(0) ?? ''; @@ -2158,7 +2201,6 @@ function pushTransactionViolationsOnyxData( singleRemainingTag = enabledTagKeys.length === 1 ? enabledTagKeys.at(0) : undefined; } - // Multi-level tag auto-selection (per level) const sortedTagKeys = tagListKeys.length > 1 ? getSortedTagKeys(optimisticTagLists) : []; const perLevelSingleTag: Array = sortedTagKeys.map((key) => { const tags = optimisticTagLists[key]?.tags ?? {}; @@ -2168,81 +2210,124 @@ function pushTransactionViolationsOnyxData( return enabledKeys.length === 1 ? enabledKeys.at(0) : undefined; }); - // Iterate through all policy reports to find transactions that need optimistic violations for (const { report, - transactionsAndViolations: {transactions, violations}, + transactionsAndViolations: {transactions}, } of nonInvoiceReportItems) { - const isEligibleForAutoSelect = isOpenOrProcessingReport(report); + if (!isOpenOrProcessingReport(report)) { + continue; + } for (const transaction of Object.values(transactions)) { - let modifiedTransaction = transaction; const transactionUpdates: Partial = {}; const transactionRollback: Partial = {}; - if (isEligibleForAutoSelect) { - // Category auto-select: gated to calls that actually mutate categories so toggling unrelated - // policy settings doesn't rewrite transaction category values. - if (!isCategoriesUpdateEmpty && singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { - transactionUpdates.category = singleRemainingCategory; - transactionRollback.category = transaction.category; - } + // Category auto-select: gated to calls that include a category update (delete or disable) + // so toggling unrelated policy settings doesn't rewrite transaction category values. + if (!isCategoriesUpdateEmpty && singleRemainingCategory && transaction.category && !optimisticCategories[transaction.category]?.enabled) { + transactionUpdates.category = singleRemainingCategory; + transactionRollback.category = transaction.category; + } - // Single-level tag auto-select: gated to tag mutations for the same reason. - if (!isTagListsUpdateEmpty && tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { - const tagListName = tagListKeys.at(0) ?? ''; - const isTagInPolicy = !!optimisticTagLists[tagListName]?.tags?.[transaction.tag]?.enabled; - if (!isTagInPolicy) { - transactionUpdates.tag = singleRemainingTag; - transactionRollback.tag = transaction.tag; - } + // Single-level tag auto-select: gated to calls that include a tag-list update for the same reason. + if (!isTagListsUpdateEmpty && tagListKeys.length === 1 && singleRemainingTag && transaction.tag) { + const tagListName = tagListKeys.at(0) ?? ''; + const isTagInPolicy = !!optimisticTagLists[tagListName]?.tags?.[transaction.tag]?.enabled; + if (!isTagInPolicy) { + transactionUpdates.tag = singleRemainingTag; + transactionRollback.tag = transaction.tag; } + } - // Multi-level tag auto-select. Skipped for dependent-tag policies because the per-level - // sole-remaining check ignores parent-tag filtering and could write an invalid combination. - if (!isTagListsUpdateEmpty && !hasDependentTagsValue && tagListKeys.length > 1 && transaction.tag) { - const currentTags = getTagArrayFromName(transaction.tag); - let anyTagChanged = false; - const newTags = [...currentTags]; - - for (let i = 0; i < sortedTagKeys.length; i++) { - const currentTag = currentTags.at(i); - if (!currentTag) { - continue; - } - const sortedTagKey = sortedTagKeys.at(i) ?? ''; - const levelTags = optimisticTagLists[sortedTagKey]?.tags ?? {}; - const isInPolicy = !!levelTags[currentTag]?.enabled; - const singleTag = perLevelSingleTag.at(i); - if (!isInPolicy && singleTag) { - newTags[i] = singleTag; - anyTagChanged = true; - } + // Multi-level tag auto-select. Skipped for dependent-tag policies because the per-level + // sole-remaining check ignores parent-tag filtering and could write an invalid combination. + if (!isTagListsUpdateEmpty && !hasDependentTagsValue && tagListKeys.length > 1 && transaction.tag) { + const currentTags = getTagArrayFromName(transaction.tag); + let anyTagChanged = false; + const newTags = [...currentTags]; + + for (let i = 0; i < sortedTagKeys.length; i++) { + const currentTag = currentTags.at(i); + if (!currentTag) { + continue; } - - if (anyTagChanged) { - transactionUpdates.tag = newTags.join(CONST.COLON); - transactionRollback.tag = transaction.tag; + const sortedTagKey = sortedTagKeys.at(i) ?? ''; + const levelTags = optimisticTagLists[sortedTagKey]?.tags ?? {}; + const isInPolicy = !!levelTags[currentTag]?.enabled; + const singleTag = perLevelSingleTag.at(i); + if (!isInPolicy && singleTag) { + newTags[i] = singleTag; + anyTagChanged = true; } } + + if (anyTagChanged) { + transactionUpdates.tag = newTags.join(CONST.COLON); + transactionRollback.tag = transaction.tag; + } } - // If auto-selection modified the transaction, push optimistic transaction updates - if (Object.keys(transactionUpdates).length > 0) { - modifiedTransaction = {...transaction, ...transactionUpdates}; + if (Object.keys(transactionUpdates).length === 0) { + continue; + } - onyxData.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, - value: transactionUpdates, - }); + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionUpdates, + }); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transactionRollback, + }); + } + } +} - onyxData.failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, - value: transactionRollback, - }); - } +/** + * Updates optimistic transaction violations to OnyxData for the given policy and categories onyx update. + * + * If `pushTransactionAutoSelectionsOnyxData` was called earlier on the same OnyxData object, this + * function picks up the auto-selected transaction values from `onyxData.optimisticData` and uses + * them when computing violations — so a transaction whose category/tag was just auto-replaced will + * not have a stale `*OutOfPolicy` violation pushed for it. + * + * @param onyxData - The OnyxData object to push updates to + * @param policyData - The current policy Data + * @param policyUpdate - Changed policy properties, if none pass empty object + * @param categoriesUpdate - Changed categories properties, if none pass empty object + * @param tagListsUpdate - Changed tag properties, if none pass empty object + */ +function pushTransactionViolationsOnyxData( + onyxData: PolicyOptimisticOnyxData, + policyData: PolicyData, + policyUpdate: Partial = {}, + categoriesUpdate: Record> = {}, + tagListsUpdate: Record> = {}, +) { + const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); + if (nonInvoiceReportItems.length === 0) { + return; + } + + const {optimisticPolicy, optimisticCategories, optimisticTagLists, hasDependentTagsValue, isPolicyUpdateEmpty, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState( + policyData, + policyUpdate, + categoriesUpdate, + tagListsUpdate, + ); + + if (isPolicyUpdateEmpty && isCategoriesUpdateEmpty && isTagListsUpdateEmpty) { + return; + } + + for (const { + transactionsAndViolations: {transactions, violations}, + } of nonInvoiceReportItems) { + for (const transaction of Object.values(transactions)) { + const pendingUpdate = getPendingTransactionUpdate(onyxData, transaction.transactionID); + const modifiedTransaction = isEmptyObject(pendingUpdate) ? transaction : {...transaction, ...pendingUpdate}; const existingViolations = violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; const optimisticViolations = ViolationsUtils.getViolationsOnyxData( @@ -13834,6 +13919,7 @@ export { getHumanReadableStatus, getReportPersonalDetailsParticipants, isWorkspaceEligibleForReportChange, + pushTransactionAutoSelectionsOnyxData, pushTransactionViolationsOnyxData, navigateOnDeleteExpense, canRejectReportAction, diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index c24904ca07b4..6ef2b633d847 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -33,7 +33,7 @@ import Log from '@libs/Log'; import enhanceParameters from '@libs/Network/enhanceParameters'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import {goBackWhenEnableFeature} from '@libs/PolicyUtils'; -import {pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; +import {pushTransactionAutoSelectionsOnyxData, pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; import {getFinishOnboardingTaskOnyxData} from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -453,6 +453,8 @@ function setWorkspaceCategoryEnabled({ ], }; + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); appendSetupCategoriesOnboardingData( onyxData, @@ -600,8 +602,9 @@ function setPolicyCategoryReceiptsRequired(policyData: PolicyData, categoryName: ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryReceiptsRequiredParams = { policyID, categoryName, @@ -665,8 +668,9 @@ function removePolicyCategoryReceiptsRequired(policyData: PolicyData, categoryNa ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: RemovePolicyCategoryReceiptsRequiredParams = { policyID, categoryName, @@ -729,8 +733,9 @@ function setPolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, categ ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryItemizedReceiptsRequiredParams = { policyID, categoryName, @@ -794,8 +799,9 @@ function removePolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, ca ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: RemovePolicyCategoryItemizedReceiptsRequiredParams = { policyID, categoryName, @@ -865,8 +871,9 @@ function setPolicyCategoryReceiptsAndItemizedReceiptRequired(policyData: PolicyD ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams = { policyID, categoryName, @@ -1099,8 +1106,9 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: return acc; }, {}); - pushTransactionViolationsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); const parameters = { policyID, categories: JSON.stringify({ @@ -1296,8 +1304,9 @@ function setWorkspaceRequiresCategory(policyData: PolicyData, requiresCategory: ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData); const parameters = { policyID, requiresCategory, @@ -1389,6 +1398,8 @@ function deleteWorkspaceCategories( ], }; + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); appendSetupCategoriesOnboardingData( onyxData, @@ -1476,8 +1487,9 @@ function enablePolicyCategories(policyData: PolicyData, enabled: boolean, should ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); + pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); const parameters: EnablePolicyCategoriesParams = {policyID, enabled}; // We can't use writeWithNoDuplicatesEnableFeatureConflicts because the categories data is also changed when disabling/enabling this feature diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index e3ae881fc9fb..d3519aa5d33e 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5012,14 +5012,16 @@ function enablePolicyRules(policy: OnyxEntry, enabled: boolean, shouldGo ], }; if (policyData) { - ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, { + const policyUpdate = { areRulesEnabled: enabled, preventSelfApproval: false, ...(!enabled ? DISABLED_MAX_EXPENSE_VALUES : {}), pendingFields: { areRulesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - }); + }; + ReportUtils.pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate); + ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate); } if (enabled && isControlPolicy(policy) && policy?.outputCurrency === CONST.CURRENCY.USD) { diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 907b8f913fa9..b29318f44cc9 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -26,7 +26,7 @@ import Log from '@libs/Log'; import enhanceParameters from '@libs/Network/enhanceParameters'; import * as PolicyUtils from '@libs/PolicyUtils'; import {goBackWhenEnableFeature} from '@libs/PolicyUtils'; -import {pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; +import {pushTransactionAutoSelectionsOnyxData, pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; import {getTagArrayFromName} from '@libs/TransactionUtils'; import type {PolicyTagList} from '@pages/workspace/tags/types'; import {getFinishOnboardingTaskOnyxData} from '@userActions/Task'; @@ -204,8 +204,9 @@ function createPolicyTag({ ], }; - pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, tagListsOptimisticData); + pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, {}, tagListsOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, tagListsOptimisticData); const parameters = { policyID, tags: JSON.stringify([{name: newTagName}]), @@ -348,6 +349,17 @@ function setWorkspaceTagEnabled(policyData: PolicyData, tagsToUpdate: Record, taxesToDelete: string[], l (rate) => !!rate.attributes?.taxRateExternalID && taxesToDelete.includes(rate.attributes?.taxRateExternalID), ); - if (!policy || !policyTaxRates) { + if (!policyTaxRates) { console.debug('Policy or tax rates not found'); return; } diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 7904759939c8..5814741ed9e3 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -141,6 +141,7 @@ import { parseReportActionHtmlToText, parseReportRouteParams, prepareOnboardingOnyxData, + pushTransactionAutoSelectionsOnyxData, pushTransactionViolationsOnyxData, reasonForReportToBeInOptionList, requiresAttentionFromCurrentUser, @@ -9643,8 +9644,9 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); const expectedOnyxData = { // Expecting the optimistic data to contain the OUT_OF_POLICY violations for the deleted category and tag optimisticData: [ @@ -9732,8 +9734,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - // The optimistic data should contain a transaction merge auto-selecting the remaining category expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, @@ -9799,8 +9801,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9866,8 +9868,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9931,8 +9933,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9996,8 +9998,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - // No transaction merge should be present — only the violation push const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); @@ -10062,8 +10064,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - // No transaction merge should be present const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); @@ -10119,8 +10121,8 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; // policyUpdate-only call (simulates setPolicyRulesEnabled and similar) + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); - const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); }); @@ -10185,8 +10187,8 @@ describe('ReportUtils', () => { }; const onyxData = {optimisticData: [], failureData: []}; + pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); }); From ee8d9155609a918c497f4ed3167839112dd9fda8 Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 30 Apr 2026 16:22:42 +0500 Subject: [PATCH 014/519] fixed prettier --- tests/unit/ReportUtilsTest.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 5814741ed9e3..46002b05865c 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -9684,7 +9684,6 @@ describe('ReportUtils', () => { // Given a policy with 2 enabled categories, where the first one is being deleted const fakePolicyCategories = createRandomPolicyCategories(2); for (const cat of Object.values(fakePolicyCategories)) { - // eslint-disable-next-line no-param-reassign cat.enabled = true; } const categoryNames = Object.keys(fakePolicyCategories); @@ -9819,7 +9818,6 @@ describe('ReportUtils', () => { // Given a policy with 2 enabled categories, where the first one is being disabled (UPDATE, not DELETE) const fakePolicyCategories = createRandomPolicyCategories(2); for (const cat of Object.values(fakePolicyCategories)) { - // eslint-disable-next-line no-param-reassign cat.enabled = true; } const categoryNames = Object.keys(fakePolicyCategories); @@ -9951,7 +9949,6 @@ describe('ReportUtils', () => { // Given a policy with 3 enabled categories, where the first one is being deleted (2 remain — auto-select should NOT trigger) const fakePolicyCategories = createRandomPolicyCategories(3); for (const cat of Object.values(fakePolicyCategories)) { - // eslint-disable-next-line no-param-reassign cat.enabled = true; } const categoryToDelete = Object.keys(fakePolicyCategories).at(0) ?? ''; @@ -10016,7 +10013,6 @@ describe('ReportUtils', () => { // Given a sole remaining enabled category but the report is already APPROVED (not eligible for auto-select) const fakePolicyCategories = createRandomPolicyCategories(2); for (const cat of Object.values(fakePolicyCategories)) { - // eslint-disable-next-line no-param-reassign cat.enabled = true; } const categoryToDelete = Object.keys(fakePolicyCategories).at(0) ?? ''; @@ -10076,7 +10072,6 @@ describe('ReportUtils', () => { // (e.g. toggling an unrelated workspace setting). Auto-select must NOT mutate transactions. const fakePolicyCategories = createRandomPolicyCategories(2); for (const cat of Object.values(fakePolicyCategories)) { - // eslint-disable-next-line no-param-reassign cat.enabled = true; } const categoryNames = Object.keys(fakePolicyCategories); From 4213c623ebb3e076a7f4b3b0403297aa6a194ef5 Mon Sep 17 00:00:00 2001 From: Mukher Date: Mon, 4 May 2026 11:44:48 +0500 Subject: [PATCH 015/519] Refactor pushTransactionAutoSelectionsOnyxData to return updates map --- src/libs/ReportUtils.ts | 44 ++++++++++++----------------- src/libs/actions/Policy/Category.ts | 26 ++++------------- src/libs/actions/Policy/Policy.ts | 1 - src/libs/actions/Policy/Tag.ts | 23 ++++----------- tests/unit/ReportUtilsTest.ts | 36 +++++++++++------------ 5 files changed, 48 insertions(+), 82 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 8578d522253d..e2025787026a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2137,26 +2137,11 @@ function getOptimisticPolicyState( }; } -/** - * Reads any pending transaction merges already pushed to the OnyxData object so callers running - * after `pushTransactionAutoSelectionsOnyxData` see the auto-selected category/tag values when - * computing violations. - */ -function getPendingTransactionUpdate(onyxData: PolicyOptimisticOnyxData, transactionID: string): Partial { - const targetKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; - const merges: Partial = {}; - for (const update of onyxData.optimisticData ?? []) { - if (update.onyxMethod === Onyx.METHOD.MERGE && update.key === targetKey) { - Object.assign(merges, update.value as Partial); - } - } - return merges; -} - /** * Auto-selects the sole remaining enabled category/tag for transactions on open or processing reports * when a category/tag is being deleted or disabled. Pushes optimistic transaction merges and matching - * failure rollbacks to the provided OnyxData object. + * failure rollbacks to the provided OnyxData object, and returns the per-transaction updates so callers + * can hand them to `pushTransactionViolationsOnyxData` for violation recomputation. * * Call this BEFORE `pushTransactionViolationsOnyxData` so that violations are recomputed against * the auto-selected values (suppressing the violation that would otherwise be created). @@ -2167,15 +2152,17 @@ function pushTransactionAutoSelectionsOnyxData( policyUpdate: Partial = {}, categoriesUpdate: Record> = {}, tagListsUpdate: Record> = {}, -) { +): Map> { + const autoSelections = new Map>(); + // Auto-selection is only meaningful when categories or tag lists are being updated if (isEmptyObject(categoriesUpdate) && isEmptyObject(tagListsUpdate)) { - return; + return autoSelections; } const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); if (nonInvoiceReportItems.length === 0) { - return; + return autoSelections; } const {optimisticCategories, optimisticTagLists, hasDependentTagsValue, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState( @@ -2271,6 +2258,8 @@ function pushTransactionAutoSelectionsOnyxData( continue; } + autoSelections.set(transaction.transactionID, transactionUpdates); + onyxData.optimisticData?.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, @@ -2283,21 +2272,23 @@ function pushTransactionAutoSelectionsOnyxData( }); } } + + return autoSelections; } /** * Updates optimistic transaction violations to OnyxData for the given policy and categories onyx update. * - * If `pushTransactionAutoSelectionsOnyxData` was called earlier on the same OnyxData object, this - * function picks up the auto-selected transaction values from `onyxData.optimisticData` and uses - * them when computing violations — so a transaction whose category/tag was just auto-replaced will - * not have a stale `*OutOfPolicy` violation pushed for it. + * Pass the map returned by `pushTransactionAutoSelectionsOnyxData` as `transactionAutoSelections` so + * a transaction whose category/tag was just auto-replaced is evaluated against the new value (and does + * not get a stale `*OutOfPolicy` violation). * * @param onyxData - The OnyxData object to push updates to * @param policyData - The current policy Data * @param policyUpdate - Changed policy properties, if none pass empty object * @param categoriesUpdate - Changed categories properties, if none pass empty object * @param tagListsUpdate - Changed tag properties, if none pass empty object + * @param transactionAutoSelections - Auto-selected category/tag updates per transactionID (from `pushTransactionAutoSelectionsOnyxData`) */ function pushTransactionViolationsOnyxData( onyxData: PolicyOptimisticOnyxData, @@ -2305,6 +2296,7 @@ function pushTransactionViolationsOnyxData( policyUpdate: Partial = {}, categoriesUpdate: Record> = {}, tagListsUpdate: Record> = {}, + transactionAutoSelections: Map> = new Map>(), ) { const nonInvoiceReportItems = getNonInvoiceReportItemsForPolicy(policyData); if (nonInvoiceReportItems.length === 0) { @@ -2326,8 +2318,8 @@ function pushTransactionViolationsOnyxData( transactionsAndViolations: {transactions, violations}, } of nonInvoiceReportItems) { for (const transaction of Object.values(transactions)) { - const pendingUpdate = getPendingTransactionUpdate(onyxData, transaction.transactionID); - const modifiedTransaction = isEmptyObject(pendingUpdate) ? transaction : {...transaction, ...pendingUpdate}; + const pendingUpdate = transactionAutoSelections.get(transaction.transactionID); + const modifiedTransaction = pendingUpdate ? {...transaction, ...pendingUpdate} : transaction; const existingViolations = violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; const optimisticViolations = ViolationsUtils.getViolationsOnyxData( diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 6ef2b633d847..75b16e8ae8b2 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -453,9 +453,9 @@ function setWorkspaceCategoryEnabled({ ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData, {}, autoSelections); appendSetupCategoriesOnboardingData( onyxData, setupCategoryTaskReport, @@ -602,8 +602,6 @@ function setPolicyCategoryReceiptsRequired(policyData: PolicyData, categoryName: ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryReceiptsRequiredParams = { policyID, @@ -668,8 +666,6 @@ function removePolicyCategoryReceiptsRequired(policyData: PolicyData, categoryNa ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: RemovePolicyCategoryReceiptsRequiredParams = { policyID, @@ -733,8 +729,6 @@ function setPolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, categ ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryItemizedReceiptsRequiredParams = { policyID, @@ -799,8 +793,6 @@ function removePolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, ca ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: RemovePolicyCategoryItemizedReceiptsRequiredParams = { policyID, @@ -871,8 +863,6 @@ function setPolicyCategoryReceiptsAndItemizedReceiptRequired(policyData: PolicyD ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); const parameters: SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams = { policyID, @@ -1106,8 +1096,6 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: return acc; }, {}); - pushTransactionAutoSelectionsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); - pushTransactionViolationsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); const parameters = { policyID, @@ -1304,8 +1292,6 @@ function setWorkspaceRequiresCategory(policyData: PolicyData, requiresCategory: ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData); const parameters = { policyID, @@ -1398,9 +1384,9 @@ function deleteWorkspaceCategories( ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); - pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData); + pushTransactionViolationsOnyxData(onyxData, policyData, optimisticPolicyData, optimisticPolicyCategoriesData, {}, autoSelections); appendSetupCategoriesOnboardingData( onyxData, setupCategoryTaskReport, @@ -1487,9 +1473,9 @@ function enablePolicyCategories(policyData: PolicyData, enabled: boolean, should ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); - pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); + pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate, {}, autoSelections); const parameters: EnablePolicyCategoriesParams = {policyID, enabled}; // We can't use writeWithNoDuplicatesEnableFeatureConflicts because the categories data is also changed when disabling/enabling this feature diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index d3519aa5d33e..808cf6cb0622 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5020,7 +5020,6 @@ function enablePolicyRules(policy: OnyxEntry, enabled: boolean, shouldGo areRulesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, }; - ReportUtils.pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate); ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate); } diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index b29318f44cc9..f5469198533c 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -204,8 +204,6 @@ function createPolicyTag({ ], }; - pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {}, {}, tagListsOptimisticData); - pushTransactionViolationsOnyxData(onyxData, policyData, {}, {}, tagListsOptimisticData); const parameters = { policyID, @@ -349,7 +347,7 @@ function setWorkspaceTagEnabled(policyData: PolicyData, tagsToUpdate: Record { const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, fakePolicyTagListsUpdate, autoSelections); const expectedOnyxData = { // Expecting the optimistic data to contain the OUT_OF_POLICY violations for the deleted category and tag optimisticData: [ @@ -9733,8 +9733,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); // The optimistic data should contain a transaction merge auto-selecting the remaining category expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, @@ -9800,8 +9800,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate, autoSelections); expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9866,8 +9866,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9931,8 +9931,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate, autoSelections); expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, @@ -9995,8 +9995,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); // No transaction merge should be present — only the violation push const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); @@ -10060,8 +10060,8 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); // No transaction merge should be present const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); @@ -10116,8 +10116,8 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; // policyUpdate-only call (simulates setPolicyRulesEnabled and similar) - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); - pushTransactionViolationsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); + pushTransactionViolationsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}, autoSelections); const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); }); @@ -10182,8 +10182,8 @@ describe('ReportUtils', () => { }; const onyxData = {optimisticData: [], failureData: []}; - pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); - pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate); + pushTransactionViolationsOnyxData(onyxData, result.current, {}, {}, fakePolicyTagListsUpdate, autoSelections); const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); }); From 0756c3a8db1c88eea8f01b3ea60b806ce1eda8f8 Mon Sep 17 00:00:00 2001 From: Mukher Date: Mon, 4 May 2026 15:48:10 +0500 Subject: [PATCH 016/519] reverted unnecessary code change --- src/libs/actions/Policy/Category.ts | 8 ++++++++ src/libs/actions/Policy/Policy.ts | 5 ++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 75b16e8ae8b2..1dedb110c313 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -603,6 +603,7 @@ function setPolicyCategoryReceiptsRequired(policyData: PolicyData, categoryName: }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const parameters: SetPolicyCategoryReceiptsRequiredParams = { policyID, categoryName, @@ -667,6 +668,7 @@ function removePolicyCategoryReceiptsRequired(policyData: PolicyData, categoryNa }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const parameters: RemovePolicyCategoryReceiptsRequiredParams = { policyID, categoryName, @@ -730,6 +732,7 @@ function setPolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, categ }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const parameters: SetPolicyCategoryItemizedReceiptsRequiredParams = { policyID, categoryName, @@ -794,6 +797,7 @@ function removePolicyCategoryItemizedReceiptsRequired(policyData: PolicyData, ca }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const parameters: RemovePolicyCategoryItemizedReceiptsRequiredParams = { policyID, categoryName, @@ -864,6 +868,7 @@ function setPolicyCategoryReceiptsAndItemizedReceiptRequired(policyData: PolicyD }; pushTransactionViolationsOnyxData(onyxData, policyData, {}, policyCategoriesOptimisticData); + const parameters: SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams = { policyID, categoryName, @@ -1097,6 +1102,7 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: }, {}); pushTransactionViolationsOnyxData(onyxData, {...policyData, categories: policyCategories}, policyOptimisticData, policyCategoriesOptimisticData); + const parameters = { policyID, categories: JSON.stringify({ @@ -1293,6 +1299,7 @@ function setWorkspaceRequiresCategory(policyData: PolicyData, requiresCategory: }; pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData); + const parameters = { policyID, requiresCategory, @@ -1476,6 +1483,7 @@ function enablePolicyCategories(policyData: PolicyData, enabled: boolean, should const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate); pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate, policyCategoriesUpdate, {}, autoSelections); + const parameters: EnablePolicyCategoriesParams = {policyID, enabled}; // We can't use writeWithNoDuplicatesEnableFeatureConflicts because the categories data is also changed when disabling/enabling this feature diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 808cf6cb0622..e3ae881fc9fb 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5012,15 +5012,14 @@ function enablePolicyRules(policy: OnyxEntry, enabled: boolean, shouldGo ], }; if (policyData) { - const policyUpdate = { + ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, { areRulesEnabled: enabled, preventSelfApproval: false, ...(!enabled ? DISABLED_MAX_EXPENSE_VALUES : {}), pendingFields: { areRulesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - }; - ReportUtils.pushTransactionViolationsOnyxData(onyxData, policyData, policyUpdate); + }); } if (enabled && isControlPolicy(policy) && policy?.outputCurrency === CONST.CURRENCY.USD) { From 51948dbdbfe06a699fe731c478826962570d75e6 Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 5 May 2026 01:48:01 +0430 Subject: [PATCH 017/519] fix(reports): update previous report after moving expenses --- src/libs/actions/Transaction.ts | 49 +++++++++++++++++---- tests/unit/TransactionTest.ts | 77 ++++++++++++++++++++++++++++++--- 2 files changed, 111 insertions(+), 15 deletions(-) diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 4fb36d00f19a..f940f0d2f9e7 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -998,6 +998,19 @@ function changeTransactionsReport({ ...pendingFields, }; }; + const clearOptimisticPendingFields = (reportIDToUpdate: string | undefined, fieldNames: Array>) => { + if (!reportIDToUpdate || !optimisticPendingFieldsByReport[reportIDToUpdate]) { + return; + } + + for (const fieldName of fieldNames) { + delete optimisticPendingFieldsByReport[reportIDToUpdate][fieldName]; + } + + if (Object.keys(optimisticPendingFieldsByReport[reportIDToUpdate]).length === 0) { + delete optimisticPendingFieldsByReport[reportIDToUpdate]; + } + }; const clearAccumulatedReportTotals = (reportIDToUpdate: string) => { delete updatedReportTotals[reportIDToUpdate]; delete updatedReportNonReimbursableTotals[reportIDToUpdate]; @@ -1014,6 +1027,14 @@ function changeTransactionsReport({ total: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }); }; + const clearStaleReportState = (reportIDToUpdate: string | undefined) => { + if (!reportIDToUpdate) { + return; + } + + staleReportIDs.delete(reportIDToUpdate); + clearOptimisticPendingFields(reportIDToUpdate, ['total']); + }; const getTargetReportCurrencies = (targetReportID: string) => { if (!targetReportCurrenciesByReport[targetReportID]) { targetReportCurrenciesByReport[targetReportID] = new Set( @@ -1204,22 +1225,32 @@ function changeTransactionsReport({ const {amount: transactionAmount = 0, currency: transactionCurrency} = getTransactionDetails(transaction, undefined, undefined, allowNegative) ?? {}; const resolvedTransactionCurrency = transactionCurrency ?? transaction.currency; const oldReportTotal = oldReport?.total ?? 0; - const updatedReportTotal = transactionAmount < 0 ? oldReportTotal - transactionAmount : oldReportTotal + transactionAmount; if (oldReport) { const oldReportTransactionCount = updatedReportTransactionCounts[oldReportID] ?? oldReport.transactionCount ?? 0; updatedReportTransactionCounts[oldReportID] = Math.max(0, oldReportTransactionCount - 1); - if (staleReportIDs.has(oldReportID) || oldReport.pendingFields?.total) { + const remainingTransactions = getReportTransactions(oldReportID).filter( + (reportTransaction) => reportTransaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !transactionIDs.includes(reportTransaction.transactionID), + ); + const willBeEmpty = remainingTransactions.length === 0; + + if (willBeEmpty) { + clearStaleReportState(oldReportID); + updatedReportTotals[oldReportID] = 0; + updatedReportNonReimbursableTotals[oldReportID] = 0; + updatedReportUnheldNonReimbursableTotals[oldReportID] = 0; + } else if (staleReportIDs.has(oldReportID) || oldReport.pendingFields?.total) { markReportTotalAsStale(oldReportID); } else if (oldReport.currency === transactionCurrency) { - updatedReportTotals[oldReportID] = updatedReportTotals[oldReportID] ? updatedReportTotals[oldReportID] : updatedReportTotal; - updatedReportNonReimbursableTotals[oldReportID] = - (updatedReportNonReimbursableTotals[oldReportID] ? updatedReportNonReimbursableTotals[oldReportID] : (oldReport?.nonReimbursableTotal ?? 0)) + - (transaction?.reimbursable ? 0 : transactionAmount); - updatedReportUnheldNonReimbursableTotals[oldReportID] = - (updatedReportUnheldNonReimbursableTotals[oldReportID] ? updatedReportUnheldNonReimbursableTotals[oldReportID] : (oldReport?.unheldNonReimbursableTotal ?? 0)) + - (transaction?.reimbursable && !isOnHold(transaction) ? 0 : transactionAmount); + const currentTotal = updatedReportTotals[oldReportID] ?? oldReportTotal; + updatedReportTotals[oldReportID] = currentTotal + transactionAmount; + + const currentNonReimbursableTotal = updatedReportNonReimbursableTotals[oldReportID] ?? oldReport?.nonReimbursableTotal ?? 0; + updatedReportNonReimbursableTotals[oldReportID] = currentNonReimbursableTotal + (transaction?.reimbursable ? 0 : transactionAmount); + + const currentUnheldNonReimbursableTotal = updatedReportUnheldNonReimbursableTotals[oldReportID] ?? oldReport?.unheldNonReimbursableTotal ?? 0; + updatedReportUnheldNonReimbursableTotals[oldReportID] = currentUnheldNonReimbursableTotal + (transaction?.reimbursable && !isOnHold(transaction) ? 0 : transactionAmount); } else { markReportTotalAsStale(oldReportID); } diff --git a/tests/unit/TransactionTest.ts b/tests/unit/TransactionTest.ts index b915b776f75e..0ec5d100857f 100644 --- a/tests/unit/TransactionTest.ts +++ b/tests/unit/TransactionTest.ts @@ -693,7 +693,7 @@ describe('Transaction', () => { expect(report?.total).toBe(0); }); - it('should update the old report total when the currency is the same', async () => { + it('should reset the old report total to 0 when moving the last same-currency expense', async () => { const oldExpenseReport = { ...createRandomReport(1, undefined), total: -200, @@ -738,11 +738,11 @@ describe('Transaction', () => { }); }); - expect(report?.total).toBe(oldExpenseReport.total - transaction.amount); - expect(report?.nonReimbursableTotal).toBe(oldExpenseReport.nonReimbursableTotal - transaction.amount); + expect(report?.total).toBe(0); + expect(report?.nonReimbursableTotal).toBe(0); }); - it('should not update the old report total when the currency is different', async () => { + it('should reset the old report total to 0 when no expenses remain, even if the currency is different', async () => { const oldExpenseReport = { ...createRandomReport(1, undefined), total: -200, @@ -787,8 +787,73 @@ describe('Transaction', () => { }); }); - expect(report?.total).toBe(oldExpenseReport.total); - expect(report?.nonReimbursableTotal).toBe(oldExpenseReport.nonReimbursableTotal); + expect(report?.total).toBe(0); + expect(report?.nonReimbursableTotal).toBe(0); + }); + + it('should reset the old report total to 0 after moving all same-currency expenses to a new report', async () => { + const oldExpenseReport = { + ...createRandomReport(1, undefined), + total: -300, + nonReimbursableTotal: -300, + currency: CONST.CURRENCY.USD, + transactionCount: 2, + }; + const firstTransaction = { + ...generateTransaction({ + reportID: oldExpenseReport.reportID, + }), + amount: -100, + reimbursable: false, + currency: CONST.CURRENCY.USD, + }; + const secondTransaction = { + ...generateTransaction({ + reportID: oldExpenseReport.reportID, + }), + amount: -200, + reimbursable: false, + currency: CONST.CURRENCY.USD, + }; + const firstIOUAction = createIOUAction(firstTransaction, FAKE_OLD_REPORT_ID); + const secondIOUAction = createIOUAction(secondTransaction, FAKE_OLD_REPORT_ID); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransaction.transactionID}`, firstTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${secondTransaction.transactionID}`, secondTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${oldExpenseReport.reportID}`, oldExpenseReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldExpenseReport.reportID}`, { + [firstIOUAction.reportActionID]: firstIOUAction, + [secondIOUAction.reportActionID]: secondIOUAction, + }); + + const fakeReport = await getReportFromUseOnyx(FAKE_NEW_REPORT_ID); + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransaction.transactionID}`]: firstTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${secondTransaction.transactionID}`]: secondTransaction, + }; + changeTransactionsReport({ + transactionIDs: [firstTransaction.transactionID, secondTransaction.transactionID], + isASAPSubmitBetaEnabled: false, + accountID: CURRENT_USER_ID, + email: 'test@example.com', + newReport: fakeReport, + policy: undefined, + allTransactions, + policyTagList: undefined, + }); + await waitForBatchedUpdates(); + + const report = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${oldExpenseReport.reportID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + expect(report?.total).toBe(0); + expect(report?.nonReimbursableTotal).toBe(0); }); it('should keep both reports stale and preserve the displayed totals for mixed-currency partial moves', async () => { From fcbb377b84a3474901d9799dbaa61fc70d8a56b0 Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 6 May 2026 09:32:59 +0430 Subject: [PATCH 018/519] fix empty report reset for partial search moves --- src/libs/actions/Transaction.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 3dd646a48031..5cce61c3fe64 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -1228,12 +1228,9 @@ function changeTransactionsReport({ if (oldReport) { const oldReportTransactionCount = updatedReportTransactionCounts[oldReportID] ?? oldReport.transactionCount ?? 0; - updatedReportTransactionCounts[oldReportID] = Math.max(0, oldReportTransactionCount - 1); - - const remainingTransactions = getReportTransactions(oldReportID).filter( - (reportTransaction) => reportTransaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !transactionIDs.includes(reportTransaction.transactionID), - ); - const willBeEmpty = remainingTransactions.length === 0; + const updatedOldReportTransactionCount = Math.max(0, oldReportTransactionCount - 1); + updatedReportTransactionCounts[oldReportID] = updatedOldReportTransactionCount; + const willBeEmpty = updatedOldReportTransactionCount === 0; if (willBeEmpty) { clearStaleReportState(oldReportID); From 324f72f3835f0cb3da3736619718ba948613b093 Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 6 May 2026 21:07:03 +0430 Subject: [PATCH 019/519] fix(chat): show offline message when opening video without connection --- .../AttachmentModalBaseContent/index.tsx | 43 +++++++++++++++---- .../AttachmentModalBaseContent/types.ts | 3 ++ .../report/ReportAttachmentModalContent.tsx | 5 +++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index 44a79444b5d1..750d38e65f5c 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import Animated, {FadeIn, LayoutAnimationConfig, useSharedValue} from 'react-native-reanimated'; import ActivityIndicator from '@components/ActivityIndicator'; +import AttachmentOfflineIndicator from '@components/AttachmentOfflineIndicator'; import AttachmentCarousel from '@components/Attachments/AttachmentCarousel'; import {AttachmentCarouselPagerActionsContext, AttachmentCarouselPagerStateContext} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import type {AttachmentCarouselPagerActionsContextType, AttachmentCarouselPagerStateContextType} from '@components/Attachments/AttachmentCarousel/Pager/types'; @@ -52,6 +53,7 @@ function AttachmentModalBaseContent({ threeDotsMenuItems: threeDotsMenuItemsProp, isLoading = false, shouldShowNotFoundPage = false, + shouldShowOfflineBlockingView = false, shouldShowCarousel = true, shouldDisableSendButton = false, shouldDisplayHelpButton = false, @@ -141,10 +143,17 @@ function AttachmentModalBaseContent({ [onCarouselAttachmentChange, setFile], ); - const threeDotsMenuItems = useMemo( - () => (typeof threeDotsMenuItemsProp === 'function' ? threeDotsMenuItemsProp({file: fileToDisplay, source, isLocalSource}) : (threeDotsMenuItemsProp ?? [])), - [fileToDisplay, isLocalSource, source, threeDotsMenuItemsProp], - ); + const threeDotsMenuItems = useMemo(() => { + if (shouldShowOfflineBlockingView) { + return []; + } + + if (typeof threeDotsMenuItemsProp === 'function') { + return threeDotsMenuItemsProp({file: fileToDisplay, source, isLocalSource}); + } + + return threeDotsMenuItemsProp ?? []; + }, [fileToDisplay, isLocalSource, shouldShowOfflineBlockingView, source, threeDotsMenuItemsProp]); const [isDownloadButtonReadyToBeShown, setIsDownloadButtonReadyToBeShown] = useState(true); const setDownloadButtonVisibility = useCallback( @@ -188,13 +197,26 @@ function AttachmentModalBaseContent({ const {isAttachmentLoaded} = useContext(AttachmentStateContext); const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); const shouldShowDownloadButton = useMemo(() => { - const isValidContext = !isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH; - if (!isValidContext || isErrorInAttachment(source) || isEReceipt) { + const isValidContext = !isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH || !!onDownloadAttachment; + if (!isValidContext || isErrorInAttachment(source) || isEReceipt || shouldShowOfflineBlockingView) { return false; } return !!onDownloadAttachment && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isOffline && !isLocalSource && isAttachmentLoaded?.(source); - }, [isAttachmentLoaded, isDownloadButtonReadyToBeShown, isErrorInAttachment, isLocalSource, isOffline, onDownloadAttachment, report, shouldShowNotFoundPage, source, type, isEReceipt]); + }, [ + isAttachmentLoaded, + isDownloadButtonReadyToBeShown, + isErrorInAttachment, + isLocalSource, + isOffline, + onDownloadAttachment, + report, + shouldShowNotFoundPage, + shouldShowOfflineBlockingView, + source, + type, + isEReceipt, + ]); // We need to pass a shared value of type boolean to the context, so `falseSV` acts as a default value. const falseSV = useSharedValue(false); @@ -219,7 +241,7 @@ function AttachmentModalBaseContent({ [setAttachmentError, shouldCloseOnSwipeDown, onClose], ); - const shouldDisplayContent = !shouldShowNotFoundPage && !isLoading; + const shouldDisplayContent = !shouldShowNotFoundPage && !shouldShowOfflineBlockingView && !isLoading; const Content = useMemo(() => { if (AttachmentContent) { return ( @@ -340,6 +362,11 @@ function AttachmentModalBaseContent({ onLinkPress={onClose} /> )} + {shouldShowOfflineBlockingView && !isLoading && ( + + + + )} {shouldDisplayContent && (customAttachmentContent ?? Content)} {!!footerActionButtons && ( diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts index d15d17f52809..eb766424cf64 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts @@ -85,6 +85,9 @@ type AttachmentModalBaseContentProps = { /** Whether to display not found page */ shouldShowNotFoundPage?: boolean; + /** Whether to display an offline blocking view instead of the attachment */ + shouldShowOfflineBlockingView?: boolean; + /** Whether to show an attachment carousel */ shouldShowCarousel?: boolean; diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx index b7b48009e881..b70b8998ad5e 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx @@ -1,3 +1,4 @@ +import {Str} from 'expensify-common'; import React, {useEffect, useRef} from 'react'; import type {View} from 'react-native'; import type {Attachment} from '@components/Attachments/types'; @@ -96,6 +97,9 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen // which already passes a resolved source. Keep normalization for other types to support email entry points. const source = getValidatedImageSource(sourceParam, type !== CONST.ATTACHMENT_TYPE.SEARCH); const modalType = useReportAttachmentModalType(source); + const isRemoteSource = typeof source === 'string' && !CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => source.startsWith(prefix)); + const isVideo = (typeof source === 'string' && Str.isVideo(source)) || (!!originalFileName && Str.isVideo(originalFileName)); + const shouldShowOfflineBlockingView = isOffline && isRemoteSource && isVideo; const shouldShowNotFoundPage = !isLoading && type !== CONST.ATTACHMENT_TYPE.SEARCH && !report?.reportID; @@ -104,6 +108,7 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen type, report, shouldShowNotFoundPage, + shouldShowOfflineBlockingView, isAuthTokenRequired: !!isAuthTokenRequired, attachmentLink: attachmentLink ?? '', originalFileName: originalFileName ?? '', From 2ae9d23ee29d2b61d050a394b5acb9287e93e9dd Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 7 May 2026 07:36:44 +0500 Subject: [PATCH 020/519] reverted multi-level tags auto-selection --- src/libs/ReportUtils.ts | 50 ++++------------------------------- tests/unit/ReportUtilsTest.ts | 12 ++++----- 2 files changed, 11 insertions(+), 51 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e2025787026a..3a8cce6b5c05 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -152,7 +152,6 @@ import { getPolicyNameByID, getPolicyRole, getRuleApprovers, - getSortedTagKeys, getSubmitToAccountID, hasDependentTags as hasDependentTagsPolicyUtils, hasDynamicExternalWorkflow, @@ -272,7 +271,6 @@ import { getRecentTransactions, getReimbursable, getTag, - getTagArrayFromName, getTaxAmount, getTaxCode, getTaxName, @@ -2165,12 +2163,7 @@ function pushTransactionAutoSelectionsOnyxData( return autoSelections; } - const {optimisticCategories, optimisticTagLists, hasDependentTagsValue, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState( - policyData, - policyUpdate, - categoriesUpdate, - tagListsUpdate, - ); + const {optimisticCategories, optimisticTagLists, isCategoriesUpdateEmpty, isTagListsUpdateEmpty} = getOptimisticPolicyState(policyData, policyUpdate, categoriesUpdate, tagListsUpdate); const enabledCategoryKeys = Object.entries(optimisticCategories) .filter(([, cat]) => cat.enabled && cat.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) @@ -2179,6 +2172,10 @@ function pushTransactionAutoSelectionsOnyxData( const tagListKeys = Object.keys(optimisticTagLists); + // Auto-replace is scoped to single-level tags only. Web-E's removeTags() throws "NTagging not supported yet" for + // multi-level tag lists, and the auto-replace metadata we send to Auth doesn't carry tagListIndex/tagListName, + // so the backend can't know which level of the multi-level tag string should be replaced. Multi-level support + // is tracked as a separate NTag follow-up. let singleRemainingTag: string | undefined; if (tagListKeys.length === 1) { const tagListName = tagListKeys.at(0) ?? ''; @@ -2188,15 +2185,6 @@ function pushTransactionAutoSelectionsOnyxData( singleRemainingTag = enabledTagKeys.length === 1 ? enabledTagKeys.at(0) : undefined; } - const sortedTagKeys = tagListKeys.length > 1 ? getSortedTagKeys(optimisticTagLists) : []; - const perLevelSingleTag: Array = sortedTagKeys.map((key) => { - const tags = optimisticTagLists[key]?.tags ?? {}; - const enabledKeys = Object.entries(tags) - .filter(([, tag]) => tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map(([k]) => k); - return enabledKeys.length === 1 ? enabledKeys.at(0) : undefined; - }); - for (const { report, transactionsAndViolations: {transactions}, @@ -2226,34 +2214,6 @@ function pushTransactionAutoSelectionsOnyxData( } } - // Multi-level tag auto-select. Skipped for dependent-tag policies because the per-level - // sole-remaining check ignores parent-tag filtering and could write an invalid combination. - if (!isTagListsUpdateEmpty && !hasDependentTagsValue && tagListKeys.length > 1 && transaction.tag) { - const currentTags = getTagArrayFromName(transaction.tag); - let anyTagChanged = false; - const newTags = [...currentTags]; - - for (let i = 0; i < sortedTagKeys.length; i++) { - const currentTag = currentTags.at(i); - if (!currentTag) { - continue; - } - const sortedTagKey = sortedTagKeys.at(i) ?? ''; - const levelTags = optimisticTagLists[sortedTagKey]?.tags ?? {}; - const isInPolicy = !!levelTags[currentTag]?.enabled; - const singleTag = perLevelSingleTag.at(i); - if (!isInPolicy && singleTag) { - newTags[i] = singleTag; - anyTagChanged = true; - } - } - - if (anyTagChanged) { - transactionUpdates.tag = newTags.join(CONST.COLON); - transactionRollback.tag = transaction.tag; - } - } - if (Object.keys(transactionUpdates).length === 0) { continue; } diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 29253c860ebb..a4727862ab68 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10122,14 +10122,15 @@ describe('ReportUtils', () => { expect(hasTransactionMerge).toBe(false); }); - it('should not auto-select multi-level tags when the policy has dependent tags', async () => { - // Given a multi-level tag policy with dependent tags, where one level has a sole remaining tag. - // Auto-select should be skipped because the per-level sole-remaining check ignores parent filtering. + it('should not auto-select tags for multi-level tag policies', async () => { + // Auto-replace is intentionally scoped to single-level tags. Web-E does not support multi-level tag + // removal yet, and the auto-replace metadata sent to Auth doesn't carry tagListIndex/tagListName, + // so the backend can't determine which level of the multi-level tag string to replace. const level1Tags: PolicyTags = { - Engineering: {name: 'Engineering', enabled: true, rules: {parentTagsFilter: ''}}, + Engineering: {name: 'Engineering', enabled: true}, }; const level2Tags: PolicyTags = { - Q1: {name: 'Q1', enabled: true, rules: {parentTagsFilter: 'Engineering'}}, + Q1: {name: 'Q1', enabled: true}, }; const fakePolicyTagsLists: PolicyTagLists = { Department: {name: 'Department', orderWeight: 0, required: false, tags: level1Tags}, @@ -10173,7 +10174,6 @@ describe('ReportUtils', () => { const {result} = renderHook(() => usePolicyData(fakePolicyID), {wrapper: OnyxListItemProvider}); await waitForBatchedUpdates(); - // tagListsUpdate is non-empty so we exercise the multi-level branch's dependent-tag guard const tagsToDelete: Record>> = { Sales: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false}, }; From 778c93276630eafb71e41491a021abe5c94e1896 Mon Sep 17 00:00:00 2001 From: Nabi Date: Thu, 7 May 2026 10:51:52 +0430 Subject: [PATCH 021/519] fix prettier --- src/components/Attachments/AttachmentView/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index 19d32f204604..f9ec840970f7 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -4,8 +4,8 @@ import type {RotationDegrees} from 'react-fast-pdf'; import type {GestureResponderEvent, ImageURISource, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {useAttachmentCarouselPagerActions} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import AttachmentOfflineIndicator from '@components/AttachmentOfflineIndicator'; +import {useAttachmentCarouselPagerActions} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import Button from '@components/Button'; import DistanceEReceipt from '@components/DistanceEReceipt'; @@ -172,7 +172,7 @@ function AttachmentView({ const [imageError, setImageError] = useState(false); const {isOffline} = useNetwork({onReconnect: () => setImageError(false)}); -const isLocalVideoSource = + const isLocalVideoSource = typeof source === 'string' && (source.startsWith('blob:') || source.startsWith('file:') || CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => source.startsWith(prefix))); const shouldShowOfflineVideoIndicator = isOffline && !!isVideo && typeof source === 'string' && !isLocalVideoSource; @@ -372,7 +372,7 @@ const isLocalVideoSource = ); } - if (shouldShowOfflineVideoIndicator) { + if (shouldShowOfflineVideoIndicator) { return ; } From a819e3c8db00c980a4b49565ec861140483c9698 Mon Sep 17 00:00:00 2001 From: Nabi Date: Thu, 7 May 2026 13:48:34 +0430 Subject: [PATCH 022/519] simplify isLocalVideoSource condition --- src/components/Attachments/AttachmentView/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index f9ec840970f7..af359840a3da 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -172,8 +172,7 @@ function AttachmentView({ const [imageError, setImageError] = useState(false); const {isOffline} = useNetwork({onReconnect: () => setImageError(false)}); - const isLocalVideoSource = - typeof source === 'string' && (source.startsWith('blob:') || source.startsWith('file:') || CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => source.startsWith(prefix))); + const isLocalVideoSource = typeof source === 'string' && CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => source.startsWith(prefix)); const shouldShowOfflineVideoIndicator = isOffline && !!isVideo && typeof source === 'string' && !isLocalVideoSource; useEffect(() => { From 88da69988c246409415cf045cc6c4df81bd0826c Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Tue, 12 May 2026 09:31:37 +0700 Subject: [PATCH 023/519] Create new temporaryGetDisplayNameOrDefault function --- src/components/ArchivedReportFooter.tsx | 9 +- src/libs/PersonalDetailsUtils.ts | 63 +++++++++ tests/unit/libs/PersonalDetailsUtilsTest.ts | 140 ++++++++++++++++++++ 3 files changed, 208 insertions(+), 4 deletions(-) diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx index d931f2fe27a1..102cf77e8b81 100644 --- a/src/components/ArchivedReportFooter.tsx +++ b/src/components/ArchivedReportFooter.tsx @@ -5,7 +5,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {getDisplayNameOrDefault, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getOriginalMessage, isClosedAction} from '@libs/ReportActionsUtils'; import {getPolicyName} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -24,20 +24,21 @@ function ArchivedReportFooter({reportID}: ArchivedReportFooterProps) { const {translate} = useLocalize(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [areTranslationsLoading] = useOnyx(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING); const [personalDetails = getEmptyObject()] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [reportClosedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: getLastClosedReportAction}); const originalMessage = isClosedAction(reportClosedAction) ? getOriginalMessage(reportClosedAction) : null; const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT; const actorPersonalDetails = personalDetails?.[reportClosedAction?.actorAccountID ?? CONST.DEFAULT_NUMBER_ID]; - let displayName = getDisplayNameOrDefault(actorPersonalDetails); + let displayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: actorPersonalDetails, areTranslationsLoading}); let oldDisplayName: string | undefined; if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED) { const newAccountID = originalMessage?.newAccountID; const oldAccountID = originalMessage?.oldAccountID; - displayName = getDisplayNameOrDefault(personalDetails?.[newAccountID ?? CONST.DEFAULT_NUMBER_ID]); - oldDisplayName = getDisplayNameOrDefault(personalDetails?.[oldAccountID ?? CONST.DEFAULT_NUMBER_ID]); + displayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: personalDetails?.[newAccountID ?? CONST.DEFAULT_NUMBER_ID], areTranslationsLoading}); + oldDisplayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: personalDetails?.[oldAccountID ?? CONST.DEFAULT_NUMBER_ID], areTranslationsLoading}); } const shouldRenderHTML = archiveReason !== CONST.REPORT.ARCHIVE_REASON.DEFAULT && archiveReason !== CONST.REPORT.ARCHIVE_REASON.BOOKING_END_DATE_HAS_PASSED; diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 81ed9e4a5909..baa5e8698295 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -100,6 +100,68 @@ function getDisplayNameOrDefault( return shouldFallbackToHidden ? hiddenTranslation : ''; } +function temporaryGetDisplayNameOrDefault({ + passedPersonalDetails, + defaultValue = '', + shouldFallbackToHidden = true, + shouldAddCurrentUserPostfix = false, + youAfterTranslation, + areTranslationsLoading, +}: { + passedPersonalDetails?: Partial | null; + defaultValue?: string; + shouldFallbackToHidden?: boolean; + shouldAddCurrentUserPostfix?: boolean; + youAfterTranslation?: string; + areTranslationsLoading: boolean | undefined; +}): string { + let temporaryHiddenTranslation = ''; + let temporaryYouTranslation = ''; + if (areTranslationsLoading === false) { + temporaryHiddenTranslation = translateLocal('common.hidden'); + temporaryYouTranslation = translateLocal('common.you').toLowerCase(); + } + let displayName = passedPersonalDetails?.displayName ?? ''; + + let login = passedPersonalDetails?.login ?? ''; + + // If the displayName starts with the merged account prefix, remove it. + if (regexMergedAccount.test(displayName)) { + // Remove the merged account prefix from the displayName. + displayName = displayName.replaceAll(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); + } + + // If the displayName is not set by the user, the backend sets the displayName same as the login so + // we need to remove the sms domain from the displayName if it is an sms login. + if (Str.isSMSLogin(login)) { + if (displayName === login) { + displayName = Str.removeSMSDomain(displayName); + } + login = Str.removeSMSDomain(login); + } + + if (shouldAddCurrentUserPostfix && !!displayName) { + displayName = `${displayName} (${youAfterTranslation ?? temporaryYouTranslation})`; + } + + if (passedPersonalDetails?.accountID === CONST.ACCOUNT_ID.CONCIERGE) { + displayName = CONST.CONCIERGE_DISPLAY_NAME; + } + + if (displayName) { + return displayName; + } + + if (defaultValue) { + return defaultValue; + } + + if (login) { + return login; + } + return shouldFallbackToHidden ? temporaryHiddenTranslation : ''; +} + /** * Given a list of account IDs (as number) it will return an array of personal details objects. * @param accountIDs - Array of accountIDs @@ -477,4 +539,5 @@ export { arePersonalDetailsMissing, areTravelPersonalDetailsMissing, createPersonalDetailsLookupByAccountID, + temporaryGetDisplayNameOrDefault, }; diff --git a/tests/unit/libs/PersonalDetailsUtilsTest.ts b/tests/unit/libs/PersonalDetailsUtilsTest.ts index 126ccac1d07a..5c7fbdc8a973 100644 --- a/tests/unit/libs/PersonalDetailsUtilsTest.ts +++ b/tests/unit/libs/PersonalDetailsUtilsTest.ts @@ -1,3 +1,4 @@ +import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; import { arePersonalDetailsMissing, @@ -8,12 +9,26 @@ import { getEffectiveDisplayName, getPersonalDetailByEmail, getPersonalDetailsOnyxDataForOptimisticUsers, + temporaryGetDisplayNameOrDefault, } from '@libs/PersonalDetailsUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; import {formatPhoneNumber} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +jest.mock('@libs/Localize', () => ({ + translateLocal: jest.fn((phraseKey: string) => { + if (phraseKey === 'common.hidden') { + return 'Hidden'; + } + if (phraseKey === 'common.you') { + return 'You'; + } + return phraseKey; + }), +})); + type PersonalDetailsForDisplayName = Pick & { firstName?: string | null; lastName?: string | null; @@ -702,4 +717,129 @@ describe('PersonalDetailsUtils', () => { expect(result[1]).toEqual({accountID: 1, login: 'second@example.com', displayName: 'Second'}); }); }); + + describe('temporaryGetDisplayNameOrDefault', () => { + test('should return displayName when present', () => { + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: {accountID: 1, displayName: 'Ada Lovelace', login: 'ada@example.com'}, + areTranslationsLoading: false, + }), + ).toBe('Ada Lovelace'); + }); + + test('should strip merged-account prefix from displayName', () => { + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: { + accountID: 1, + displayName: 'MERGED_99@visible.name@example.com', + login: 'user@example.com', + }, + areTranslationsLoading: false, + }), + ).toBe('visible.name@example.com'); + }); + + test('should normalize SMS login when displayName equals login', () => { + const smsLogin = '+18005550000@expensify.sms'; + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: { + accountID: 1, + login: smsLogin, + displayName: smsLogin, + }, + areTranslationsLoading: false, + }), + ).toBe(Str.removeSMSDomain(smsLogin)); + }); + + test('should append current-user postfix using localized "you" when translations are ready', () => { + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: {accountID: 1, displayName: 'Sam', login: 'sam@example.com'}, + shouldAddCurrentUserPostfix: true, + areTranslationsLoading: false, + }), + ).toBe('Sam (you)'); + }); + + test('should prefer explicit youAfterTranslation over localized "you"', () => { + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: {accountID: 1, displayName: 'Sam', login: 'sam@example.com'}, + shouldAddCurrentUserPostfix: true, + youAfterTranslation: 'vous', + areTranslationsLoading: false, + }), + ).toBe('Sam (vous)'); + }); + + test('should return concierge display name for concierge accountID', () => { + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: { + accountID: CONST.ACCOUNT_ID.CONCIERGE, + displayName: 'Ignored', + login: CONST.EMAIL.CONCIERGE, + }, + areTranslationsLoading: false, + }), + ).toBe(CONST.CONCIERGE_DISPLAY_NAME); + }); + + test('should return defaultValue when displayName is empty', () => { + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: {accountID: 1, login: 'only@example.com'}, + defaultValue: 'Custom default', + areTranslationsLoading: false, + }), + ).toBe('Custom default'); + }); + + test('should fall back to login when displayName and defaultValue are empty', () => { + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: {accountID: 1, login: 'fallback@example.com'}, + areTranslationsLoading: false, + }), + ).toBe('fallback@example.com'); + }); + + test('should return hidden translation when nothing else applies and translations are ready', () => { + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: {accountID: 1}, + areTranslationsLoading: false, + }), + ).toBe('Hidden'); + }); + + test('should return empty string for hidden fallback when translations are not ready', () => { + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: {accountID: 1}, + areTranslationsLoading: true, + }), + ).toBe(''); + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: {accountID: 1}, + areTranslationsLoading: undefined, + }), + ).toBe(''); + }); + + test('should return empty string when shouldFallbackToHidden is false and nothing else applies', () => { + expect( + temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: {accountID: 1}, + shouldFallbackToHidden: false, + areTranslationsLoading: false, + }), + ).toBe(''); + }); + }); }); From 4a43f409b9ba8f30774310540e2c3e6ce148beac Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Tue, 12 May 2026 09:37:46 +0700 Subject: [PATCH 024/519] Fix spell check --- tests/unit/libs/PersonalDetailsUtilsTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/libs/PersonalDetailsUtilsTest.ts b/tests/unit/libs/PersonalDetailsUtilsTest.ts index 5c7fbdc8a973..c9e4bfb8d91f 100644 --- a/tests/unit/libs/PersonalDetailsUtilsTest.ts +++ b/tests/unit/libs/PersonalDetailsUtilsTest.ts @@ -770,10 +770,10 @@ describe('PersonalDetailsUtils', () => { temporaryGetDisplayNameOrDefault({ passedPersonalDetails: {accountID: 1, displayName: 'Sam', login: 'sam@example.com'}, shouldAddCurrentUserPostfix: true, - youAfterTranslation: 'vous', + youAfterTranslation: 'anotherYou', areTranslationsLoading: false, }), - ).toBe('Sam (vous)'); + ).toBe('Sam (anotherYou)'); }); test('should return concierge display name for concierge accountID', () => { From c9399f036e470d9a9789b957153fa4fd217d7ea4 Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Tue, 12 May 2026 20:09:27 +0700 Subject: [PATCH 025/519] additional overloads --- src/libs/PersonalDetailsUtils.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index baa5e8698295..9b546d924014 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -100,6 +100,29 @@ function getDisplayNameOrDefault( return shouldFallbackToHidden ? hiddenTranslation : ''; } +function temporaryGetDisplayNameOrDefault(params: { + passedPersonalDetails?: Partial | null; + defaultValue?: string; + shouldFallbackToHidden?: boolean; + shouldAddCurrentUserPostfix: true; + youAfterTranslation: string; + areTranslationsLoading: true | undefined; +}): string; +function temporaryGetDisplayNameOrDefault(params: { + passedPersonalDetails?: Partial | null; + defaultValue?: string; + shouldFallbackToHidden?: boolean; + shouldAddCurrentUserPostfix: true; + youAfterTranslation?: string; + areTranslationsLoading: false; +}): string; +function temporaryGetDisplayNameOrDefault(params: { + passedPersonalDetails?: Partial | null; + defaultValue?: string; + shouldFallbackToHidden?: boolean; + shouldAddCurrentUserPostfix?: false; + areTranslationsLoading: boolean | undefined; +}): string; function temporaryGetDisplayNameOrDefault({ passedPersonalDetails, defaultValue = '', From 419beb6ebb58335c743cc6056cb73c48bf95cd86 Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Tue, 12 May 2026 20:10:43 +0700 Subject: [PATCH 026/519] Remove unused import --- src/components/ArchivedReportFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx index 102cf77e8b81..05bb51f90651 100644 --- a/src/components/ArchivedReportFooter.tsx +++ b/src/components/ArchivedReportFooter.tsx @@ -5,7 +5,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getDisplayNameOrDefault, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getOriginalMessage, isClosedAction} from '@libs/ReportActionsUtils'; import {getPolicyName} from '@libs/ReportUtils'; import CONST from '@src/CONST'; From e95044da2759b1beb2706150453a43cb600f6ecf Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Tue, 12 May 2026 20:30:33 +0700 Subject: [PATCH 027/519] Update eslint.seatbelt.tsv --- config/eslint/eslint.seatbelt.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 5c3ca5d202dd..787a511eddeb 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -296,7 +296,7 @@ "../../src/libs/OptionsListUtils/index.ts" "rulesdir/no-onyx-connect" 3 "../../src/libs/OptionsListUtils/searchMatchUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 2 "../../src/libs/Parser.ts" "rulesdir/no-onyx-connect" 2 -"../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 3 +"../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 5 "../../src/libs/PersonalDetailsUtils.ts" "rulesdir/no-onyx-connect" 2 "../../src/libs/Pusher/index.native.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/libs/Pusher/index.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 From 3a0a5c9ea9a99f9f721b70c8033d42e27918bd68 Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Tue, 12 May 2026 21:08:02 +0700 Subject: [PATCH 028/519] use translate function --- src/components/ArchivedReportFooter.tsx | 7 +-- src/libs/PersonalDetailsUtils.ts | 26 +++------ tests/unit/libs/PersonalDetailsUtilsTest.ts | 65 +++++++++------------ 3 files changed, 39 insertions(+), 59 deletions(-) diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx index 05bb51f90651..193291e2f1a5 100644 --- a/src/components/ArchivedReportFooter.tsx +++ b/src/components/ArchivedReportFooter.tsx @@ -24,21 +24,20 @@ function ArchivedReportFooter({reportID}: ArchivedReportFooterProps) { const {translate} = useLocalize(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [areTranslationsLoading] = useOnyx(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING); const [personalDetails = getEmptyObject()] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [reportClosedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: getLastClosedReportAction}); const originalMessage = isClosedAction(reportClosedAction) ? getOriginalMessage(reportClosedAction) : null; const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT; const actorPersonalDetails = personalDetails?.[reportClosedAction?.actorAccountID ?? CONST.DEFAULT_NUMBER_ID]; - let displayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: actorPersonalDetails, areTranslationsLoading}); + let displayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: actorPersonalDetails, translate}); let oldDisplayName: string | undefined; if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED) { const newAccountID = originalMessage?.newAccountID; const oldAccountID = originalMessage?.oldAccountID; - displayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: personalDetails?.[newAccountID ?? CONST.DEFAULT_NUMBER_ID], areTranslationsLoading}); - oldDisplayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: personalDetails?.[oldAccountID ?? CONST.DEFAULT_NUMBER_ID], areTranslationsLoading}); + displayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: personalDetails?.[newAccountID ?? CONST.DEFAULT_NUMBER_ID], translate}); + oldDisplayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: personalDetails?.[oldAccountID ?? CONST.DEFAULT_NUMBER_ID], translate}); } const shouldRenderHTML = archiveReason !== CONST.REPORT.ARCHIVE_REASON.DEFAULT && archiveReason !== CONST.REPORT.ARCHIVE_REASON.BOOKING_END_DATE_HAS_PASSED; diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 9b546d924014..dcceff4958f3 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; @@ -100,28 +100,20 @@ function getDisplayNameOrDefault( return shouldFallbackToHidden ? hiddenTranslation : ''; } -function temporaryGetDisplayNameOrDefault(params: { - passedPersonalDetails?: Partial | null; - defaultValue?: string; - shouldFallbackToHidden?: boolean; - shouldAddCurrentUserPostfix: true; - youAfterTranslation: string; - areTranslationsLoading: true | undefined; -}): string; function temporaryGetDisplayNameOrDefault(params: { passedPersonalDetails?: Partial | null; defaultValue?: string; shouldFallbackToHidden?: boolean; shouldAddCurrentUserPostfix: true; youAfterTranslation?: string; - areTranslationsLoading: false; + translate: LocalizedTranslate; }): string; function temporaryGetDisplayNameOrDefault(params: { passedPersonalDetails?: Partial | null; defaultValue?: string; shouldFallbackToHidden?: boolean; shouldAddCurrentUserPostfix?: false; - areTranslationsLoading: boolean | undefined; + translate: LocalizedTranslate; }): string; function temporaryGetDisplayNameOrDefault({ passedPersonalDetails, @@ -129,21 +121,17 @@ function temporaryGetDisplayNameOrDefault({ shouldFallbackToHidden = true, shouldAddCurrentUserPostfix = false, youAfterTranslation, - areTranslationsLoading, + translate, }: { passedPersonalDetails?: Partial | null; defaultValue?: string; shouldFallbackToHidden?: boolean; shouldAddCurrentUserPostfix?: boolean; youAfterTranslation?: string; - areTranslationsLoading: boolean | undefined; + translate: LocalizedTranslate; }): string { - let temporaryHiddenTranslation = ''; - let temporaryYouTranslation = ''; - if (areTranslationsLoading === false) { - temporaryHiddenTranslation = translateLocal('common.hidden'); - temporaryYouTranslation = translateLocal('common.you').toLowerCase(); - } + const temporaryHiddenTranslation = translate('common.hidden'); + const temporaryYouTranslation = translate('common.you').toLowerCase(); let displayName = passedPersonalDetails?.displayName ?? ''; let login = passedPersonalDetails?.login ?? ''; diff --git a/tests/unit/libs/PersonalDetailsUtilsTest.ts b/tests/unit/libs/PersonalDetailsUtilsTest.ts index c9e4bfb8d91f..0a244dcf9508 100644 --- a/tests/unit/libs/PersonalDetailsUtilsTest.ts +++ b/tests/unit/libs/PersonalDetailsUtilsTest.ts @@ -1,5 +1,6 @@ import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; import { arePersonalDetailsMissing, areTravelPersonalDetailsMissing, @@ -17,15 +18,20 @@ import type {PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from import {formatPhoneNumber} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; -jest.mock('@libs/Localize', () => ({ - translateLocal: jest.fn((phraseKey: string) => { - if (phraseKey === 'common.hidden') { - return 'Hidden'; - } - if (phraseKey === 'common.you') { - return 'You'; - } - return phraseKey; +const mockTranslate = jest.fn((key: string) => { + if (key === 'common.hidden') { + return 'Hidden'; + } + if (key === 'common.you') { + return 'you'; + } + return key; +}); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({ + translate: mockTranslate, }), })); @@ -719,11 +725,13 @@ describe('PersonalDetailsUtils', () => { }); describe('temporaryGetDisplayNameOrDefault', () => { + const {translate} = useLocalize(); + test('should return displayName when present', () => { expect( temporaryGetDisplayNameOrDefault({ passedPersonalDetails: {accountID: 1, displayName: 'Ada Lovelace', login: 'ada@example.com'}, - areTranslationsLoading: false, + translate, }), ).toBe('Ada Lovelace'); }); @@ -736,7 +744,7 @@ describe('PersonalDetailsUtils', () => { displayName: 'MERGED_99@visible.name@example.com', login: 'user@example.com', }, - areTranslationsLoading: false, + translate, }), ).toBe('visible.name@example.com'); }); @@ -750,17 +758,17 @@ describe('PersonalDetailsUtils', () => { login: smsLogin, displayName: smsLogin, }, - areTranslationsLoading: false, + translate, }), ).toBe(Str.removeSMSDomain(smsLogin)); }); - test('should append current-user postfix using localized "you" when translations are ready', () => { + test('should append current-user postfix using localized "you"', () => { expect( temporaryGetDisplayNameOrDefault({ passedPersonalDetails: {accountID: 1, displayName: 'Sam', login: 'sam@example.com'}, shouldAddCurrentUserPostfix: true, - areTranslationsLoading: false, + translate, }), ).toBe('Sam (you)'); }); @@ -771,7 +779,7 @@ describe('PersonalDetailsUtils', () => { passedPersonalDetails: {accountID: 1, displayName: 'Sam', login: 'sam@example.com'}, shouldAddCurrentUserPostfix: true, youAfterTranslation: 'anotherYou', - areTranslationsLoading: false, + translate, }), ).toBe('Sam (anotherYou)'); }); @@ -784,7 +792,7 @@ describe('PersonalDetailsUtils', () => { displayName: 'Ignored', login: CONST.EMAIL.CONCIERGE, }, - areTranslationsLoading: false, + translate, }), ).toBe(CONST.CONCIERGE_DISPLAY_NAME); }); @@ -794,7 +802,7 @@ describe('PersonalDetailsUtils', () => { temporaryGetDisplayNameOrDefault({ passedPersonalDetails: {accountID: 1, login: 'only@example.com'}, defaultValue: 'Custom default', - areTranslationsLoading: false, + translate, }), ).toBe('Custom default'); }); @@ -803,41 +811,26 @@ describe('PersonalDetailsUtils', () => { expect( temporaryGetDisplayNameOrDefault({ passedPersonalDetails: {accountID: 1, login: 'fallback@example.com'}, - areTranslationsLoading: false, + translate, }), ).toBe('fallback@example.com'); }); - test('should return hidden translation when nothing else applies and translations are ready', () => { + test('should return hidden translation when nothing else applies', () => { expect( temporaryGetDisplayNameOrDefault({ passedPersonalDetails: {accountID: 1}, - areTranslationsLoading: false, + translate, }), ).toBe('Hidden'); }); - test('should return empty string for hidden fallback when translations are not ready', () => { - expect( - temporaryGetDisplayNameOrDefault({ - passedPersonalDetails: {accountID: 1}, - areTranslationsLoading: true, - }), - ).toBe(''); - expect( - temporaryGetDisplayNameOrDefault({ - passedPersonalDetails: {accountID: 1}, - areTranslationsLoading: undefined, - }), - ).toBe(''); - }); - test('should return empty string when shouldFallbackToHidden is false and nothing else applies', () => { expect( temporaryGetDisplayNameOrDefault({ passedPersonalDetails: {accountID: 1}, shouldFallbackToHidden: false, - areTranslationsLoading: false, + translate, }), ).toBe(''); }); From 2916acc5cef626cc5dfea626ac7461993bed7a8d Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Tue, 12 May 2026 21:10:42 +0700 Subject: [PATCH 029/519] Update eslint.seatbelt.tsv --- config/eslint/eslint.seatbelt.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 787a511eddeb..5c3ca5d202dd 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -296,7 +296,7 @@ "../../src/libs/OptionsListUtils/index.ts" "rulesdir/no-onyx-connect" 3 "../../src/libs/OptionsListUtils/searchMatchUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 2 "../../src/libs/Parser.ts" "rulesdir/no-onyx-connect" 2 -"../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 5 +"../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 3 "../../src/libs/PersonalDetailsUtils.ts" "rulesdir/no-onyx-connect" 2 "../../src/libs/Pusher/index.native.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/libs/Pusher/index.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 From 051195eddff49e8b63b7c51003315c89db99f792 Mon Sep 17 00:00:00 2001 From: "Jules (via MelvinBot)" Date: Wed, 20 May 2026 00:07:33 +0000 Subject: [PATCH 030/519] Upgrade claude-review workflow model to Claude Opus 4.7 Update both code review and docs review steps from claude-opus-4-6 to claude-opus-4-7. Co-authored-by: Jules --- .github/workflows/claude-review.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index d85e40a0dc76..28ede786ef4e 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -95,7 +95,7 @@ jobs: allowed_non_write_users: "*" prompt: "/review-code-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}" claude_args: | - --model claude-opus-4-6 + --model claude-opus-4-7 --allowedTools "Task,Glob,Grep,Read,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(check-compiler.sh:*)" --json-schema '${{ steps.toolkit.outputs.schema_json }}' - name: Post code review results @@ -130,7 +130,7 @@ jobs: allowed_non_write_users: "*" prompt: "/review-helpdot-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}" claude_args: | - --model claude-opus-4-6 + --model claude-opus-4-7 --allowedTools "Task,Glob,Grep,Read,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),mcp__github_inline_comment__create_inline_comment" - name: Remove in-progress indicator From d8fbe7e29f2737ed21c53d7d12de5fe368784e13 Mon Sep 17 00:00:00 2001 From: Nabi Date: Thu, 21 May 2026 09:44:00 +0430 Subject: [PATCH 031/519] Fix offline video handling in chat preview --- .../Attachments/AttachmentView/index.tsx | 5 +-- src/components/VideoPlayerPreview/index.tsx | 32 +++++++++++++------ src/libs/AttachmentUtils.ts | 7 ++-- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index af359840a3da..5fb62f2c9807 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -28,6 +28,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {add as addCachedPDFPaths} from '@libs/actions/CachedPDFPaths'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import {isLocalAttachmentSource} from '@libs/AttachmentUtils'; import {getFileResolution, isHighResolutionImage} from '@libs/fileDownload/FileUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {hasEReceipt, hasReceiptSource, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, isPerDiemRequest} from '@libs/TransactionUtils'; @@ -172,7 +173,7 @@ function AttachmentView({ const [imageError, setImageError] = useState(false); const {isOffline} = useNetwork({onReconnect: () => setImageError(false)}); - const isLocalVideoSource = typeof source === 'string' && CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => source.startsWith(prefix)); + const isLocalVideoSource = typeof source === 'string' && isLocalAttachmentSource(source); const shouldShowOfflineVideoIndicator = isOffline && !!isVideo && typeof source === 'string' && !isLocalVideoSource; useEffect(() => { @@ -379,7 +380,7 @@ function AttachmentView({ return ( source.startsWith(prefix))} + shouldUseSharedVideoElement={!isLocalAttachmentSource(source)} isHovered={isHovered} duration={duration} reportID={reportID} diff --git a/src/components/VideoPlayerPreview/index.tsx b/src/components/VideoPlayerPreview/index.tsx index 5860f016e856..f42491e0777b 100644 --- a/src/components/VideoPlayerPreview/index.tsx +++ b/src/components/VideoPlayerPreview/index.tsx @@ -3,6 +3,7 @@ import type {SourceLoadEventPayload} from 'expo-video'; import React, {useEffect, useState} from 'react'; import type {GestureResponderEvent} from 'react-native'; import {View} from 'react-native'; +import AttachmentOfflineIndicator from '@components/AttachmentOfflineIndicator'; import {useIsOnSearch} from '@components/Search/SearchScopeProvider'; import VideoPlayer from '@components/VideoPlayer'; import IconButton from '@components/VideoPlayer/IconButton'; @@ -10,10 +11,12 @@ import {usePlaybackActionsContext, usePlaybackStateContext} from '@components/Vi import useCheckIfRouteHasRemainedUnchanged from '@hooks/useCheckIfRouteHasRemainedUnchanged'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useThumbnailDimensions from '@hooks/useThumbnailDimensions'; +import {isLocalAttachmentSource} from '@libs/AttachmentUtils'; import getPlatform from '@libs/getPlatform'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; @@ -56,6 +59,7 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi const {currentlyPlayingURL, currentRouteReportID} = usePlaybackStateContext(); const {updateCurrentURLAndReportID} = usePlaybackActionsContext(); const report = useReportOrReportDraft(reportID); + const {isOffline} = useNetwork(); /* This needs to be isSmallScreenWidth because we want to be able to play video in chat (not in attachment modal) when preview is inside an RHP */ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -63,13 +67,15 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi const [isThumbnail, setIsThumbnail] = useState(true); const [webMeasuredDimensions, setWebMeasuredDimensions] = useState(null); + const isLocalVideoSource = isLocalAttachmentSource(videoUrl); + const shouldShowOfflineVideoIndicator = isOffline && !isLocalVideoSource; const measuredDimensions = getPlatform() === CONST.PLATFORM.WEB && videoUrl && webMeasuredDimensions ? webMeasuredDimensions : videoDimensions; const {thumbnailDimensionsStyles} = useThumbnailDimensions(measuredDimensions.width, measuredDimensions.height); const isOnSearch = useIsOnSearch(); const navigation = useNavigation(); useEffect(() => { - if (!videoUrl || getPlatform() !== CONST.PLATFORM.WEB) { + if (!videoUrl || getPlatform() !== CONST.PLATFORM.WEB || shouldShowOfflineVideoIndicator) { return; } const video = document.createElement('video'); @@ -87,7 +93,7 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi return () => { video.src = ''; }; - }, [videoUrl, videoDimensions.width, videoDimensions.height]); + }, [videoUrl, videoDimensions.width, videoDimensions.height, shouldShowOfflineVideoIndicator]); // We want to play the video only when the user is on the page where it was initially rendered const doesUserRemainOnFirstRenderRoute = useCheckIfRouteHasRemainedUnchanged(videoUrl); @@ -105,6 +111,9 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi }; const handleOnPress = () => { + if (shouldShowOfflineVideoIndicator) { + return; + } updateCurrentURLAndReportID(videoUrl, report, reportID); if (isSmallScreenWidth) { onShowModalPress(); @@ -120,20 +129,23 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi if (prevPlaybackKey !== playbackKey) { setPrevPlaybackKey(playbackKey); const isFocused = doesUserRemainOnFirstRenderRoute(); - if (videoUrl === currentlyPlayingURL && reportID === currentRouteReportID && isFocused) { + if (videoUrl === currentlyPlayingURL && reportID === currentRouteReportID && isFocused && !shouldShowOfflineVideoIndicator) { setIsThumbnail(false); } } return ( - {isSmallScreenWidth || isThumbnail || isDeleted ? ( - + {isSmallScreenWidth || isThumbnail || isDeleted || shouldShowOfflineVideoIndicator ? ( + <> + + {shouldShowOfflineVideoIndicator && } + ) : ( source.startsWith(prefix)); +} + +export {getImageCacheFileExtension, isLocalAttachmentSource}; From d35ebc58ffdcbbb5e1b5617db76e5566a3c45dce Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Thu, 21 May 2026 23:55:27 +0500 Subject: [PATCH 032/519] change SingleSelectPopup with MultiSelectPopup in DomainMembersPage --- src/pages/domain/Members/DomainMembersPage.tsx | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/pages/domain/Members/DomainMembersPage.tsx b/src/pages/domain/Members/DomainMembersPage.tsx index 3db45039f0d5..fa5b61b35583 100644 --- a/src/pages/domain/Members/DomainMembersPage.tsx +++ b/src/pages/domain/Members/DomainMembersPage.tsx @@ -8,7 +8,7 @@ import DecisionModal from '@components/DecisionModal'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/DropdownButton'; import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; -import SingleSelectPopup from '@components/Search/FilterDropdowns/SingleSelectPopup'; +import MultiSelectPopup from '@components/Search/FilterDropdowns/MultiSelectPopup'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import Text from '@components/Text'; import useClearSelectedDomainMembersOnMoveComplete from '@hooks/useClearSelectedDomainMembersOnMoveComplete'; @@ -31,7 +31,6 @@ import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; import type {DomainSplitNavigatorParamList} from '@navigation/types'; import BaseDomainMembersPage from '@pages/domain/BaseDomainMembersPage'; -import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -73,26 +72,23 @@ function DomainMembersPage({route}: DomainMembersPageProps) { selector: memberAccountIDsSelector, }); - const {groupPreFilter, groupOptions, selectedGroup, handleGroupChange, dropdownLabel, groups} = useDomainGroupFilter(domainAccountID); + const {groupPreFilter, groupOptions, selectedGroups, handleGroupChange, dropdownLabel, groups} = useDomainGroupFilter(domainAccountID); - const groupPopoverComponent = ({closeOverlay, isExpanded}: PopoverComponentProps) => ( - ( + ); const groupFilterDropdown = - groupOptions.length > 1 ? ( + groupOptions.length > 0 ? ( g.text)} PopoverComponent={groupPopoverComponent} innerStyles={[styles.gap2, shouldUseNarrowLayout && styles.mw100]} wrapperStyle={shouldUseNarrowLayout && styles.w100} From cbc5eb5803c2f31208f014b769c1675d85126d8d Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Fri, 22 May 2026 22:47:47 +0500 Subject: [PATCH 033/519] Refactor role filter to support multi-select functionality in WorkspaceMembersPage --- src/pages/workspace/WorkspaceMembersPage.tsx | 66 ++++++++++---------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 5fb7417590f4..b14dbf8ee239 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -11,10 +11,10 @@ import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericE import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; import MessagesRow from '@components/MessagesRow'; import {ModalActions} from '@components/Modal/Global/ModalContext'; -import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect'; import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/DropdownButton'; import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; -import SingleSelectPopup from '@components/Search/FilterDropdowns/SingleSelectPopup'; +import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; +import MultiSelectPopup from '@components/Search/FilterDropdowns/MultiSelectPopup'; import SearchBar from '@components/SearchBar'; import TableListItem from '@components/SelectionList/ListItem/TableListItem'; import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; @@ -110,14 +110,14 @@ type MemberOption = Omit & { }; const WORKSPACE_MEMBER_FILTER_VALUES = { - ALL: 'all', + MEMBERS: 'members', ADMINS: 'admins', APPROVERS: 'approvers', AUDITORS: 'auditors', } as const; type WorkspaceMemberFilterValue = ValueOf; -type WorkspaceMemberFilterOption = SingleSelectItem; +type WorkspaceMemberFilterOption = MultiSelectItem; function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembersPageProps) { useWorkspaceDocumentTitle(policy?.name, 'common.members'); @@ -134,7 +134,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const prevAccountIDs = usePrevious(accountIDs); const textInputRef = useRef(null); const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); - const [selectedRoleFilter, setSelectedRoleFilter] = useState(null); + const [selectedRoleFilters, setSelectedRoleFilters] = useState([]); const isOfflineAndNoMemberDataAvailable = isEmptyObject(policy?.employeeList) && isOffline; const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const {isAccountLocked} = useLockedAccountState(); @@ -511,7 +511,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers }, []); const sortMembers = useCallback((memberOptions: MemberOption[]) => sortAlphabetically(memberOptions, 'text', localeCompare), [localeCompare]); const roleFilterOptions: WorkspaceMemberFilterOption[] = [ - {text: translate('workspace.people.allMembers'), value: WORKSPACE_MEMBER_FILTER_VALUES.ALL}, + {text: translate('workspace.people.members'), value: WORKSPACE_MEMBER_FILTER_VALUES.MEMBERS}, {text: translate('workspace.people.admins'), value: WORKSPACE_MEMBER_FILTER_VALUES.ADMINS}, {text: translate('workspace.people.approvers'), value: WORKSPACE_MEMBER_FILTER_VALUES.APPROVERS}, ]; @@ -523,34 +523,36 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers }); } - const handleRoleFilterChange = (item: WorkspaceMemberFilterOption | undefined) => { + const handleRoleFilterChange = (items: WorkspaceMemberFilterOption[]) => { setSelectedEmployees([]); - - if (!item || item.value === WORKSPACE_MEMBER_FILTER_VALUES.ALL) { - setSelectedRoleFilter(null); - return; - } - - setSelectedRoleFilter(item); + setSelectedRoleFilters(items); }; const rolePreFilter = (member: MemberOption) => { - if (!selectedRoleFilter) { + if (selectedRoleFilters.length === 0) { return true; } const employee = policy?.employeeList?.[member.login]; - - switch (selectedRoleFilter.value) { - case WORKSPACE_MEMBER_FILTER_VALUES.ADMINS: - return member.login === policy?.owner || employee?.role === CONST.POLICY.ROLE.ADMIN; - case WORKSPACE_MEMBER_FILTER_VALUES.APPROVERS: - return isPolicyApprover(policy, member.login); - case WORKSPACE_MEMBER_FILTER_VALUES.AUDITORS: - return employee?.role === CONST.POLICY.ROLE.AUDITOR; - default: - return true; - } + const isAdmin = member.login === policy?.owner || employee?.role === CONST.POLICY.ROLE.ADMIN; + const isApprover = isPolicyApprover(policy, member.login); + const isAuditor = employee?.role === CONST.POLICY.ROLE.AUDITOR; + const isMember = !isAdmin && !isApprover && !isAuditor; + + return selectedRoleFilters.some(({value}) => { + switch (value) { + case WORKSPACE_MEMBER_FILTER_VALUES.MEMBERS: + return isMember; + case WORKSPACE_MEMBER_FILTER_VALUES.ADMINS: + return isAdmin; + case WORKSPACE_MEMBER_FILTER_VALUES.APPROVERS: + return isApprover; + case WORKSPACE_MEMBER_FILTER_VALUES.AUDITORS: + return isAuditor; + default: + return false; + } + }); }; const [inputValue, setInputValue, filteredData] = useSearchResults(data, filterMember, sortMembers, rolePreFilter); @@ -598,7 +600,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const displayedFilteredData = isFilteringMembers ? debouncedFilteredData : filteredData; const hasNoDisplayedMembers = displayedFilteredData.length === 0; const shouldShowRoleFilter = data.length > 0; - const shouldShowRoleFilterEmptyState = shouldShowRoleFilter && !!selectedRoleFilter && inputValue.length === 0 && hasNoDisplayedMembers; + const shouldShowRoleFilterEmptyState = shouldShowRoleFilter && selectedRoleFilters.length > 0 && inputValue.length === 0 && hasNoDisplayedMembers; const shouldShowEmptySearchMessage = !shouldShowRoleFilterEmptyState && hasNoDisplayedMembers; const noResultsMessage = translate('common.noResultsFoundMatching', inputValue); @@ -608,20 +610,20 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers useDebouncedAccessibilityAnnouncement(noResultsMessage, shouldShowEmptySearchMessage, inputValue); const rolePopoverComponent = ({closeOverlay}: PopoverComponentProps) => ( - ); + const roleFilterDropdownLabel = selectedRoleFilters.length > 0 ? selectedRoleFilters.map(({text}) => text).join(', ') : translate('workspace.people.members'); + const roleFilterDropdown = shouldShowRoleFilter ? ( Date: Fri, 22 May 2026 22:52:06 +0500 Subject: [PATCH 034/519] change allMembers to members in all locals --- src/languages/de.ts | 2 +- src/languages/en.ts | 4 ++-- src/languages/es.ts | 4 ++-- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index d4cc8cb34747..2514990fae67 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6117,7 +6117,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU `Wenn du ${memberName} aus diesem Workspace entfernst, ersetzen wir sie/ihn als technischen Kontakt durch ${workspaceOwner}, den/die Workspace-Inhaber:in.`, cannotRemoveUserDueToReport: ({memberName}: {memberName: string}) => `${memberName} hat einen ausstehenden Bericht in Bearbeitung, zu dem eine Aktion erforderlich ist. Bitte bitten Sie diese Person, die erforderliche Aktion abzuschließen, bevor Sie sie aus dem Workspace entfernen.`, - allMembers: 'Alle Mitglieder', + members: 'Mitglieder', admins: 'Admins', approvers: 'Genehmigende', auditors: 'Prüfer', diff --git a/src/languages/en.ts b/src/languages/en.ts index d4d96ea5bda0..06af4310fe15 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6125,7 +6125,7 @@ const translations = { workspaceMembersCount: (count: number) => `Total workspace members: ${count}`, configureHRSync: (providerName: string) => `Configure ${providerName} sync.`, syncWithHR: (providerName: string) => `Sync with ${providerName}`, - allMembers: 'All members', + members: 'Members', admins: 'Admins', approvers: 'Approvers', auditors: 'Auditors', @@ -9393,7 +9393,7 @@ const translations = { title: 'No members in this group', subtitle: 'Add a member or try changing the filter above.', }, - allMembers: 'All members', + allMembers: 'Members', email: 'Email address', closeAccountPrompt: 'Are you sure? This action is permanent.', forceCloseAccount: () => ({ diff --git a/src/languages/es.ts b/src/languages/es.ts index 3989664a7e72..37e2525409a8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5938,7 +5938,7 @@ ${amount} para ${merchant} - ${date}`, addedWithPrimary: 'Se agregaron algunos miembros con sus nombres de usuario principales.', invitedBySecondaryLogin: (secondaryLogin) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`, workspaceMembersCount: (count) => `Total de miembros del espacio de trabajo: ${count}`, - allMembers: 'Todos los miembros', + members: 'Miembros', admins: 'Administradores', approvers: 'Aprobadores', auditors: 'Auditores', @@ -9552,7 +9552,7 @@ ${amount} para ${merchant} - ${date}`, title: 'No hay miembros en este grupo', subtitle: 'Añade un miembro o intenta cambiar el filtro de arriba.', }, - allMembers: 'Todos los miembros', + allMembers: 'Miembros', email: 'Dirección de correo electrónico', closeAccount: () => ({ one: 'Cerrar cuenta', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 10df819c4dea..069db81ea748 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6136,7 +6136,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. `Si vous supprimez ${memberName} de cet espace de travail, nous le remplacerons en tant que contact technique par ${workspaceOwner}, le responsable de l’espace de travail.`, cannotRemoveUserDueToReport: ({memberName}: {memberName: string}) => `${memberName} a une note de frais en cours de traitement sur laquelle il doit agir. Veuillez lui demander d’effectuer l’action requise avant de le retirer de l’espace de travail.`, - allMembers: 'Tous les membres', + members: 'Membres', admins: 'Administrateurs', approvers: 'Approbateurs', auditors: 'Auditeurs', diff --git a/src/languages/it.ts b/src/languages/it.ts index 771c2195622e..33befd1d1cc1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6106,7 +6106,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. `Se rimuovi ${memberName} da questo spazio di lavoro, lo sostituiremo come referente tecnico con ${workspaceOwner}, il proprietario dello spazio di lavoro.`, cannotRemoveUserDueToReport: ({memberName}: {memberName: string}) => `${memberName} ha un rapporto in sospeso su cui deve intervenire. Chiedi loro di completare l’azione richiesta prima di rimuoverlə dallo spazio di lavoro.`, - allMembers: 'Tutti i membri', + members: 'Membri', admins: 'Amministratori', approvers: 'Approvatori', auditors: 'Revisori', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 76e9b5d8226a..0614a594facf 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6038,7 +6038,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO `このワークスペースから${memberName}を削除すると、技術連絡先はワークスペースのオーナーである${workspaceOwner}に置き換えられます。`, cannotRemoveUserDueToReport: ({memberName}: {memberName: string}) => `${memberName} は、対応が必要な未処理のレポートがあります。ワークスペースから削除する前に、必要な対応を完了するよう依頼してください。`, - allMembers: 'すべてのメンバー', + members: 'メンバー', admins: '管理者', approvers: '承認者', auditors: '監査担当者', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 3781da1a484f..1ed2321e0ad9 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6085,7 +6085,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ `Als je ${memberName} uit deze workspace verwijdert, vervangen we hen als technisch contactpersoon door ${workspaceOwner}, de eigenaar van de workspace.`, cannotRemoveUserDueToReport: ({memberName}: {memberName: string}) => `${memberName} heeft een openstaand rapport in verwerking waarop actie moet worden ondernomen. Vraag hen dit vereiste actiepunt af te ronden voordat je hen uit de workspace verwijdert.`, - allMembers: 'Alle leden', + members: 'Leden', admins: 'Beheerders', approvers: 'Fiatteurs', auditors: 'Accountants', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 0e635c3c4fe7..fcaf1e5913ae 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6079,7 +6079,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy `Jeśli usuniesz ${memberName} z tej przestrzeni roboczej, zastąpimy tę osobę jako kontakt techniczny użytkownikiem ${workspaceOwner}, właścicielem przestrzeni roboczej.`, cannotRemoveUserDueToReport: ({memberName}: {memberName: string}) => `${memberName} ma nierozliczony raport w trakcie przetwarzania, który wymaga działania. Poproś tę osobę o wykonanie wymaganej czynności przed jej usunięciem z przestrzeni roboczej.`, - allMembers: 'Wszyscy członkowie', + members: 'Członkowie', admins: 'Administratorzy', approvers: 'Osoby zatwierdzające', auditors: 'Audytorzy', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 220652ed4b25..6fa5d96b9796 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6085,7 +6085,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS `Se você remover ${memberName} deste workspace, nós vamos substituí-lo como contato técnico por ${workspaceOwner}, o proprietário do workspace.`, cannotRemoveUserDueToReport: ({memberName}: {memberName: string}) => `${memberName} tem um relatório pendente de processamento que requer ação. Peça para que concluam a ação necessária antes de removê-los do workspace.`, - allMembers: 'Todos os membros', + members: 'Membros', admins: 'Administradores', approvers: 'Aprovadores', auditors: 'Auditores', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index da82309ddb86..c0b8250973fb 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5926,7 +5926,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM removeMemberPromptTechContact: ({memberName, workspaceOwner}: {memberName: string; workspaceOwner: string}) => `如果您将 ${memberName} 从此工作区中移除,我们会将其技术联系人替换为工作区所有者 ${workspaceOwner}。`, cannotRemoveUserDueToReport: ({memberName}: {memberName: string}) => `${memberName} 还有一份待处理报告需要处理。请在将其从工作区中移除之前,先让 TA 完成所需操作。`, - allMembers: '所有成员', + members: '成员', admins: '管理员', approvers: '审批人', auditors: '审计员', From ec4fea73ab78abacb029452298a793ef1097ec87 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Fri, 22 May 2026 23:15:27 +0500 Subject: [PATCH 035/519] Set DropdownButton value to null in DomainMembersPage --- src/pages/domain/Members/DomainMembersPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/domain/Members/DomainMembersPage.tsx b/src/pages/domain/Members/DomainMembersPage.tsx index fa5b61b35583..5a69f05c0d9d 100644 --- a/src/pages/domain/Members/DomainMembersPage.tsx +++ b/src/pages/domain/Members/DomainMembersPage.tsx @@ -88,7 +88,7 @@ function DomainMembersPage({route}: DomainMembersPageProps) { groupOptions.length > 0 ? ( g.text)} + value={null} PopoverComponent={groupPopoverComponent} innerStyles={[styles.gap2, shouldUseNarrowLayout && styles.mw100]} wrapperStyle={shouldUseNarrowLayout && styles.w100} From a0b8fa00e0d5a4c3dffb5bb4f91a3bb9c3b1b689 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 23 May 2026 00:18:06 +0500 Subject: [PATCH 036/519] Refactor useDomainGroupFilter to support multi-select functionality for group selection --- src/hooks/useDomainGroupFilter.ts | 87 ++++++++++++++----------------- 1 file changed, 38 insertions(+), 49 deletions(-) diff --git a/src/hooks/useDomainGroupFilter.ts b/src/hooks/useDomainGroupFilter.ts index 062114fbc408..7c248af9bd21 100644 --- a/src/hooks/useDomainGroupFilter.ts +++ b/src/hooks/useDomainGroupFilter.ts @@ -1,33 +1,28 @@ import {groupsSelector} from '@selectors/Domain'; import type {DomainSecurityGroupWithID} from '@selectors/Domain'; import {useEffect, useState} from 'react'; -import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect'; +import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; import type {MemberOption} from '@pages/domain/BaseDomainMembersPage'; import ONYXKEYS from '@src/ONYXKEYS'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; -const ALL_MEMBERS_VALUE = 'all'; - type UseDomainGroupFilterResult = { - /** Pre-filter function for useSearchResults that filters members by the selected group. */ + /** Pre-filter function for useSearchResults that filters members by the selected groups. */ groupPreFilter: (item: MemberOption) => boolean; - /** All group dropdown options including the "All Members" entry. */ - groupOptions: Array>; + /** All group dropdown options. */ + groupOptions: Array>; - /** The currently selected group, or null when "All Members" is active. */ - selectedGroup: SingleSelectItem | null; + /** The currently selected groups. */ + selectedGroups: Array>; - /** Handler for when the user picks a different group in the dropdown. */ - handleGroupChange: (item: SingleSelectItem | undefined) => void; + /** Handler for when the user changes the group selection. */ + handleGroupChange: (items: Array>) => void; /** Display label for the dropdown button. */ dropdownLabel: string; - /** Translated "All Members" label (useful as the default value for SingleSelectPopup). */ - allMembersLabel: string; - /** The raw security groups from Onyx, needed for per-row group name display. */ groups: DomainSecurityGroupWithID[] | undefined; }; @@ -37,55 +32,49 @@ function useDomainGroupFilter(domainAccountID: number): UseDomainGroupFilterResu const [groups] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: groupsSelector}); - const [selectedGroup, setSelectedGroup] = useState | null>(null); - - const allMembersLabel = translate('domain.members.allMembers'); + const [selectedGroups, setSelectedGroups] = useState>>([]); - const groupOptions: Array> = [ - {text: allMembersLabel, value: ALL_MEMBERS_VALUE}, - ...(groups ?? []).map((group) => ({text: group.details.name ?? '', value: group.id})), - ]; + const groupOptions: Array> = (groups ?? []).map((group) => ({text: group.details.name ?? '', value: group.id})); - const matchedGroup = selectedGroup && selectedGroup.value !== ALL_MEMBERS_VALUE ? groups?.find((g) => g.id === selectedGroup.value) : undefined; - - // If the selected group disappears from Onyx (e.g. during rollback/refresh), clear the - // selection from state so it cannot silently reactivate if the same group ID reappears later. + // If any selected groups disappear from Onyx (e.g. during rollback/refresh), remove them + // from state so they cannot silently reactivate if the same group ID reappears later. useEffect(() => { - if (!selectedGroup || selectedGroup.value === ALL_MEMBERS_VALUE || matchedGroup) { + if (selectedGroups.length === 0) { return; } - setSelectedGroup(null); - }, [matchedGroup, selectedGroup]); - - const effectiveSelection = matchedGroup ? selectedGroup : null; - - const selectedGroupMemberIDs = matchedGroup - ? new Set( - Object.keys(matchedGroup.details.shared) - .map(Number) - .filter((id) => !Number.isNaN(id)), - ) - : null; + const valid = selectedGroups.filter((selectedGroup) => groups?.some((group) => group.id === selectedGroup.value)); + if (valid.length !== selectedGroups.length) { + setSelectedGroups(valid); + } + }, [groups, selectedGroups]); + + let selectedGroupMemberIDs: Set | null = null; + if (selectedGroups.length > 0) { + selectedGroupMemberIDs = new Set(); + for (const selectedGroup of selectedGroups) { + const securityGroup = groups?.find((group) => group.id === selectedGroup.value); + if (!securityGroup) { + continue; + } + for (const memberKey of Object.keys(securityGroup.details.shared)) { + const memberID = Number(memberKey); + if (!Number.isNaN(memberID)) { + selectedGroupMemberIDs.add(memberID); + } + } + } + } const groupPreFilter = (item: MemberOption) => !selectedGroupMemberIDs || selectedGroupMemberIDs.has(item.accountID); - const handleGroupChange = (item: SingleSelectItem | undefined) => { - if (!item || item.value === ALL_MEMBERS_VALUE) { - setSelectedGroup(null); - } else { - setSelectedGroup(item); - } - }; - - const dropdownLabel = effectiveSelection?.text ?? allMembersLabel; + const dropdownLabel = selectedGroups.length > 0 ? selectedGroups.map((g) => g.text).join(', ') : translate('workspace.common.members'); return { groupPreFilter, groupOptions, - selectedGroup: effectiveSelection, - handleGroupChange, + selectedGroups, + handleGroupChange: setSelectedGroups, dropdownLabel, - allMembersLabel, groups, }; } From 79d8a86d9d5f3e457b0fbce45c9f4bd910834566 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 23 May 2026 01:02:48 +0500 Subject: [PATCH 037/519] Update innerStyles in WorkspaceMembersPage to ensure consistent layout --- src/pages/workspace/WorkspaceMembersPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index b14dbf8ee239..f03ee51b4dfb 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -626,7 +626,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers label={roleFilterDropdownLabel} value={null} PopoverComponent={rolePopoverComponent} - innerStyles={[styles.gap2, shouldUseNarrowLayout && styles.mw100]} + innerStyles={[styles.gap2, styles.mw100]} wrapperStyle={shouldUseNarrowLayout ? styles.flexGrow0 : undefined} labelStyle={styles.fontSizeLabel} caretWrapperStyle={styles.gap2} From caa597265b79e1a25d41b399eca1068334c3d35c Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 23 May 2026 02:37:23 +0500 Subject: [PATCH 038/519] Refactor useDomainGroupFilter tests to support multi-select functionality and update related tests --- tests/unit/hooks/useDomainGroupFilter.test.ts | 183 +++++++++++------- 1 file changed, 115 insertions(+), 68 deletions(-) diff --git a/tests/unit/hooks/useDomainGroupFilter.test.ts b/tests/unit/hooks/useDomainGroupFilter.test.ts index 7a634a25926e..8e564d2ef287 100644 --- a/tests/unit/hooks/useDomainGroupFilter.test.ts +++ b/tests/unit/hooks/useDomainGroupFilter.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {act, renderHook, waitFor} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; +import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; import useDomainGroupFilter from '@hooks/useDomainGroupFilter'; import type {MemberOption} from '@pages/domain/BaseDomainMembersPage'; import CONST from '@src/CONST'; @@ -55,31 +56,29 @@ describe('useDomainGroupFilter', () => { }); describe('initial state', () => { - it('should return null selectedGroup initially', () => { + it('should return empty selectedGroups initially', () => { const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); - expect(result.current.selectedGroup).toBeNull(); + expect(result.current.selectedGroups).toEqual([]); }); - it('should return "All Members" as the dropdown label initially', () => { + it('should return the default dropdown label when no group is selected', () => { const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); - expect(result.current.dropdownLabel).toBe(result.current.allMembersLabel); + expect(result.current.dropdownLabel).toEqual(expect.any(String)); }); - it('should include "All Members" as the first group option', () => { + it('should return empty groupOptions when no groups exist in Onyx', () => { const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); - expect(result.current.groupOptions.at(0)?.value).toBe('all'); + expect(result.current.groupOptions).toHaveLength(0); }); }); describe('groupOptions', () => { - it('should contain only "All Members" when no groups exist in Onyx', () => { + it('should return empty array when no groups exist in Onyx', () => { const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); - - expect(result.current.groupOptions).toHaveLength(1); - expect(result.current.groupOptions?.at(0)?.value).toBe('all'); + expect(result.current.groupOptions).toHaveLength(0); }); - it('should list all security groups from the domain after the "All Members" entry', async () => { + it('should list all security groups from the domain', async () => { const domain = buildDomain({ '1': {members: {'100': 'read', '200': 'read'}, name: 'Engineering'}, '2': {members: {'300': 'read'}, name: 'Marketing'}, @@ -89,12 +88,11 @@ describe('useDomainGroupFilter', () => { const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); await waitFor(() => { - expect(result.current.groupOptions).toHaveLength(3); + expect(result.current.groupOptions).toHaveLength(2); }); - expect(result.current.groupOptions.at(0)?.value).toBe('all'); - expect(result.current.groupOptions.at(1)).toEqual({text: 'Engineering', value: '1'}); - expect(result.current.groupOptions.at(2)).toEqual({text: 'Marketing', value: '2'}); + expect(result.current.groupOptions.at(0)).toEqual({text: 'Engineering', value: '1'}); + expect(result.current.groupOptions.at(1)).toEqual({text: 'Marketing', value: '2'}); }); }); @@ -106,7 +104,7 @@ describe('useDomainGroupFilter', () => { expect(result.current.groupPreFilter(buildMemberOption(999))).toBe(true); }); - it('should filter members to the selected group', async () => { + it('should filter members to a single selected group', async () => { const domain = buildDomain({ '1': {members: {'100': 'read', '200': 'read'}, name: 'Engineering'}, '2': {members: {'300': 'read'}, name: 'Marketing'}, @@ -116,11 +114,11 @@ describe('useDomainGroupFilter', () => { const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); await waitFor(() => { - expect(result.current.groupOptions).toHaveLength(3); + expect(result.current.groupOptions).toHaveLength(2); }); act(() => { - result.current.handleGroupChange({text: 'Engineering', value: '1'}); + result.current.handleGroupChange([{text: 'Engineering', value: '1'}]); }); expect(result.current.groupPreFilter(buildMemberOption(100))).toBe(true); @@ -128,33 +126,35 @@ describe('useDomainGroupFilter', () => { expect(result.current.groupPreFilter(buildMemberOption(300))).toBe(false); }); - it('should allow all members again after switching back to "All Members"', async () => { + it('should show the union of members when multiple groups are selected', async () => { const domain = buildDomain({ - '1': {members: {'100': 'read'}, name: 'Group 1'}, - '2': {members: {'200': 'read'}, name: 'Group 2'}, + '1': {members: {'100': 'read'}, name: 'Engineering'}, + '2': {members: {'200': 'read'}, name: 'Marketing'}, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); await waitFor(() => { - expect(result.current.groupOptions).toHaveLength(3); + expect(result.current.groupOptions).toHaveLength(2); }); act(() => { - result.current.handleGroupChange({text: 'Group 1', value: '1'}); + result.current.handleGroupChange([ + {text: 'Engineering', value: '1'}, + {text: 'Marketing', value: '2'}, + ]); }); - expect(result.current.groupPreFilter(buildMemberOption(200))).toBe(false); - act(() => { - result.current.handleGroupChange({text: 'All Members', value: 'all'}); - }); + expect(result.current.groupPreFilter(buildMemberOption(100))).toBe(true); expect(result.current.groupPreFilter(buildMemberOption(200))).toBe(true); + expect(result.current.groupPreFilter(buildMemberOption(999))).toBe(false); }); - it('should allow all members and reset selection when group is not found', async () => { + it('should allow all members again after clearing the selection', async () => { const domain = buildDomain({ '1': {members: {'100': 'read'}, name: 'Group 1'}, + '2': {members: {'200': 'read'}, name: 'Group 2'}, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); @@ -165,18 +165,19 @@ describe('useDomainGroupFilter', () => { }); act(() => { - result.current.handleGroupChange({text: 'Nonexistent', value: 'doesNotExist'}); + result.current.handleGroupChange([{text: 'Group 1', value: '1'}]); }); + expect(result.current.groupPreFilter(buildMemberOption(200))).toBe(false); - expect(result.current.groupPreFilter(buildMemberOption(100))).toBe(true); - expect(result.current.groupPreFilter(buildMemberOption(999))).toBe(true); - expect(result.current.selectedGroup).toBeNull(); - expect(result.current.dropdownLabel).toBe(result.current.allMembersLabel); + act(() => { + result.current.handleGroupChange([]); + }); + expect(result.current.groupPreFilter(buildMemberOption(200))).toBe(true); }); }); describe('handleGroupChange', () => { - it('should set selectedGroup when a specific group is chosen', async () => { + it('should set selectedGroups when groups are chosen', async () => { const domain = buildDomain({ '1': {members: {'100': 'read'}, name: 'Engineering'}, }); @@ -185,19 +186,20 @@ describe('useDomainGroupFilter', () => { const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); await waitFor(() => { - expect(result.current.groupOptions).toHaveLength(2); + expect(result.current.groupOptions).toHaveLength(1); }); act(() => { - result.current.handleGroupChange({text: 'Engineering', value: '1'}); + result.current.handleGroupChange([{text: 'Engineering', value: '1'}]); }); - expect(result.current.selectedGroup).toEqual({text: 'Engineering', value: '1'}); + expect(result.current.selectedGroups).toEqual([{text: 'Engineering', value: '1'}]); }); - it('should clear selectedGroup when "All Members" is chosen', async () => { + it('should support selecting multiple groups simultaneously', async () => { const domain = buildDomain({ '1': {members: {'100': 'read'}, name: 'Engineering'}, + '2': {members: {'200': 'read'}, name: 'Marketing'}, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); @@ -207,18 +209,19 @@ describe('useDomainGroupFilter', () => { expect(result.current.groupOptions).toHaveLength(2); }); + const selection: Array> = [ + {text: 'Engineering', value: '1'}, + {text: 'Marketing', value: '2'}, + ]; act(() => { - result.current.handleGroupChange({text: 'Engineering', value: '1'}); + result.current.handleGroupChange(selection); }); - expect(result.current.selectedGroup).not.toBeNull(); - act(() => { - result.current.handleGroupChange({text: 'All Members', value: 'all'}); - }); - expect(result.current.selectedGroup).toBeNull(); + expect(result.current.selectedGroups).toHaveLength(2); + expect(result.current.selectedGroups).toEqual(selection); }); - it('should clear selectedGroup when null is passed', async () => { + it('should clear selectedGroups when an empty array is passed', async () => { const domain = buildDomain({ '1': {members: {'100': 'read'}, name: 'Engineering'}, }); @@ -227,25 +230,44 @@ describe('useDomainGroupFilter', () => { const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); await waitFor(() => { - expect(result.current.groupOptions).toHaveLength(2); + expect(result.current.groupOptions).toHaveLength(1); }); act(() => { - result.current.handleGroupChange({text: 'Engineering', value: '1'}); + result.current.handleGroupChange([{text: 'Engineering', value: '1'}]); }); - expect(result.current.selectedGroup).not.toBeNull(); + expect(result.current.selectedGroups).toHaveLength(1); act(() => { - result.current.handleGroupChange(undefined); + result.current.handleGroupChange([]); }); - expect(result.current.selectedGroup).toBeNull(); + expect(result.current.selectedGroups).toHaveLength(0); }); }); describe('dropdownLabel', () => { - it('should show "All Members" label when no group is selected', () => { + it('should show the default label when no group is selected', async () => { + const domain = buildDomain({ + '1': {members: {'100': 'read'}, name: 'Engineering'}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); + const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); - expect(result.current.dropdownLabel).toBe(result.current.allMembersLabel); + const defaultLabel = result.current.dropdownLabel; + + await waitFor(() => { + expect(result.current.groupOptions).toHaveLength(1); + }); + + act(() => { + result.current.handleGroupChange([{text: 'Engineering', value: '1'}]); + }); + expect(result.current.dropdownLabel).not.toBe(defaultLabel); + + act(() => { + result.current.handleGroupChange([]); + }); + expect(result.current.dropdownLabel).toBe(defaultLabel); }); it('should show the selected group name as the label', async () => { @@ -257,21 +279,22 @@ describe('useDomainGroupFilter', () => { const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); await waitFor(() => { - expect(result.current.groupOptions).toHaveLength(2); + expect(result.current.groupOptions).toHaveLength(1); }); act(() => { - result.current.handleGroupChange({text: 'Engineering', value: '1'}); + result.current.handleGroupChange([{text: 'Engineering', value: '1'}]); }); expect(result.current.dropdownLabel).toBe('Engineering'); }); - it('should show "All Members" label when the selected group is removed from Onyx', async () => { + it('should show comma-joined names when multiple groups are selected', async () => { const domain = buildDomain({ '1': {members: {'100': 'read'}, name: 'Engineering'}, + '2': {members: {'200': 'read'}, name: 'Marketing'}, }); - await Onyx.set(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); + await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); @@ -280,10 +303,32 @@ describe('useDomainGroupFilter', () => { }); act(() => { - result.current.handleGroupChange({text: 'Engineering', value: '1'}); + result.current.handleGroupChange([ + {text: 'Engineering', value: '1'}, + {text: 'Marketing', value: '2'}, + ]); + }); + + expect(result.current.dropdownLabel).toBe('Engineering, Marketing'); + }); + + it('should revert to the default label when selected groups are removed from Onyx', async () => { + const domain = buildDomain({ + '1': {members: {'100': 'read'}, name: 'Engineering'}, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); + + const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); + const defaultLabel = result.current.dropdownLabel; + + await waitFor(() => { + expect(result.current.groupOptions).toHaveLength(1); + }); + + act(() => { + result.current.handleGroupChange([{text: 'Engineering', value: '1'}]); }); expect(result.current.dropdownLabel).toBe('Engineering'); - expect(result.current.selectedGroup).not.toBeNull(); // Replace the domain with one that no longer has the selected group const updatedDomain = buildDomain({ @@ -295,8 +340,8 @@ describe('useDomainGroupFilter', () => { expect(result.current.groupOptions.find((o) => o.value === '1')).toBeUndefined(); }); - expect(result.current.dropdownLabel).toBe(result.current.allMembersLabel); - expect(result.current.selectedGroup).toBeNull(); + expect(result.current.dropdownLabel).toBe(defaultLabel); + expect(result.current.selectedGroups).toHaveLength(0); expect(result.current.groupPreFilter(buildMemberOption(100))).toBe(true); }); @@ -307,35 +352,36 @@ describe('useDomainGroupFilter', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); + const defaultLabel = result.current.dropdownLabel; await waitFor(() => { - expect(result.current.groupOptions).toHaveLength(2); + expect(result.current.groupOptions).toHaveLength(1); }); // Select the group act(() => { - result.current.handleGroupChange({text: 'Engineering', value: '1'}); + result.current.handleGroupChange([{text: 'Engineering', value: '1'}]); }); - expect(result.current.selectedGroup).not.toBeNull(); + expect(result.current.selectedGroups).toHaveLength(1); // Group disappears from Onyx (e.g. optimistic update removed or data cleared) await Onyx.set(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, buildDomain({})); await waitFor(() => { - expect(result.current.selectedGroup).toBeNull(); + expect(result.current.selectedGroups).toHaveLength(0); }); - expect(result.current.dropdownLabel).toBe(result.current.allMembersLabel); + expect(result.current.dropdownLabel).toBe(defaultLabel); // Group reappears with the same ID (rollback / re-sync) await Onyx.set(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); await waitFor(() => { - expect(result.current.groupOptions).toHaveLength(2); + expect(result.current.groupOptions).toHaveLength(1); }); // Filter must remain inactive — the previous selection was cleared from state - expect(result.current.selectedGroup).toBeNull(); - expect(result.current.dropdownLabel).toBe(result.current.allMembersLabel); + expect(result.current.selectedGroups).toHaveLength(0); + expect(result.current.dropdownLabel).toBe(defaultLabel); expect(result.current.groupPreFilter(buildMemberOption(100))).toBe(true); expect(result.current.groupPreFilter(buildMemberOption(999))).toBe(true); }); @@ -346,6 +392,7 @@ describe('useDomainGroupFilter', () => { const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); expect(result.current.groups).toEqual([]); }); + it('should return parsed security groups from Onyx', async () => { const domain = buildDomain({ '1': {members: {'100': 'read'}, name: 'Engineering'}, From a7a73fa931dbaff0e82e38ff5a850e97203225d7 Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Mon, 25 May 2026 15:21:30 +0700 Subject: [PATCH 039/519] remove overloaded calls --- src/libs/PersonalDetailsUtils.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index d90068424f5f..952f720e3e30 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -102,21 +102,6 @@ function getDisplayNameOrDefault( return shouldFallbackToHidden ? hiddenTranslation : ''; } -function temporaryGetDisplayNameOrDefault(params: { - passedPersonalDetails?: Partial | null; - defaultValue?: string; - shouldFallbackToHidden?: boolean; - shouldAddCurrentUserPostfix: true; - youAfterTranslation?: string; - translate: LocalizedTranslate; -}): string; -function temporaryGetDisplayNameOrDefault(params: { - passedPersonalDetails?: Partial | null; - defaultValue?: string; - shouldFallbackToHidden?: boolean; - shouldAddCurrentUserPostfix?: false; - translate: LocalizedTranslate; -}): string; function temporaryGetDisplayNameOrDefault({ passedPersonalDetails, defaultValue = '', From 845f216ec88b889ac6cb039a0410dafcb46582c6 Mon Sep 17 00:00:00 2001 From: "Sahil (via MelvinBot)" Date: Mon, 25 May 2026 14:59:03 +0000 Subject: [PATCH 040/519] Fix offline expense details not displaying after IOU payment When paying an IOU offline and then opening the expense from the workspace chat, the total showed as loading and no expense details were displayed. Three issues contributed: 1. createWorkspaceFromIOUPayment did not set parentReportID on the expense report to point to the new workspace chat, causing "Not Found" on large screens when the parent action lookup failed. 2. ReportFetchHandler's transaction thread creation guard blocked the offline fallback because hasOnceLoadedReportActions is only set on API success, which never fires offline. Added isOffline awareness so the thread can be created from locally available data. 3. convertIOUReportToExpenseReport did not set iouReportID on the new policy expense chat, preventing the expense detail view from resolving the report. Co-authored-by: Sahil --- src/libs/actions/Policy/Policy.ts | 1 + src/libs/actions/Report/index.ts | 18 ++++++++++++++++++ src/pages/inbox/ReportFetchHandler.tsx | 4 ++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 6932cf9459e3..8e0e0602c5dc 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4451,6 +4451,7 @@ function createWorkspaceFromIOUPayment( const expenseReport = { ...iouReport, chatReportID: memberData.workspaceChatReportID, + parentReportID: memberData.workspaceChatReportID, policyID, policyName: workspaceName, type: CONST.REPORT.TYPE.EXPENSE, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 1b91d15105a8..24b39eb99bef 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -6881,6 +6881,24 @@ function convertIOUReportToExpenseReport(iouReport: Report, policy: Policy, poli }, }); + // Attach the moved report to the destination policy expense chat so the expense detail view + // can resolve the report via iouReportID when navigated to offline. + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticPolicyExpenseChatReportID}`, + value: { + iouReportID: reportID, + hasOutstandingChildRequest: !isReportManuallyReimbursed(iouReport), + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticPolicyExpenseChatReportID}`, + value: { + iouReportID: null, + }, + }); + // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved const movedReportAction = buildOptimisticMovedReportAction(iouReport.policyID, policyID, optimisticPolicyExpenseChatReportID, reportID, policy.name); optimisticData.push({ diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 54456375f6b0..7e535677b68d 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -208,13 +208,13 @@ function ReportFetchHandler() { if ( transactionThreadReportID !== CONST.FAKE_REPORT_ID || transactionThreadReport?.reportID || - (!reportLoadingState.hasOnceLoadedReportActions && !reportMetadata?.isOptimisticReport) + (!reportLoadingState.hasOnceLoadedReportActions && !reportMetadata?.isOptimisticReport && !isOffline) ) { return; } createOneTransactionThread(); - }, [reportLoadingState.hasOnceLoadedReportActions, reportMetadata?.isOptimisticReport, transactionThreadReport?.reportID, transactionThreadReportID]); + }, [reportLoadingState.hasOnceLoadedReportActions, reportMetadata?.isOptimisticReport, transactionThreadReport?.reportID, transactionThreadReportID, isOffline]); useEffect(() => { if (isLoadingReportData || !prevIsLoadingReportData || !prevIsAnonymousUser.current || isAnonymousUser) { From 6b3392ca8ea449ec3aa295e188d61017aaf6e40d Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 26 May 2026 00:48:37 +0500 Subject: [PATCH 041/519] update dropdown labels to include context for group and role filters prefix --- src/hooks/useDomainGroupFilter.ts | 2 +- src/pages/workspace/WorkspaceMembersPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useDomainGroupFilter.ts b/src/hooks/useDomainGroupFilter.ts index 7c248af9bd21..76b87b25112b 100644 --- a/src/hooks/useDomainGroupFilter.ts +++ b/src/hooks/useDomainGroupFilter.ts @@ -67,7 +67,7 @@ function useDomainGroupFilter(domainAccountID: number): UseDomainGroupFilterResu const groupPreFilter = (item: MemberOption) => !selectedGroupMemberIDs || selectedGroupMemberIDs.has(item.accountID); - const dropdownLabel = selectedGroups.length > 0 ? selectedGroups.map((g) => g.text).join(', ') : translate('workspace.common.members'); + const dropdownLabel = `${translate('common.group')}: ${selectedGroups.length > 0 ? selectedGroups.map((g) => g.text).join(', ') : translate('common.all')}`; return { groupPreFilter, diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 51ca97ddc979..69ee764cb875 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -665,7 +665,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers /> ); - const roleFilterDropdownLabel = selectedRoleFilters.length > 0 ? selectedRoleFilters.map(({text}) => text).join(', ') : translate('workspace.people.members'); + const roleFilterDropdownLabel = `${translate('common.role')}: ${selectedRoleFilters.length > 0 ? selectedRoleFilters.map(({text}) => text).join(', ') : translate('common.all')}`; const roleFilterDropdown = shouldShowRoleFilter ? ( Date: Tue, 26 May 2026 01:15:54 +0500 Subject: [PATCH 042/519] add LocaleContextProvider wrapper to useDomainGroupFilter tests and update dropdown label format --- tests/unit/hooks/useDomainGroupFilter.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/unit/hooks/useDomainGroupFilter.test.ts b/tests/unit/hooks/useDomainGroupFilter.test.ts index 8e564d2ef287..6228f46a25c5 100644 --- a/tests/unit/hooks/useDomainGroupFilter.test.ts +++ b/tests/unit/hooks/useDomainGroupFilter.test.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {act, renderHook, waitFor} from '@testing-library/react-native'; +import React from 'react'; import Onyx from 'react-native-onyx'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; import useDomainGroupFilter from '@hooks/useDomainGroupFilter'; import type {MemberOption} from '@pages/domain/BaseDomainMembersPage'; @@ -246,13 +248,15 @@ describe('useDomainGroupFilter', () => { }); describe('dropdownLabel', () => { + const LocaleWrapper = ({children}: {children: React.ReactNode}) => React.createElement(LocaleContextProvider, null, children); + it('should show the default label when no group is selected', async () => { const domain = buildDomain({ '1': {members: {'100': 'read'}, name: 'Engineering'}, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); - const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); + const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID), {wrapper: LocaleWrapper}); const defaultLabel = result.current.dropdownLabel; await waitFor(() => { @@ -276,7 +280,7 @@ describe('useDomainGroupFilter', () => { }); await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); - const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); + const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID), {wrapper: LocaleWrapper}); await waitFor(() => { expect(result.current.groupOptions).toHaveLength(1); @@ -286,7 +290,7 @@ describe('useDomainGroupFilter', () => { result.current.handleGroupChange([{text: 'Engineering', value: '1'}]); }); - expect(result.current.dropdownLabel).toBe('Engineering'); + expect(result.current.dropdownLabel).toBe('Group: Engineering'); }); it('should show comma-joined names when multiple groups are selected', async () => { @@ -296,7 +300,7 @@ describe('useDomainGroupFilter', () => { }); await Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); - const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); + const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID), {wrapper: LocaleWrapper}); await waitFor(() => { expect(result.current.groupOptions).toHaveLength(2); @@ -309,7 +313,7 @@ describe('useDomainGroupFilter', () => { ]); }); - expect(result.current.dropdownLabel).toBe('Engineering, Marketing'); + expect(result.current.dropdownLabel).toBe('Group: Engineering, Marketing'); }); it('should revert to the default label when selected groups are removed from Onyx', async () => { @@ -318,7 +322,7 @@ describe('useDomainGroupFilter', () => { }); await Onyx.set(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); - const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); + const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID), {wrapper: LocaleWrapper}); const defaultLabel = result.current.dropdownLabel; await waitFor(() => { @@ -328,7 +332,7 @@ describe('useDomainGroupFilter', () => { act(() => { result.current.handleGroupChange([{text: 'Engineering', value: '1'}]); }); - expect(result.current.dropdownLabel).toBe('Engineering'); + expect(result.current.dropdownLabel).toBe('Group: Engineering'); // Replace the domain with one that no longer has the selected group const updatedDomain = buildDomain({ @@ -351,7 +355,7 @@ describe('useDomainGroupFilter', () => { }); await Onyx.set(`${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}`, domain); - const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID)); + const {result} = renderHook(() => useDomainGroupFilter(DOMAIN_ACCOUNT_ID), {wrapper: LocaleWrapper}); const defaultLabel = result.current.dropdownLabel; await waitFor(() => { From d1dc24480f5063336414b1e4ae0b3da0cd5d1fde Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 26 May 2026 01:16:41 +0500 Subject: [PATCH 043/519] add tests for role filter dropdown label updates in WorkspaceMembers --- tests/ui/WorkspaceMembersTest.tsx | 89 +++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index fcb0b6be3006..f6eaa68fe343 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -8,6 +8,7 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import {ModalProvider} from '@components/Modal/Global/ModalContext'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; +import * as usePopoverPositionModule from '@hooks/usePopoverPosition'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; @@ -344,4 +345,92 @@ describe('WorkspaceMembers', () => { unmount(); }); }); + + describe('Role filter dropdown label', () => { + beforeEach(() => { + jest.spyOn(usePopoverPositionModule, 'default').mockReturnValue({ + calculatePopoverPosition: () => Promise.resolve({horizontal: 0, vertical: 0, width: 0, height: 0}), + }); + }); + + it('should display "Role: All" by default when no filter is applied', async () => { + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText(ADMIN_OPTION)).toBeOnTheScreen(); + }); + + const expectedLabel = `${TestHelper.translateLocal('common.role')}: ${TestHelper.translateLocal('common.all')}`; + expect(screen.getByText(expectedLabel)).toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should update to "Role: Members" when the Members filter is applied', async () => { + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText(ADMIN_OPTION)).toBeOnTheScreen(); + }); + + const defaultLabel = `${TestHelper.translateLocal('common.role')}: ${TestHelper.translateLocal('common.all')}`; + fireEvent.press(screen.getByText(defaultLabel)); + await waitForBatchedUpdatesWithAct(); + + const membersOption = TestHelper.translateLocal('workspace.people.members'); + await waitFor(() => { + expect(screen.getAllByText(membersOption).length).toBeGreaterThan(0); + }); + fireEvent.press(screen.getAllByText(membersOption).at(-1)!); + + const applyText = TestHelper.translateLocal('common.apply'); + fireEvent.press(screen.getByText(applyText)); + await waitForBatchedUpdatesWithAct(); + + const expectedLabel = `${TestHelper.translateLocal('common.role')}: ${membersOption}`; + await waitFor(() => { + expect(screen.getByText(expectedLabel)).toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should update to "Role: Members, Admins" when both filters are applied', async () => { + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText(ADMIN_OPTION)).toBeOnTheScreen(); + }); + + const defaultLabel = `${TestHelper.translateLocal('common.role')}: ${TestHelper.translateLocal('common.all')}`; + fireEvent.press(screen.getByText(defaultLabel)); + await waitForBatchedUpdatesWithAct(); + + const membersOption = TestHelper.translateLocal('workspace.people.members'); + const adminsOption = TestHelper.translateLocal('workspace.people.admins'); + await waitFor(() => { + expect(screen.getAllByText(membersOption).length).toBeGreaterThan(0); + }); + fireEvent.press(screen.getAllByText(membersOption).at(-1)!); + fireEvent.press(screen.getAllByText(adminsOption).at(-1)!); + + const applyText = TestHelper.translateLocal('common.apply'); + fireEvent.press(screen.getByText(applyText)); + await waitForBatchedUpdatesWithAct(); + + // Verify the label shows both filters comma separated + const expectedLabel = `${TestHelper.translateLocal('common.role')}: ${membersOption}, ${adminsOption}`; + await waitFor(() => { + expect(screen.getByText(expectedLabel)).toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + }); }); From e3cec5dda7f0f160f88c5d068697279dcda7502d Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 26 May 2026 01:30:24 +0500 Subject: [PATCH 044/519] lint --- tests/ui/WorkspaceMembersTest.tsx | 9 ++++++--- tests/unit/hooks/useDomainGroupFilter.test.ts | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index f6eaa68fe343..513e708401fa 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -384,7 +384,8 @@ describe('WorkspaceMembers', () => { await waitFor(() => { expect(screen.getAllByText(membersOption).length).toBeGreaterThan(0); }); - fireEvent.press(screen.getAllByText(membersOption).at(-1)!); + const membersItems = screen.getAllByText(membersOption); + fireEvent.press(membersItems[membersItems.length - 1]); const applyText = TestHelper.translateLocal('common.apply'); fireEvent.press(screen.getByText(applyText)); @@ -416,8 +417,10 @@ describe('WorkspaceMembers', () => { await waitFor(() => { expect(screen.getAllByText(membersOption).length).toBeGreaterThan(0); }); - fireEvent.press(screen.getAllByText(membersOption).at(-1)!); - fireEvent.press(screen.getAllByText(adminsOption).at(-1)!); + const memberItems = screen.getAllByText(membersOption); + fireEvent.press(memberItems[memberItems.length - 1]); + const adminItems = screen.getAllByText(adminsOption); + fireEvent.press(adminItems[adminItems.length - 1]); const applyText = TestHelper.translateLocal('common.apply'); fireEvent.press(screen.getByText(applyText)); diff --git a/tests/unit/hooks/useDomainGroupFilter.test.ts b/tests/unit/hooks/useDomainGroupFilter.test.ts index 6228f46a25c5..3a7616a8ac72 100644 --- a/tests/unit/hooks/useDomainGroupFilter.test.ts +++ b/tests/unit/hooks/useDomainGroupFilter.test.ts @@ -248,7 +248,9 @@ describe('useDomainGroupFilter', () => { }); describe('dropdownLabel', () => { - const LocaleWrapper = ({children}: {children: React.ReactNode}) => React.createElement(LocaleContextProvider, null, children); + function LocaleWrapper({children}: {children: React.ReactNode}) { + return React.createElement(LocaleContextProvider, null, children); + } it('should show the default label when no group is selected', async () => { const domain = buildDomain({ From a808e09ea586c1f1625415ae08828c5f0339535a Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 26 May 2026 02:00:42 +0500 Subject: [PATCH 045/519] simplify role selection in WorkspaceMembersTest --- tests/ui/WorkspaceMembersTest.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index 513e708401fa..ae1eddeac982 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -381,11 +381,7 @@ describe('WorkspaceMembers', () => { await waitForBatchedUpdatesWithAct(); const membersOption = TestHelper.translateLocal('workspace.people.members'); - await waitFor(() => { - expect(screen.getAllByText(membersOption).length).toBeGreaterThan(0); - }); - const membersItems = screen.getAllByText(membersOption); - fireEvent.press(membersItems[membersItems.length - 1]); + fireEvent.press(await screen.findByRole(CONST.ROLE.CHECKBOX, {name: membersOption})); const applyText = TestHelper.translateLocal('common.apply'); fireEvent.press(screen.getByText(applyText)); @@ -414,13 +410,8 @@ describe('WorkspaceMembers', () => { const membersOption = TestHelper.translateLocal('workspace.people.members'); const adminsOption = TestHelper.translateLocal('workspace.people.admins'); - await waitFor(() => { - expect(screen.getAllByText(membersOption).length).toBeGreaterThan(0); - }); - const memberItems = screen.getAllByText(membersOption); - fireEvent.press(memberItems[memberItems.length - 1]); - const adminItems = screen.getAllByText(adminsOption); - fireEvent.press(adminItems[adminItems.length - 1]); + fireEvent.press(await screen.findByRole(CONST.ROLE.CHECKBOX, {name: membersOption})); + fireEvent.press(screen.getByRole(CONST.ROLE.CHECKBOX, {name: adminsOption})); const applyText = TestHelper.translateLocal('common.apply'); fireEvent.press(screen.getByText(applyText)); From 29f1a8e5a07e7b56a42d1ac86cb20ad16e0e0df8 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Wed, 27 May 2026 10:49:41 +0700 Subject: [PATCH 046/519] fix ts error --- src/libs/actions/Policy/Member.ts | 6 +++-- .../workspace/members/ImportedMembersPage.tsx | 22 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index c11739d7c85a..02b858aca3e2 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1009,8 +1009,10 @@ async function importPolicyMembers(policy: OnyxEntry, members: PolicyMem const importFinalModal = getImportMembersFinalModal(added, updated); const shouldUpdateApprovalMode = members.some((member) => !!member.submitsTo || !!member.forwardsTo) && isControlPolicy(policy); + + const successData: Array> = []; if (shouldUpdateApprovalMode) { - onyxData.successData?.push({ + successData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, value: { @@ -1039,7 +1041,7 @@ async function importPolicyMembers(policy: OnyxEntry, members: PolicyMem // We need the server result immediately so the initiating page can show the final confirmation modal // without storing transient modal state in Onyx. // eslint-disable-next-line rulesdir/no-api-side-effects-method - const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.IMPORT_MEMBERS_SPREADSHEET, parameters); + const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.IMPORT_MEMBERS_SPREADSHEET, parameters, {successData}); return response?.jsonCode === CONST.JSON_CODE.SUCCESS ? importFinalModal : getImportFailedFinalModal(); } catch { return getImportFailedFinalModal(); diff --git a/src/pages/workspace/members/ImportedMembersPage.tsx b/src/pages/workspace/members/ImportedMembersPage.tsx index 1cc0d57d8a14..35f1a9c7e2c2 100644 --- a/src/pages/workspace/members/ImportedMembersPage.tsx +++ b/src/pages/workspace/members/ImportedMembersPage.tsx @@ -15,7 +15,7 @@ import {findDuplicate, generateColumnNames} from '@libs/importSpreadsheetUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {isControlPolicy, isPolicyMemberWithoutPendingDelete} from '@libs/PolicyUtils'; +import {isControlPolicy as isControlPolicyUtil, isPolicyMemberWithoutPendingDelete} from '@libs/PolicyUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -37,19 +37,25 @@ function ImportedMembersPage({route}: ImportedMembersPageProps) { const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); const {containsHeader = true} = spreadsheet ?? {}; + const isControlPolicy = isControlPolicyUtil(policy); const columnRoles: ColumnRole[] = [ {text: translate('common.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, {text: translate('common.email'), value: CONST.CSV_IMPORT_COLUMNS.EMAIL, isRequired: true}, {text: translate('common.role'), value: CONST.CSV_IMPORT_COLUMNS.ROLE}, - {text: translate('common.submitTo'), value: CONST.CSV_IMPORT_COLUMNS.SUBMIT_TO}, - {text: translate('common.forwardTo'), value: CONST.CSV_IMPORT_COLUMNS.APPROVE_TO}, - {text: translate('workspace.common.customField1'), value: CONST.CSV_IMPORT_COLUMNS.CUSTOM_FIELD_1}, - {text: translate('workspace.common.customField2'), value: CONST.CSV_IMPORT_COLUMNS.CUSTOM_FIELD_2}, - {text: translate('common.approvalLimit'), value: CONST.CSV_IMPORT_COLUMNS.REPORT_THRESHOLD}, - {text: translate('common.overLimitForwardTo'), value: CONST.CSV_IMPORT_COLUMNS.APPROVE_TO_ALTERNATE}, ]; + if (isControlPolicy) { + columnRoles.push( + {text: translate('common.submitTo'), value: CONST.CSV_IMPORT_COLUMNS.SUBMIT_TO}, + {text: translate('common.forwardTo'), value: CONST.CSV_IMPORT_COLUMNS.APPROVE_TO}, + {text: translate('workspace.common.customField1'), value: CONST.CSV_IMPORT_COLUMNS.CUSTOM_FIELD_1}, + {text: translate('workspace.common.customField2'), value: CONST.CSV_IMPORT_COLUMNS.CUSTOM_FIELD_2}, + {text: translate('common.approvalLimit'), value: CONST.CSV_IMPORT_COLUMNS.REPORT_THRESHOLD}, + {text: translate('common.overLimitForwardTo'), value: CONST.CSV_IMPORT_COLUMNS.APPROVE_TO_ALTERNATE}, + ); + } + const requiredColumns = columnRoles.filter((role) => role.isRequired).map((role) => role); // checks if all required columns are mapped and no column is mapped more than once @@ -100,7 +106,7 @@ function ImportedMembersPage({route}: ImportedMembersPageProps) { ?.at(membersRolesColumn) ?.some((role, index) => (containsHeader ? spreadsheet?.data?.at(membersRolesColumn)?.at(index + 1) : (role ?? '')) === CONST.POLICY.ROLE.AUDITOR); - if (hasAuditorRole && !isControlPolicy(policy)) { + if (hasAuditorRole && !isControlPolicy) { Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(route.params.policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.auditor.alias, Navigation.getActiveRoute())); return; } From d3534465854bce71c75d97659ab48faacdce6ddc Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Wed, 27 May 2026 10:51:24 +0700 Subject: [PATCH 047/519] run prettier --- src/libs/actions/Policy/Member.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 02b858aca3e2..1a23324c699b 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1009,7 +1009,7 @@ async function importPolicyMembers(policy: OnyxEntry, members: PolicyMem const importFinalModal = getImportMembersFinalModal(added, updated); const shouldUpdateApprovalMode = members.some((member) => !!member.submitsTo || !!member.forwardsTo) && isControlPolicy(policy); - + const successData: Array> = []; if (shouldUpdateApprovalMode) { successData.push({ From 7ff297d78b372206c2ee338b8f785236f473caa9 Mon Sep 17 00:00:00 2001 From: Jules Rosser Date: Wed, 27 May 2026 17:42:06 -0700 Subject: [PATCH 048/519] Set --effort xhigh for claude-review steps --- .github/workflows/claude-review.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 28ede786ef4e..be220512c31e 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -96,6 +96,7 @@ jobs: prompt: "/review-code-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}" claude_args: | --model claude-opus-4-7 + --effort xhigh --allowedTools "Task,Glob,Grep,Read,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(check-compiler.sh:*)" --json-schema '${{ steps.toolkit.outputs.schema_json }}' - name: Post code review results @@ -131,6 +132,7 @@ jobs: prompt: "/review-helpdot-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}" claude_args: | --model claude-opus-4-7 + --effort xhigh --allowedTools "Task,Glob,Grep,Read,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),mcp__github_inline_comment__create_inline_comment" - name: Remove in-progress indicator From 44a54711bac77d29dab34a0042cfa69a0b8ca957 Mon Sep 17 00:00:00 2001 From: Jules Rosser Date: Wed, 27 May 2026 20:00:50 -0700 Subject: [PATCH 049/519] Bump claude-code-action to v1.0.133 for opus-4-7 / xhigh support --- .github/workflows/claude-review.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index be220512c31e..9240a10cdae2 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -87,7 +87,7 @@ jobs: - name: Run Claude Code (code) id: code-review if: steps.filter.outputs.code == 'true' - uses: anthropics/claude-code-action@ba026a3e56b9f646ae3b1be02dd9c0812aa2f8ae # v1.0.86 + uses: anthropics/claude-code-action@db614ad5ed8c94def23ad6cc336751c8ba450ac1 # v1.0.133 with: display_report: "true" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} @@ -123,7 +123,7 @@ jobs: - name: Run Claude Code (docs) if: steps.filter.outputs.docs == 'true' - uses: anthropics/claude-code-action@ba026a3e56b9f646ae3b1be02dd9c0812aa2f8ae # v1.0.86 + uses: anthropics/claude-code-action@db614ad5ed8c94def23ad6cc336751c8ba450ac1 # v1.0.133 with: display_report: "true" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 070e9f4865c7e29b5ac314c4ea38fe5cbca5c7f2 Mon Sep 17 00:00:00 2001 From: Jules Rosser Date: Wed, 27 May 2026 20:02:25 -0700 Subject: [PATCH 050/519] Bump claude-code-action to v1.0.133 in deployBlockerInvestigation workflow --- .github/workflows/deployBlockerInvestigation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployBlockerInvestigation.yml b/.github/workflows/deployBlockerInvestigation.yml index 0618ef144267..18d7a45848c2 100644 --- a/.github/workflows/deployBlockerInvestigation.yml +++ b/.github/workflows/deployBlockerInvestigation.yml @@ -70,7 +70,7 @@ jobs: - name: Run Claude to investigate deploy blocker if: steps.validate.outputs.valid == 'true' - uses: anthropics/claude-code-action@ba026a3e56b9f646ae3b1be02dd9c0812aa2f8ae # v1.0.86 + uses: anthropics/claude-code-action@db614ad5ed8c94def23ad6cc336751c8ba450ac1 # v1.0.133 with: display_report: "true" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 60ba12aeab8b2e6e0b2e950388b721d967c788a9 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Fri, 29 May 2026 00:55:50 +0700 Subject: [PATCH 051/519] feat: ai feature promo modal navigation, basic component and guards --- src/CONST/index.ts | 8 + src/NAVIGATORS.ts | 1 + src/ROUTES.ts | 5 + src/SCREENS.ts | 4 + src/components/AIFeaturesPromoModal/index.tsx | 34 ++++ .../Navigation/AppNavigator/AuthScreens.tsx | 6 + .../AIFeaturesPromoModalNavigator.tsx | 26 +++ .../Navigation/guards/AIFeaturesPromoGuard.ts | 174 ++++++++++++++++++ src/libs/Navigation/guards/index.ts | 4 + src/libs/Navigation/linkingConfig/config.ts | 9 + src/libs/Navigation/types.ts | 6 + src/types/onyx/DismissedProductTraining.ts | 5 + 12 files changed, 282 insertions(+) create mode 100644 src/components/AIFeaturesPromoModal/index.tsx create mode 100644 src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx create mode 100644 src/libs/Navigation/guards/AIFeaturesPromoGuard.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 78b741d7aec0..12eecd80e920 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9000,6 +9000,14 @@ const CONST = { MIGRATED_USER_WELCOME_MODAL: 'migratedUserWelcomeModal', + AI_FEATURES_PROMO_MODAL: 'aiFeaturesPromoModal', + + AI_FEATURES_PROMO_LEARN_MORE_URLS: { + SPEND_ANALYSIS: 'https://help.expensify.com/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend', + EXPENSE_ASSISTANT: 'https://help.expensify.com/articles/new-expensify/concierge-ai/Expense-Assistant', + BUILD_AGENTS: 'https://help.expensify.com/articles/new-expensify/concierge-ai/Expense-Assistant', + }, + BASE_LIST_ITEM_TEST_ID: 'base-list-item-', SELECTION_BUTTON_TEST_ID: 'selection-button-', PRODUCT_TRAINING_TOOLTIP_NAMES: { diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts index 23f0755290cc..6e93648f621a 100644 --- a/src/NAVIGATORS.ts +++ b/src/NAVIGATORS.ts @@ -9,6 +9,7 @@ export default { FEATURE_TRAINING_MODAL_NAVIGATOR: 'FeatureTrainingModalNavigator', EXPLANATION_MODAL_NAVIGATOR: 'ExplanationModalNavigator', MIGRATED_USER_MODAL_NAVIGATOR: 'MigratedUserModalNavigator', + AI_FEATURES_PROMO_MODAL_NAVIGATOR: 'AIFeaturesPromoModalNavigator', TEST_DRIVE_MODAL_NAVIGATOR: 'TestDriveModalNavigator', TEST_DRIVE_DEMO_NAVIGATOR: 'TestDriveDemoNavigator', REPORTS_SPLIT_NAVIGATOR: 'ReportsSplitNavigator', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 3966b0b9e5a8..0d51153b9588 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3328,6 +3328,11 @@ const ROUTES = { getRoute: (backTo?: string) => getUrlWithBackToParam('onboarding/migrated-user-welcome', backTo, false), }, + AI_FEATURES_PROMO_MODAL: { + route: 'ai-features-promo', + + getRoute: (backTo?: string) => getUrlWithBackToParam('ai-features-promo', backTo, false), + }, TRANSACTION_RECEIPT: { route: 'r/:reportID/transaction/:transactionID/receipt/:action?/:iouType?', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 8eaebf73b0d1..0db9cd64ef75 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -960,6 +960,10 @@ const SCREENS = { ROOT: 'MigratedUserWelcomeModal_Root', }, + AI_FEATURES_PROMO_MODAL: { + ROOT: 'AIFeaturesPromoModal_Root', + }, + TEST_DRIVE_MODAL: { ROOT: 'TestDrive_Modal_Root', }, diff --git a/src/components/AIFeaturesPromoModal/index.tsx b/src/components/AIFeaturesPromoModal/index.tsx new file mode 100644 index 000000000000..1dda5ecb0ca6 --- /dev/null +++ b/src/components/AIFeaturesPromoModal/index.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import FeatureTrainingModal from '@components/FeatureTrainingModal'; +import LottieAnimations from '@components/LottieAnimations'; +import useLocalize from '@hooks/useLocalize'; +import {dismissProductTraining} from '@libs/actions/Welcome'; +import Log from '@libs/Log'; +import CONST from '@src/CONST'; + +function AIFeaturesPromoModal() { + const {translate} = useLocalize(); + + const onClose = () => { + Log.hmmm('[AIFeaturesPromoModal] onClose called, dismissing product training'); + dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, true); + }; + + const onConfirm = () => { + Log.hmmm('[AIFeaturesPromoModal] onConfirm called, dismissing product training'); + dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL); + }; + + return ( + + ); +} + +export default AIFeaturesPromoModal; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 721f8814fc15..072b627776fd 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -55,6 +55,7 @@ import DelegatorConnectGuard from './DelegatorConnectGate'; import hideKeyboardOnSwipe from './hideKeyboardOnSwipe'; import KeyboardShortcutsHandler from './KeyboardShortcutsHandler'; import {ShareModalStackNavigator} from './ModalStackNavigators'; +import AIFeaturesPromoModalNavigator from './Navigators/AIFeaturesPromoModalNavigator'; import ExplanationModalNavigator from './Navigators/ExplanationModalNavigator'; import FeatureTrainingModalNavigator from './Navigators/FeatureTrainingModalNavigator'; import MigratedUserWelcomeModalNavigator from './Navigators/MigratedUserWelcomeModalNavigator'; @@ -309,6 +310,11 @@ function AuthScreens() { options={rootNavigatorScreenOptions.basicModalNavigator} component={MigratedUserWelcomeModalNavigator} /> + (); + +function AIFeaturesPromoModalNavigator() { + return ( + + + + + + + + ); +} + +export default AIFeaturesPromoModalNavigator; diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts new file mode 100644 index 000000000000..192b89324c0e --- /dev/null +++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts @@ -0,0 +1,174 @@ +import type {NavigationAction, NavigationState} from '@react-navigation/native'; +import {findFocusedRoute} from '@react-navigation/native'; +import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding'; +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import isProductTrainingElementDismissed from '@libs/TooltipUtils'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {DismissedProductTraining, Onboarding, Session} from '@src/types/onyx'; +import type {GuardResult, NavigationGuard} from './types'; + +let session: OnyxEntry; +let isLoadingApp = true; + +let dismissedProductTraining: OnyxEntry; +let isDismissedProductTrainingLoaded = false; + +let hasBeenAddedToNudgeMigration = false; +let isTryNewDotLoaded = false; + +let onboarding: OnyxEntry; +let isOnboardingLoaded = false; + +let hasRedirectedToAIFeaturesPromoModal = false; + +/** + * Same-session protection. + * + * Per the issue requirements, the AI features promo modal must not appear in the same + * session as the migration welcome modal or the onboarding flow. These flags trip when + * we observe either of those modals being "active" (pending dismissal / not yet + * completed) at any point during this process lifetime, and suppress the AI promo for + * the rest of the session. + */ +let observedActiveMigrationModalThisSession = false; +let observedActiveOnboardingThisSession = false; + +function resetSessionFlag() { + hasRedirectedToAIFeaturesPromoModal = false; + observedActiveMigrationModalThisSession = false; + observedActiveOnboardingThisSession = false; +} + +/** + * Proactively navigate to the AI features promo modal when all conditions are met. + * Waits for the gating NVPs to load to avoid racing with the migration / onboarding guards. + */ +function navigateToAIFeaturesPromoModalIfReady() { + if ( + !session?.authToken || + isLoadingApp || + hasRedirectedToAIFeaturesPromoModal || + !isDismissedProductTrainingLoaded || + !isTryNewDotLoaded || + !isOnboardingLoaded || + isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining) || + observedActiveMigrationModalThisSession || + observedActiveOnboardingThisSession + ) { + return; + } + + Log.info('[AIFeaturesPromoGuard] Proactively navigating to AI features promo modal'); + hasRedirectedToAIFeaturesPromoModal = true; + Navigation.navigate(ROUTES.AI_FEATURES_PROMO_MODAL.getRoute()); +} + +/** + * Called by guards/index.ts when session or loading app state changes. + * Reuses the shared Onyx subscriptions from guards/index.ts to avoid duplicate connections. + */ +function onSessionOrLoadingAppChanged(sessionValue: OnyxEntry, isLoadingAppValue: boolean) { + session = sessionValue; + isLoadingApp = isLoadingAppValue; + navigateToAIFeaturesPromoModalIfReady(); +} + +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, + callback: (value) => { + dismissedProductTraining = value; + isDismissedProductTrainingLoaded = true; + if (isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, value)) { + hasRedirectedToAIFeaturesPromoModal = false; + } + // If the migration welcome modal is currently still pending, suppress AI promo this session. + if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, value)) { + observedActiveMigrationModalThisSession = true; + } + navigateToAIFeaturesPromoModalIfReady(); + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_TRY_NEW_DOT, + callback: (value) => { + const result = value ? tryNewDotOnyxSelector(value) : undefined; + hasBeenAddedToNudgeMigration = result?.hasBeenAddedToNudgeMigration ?? false; + isTryNewDotLoaded = true; + if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, dismissedProductTraining)) { + observedActiveMigrationModalThisSession = true; + } + navigateToAIFeaturesPromoModalIfReady(); + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_ONBOARDING, + callback: (value) => { + onboarding = value; + isOnboardingLoaded = true; + if (!hasCompletedGuidedSetupFlowSelector(onboarding)) { + observedActiveOnboardingThisSession = true; + } + navigateToAIFeaturesPromoModalIfReady(); + }, +}); + +/** + * Block navigation while the AI features promo modal is active (on top of the stack). + * Mirrors the pattern from MigratedUserWelcomeModalGuard. + */ +function shouldBlockWhileModalActive(state: NavigationState, action: NavigationAction): boolean { + const isAllowedAction = action.type === CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL || action.type === CONST.NAVIGATION.ACTION_TYPE.GO_BACK; + return ( + hasRedirectedToAIFeaturesPromoModal && + !isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining) && + state.routes.at(-1)?.name === NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR && + !isAllowedAction + ); +} + +/** Prevents redirect loops by detecting when we're already on or resetting to the modal. */ +function isNavigatingToAIFeaturesPromoModal(state: NavigationState, action: NavigationAction): boolean { + const isOnModal = findFocusedRoute(state)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.ROOT; + const isResettingToModal = action.type === 'RESET' && !!action.payload && findFocusedRoute(action.payload as NavigationState)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.ROOT; + + return isOnModal || isResettingToModal; +} + +/** + * AIFeaturesPromoGuard surfaces the one-time AI features promo modal. + * + * This guard relies on the proactive Onyx-driven path (navigateToAIFeaturesPromoModalIfReady) + * rather than redirecting from evaluate(), because it needs to wait for higher-priority guards + * (Onboarding, MigratedUserWelcomeModal) to settle before deciding whether to fire. + */ +const AIFeaturesPromoGuard: NavigationGuard = { + name: 'AIFeaturesPromoGuard', + + evaluate: (state: NavigationState, action: NavigationAction, context): GuardResult => { + if (context.isLoading) { + return {type: 'ALLOW'}; + } + + if (shouldBlockWhileModalActive(state, action)) { + return {type: 'BLOCK', reason: '[AIFeaturesPromoGuard] Blocking navigation while AI features promo modal is active'}; + } + + if (isNavigatingToAIFeaturesPromoModal(state, action) || hasRedirectedToAIFeaturesPromoModal) { + return {type: 'ALLOW'}; + } + + return {type: 'ALLOW'}; + }, +}; + +export default AIFeaturesPromoGuard; +export {resetSessionFlag, onSessionOrLoadingAppChanged}; diff --git a/src/libs/Navigation/guards/index.ts b/src/libs/Navigation/guards/index.ts index fdc43fd6440a..e60ba997d016 100644 --- a/src/libs/Navigation/guards/index.ts +++ b/src/libs/Navigation/guards/index.ts @@ -4,6 +4,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Session} from '@src/types/onyx'; +import AIFeaturesPromoGuard, {onSessionOrLoadingAppChanged as onAIFeaturesPromoSessionOrLoadingAppChanged} from './AIFeaturesPromoGuard'; import MigratedUserWelcomeModalGuard, {onSessionOrLoadingAppChanged} from './MigratedUserWelcomeModalGuard'; import OnboardingGuard from './OnboardingGuard'; import type {GuardContext, GuardResult, NavigationGuard} from './types'; @@ -20,6 +21,7 @@ Onyx.connectWithoutView({ callback: (value) => { session = value; onSessionOrLoadingAppChanged(session, isLoadingApp); + onAIFeaturesPromoSessionOrLoadingAppChanged(session, isLoadingApp); }, }); @@ -28,6 +30,7 @@ Onyx.connectWithoutView({ callback: (value) => { isLoadingApp = value ?? true; onSessionOrLoadingAppChanged(session, isLoadingApp); + onAIFeaturesPromoSessionOrLoadingAppChanged(session, isLoadingApp); }, }); @@ -103,5 +106,6 @@ function clearGuards(): void { registerGuard(OnboardingGuard); registerGuard(MigratedUserWelcomeModalGuard); +registerGuard(AIFeaturesPromoGuard); export {registerGuard, createGuardContext, evaluateGuards, getRegisteredGuards, clearGuards}; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index f1a5498123d7..6ec8c9a3f6f6 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -67,6 +67,15 @@ const config: LinkingOptions['config'] = { }, }, + [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: { + screens: { + [SCREENS.AI_FEATURES_PROMO_MODAL.ROOT]: { + path: ROUTES.AI_FEATURES_PROMO_MODAL.route, + exact: true, + }, + }, + }, + [NAVIGATORS.TEST_DRIVE_MODAL_NAVIGATOR]: { screens: { [SCREENS.TEST_DRIVE_MODAL.ROOT]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 675cff9a5059..12be8b30c30f 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2916,6 +2916,10 @@ type MigratedUserModalNavigatorParamList = { [SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT]: undefined; }; +type AIFeaturesPromoModalNavigatorParamList = { + [SCREENS.AI_FEATURES_PROMO_MODAL.ROOT]: undefined; +}; + type TestDriveModalNavigatorParamList = { [SCREENS.TEST_DRIVE_MODAL.ROOT]: { bossEmail?: string; @@ -3092,6 +3096,7 @@ type AuthScreensParamList = SharedScreensParamList & [NAVIGATORS.FEATURE_TRAINING_MODAL_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.EXPLANATION_MODAL_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR]: NavigatorScreenParams; + [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.TEST_DRIVE_MODAL_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.TEST_DRIVE_DEMO_NAVIGATOR]: NavigatorScreenParams; [SCREENS.CONNECTION_COMPLETE]: undefined; @@ -3368,6 +3373,7 @@ export type { WorkspaceSplitNavigatorParamList, WorkspaceNavigatorParamList, MigratedUserModalNavigatorParamList, + AIFeaturesPromoModalNavigatorParamList, WorkspaceConfirmationNavigatorParamList, WorkspaceDuplicateNavigatorParamList, PolicyCopySettingsNavigatorParamList, diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts index 7cfb6f999011..17c13e6c9112 100644 --- a/src/types/onyx/DismissedProductTraining.ts +++ b/src/types/onyx/DismissedProductTraining.ts @@ -22,6 +22,11 @@ type DismissedProductTraining = { */ [CONST.MIGRATED_USER_WELCOME_MODAL]: DismissedProductTrainingElement; + /** + * When user dismisses the AI features promo modal, we store the timestamp here. + */ + [CONST.AI_FEATURES_PROMO_MODAL]: DismissedProductTrainingElement; + // TODO: CONCIERGE_LHN_GBR tooltip will be replaced by a tooltip in the #admins room // https://github.com/Expensify/App/issues/57045#issuecomment-2701455668 /** From 3b61e1e0ed926ba0dff7361c5c20f4d1d5d3c6d6 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Fri, 29 May 2026 00:58:32 +0700 Subject: [PATCH 052/519] add English terms --- src/languages/en.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/languages/en.ts b/src/languages/en.ts index 2c964abfbbee..f10457e74c74 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9231,6 +9231,28 @@ const translations = { chat: 'Chat on any expense to resolve questions quickly', }, }, + aiFeaturesPromoModal: { + title: 'Meet Concierge AI', + description: 'Your new AI-powered Expensify assistant.', + letsGo: "Let's go", + learnMore: 'Learn more', + next: 'Next', + back: 'Back', + screens: { + spendAnalysis: { + title: 'Interactive spend analysis', + description: 'Ask Concierge to break down your spending however you want — by category, person, project, or time.', + }, + expenseAssistant: { + title: 'Meet your new expense assistant', + description: 'Concierge can categorize expenses, fill in details, and follow up on missing receipts for you.', + }, + buildAgents: { + title: 'Build your own agents', + description: 'Create custom AI agents to automate the workflows that matter most to your team.', + }, + }, + }, productTrainingTooltip: { // TODO: CONCIERGE_LHN_GBR tooltip will be replaced by a tooltip in the #admins room // https://github.com/Expensify/App/issues/57045#issuecomment-2701455668 From 131b0cea99319da7787e6c1bbe3b59f3da0513c3 Mon Sep 17 00:00:00 2001 From: "Sahil (via MelvinBot)" Date: Sat, 30 May 2026 17:49:56 +0000 Subject: [PATCH 053/519] Fix moved expense showing "IOU" name after offline workspace pay When createWorkspaceFromIOUPayment runs offline (user pays the first time with workspace), the moved report kept the stale reportName "IOU" because the optimistic update spread the existing iouReport without recomputing the name. Both the policy expense chat preview and the report header read the same reportName field, so both displayed "IOU". Mirror what convertIOUReportToExpenseReport already does: - Recompute reportName via computeOptimisticReportName(expenseReport, newWorkspace, policyID, transactionsRecord). - Set childReportName on the moved report-preview action so the chat preview fallback matches. Co-authored-by: Sahil --- src/libs/actions/Policy/Policy.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 8e0e0602c5dc..04942ac38ca3 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4448,7 +4448,8 @@ function createWorkspaceFromIOUPayment( // - change the sign of the report total // - update its policyID and policyName // - update the chatReportID to point to the new expense chat - const expenseReport = { + // - recompute reportName so the header and the policy expense chat preview don't show the stale "IOU" name + const expenseReport: Report = { ...iouReport, chatReportID: memberData.workspaceChatReportID, parentReportID: memberData.workspaceChatReportID, @@ -4457,6 +4458,19 @@ function createWorkspaceFromIOUPayment( type: CONST.REPORT.TYPE.EXPENSE, total: -(iouReport?.total ?? 0), }; + + const reportTransactions = ReportUtils.getReportTransactions(iouReportID); + const transactionsRecord: Record = {}; + for (const transaction of reportTransactions) { + if (transaction?.transactionID) { + transactionsRecord[transaction.transactionID] = transaction; + } + } + const computedExpenseReportName = ReportUtils.computeOptimisticReportName(expenseReport, newWorkspace as Policy, policyID, transactionsRecord); + if (computedExpenseReportName !== null) { + expenseReport.reportName = computedExpenseReportName; + } + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, @@ -4468,9 +4482,6 @@ function createWorkspaceFromIOUPayment( value: iouReport, }); - // The expense report transactions need to have the amount reversed to negative values - const reportTransactions = ReportUtils.getReportTransactions(iouReportID); - // For performance reasons, we are going to compose a merge collection data for transactions const transactionsOptimisticData: Record = {}; const transactionFailureData: Record = {}; @@ -4530,12 +4541,14 @@ function createWorkspaceFromIOUPayment( if (reportPreviewAction?.reportActionID) { // Update the created timestamp of the report preview action to be after the expense chat created timestamp. + // Also set childReportName so the preview line falls back to the recomputed expense report name if needed. optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, value: { [reportPreviewAction.reportActionID]: { ...reportPreviewAction, + childReportName: expenseReport.reportName, message: [ { type: CONST.REPORT.MESSAGE.TYPE.TEXT, From 304f5e44cfc002e94303877b508ebe67ae3bf1a8 Mon Sep 17 00:00:00 2001 From: "Sahil (via MelvinBot)" Date: Sun, 31 May 2026 15:18:22 +0000 Subject: [PATCH 054/519] Seed title fieldList so moved expense gets a meaningful name The previous attempt called computeOptimisticReportName, but for the offline workspace-from-IOU flow the new policy isn't in Onyx yet, so getReportFieldsByPolicyID returned {} and the fallback was the literal "New report" (DEFAULT_EXPENSE_REPORT_NAME). Two changes: - Policy.ts: seed newWorkspace.fieldList with the default title field (pattern "{report:type} {report:startdate}"), mirroring buildPolicyData. Also pass that fieldList through onto the converted expense report so Report.fieldList is consistent with its policy. - ReportUtils.ts: when the Onyx-resolved fieldList is empty, fall back to the passed policy's fieldList. This makes computeOptimisticReportName usable from contexts that construct optimistic data before the policy exists in Onyx, without changing behavior for any existing caller. Co-authored-by: Sahil --- src/libs/ReportUtils.ts | 6 +++++- src/libs/actions/Policy/Policy.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 6ea25362a41b..50e4596f806d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -6681,7 +6681,11 @@ function computeOptimisticReportName(report: Report, policy: OnyxEntry, return null; } - const titleReportField = getTitleReportField(getReportFieldsByPolicyID(policyID) ?? {}); + // Prefer the Onyx-resolved fieldList by policyID; fall back to the passed policy's fieldList + // for callers building optimistic data before the policy is written to Onyx (e.g. createWorkspaceFromIOUPayment offline). + const reportFieldsFromOnyx = getReportFieldsByPolicyID(policyID) ?? {}; + const reportFields = Object.keys(reportFieldsFromOnyx).length > 0 ? reportFieldsFromOnyx : (policy?.fieldList ?? {}); + const titleReportField = getTitleReportField(reportFields); const formulaContext: FormulaContext = { report, policy, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 04942ac38ca3..289365dfb962 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4247,6 +4247,18 @@ function createWorkspaceFromIOUPayment( disabledFields: {defaultBillable: true, reimbursable: false}, requiresCategory: true, defaultReimbursable: true, + // Seed the title report field so computeOptimisticReportName can produce a meaningful name + // (e.g. "Expense {date}") instead of falling back to "New report" when this policy has no fieldList yet. + fieldList: { + [CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: { + defaultValue: CONST.POLICY.DEFAULT_REPORT_NAME_PATTERN, + type: CONST.POLICY.DEFAULT_FIELD_LIST_TYPE, + target: CONST.POLICY.DEFAULT_FIELD_LIST_TARGET, + name: CONST.POLICY.DEFAULT_FIELD_LIST_NAME, + fieldID: CONST.POLICY.FIELDS.FIELD_LIST_TITLE, + deletable: true, + }, + } as unknown as Policy['fieldList'], }; const optimisticData: Array< @@ -4457,6 +4469,7 @@ function createWorkspaceFromIOUPayment( policyName: workspaceName, type: CONST.REPORT.TYPE.EXPENSE, total: -(iouReport?.total ?? 0), + fieldList: newWorkspace.fieldList, }; const reportTransactions = ReportUtils.getReportTransactions(iouReportID); From 9e9652cf760355b0eca66ce3f339012a6d1d0bb7 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 1 Jun 2026 15:24:07 +0430 Subject: [PATCH 055/519] Fix cached video playback while offline --- .../Attachments/AttachmentView/index.tsx | 10 +----- .../HTMLRenderers/VideoRenderer.tsx | 3 +- .../VideoPlayer/BaseVideoPlayer.tsx | 17 ++++++---- src/components/VideoPlayerPreview/index.tsx | 32 ++++++------------- src/libs/AttachmentUtils.ts | 7 ++-- .../report/ReportAttachmentModalContent.tsx | 3 +- 6 files changed, 26 insertions(+), 46 deletions(-) diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index 5fb62f2c9807..92bb6738ff9f 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -4,7 +4,6 @@ import type {RotationDegrees} from 'react-fast-pdf'; import type {GestureResponderEvent, ImageURISource, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import AttachmentOfflineIndicator from '@components/AttachmentOfflineIndicator'; import {useAttachmentCarouselPagerActions} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import Button from '@components/Button'; @@ -28,7 +27,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {add as addCachedPDFPaths} from '@libs/actions/CachedPDFPaths'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; -import {isLocalAttachmentSource} from '@libs/AttachmentUtils'; import {getFileResolution, isHighResolutionImage} from '@libs/fileDownload/FileUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {hasEReceipt, hasReceiptSource, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, isPerDiemRequest} from '@libs/TransactionUtils'; @@ -173,8 +171,6 @@ function AttachmentView({ const [imageError, setImageError] = useState(false); const {isOffline} = useNetwork({onReconnect: () => setImageError(false)}); - const isLocalVideoSource = typeof source === 'string' && isLocalAttachmentSource(source); - const shouldShowOfflineVideoIndicator = isOffline && !!isVideo && typeof source === 'string' && !isLocalVideoSource; useEffect(() => { getFileResolution(file).then((resolution) => { @@ -372,15 +368,11 @@ function AttachmentView({ ); } - if (shouldShowOfflineVideoIndicator) { - return ; - } - if ((isVideo ?? (file?.name && Str.isVideo(file.name))) && typeof source === 'string') { return ( source.startsWith(prefix))} isHovered={isHovered} duration={duration} reportID={reportID} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx index aec9dccd62dd..b25cd5fbac86 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx @@ -19,7 +19,7 @@ function VideoRenderer({tnode, key}: VideoRendererProps) { const htmlAttribs = tnode.attributes; const attrHref = htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || htmlAttribs.src || htmlAttribs.href || ''; const sourceURL = tryResolveUrlFromApiRoot(attrHref); - const fileName = htmlAttribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || getFileName(`${sourceURL}`); + const fileName = getFileName(`${sourceURL}`); const thumbnailUrl = tryResolveUrlFromApiRoot(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_URL_ATTRIBUTE]); const width = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_WIDTH_ATTRIBUTE]); const height = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_HEIGHT_ATTRIBUTE]); @@ -54,7 +54,6 @@ function VideoRenderer({tnode, key}: VideoRendererProps) { source: sourceURL, accountID, isAuthTokenRequired, - originalFileName: fileName, hashKey, }); Navigation.navigate(route); diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index ee1272b54784..19b558d2d0c9 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -63,7 +63,8 @@ function BaseVideoPlayer(props: BaseVideoPlayerProps) { const report = useReportOrReportDraft(reportID); const isOffline = useNetwork().isOffline; - const [isVideoOffline, setIsVideoOffline] = useState(false); + // A player mounted while already offline should not auto-play content retained in the browser cache. + const [isVideoOffline, setIsVideoOffline] = useState(() => isOffline); const session = useSession(); const encryptedAuthToken = session?.encryptedAuthToken ?? ''; const [duration, setDuration] = useState(videoDuration); @@ -94,7 +95,7 @@ function BaseVideoPlayer(props: BaseVideoPlayerProps) { // `useEvent` — direct `.playing` read wouldn't re-render when play state changes. const {isPlaying} = useEvent(videoPlayerRef.current, 'playingChange', {isPlaying: videoPlayerRef.current.playing, oldIsPlaying: false} as PlayingChangeEventPayload); - const {currentTime, bufferedPosition} = useEvent(videoPlayerRef.current, 'timeUpdate', {currentTime: 0, bufferedPosition: 0} as TimeUpdateEventPayload); + const {currentTime} = useEvent(videoPlayerRef.current, 'timeUpdate', {currentTime: 0, bufferedPosition: 0} as TimeUpdateEventPayload); const {status} = useEvent(videoPlayerRef.current, 'statusChange', {status: shouldUseSharedVideoElement ? playerStatus.current : 'loading'} as StatusChangeEventPayload); const isLoading = useMemo(() => { @@ -161,8 +162,8 @@ function BaseVideoPlayer(props: BaseVideoPlayerProps) { return isLoading && (!isPlaying || currentTime <= 0) && !isVideoOffline && !hasError; }, [currentTime, hasError, isLoading, isVideoOffline, isPlaying]); const shouldShowOfflineIndicator = useMemo(() => { - return isVideoOffline && currentTime + bufferedPosition <= 0; - }, [bufferedPosition, currentTime, isVideoOffline]); + return isVideoOffline && !isPlaying && !isUploading; + }, [isPlaying, isUploading, isVideoOffline]); const {updateVolume} = useVolumeActions(); const {lastNonZeroVolume} = useVolumeState(); useHandleNativeVideoControls({ @@ -172,6 +173,10 @@ function BaseVideoPlayer(props: BaseVideoPlayerProps) { }); const togglePlayCurrentVideo = useCallback(() => { + if (isOffline && !isUploading) { + return; + } + if (!isCurrentlyURLSet) { updateCurrentURLAndReportID(url, report, reportID); return; @@ -195,7 +200,7 @@ function BaseVideoPlayer(props: BaseVideoPlayerProps) { allowSharedAutoPlayRef.current = true; playVideo(); - }, [isCurrentlyURLSet, isLoading, isEnded, currentTime, duration, playVideo, updateCurrentURLAndReportID, url, report, reportID, pauseVideo, replayVideo]); + }, [isOffline, isUploading, isCurrentlyURLSet, isLoading, isEnded, currentTime, duration, playVideo, updateCurrentURLAndReportID, url, report, reportID, pauseVideo, replayVideo]); const hideControl = useCallback(() => { if (isEnded || isSeeking) { @@ -294,7 +299,7 @@ function BaseVideoPlayer(props: BaseVideoPlayerProps) { setHasErrorIconVisible(false); if (isFirstLoad) { setIsFirstLoad(false); - if (videoPlayerRef.current === currentVideoPlayerRef.current && !isUploading) { + if (videoPlayerRef.current === currentVideoPlayerRef.current && !isUploading && !isOffline) { playVideo(); } } diff --git a/src/components/VideoPlayerPreview/index.tsx b/src/components/VideoPlayerPreview/index.tsx index f42491e0777b..5860f016e856 100644 --- a/src/components/VideoPlayerPreview/index.tsx +++ b/src/components/VideoPlayerPreview/index.tsx @@ -3,7 +3,6 @@ import type {SourceLoadEventPayload} from 'expo-video'; import React, {useEffect, useState} from 'react'; import type {GestureResponderEvent} from 'react-native'; import {View} from 'react-native'; -import AttachmentOfflineIndicator from '@components/AttachmentOfflineIndicator'; import {useIsOnSearch} from '@components/Search/SearchScopeProvider'; import VideoPlayer from '@components/VideoPlayer'; import IconButton from '@components/VideoPlayer/IconButton'; @@ -11,12 +10,10 @@ import {usePlaybackActionsContext, usePlaybackStateContext} from '@components/Vi import useCheckIfRouteHasRemainedUnchanged from '@hooks/useCheckIfRouteHasRemainedUnchanged'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useThumbnailDimensions from '@hooks/useThumbnailDimensions'; -import {isLocalAttachmentSource} from '@libs/AttachmentUtils'; import getPlatform from '@libs/getPlatform'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; @@ -59,7 +56,6 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi const {currentlyPlayingURL, currentRouteReportID} = usePlaybackStateContext(); const {updateCurrentURLAndReportID} = usePlaybackActionsContext(); const report = useReportOrReportDraft(reportID); - const {isOffline} = useNetwork(); /* This needs to be isSmallScreenWidth because we want to be able to play video in chat (not in attachment modal) when preview is inside an RHP */ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -67,15 +63,13 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi const [isThumbnail, setIsThumbnail] = useState(true); const [webMeasuredDimensions, setWebMeasuredDimensions] = useState(null); - const isLocalVideoSource = isLocalAttachmentSource(videoUrl); - const shouldShowOfflineVideoIndicator = isOffline && !isLocalVideoSource; const measuredDimensions = getPlatform() === CONST.PLATFORM.WEB && videoUrl && webMeasuredDimensions ? webMeasuredDimensions : videoDimensions; const {thumbnailDimensionsStyles} = useThumbnailDimensions(measuredDimensions.width, measuredDimensions.height); const isOnSearch = useIsOnSearch(); const navigation = useNavigation(); useEffect(() => { - if (!videoUrl || getPlatform() !== CONST.PLATFORM.WEB || shouldShowOfflineVideoIndicator) { + if (!videoUrl || getPlatform() !== CONST.PLATFORM.WEB) { return; } const video = document.createElement('video'); @@ -93,7 +87,7 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi return () => { video.src = ''; }; - }, [videoUrl, videoDimensions.width, videoDimensions.height, shouldShowOfflineVideoIndicator]); + }, [videoUrl, videoDimensions.width, videoDimensions.height]); // We want to play the video only when the user is on the page where it was initially rendered const doesUserRemainOnFirstRenderRoute = useCheckIfRouteHasRemainedUnchanged(videoUrl); @@ -111,9 +105,6 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi }; const handleOnPress = () => { - if (shouldShowOfflineVideoIndicator) { - return; - } updateCurrentURLAndReportID(videoUrl, report, reportID); if (isSmallScreenWidth) { onShowModalPress(); @@ -129,23 +120,20 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi if (prevPlaybackKey !== playbackKey) { setPrevPlaybackKey(playbackKey); const isFocused = doesUserRemainOnFirstRenderRoute(); - if (videoUrl === currentlyPlayingURL && reportID === currentRouteReportID && isFocused && !shouldShowOfflineVideoIndicator) { + if (videoUrl === currentlyPlayingURL && reportID === currentRouteReportID && isFocused) { setIsThumbnail(false); } } return ( - {isSmallScreenWidth || isThumbnail || isDeleted || shouldShowOfflineVideoIndicator ? ( - <> - - {shouldShowOfflineVideoIndicator && } - + {isSmallScreenWidth || isThumbnail || isDeleted ? ( + ) : ( source.startsWith(prefix)); -} - -export {getImageCacheFileExtension, isLocalAttachmentSource}; +// eslint-disable-next-line import/prefer-default-export +export {getImageCacheFileExtension}; diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx index 1da2076b3586..542451c60af1 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx @@ -96,7 +96,6 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen // Skip API root normalization for search attachments because this route is only opened from preview, // which already passes a resolved source. Keep normalization for other types to support email entry points. const source = getValidatedImageSource(sourceParam, type !== CONST.ATTACHMENT_TYPE.SEARCH); - const fileName = originalFileName ?? (typeof source === 'string' ? getFileName(source) : ''); const modalType = useReportAttachmentModalType(source); const shouldShowNotFoundPage = !isLoading && type !== CONST.ATTACHMENT_TYPE.SEARCH && !report?.reportID; @@ -108,7 +107,7 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen shouldShowNotFoundPage, isAuthTokenRequired: !!isAuthTokenRequired, attachmentLink: attachmentLink ?? '', - originalFileName: fileName, + originalFileName: originalFileName ?? '', isLoading, source, attachmentID, From 7098de77ebca769250e74f400e4a3aab3a405fe2 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 1 Jun 2026 15:51:11 +0430 Subject: [PATCH 056/519] remove un used import --- .../routes/report/ReportAttachmentModalContent.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx index 542451c60af1..b7b48009e881 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx @@ -6,7 +6,6 @@ import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; import {openReport} from '@libs/actions/Report'; import {getValidatedImageSource} from '@libs/AvatarUtils'; -import {getFileName} from '@libs/fileDownload/FileUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isReportNotFound} from '@libs/ReportUtils'; import type {AttachmentModalBaseContentProps} from '@pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types'; From 307c5693685b2a1715f0db212b74af4e7d73167c Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:00:34 +0500 Subject: [PATCH 057/519] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 99413909c1ab..4ebaf1360547 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10259,6 +10259,7 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); + // The optimistic data should contain a transaction merge auto-selecting the remaining category expect(onyxData.optimisticData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, From d466ad14af826c3e6ca7e90a973e99064a9cb700 Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:00:52 +0500 Subject: [PATCH 058/519] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 4ebaf1360547..2d5c1ba83fb9 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10640,7 +10640,6 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); const onyxData = {optimisticData: [], failureData: []}; - // policyUpdate-only call (simulates setPolicyRulesEnabled and similar) const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {requiresCategory: true}, {}, {}, autoSelections); const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); From 5f949883a9013a622be8489ad81b79ca844f8473 Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:01:08 +0500 Subject: [PATCH 059/519] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 2d5c1ba83fb9..5b14c8ddfeee 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10601,6 +10601,7 @@ describe('ReportUtils', () => { } const categoryNames = Object.keys(fakePolicyCategories); const transactionCategory = categoryNames.at(0) ?? ''; + // Mark this category disabled in Onyx so it is "out of policy" without any pending DELETE update fakePolicyCategories[transactionCategory].enabled = false; From addaecee90d0ccb46671c8349673c5541cf93522 Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:01:25 +0500 Subject: [PATCH 060/519] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 5b14c8ddfeee..7bea8ba6a282 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10522,6 +10522,7 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); + // No transaction merge should be present — only the violation push const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); From c748696aefb9ea035b9e226fe4ac50da25b633bf Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:02:05 +0500 Subject: [PATCH 061/519] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 7bea8ba6a282..388d84f0af44 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10266,6 +10266,7 @@ describe('ReportUtils', () => { key: `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`, value: {category: remainingCategory}, }); + // The failure data should restore the original category expect(onyxData.failureData).toContainEqual({ onyxMethod: Onyx.METHOD.MERGE, From 4dfd693f154c9e9bf7b0741f6225575abc3caeaf Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:02:40 +0500 Subject: [PATCH 062/519] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 388d84f0af44..bd7a214aa4fc 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10589,6 +10589,7 @@ describe('ReportUtils', () => { const onyxData = {optimisticData: [], failureData: []}; const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}); pushTransactionViolationsOnyxData(onyxData, result.current, {}, fakePolicyCategoriesUpdate, {}, autoSelections); + // No transaction merge should be present const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); From 06e8370c212ba9f32536f23ed97a645cd6502eb0 Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:02:58 +0500 Subject: [PATCH 063/519] Update tests/unit/ReportUtilsTest.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lucien Akchoté --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index bd7a214aa4fc..120d5b2f4552 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10527,6 +10527,7 @@ describe('ReportUtils', () => { // No transaction merge should be present — only the violation push const hasTransactionMerge = onyxData.optimisticData.some((update: {key: string}) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${mockTransaction.transactionID}`); expect(hasTransactionMerge).toBe(false); + // A categoryOutOfPolicy violation should still be created expect(onyxData.optimisticData).toContainEqual( expect.objectContaining({ From 2eb33737e973543462572701b6f45a7a1aa42f57 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 1 Jun 2026 18:54:45 +0430 Subject: [PATCH 064/519] optimistically reopen emptied source reports offline --- src/libs/actions/Transaction.ts | 26 ++++++++++++++++++++++++++ tests/unit/TransactionTest.ts | 19 ++++++++++++++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index efe1d0745dca..772bf5b2e12d 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -869,6 +869,8 @@ function changeTransactionsReport({ const updatedReportTransactionCounts: Record = {}; const updatedReportNonReimbursableTotals: Record = {}; const updatedReportUnheldNonReimbursableTotals: Record = {}; + const updatedReportStateNums: Record> = {}; + const updatedReportStatusNums: Record> = {}; const staleReportIDs = new Set(); const optimisticPendingFieldsByReport: Record>> = {}; const targetReportCurrenciesByReport: Record> = {}; @@ -1351,6 +1353,8 @@ function changeTransactionsReport({ updatedReportTotals[oldReportID] = 0; updatedReportNonReimbursableTotals[oldReportID] = 0; updatedReportUnheldNonReimbursableTotals[oldReportID] = 0; + updatedReportStateNums[oldReportID] = CONST.REPORT.STATE_NUM.OPEN; + updatedReportStatusNums[oldReportID] = CONST.REPORT.STATUS_NUM.OPEN; } else if (staleReportIDs.has(oldReportID) || oldReport.pendingFields?.total) { markReportTotalAsStale(oldReportID); } else if (oldReport.currency === sourceTransactionCurrency) { @@ -1693,6 +1697,26 @@ function changeTransactionsReport({ }); } + for (const reportIDToUpdate of Object.keys(updatedReportStateNums)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportIDToUpdate}`, + value: { + stateNum: updatedReportStateNums[reportIDToUpdate], + statusNum: updatedReportStatusNums[reportIDToUpdate], + }, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportIDToUpdate}`, + value: { + stateNum: allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportIDToUpdate}`]?.stateNum, + statusNum: allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportIDToUpdate}`]?.statusNum, + }, + }); + } + const reportTransactions = getReportTransactions(reportID); for (const transaction of reportTransactions) { if (!isPaidGroupPolicy(policy) || !policy?.id) { @@ -1746,6 +1770,8 @@ function changeTransactionsReport({ ...affectedReport, total: updatedTotal, transactionCount: updatedTransactionCount, + stateNum: updatedReportStateNums[affectedReportID] ?? affectedReport.stateNum, + statusNum: updatedReportStatusNums[affectedReportID] ?? affectedReport.statusNum, reportID: affectedReport.reportID ?? affectedReportID, }; diff --git a/tests/unit/TransactionTest.ts b/tests/unit/TransactionTest.ts index 92f01e5e3453..e2ce0cc4c45c 100644 --- a/tests/unit/TransactionTest.ts +++ b/tests/unit/TransactionTest.ts @@ -349,7 +349,7 @@ describe('Transaction', () => { mockAPIWrite.mockRestore(); }); - it('updates the source submitted report next step without reopening when it becomes empty', async () => { + it('updates the source submitted report next step and reopens it when it becomes empty', async () => { const mockAPIWrite = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); const buildOptimisticNextStepSpy = jest.spyOn(require('@libs/NextStepUtils'), 'buildOptimisticNextStep'); @@ -363,7 +363,7 @@ describe('Transaction', () => { ...createExpenseReport(6), reportID: FAKE_OLD_REPORT_ID, ownerAccountID: CURRENT_USER_ID, - stateNum: CONST.REPORT.STATE_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, currency: CONST.CURRENCY.USD, total: -100, @@ -394,13 +394,18 @@ describe('Transaction', () => { const sourceNextStepCall = buildOptimisticNextStepCalls.find(([params]) => params.report?.reportID === FAKE_OLD_REPORT_ID); expect(sourceNextStepCall).toBeDefined(); - expect(sourceNextStepCall?.[0].predictedNextStatus).toBe(CONST.REPORT.STATUS_NUM.SUBMITTED); + expect(sourceNextStepCall?.[0].predictedNextStatus).toBe(CONST.REPORT.STATUS_NUM.OPEN); const apiWriteCall = mockAPIWrite.mock.calls.at(0); - const optimisticData = (apiWriteCall?.[2] as {optimisticData?: Array<{key: string}>})?.optimisticData; + const optimisticData = (apiWriteCall?.[2] as {optimisticData?: Array<{key: string; value: Partial}>})?.optimisticData; const sourceNextStepUpdate = optimisticData?.find((data) => data.key === `${ONYXKEYS.COLLECTION.NEXT_STEP}${FAKE_OLD_REPORT_ID}`); + const sourceReportStateUpdate = optimisticData?.find( + (data) => data.key === `${ONYXKEYS.COLLECTION.REPORT}${FAKE_OLD_REPORT_ID}` && 'stateNum' in data.value && 'statusNum' in data.value, + ); expect(sourceNextStepUpdate).toBeDefined(); + expect(sourceReportStateUpdate?.value.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); + expect(sourceReportStateUpdate?.value.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); } finally { buildOptimisticNextStepSpy.mockRestore(); mockAPIWrite.mockRestore(); @@ -693,12 +698,14 @@ describe('Transaction', () => { expect(report?.total).toBe(0); }); - it('should reset the old report total to 0 when moving the last same-currency expense', async () => { + it('should reset the old report total to 0 and reopen it when moving the last same-currency expense', async () => { const oldExpenseReport = { ...createRandomReport(1, undefined), total: -200, nonReimbursableTotal: -200, currency: CONST.CURRENCY.USD, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, }; const transaction = { ...generateTransaction({ @@ -740,6 +747,8 @@ describe('Transaction', () => { expect(report?.total).toBe(0); expect(report?.nonReimbursableTotal).toBe(0); + expect(report?.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); + expect(report?.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); }); it('should reset the old report total to 0 when no expenses remain, even if the currency is different', async () => { From 847a4fbfe6265c51319b9724d6bb3e54d3b96556 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:14:30 +0700 Subject: [PATCH 065/519] run translation script --- src/languages/de.ts | 22 ++++++++++++++++++++++ src/languages/es.ts | 16 ++++++++++++++++ src/languages/fr.ts | 19 +++++++++++++++++++ src/languages/it.ts | 19 +++++++++++++++++++ src/languages/ja.ts | 16 ++++++++++++++++ src/languages/nl.ts | 19 +++++++++++++++++++ src/languages/pl.ts | 19 +++++++++++++++++++ src/languages/pt-BR.ts | 16 ++++++++++++++++ src/languages/zh-hans.ts | 13 +++++++++++++ 9 files changed, 159 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 57b6dfb756eb..0efacdba3fbc 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -9594,5 +9594,27 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, negativeButton: 'Nicht wirklich', }, monthPickerPage: {month: 'Monat', selectMonth: 'Bitte wählen Sie einen Monat aus'}, + aiFeaturesPromoModal: { + title: 'Lernen Sie Concierge AI kennen', + description: 'Ihr neuer KI-gestützter Expensify-Assistent.', + letsGo: "Los geht's", + learnMore: 'Mehr erfahren', + next: 'Weiter', + back: 'Zurück', + screens: { + spendAnalysis: { + title: 'Interaktive Ausgabenanalyse', + description: 'Bitten Sie Concierge, Ihre Ausgaben ganz nach Ihren Wünschen aufzuschlüsseln – nach Kategorie, Person, Projekt oder Zeitraum.', + }, + expenseAssistant: { + title: 'Lernen Sie Ihre neue Spesenassistenz kennen', + description: 'Concierge kann Ausgaben kategorisieren, Details ausfüllen und fehlenden Belegen für Sie nachgehen.', + }, + buildAgents: { + title: 'Erstellen Sie Ihre eigenen Agents', + description: 'Erstellen Sie benutzerdefinierte KI-Agenten, um die Workflows zu automatisieren, die für Ihr Team am wichtigsten sind.', + }, + }, + }, }; export default translations; diff --git a/src/languages/es.ts b/src/languages/es.ts index f94ab5bdd7e5..20b26c068662 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9839,5 +9839,21 @@ ${amount} para ${merchant} - ${date}`, lockScreenTrackingText: 'Siguiendo...', }, }, + aiFeaturesPromoModal: { + title: 'Conoce Concierge AI', + description: 'Tu nuevo asistente de Expensify potenciado por IA.', + letsGo: 'Vamos allá', + learnMore: 'Más información', + next: 'Siguiente', + back: 'Atrás', + screens: { + spendAnalysis: {title: 'Análisis interactivo del gasto', description: 'Pídele a Concierge que desglosE tus gastos como quieras: por categoría, persona, proyecto o periodo.'}, + expenseAssistant: { + title: 'Conoce a tu nuevo asistente de gastos', + description: 'Concierge puede categorizar gastos, completar detalles y hacer seguimiento de los recibos que falten por ti.', + }, + buildAgents: {title: 'Crea tus propios agentes', description: 'Crea agentes de IA personalizados para automatizar los flujos de trabajo que más le importan a tu equipo.'}, + }, + }, }; export default translations; diff --git a/src/languages/fr.ts b/src/languages/fr.ts index ffe8fe432ab7..e26aab6249bf 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -9629,5 +9629,24 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, negativeButton: 'Pas vraiment', }, monthPickerPage: {month: 'Mois', selectMonth: 'Veuillez sélectionner un mois'}, + aiFeaturesPromoModal: { + title: 'Découvrez Concierge IA', + description: 'Votre nouvel assistant Expensify, optimisé par l’IA.', + letsGo: 'Allons-y', + learnMore: 'En savoir plus', + next: 'Suivant', + back: 'Retour', + screens: { + spendAnalysis: { + title: 'Analyse interactive des dépenses', + description: 'Demandez à Concierge de ventiler vos dépenses comme vous le souhaitez — par catégorie, personne, projet ou période.', + }, + expenseAssistant: { + title: 'Découvrez votre nouvel assistant de dépenses', + description: 'Concierge peut catégoriser les dépenses, renseigner les détails et faire le suivi des reçus manquants pour vous.', + }, + buildAgents: {title: 'Créez vos propres agents', description: 'Créez des agents IA personnalisés pour automatiser les workflows qui comptent le plus pour votre équipe.'}, + }, + }, }; export default translations; diff --git a/src/languages/it.ts b/src/languages/it.ts index 14a51b1a27a3..55b42c0d0b53 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -9583,5 +9583,24 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, negativeButton: 'Non proprio', }, monthPickerPage: {month: 'Mese', selectMonth: 'Seleziona un mese'}, + aiFeaturesPromoModal: { + title: 'Incontra Concierge AI', + description: 'Il tuo nuovo assistente Expensify con tecnologia AI.', + letsGo: 'Andiamo', + learnMore: 'Scopri di più', + next: 'Avanti', + back: 'Indietro', + screens: { + spendAnalysis: { + title: 'Analisi interattiva delle spese', + description: 'Chiedi a Concierge di suddividere le tue spese come vuoi tu — per categoria, persona, progetto o periodo.', + }, + expenseAssistant: { + title: 'Scopri il tuo nuovo assistente per le spese', + description: 'Concierge può categorizzare le spese, compilare i dettagli e sollecitare le ricevute mancanti per te.', + }, + buildAgents: {title: 'Crea i tuoi agenti', description: 'Crea agenti IA personalizzati per automatizzare i flussi di lavoro più importanti per il tuo team.'}, + }, + }, }; export default translations; diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 979a87f17b04..4118c5aa3e4c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -9458,5 +9458,21 @@ ${reportName} negativeButton: 'そうでもありません', }, monthPickerPage: {month: '月', selectMonth: '月を選択してください'}, + aiFeaturesPromoModal: { + title: 'Concierge AI を紹介します', + description: '新しい AI 搭載の Expensify アシスタントです。', + letsGo: '始めましょう', + learnMore: '詳細はこちら', + next: '次へ', + back: '戻る', + screens: { + spendAnalysis: {title: 'インタラクティブな支出分析', description: 'カテゴリ、人、プロジェクト、期間など、必要な切り口で支出を分解するよう Concierge に依頼できます。'}, + expenseAssistant: { + title: '新しい経費アシスタントをご紹介します', + description: 'Concierge は、経費のカテゴリ分けや詳細の入力、不足しているレシートのフォローアップを代わりに行います。', + }, + buildAgents: {title: '独自のエージェントを作成する', description: 'チームにとって最も重要なワークフローを自動化するカスタム AI エージェントを作成しましょう。'}, + }, + }, }; export default translations; diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8540c1d87bdb..a0ceaa0758ba 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -9549,5 +9549,24 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, negativeButton: 'Niet echt', }, monthPickerPage: {month: 'Maand', selectMonth: 'Selecteer een maand'}, + aiFeaturesPromoModal: { + title: 'Maak kennis met Concierge AI', + description: 'Je nieuwe door AI aangestuurde Expensify-assistent.', + letsGo: 'Laten we gaan', + learnMore: 'Meer informatie', + next: 'Volgende', + back: 'Terug', + screens: { + spendAnalysis: { + title: 'Interactieve uitgavenanalyse', + description: 'Vraag Concierge om je uitgaven op elke gewenste manier op te splitsen — per categorie, persoon, project of tijd.', + }, + expenseAssistant: { + title: 'Maak kennis met je nieuwe onkostenassistent', + description: 'Concierge kan uitgaven categoriseren, details invullen en ontbrekende bonnen voor je opvolgen.', + }, + buildAgents: {title: 'Bouw je eigen agents', description: 'Maak aangepaste AI-agents om de workflows te automatiseren die het belangrijkst zijn voor je team.'}, + }, + }, }; export default translations; diff --git a/src/languages/pl.ts b/src/languages/pl.ts index bdbaf80e8efa..d0c90d123127 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -9528,5 +9528,24 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, negativeButton: 'Niekoniecznie', }, monthPickerPage: {month: 'Miesiąc', selectMonth: 'Wybierz miesiąc'}, + aiFeaturesPromoModal: { + title: 'Poznaj Concierge AI', + description: 'Twój nowy asystent Expensify zasilany AI.', + letsGo: 'Zaczynajmy', + learnMore: 'Dowiedz się więcej', + next: 'Dalej', + back: 'Wstecz', + screens: { + spendAnalysis: { + title: 'Interaktywna analiza wydatków', + description: 'Poproś Concierge o rozbicie swoich wydatków tak, jak chcesz — według kategorii, osoby, projektu lub czasu.', + }, + expenseAssistant: { + title: 'Poznaj swojego nowego asystenta wydatków', + description: 'Concierge może kategoryzować wydatki, uzupełniać szczegóły i śledzić brakujące paragony za ciebie.', + }, + buildAgents: {title: 'Zbuduj własne agentów', description: 'Twórz własne agentki AI, żeby automatyzować procesy, które są dla twojego zespołu najważniejsze.'}, + }, + }, }; export default translations; diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index cdfe462ac026..6713140189f4 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -9539,5 +9539,21 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, negativeButton: 'Na verdade, não', }, monthPickerPage: {month: 'Mês', selectMonth: 'Selecione um mês por favor'}, + aiFeaturesPromoModal: { + title: 'Conheça a Concierge AI', + description: 'Seu novo assistente Expensify com tecnologia de IA.', + letsGo: 'Vamos lá', + learnMore: 'Saiba mais', + next: 'Próximo', + back: 'Voltar', + screens: { + spendAnalysis: {title: 'Análise interativa de gastos', description: 'Peça ao Concierge para detalhar seus gastos como você quiser — por categoria, pessoa, projeto ou período.'}, + expenseAssistant: { + title: 'Conheça seu novo assistente de despesas', + description: 'O Concierge pode categorizar despesas, preencher detalhes e acompanhar recibos ausentes para você.', + }, + buildAgents: {title: 'Crie seus próprios agentes', description: 'Crie agentes de IA personalizados para automatizar os fluxos de trabalho que mais importam para a sua equipe.'}, + }, + }, }; export default translations; diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 95a993c47bf1..dbdfd8e0cce6 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9277,5 +9277,18 @@ ${reportName} }, proactiveAppReview: {title: '喜欢全新的 Expensify 吗?', description: '请告诉我们,这样我们就能帮助您让报销体验变得更好。', positiveButton: '太棒了!', negativeButton: '不太是'}, monthPickerPage: {month: '月份', selectMonth: '请选择月份'}, + aiFeaturesPromoModal: { + title: '了解 Concierge AI', + description: '您全新的 AI 驱动 Expensify 助理。', + letsGo: '开始使用', + learnMore: '了解详情', + next: '下一步', + back: '返回', + screens: { + spendAnalysis: {title: '交互式支出分析', description: '让 Concierge 按你想要的方式细分支出——按类别、人员、项目或时间。'}, + expenseAssistant: {title: '认识你的新报销助手', description: 'Concierge 可以帮你分类报销、填写详情,并跟进缺失的收据。'}, + buildAgents: {title: '构建你自己的代理', description: '创建自定义 AI 代理,自动化对你的团队最重要的工作流程。'}, + }, + }, }; export default translations; From 59c1cfbe25dd316e78ff87f269cbd32a801a33d0 Mon Sep 17 00:00:00 2001 From: Jules Rosser Date: Mon, 1 Jun 2026 14:48:37 -0700 Subject: [PATCH 066/519] Re-target claude-review to Opus 4.8 and set docs review effort to high --- .github/workflows/claude-review.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 9240a10cdae2..98d0bc643ad3 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -95,7 +95,7 @@ jobs: allowed_non_write_users: "*" prompt: "/review-code-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}" claude_args: | - --model claude-opus-4-7 + --model claude-opus-4-8 --effort xhigh --allowedTools "Task,Glob,Grep,Read,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(check-compiler.sh:*)" --json-schema '${{ steps.toolkit.outputs.schema_json }}' @@ -131,8 +131,8 @@ jobs: allowed_non_write_users: "*" prompt: "/review-helpdot-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}" claude_args: | - --model claude-opus-4-7 - --effort xhigh + --model claude-opus-4-8 + --effort high --allowedTools "Task,Glob,Grep,Read,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),mcp__github_inline_comment__create_inline_comment" - name: Remove in-progress indicator From 2c15181bd7f11688cd10fb20714680c3cfafc9f2 Mon Sep 17 00:00:00 2001 From: Jules Rosser Date: Mon, 1 Jun 2026 14:48:38 -0700 Subject: [PATCH 067/519] Pin Opus 4.8 and xhigh effort on deploy blocker investigation --- .github/workflows/deployBlockerInvestigation.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deployBlockerInvestigation.yml b/.github/workflows/deployBlockerInvestigation.yml index 18d7a45848c2..b41fce8f7af9 100644 --- a/.github/workflows/deployBlockerInvestigation.yml +++ b/.github/workflows/deployBlockerInvestigation.yml @@ -77,4 +77,6 @@ jobs: github_token: ${{ secrets.OS_BOTIFY_TOKEN }} prompt: "/investigate-deploy-blocker ISSUE_URL: ${{ env.ISSUE_URL }}" claude_args: | + --model claude-opus-4-8 + --effort xhigh --allowedTools "Task,Glob,Grep,Read,Write,Bash(gh issue view:*),Bash(gh issue comment:*),Bash(gh issue edit:*),Bash(gh issue list:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh pr diff:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(removeDeployBlockerLabel.sh:*)" From 73196b7b77916c594785b47bc13b5d3f158e2348 Mon Sep 17 00:00:00 2001 From: Jules Rosser Date: Mon, 1 Jun 2026 15:47:19 -0700 Subject: [PATCH 068/519] Pin claude-code-action to the v1.0.133 commit SHA, not the tag object --- .github/workflows/claude-review.yml | 4 ++-- .github/workflows/deployBlockerInvestigation.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index c09bac30a1c9..560010416300 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -80,7 +80,7 @@ jobs: - name: Run Claude Code (code) id: code-review if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' && steps.filter.outputs.code == 'true' - uses: anthropics/claude-code-action@db614ad5ed8c94def23ad6cc336751c8ba450ac1 # v1.0.133 + uses: anthropics/claude-code-action@787c5a0ce96a9a6cfb050ea0c8f4c05f2447c251 # v1.0.133 with: display_report: "true" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} @@ -119,7 +119,7 @@ jobs: - name: Run Claude Code (docs) if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' && steps.filter.outputs.docs == 'true' - uses: anthropics/claude-code-action@db614ad5ed8c94def23ad6cc336751c8ba450ac1 # v1.0.133 + uses: anthropics/claude-code-action@787c5a0ce96a9a6cfb050ea0c8f4c05f2447c251 # v1.0.133 with: display_report: "true" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/deployBlockerInvestigation.yml b/.github/workflows/deployBlockerInvestigation.yml index a9d7f96fad23..3211a295b50d 100644 --- a/.github/workflows/deployBlockerInvestigation.yml +++ b/.github/workflows/deployBlockerInvestigation.yml @@ -70,7 +70,7 @@ jobs: - name: Run Claude to investigate deploy blocker if: steps.validate.outputs.valid == 'true' - uses: anthropics/claude-code-action@db614ad5ed8c94def23ad6cc336751c8ba450ac1 # v1.0.133 + uses: anthropics/claude-code-action@787c5a0ce96a9a6cfb050ea0c8f4c05f2447c251 # v1.0.133 with: display_report: "true" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From d6cda4c9a649443de30bb7375a2fdcafd464794a Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 2 Jun 2026 06:11:47 +0500 Subject: [PATCH 069/519] fixed category and tag outOfPolcy error offline --- src/libs/Violations/ViolationsUtils.ts | 14 ++++++----- .../actions/IOUTest/UpdateMoneyRequestTest.ts | 2 ++ tests/unit/ViolationUtilsTest.ts | 25 ++++++++++++++++--- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index bf1b5e52a3fc..b416d35f40bb 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -96,15 +96,16 @@ function getTagViolationsForSingleLevelTags( const hasEnabledTagsInList = hasEnabledTags(policyTags); let newTransactionViolations = [...transactionViolations]; - // Add 'tagOutOfPolicy' violation if tag is not in policy and there are enabled tags - if (!hasTagOutOfPolicyViolation && updatedTransaction.tag && !isTagInPolicy && hasEnabledTagsInList) { + // Add 'tagOutOfPolicy' if the tag is not in policy. Not gated on enabled tags remaining, so deleting the + // last tag still flags a transaction that holds the deleted tag. Mirrors 'categoryOutOfPolicy'. + if (!hasTagOutOfPolicyViolation && updatedTransaction.tag && !isTagInPolicy) { const tagName = policyTagList[policyTagListName]?.name; const tagNameToShow = isDefaultTagName(tagName) ? undefined : tagName; newTransactionViolations.push({name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, data: {tagName: tagNameToShow}, showInReview: true}); } - // Remove 'tagOutOfPolicy' violation if tag is empty, in policy, or there are no enabled tags - if (hasTagOutOfPolicyViolation && (!updatedTransaction.tag || isTagInPolicy || !hasEnabledTagsInList)) { + // Remove 'tagOutOfPolicy' violation if tag is empty or in policy + if (hasTagOutOfPolicyViolation && (!updatedTransaction.tag || isTagInPolicy)) { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY}); } @@ -414,8 +415,9 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.SMARTSCAN_FAILED}); } - // Calculate client-side category violations - const policyRequiresCategories = !!policy.requiresCategory; + // Calculate client-side category violations. Also run when the transaction has a category (not just + // when the policy requires one) so disabling that category flags it optimistically. Mirrors tags below. + const policyRequiresCategories = !!policy.requiresCategory || !!updatedTransaction.category; if (policyRequiresCategories) { const hasCategoryOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === 'categoryOutOfPolicy'); const hasMissingCategoryViolation = transactionViolations.some((violation) => violation.name === 'missingCategory'); diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index 1a682ceaba5f..eef2e7cc7ed7 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -597,6 +597,8 @@ describe('actions/IOU/UpdateMoneyRequest', () => { reportID: expenseReportID, amount: 10000, currency: CONST.CURRENCY.USD, + // No category so the test stays focused on the rejected-expense violation + category: undefined, }; const policy: Policy = { diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index f5aa4d373b26..f7eddbbe44ae 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -939,6 +939,23 @@ describe('getViolationsOnyxData', () => { expect(result.value).not.toContainEqual(categoryOutOfPolicyViolation); expect(result.value).not.toContainEqual(missingCategoryViolation); }); + + it('should add categoryOutOfPolicy when the transaction has a category that is not in policy', () => { + transaction.category = 'Office Supplies'; + policyCategories = {Food: {name: 'Food', enabled: true}}; + + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + + expect(result.value).toContainEqual(categoryOutOfPolicyViolation); + }); }); describe('policyRequiresTags', () => { @@ -1135,7 +1152,7 @@ describe('getViolationsOnyxData', () => { expect(result.value).not.toContainEqual(missingTagViolation); }); - it('should not add tagOutOfPolicy when transaction has a stale tag and no tags are enabled', () => { + it('should add tagOutOfPolicy when transaction has a stale tag and no tags are enabled', () => { policyTags = { Meals: { name: 'Meals', @@ -1159,10 +1176,10 @@ describe('getViolationsOnyxData', () => { isInvoiceTransaction: false, }); - expect(result.value).not.toContainEqual(tagOutOfPolicyViolation); + expect(result.value).toContainEqual({...tagOutOfPolicyViolation, data: {tagName: 'Meals'}}); }); - it('should remove existing tagOutOfPolicy when transaction has a stale tag and no tags are enabled', () => { + it('should keep existing tagOutOfPolicy when transaction has a stale tag and no tags are enabled', () => { policyTags = { Meals: { name: 'Meals', @@ -1187,7 +1204,7 @@ describe('getViolationsOnyxData', () => { isInvoiceTransaction: false, }); - expect(result.value).not.toContainEqual(tagOutOfPolicyViolation); + expect(result.value).toContainEqual(tagOutOfPolicyViolation); expect(result.value).toContainEqual(duplicatedTransactionViolation); }); }); From dc884962769efc7bd09137638b0f9c83ca649c48 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:39:34 +0700 Subject: [PATCH 070/519] remove deprecated getUrlWithBackToParam --- src/ROUTES.ts | 6 +----- src/libs/Navigation/guards/AIFeaturesPromoGuard.ts | 2 +- src/libs/Navigation/linkingConfig/config.ts | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index b58efd3b6367..4e6ece62015c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3375,11 +3375,7 @@ const ROUTES = { getRoute: (backTo?: string) => getUrlWithBackToParam('onboarding/migrated-user-welcome', backTo, false), }, - AI_FEATURES_PROMO_MODAL: { - route: 'ai-features-promo', - - getRoute: (backTo?: string) => getUrlWithBackToParam('ai-features-promo', backTo, false), - }, + AI_FEATURES_PROMO_MODAL: 'ai-features-promo', TRANSACTION_RECEIPT: { route: 'r/:reportID/transaction/:transactionID/receipt/:action?/:iouType?', diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts index 192b89324c0e..3a8df06ead97 100644 --- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts +++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts @@ -67,7 +67,7 @@ function navigateToAIFeaturesPromoModalIfReady() { Log.info('[AIFeaturesPromoGuard] Proactively navigating to AI features promo modal'); hasRedirectedToAIFeaturesPromoModal = true; - Navigation.navigate(ROUTES.AI_FEATURES_PROMO_MODAL.getRoute()); + Navigation.navigate(ROUTES.AI_FEATURES_PROMO_MODAL); } /** diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index d6c4cbd3b396..3079af6ec200 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -65,7 +65,7 @@ const config: LinkingOptions['config'] = { [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: { screens: { [SCREENS.AI_FEATURES_PROMO_MODAL.ROOT]: { - path: ROUTES.AI_FEATURES_PROMO_MODAL.route, + path: ROUTES.AI_FEATURES_PROMO_MODAL, exact: true, }, }, From 03bc82f9ff7f3a382ac989320378b4f1e1d9c572 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Tue, 2 Jun 2026 12:53:53 -0400 Subject: [PATCH 071/519] add the new routes --- src/CONST/index.ts | 1 + src/ROUTES.ts | 4 ++ src/SCREENS.ts | 1 + .../configuration/SpendRulesCurrencyBase.tsx | 13 ++++++ src/languages/en.ts | 1 + .../ModalStackNavigators/index.tsx | 1 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 3 ++ src/libs/Navigation/types.ts | 4 ++ .../SpendRules/SpendRuleCurrenciesPage.tsx | 5 +++ .../rules/SpendRules/SpendRulePageBase.tsx | 40 ++++++++++++------- 11 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx create mode 100644 src/pages/workspace/rules/SpendRules/SpendRuleCurrenciesPage.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e0a12e750777..d71c46700650 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9841,6 +9841,7 @@ const CONST = { MERCHANT_RULE_PREVIEW_MATCHES: 'WorkspaceRules-MerchantRulePreviewMatches', MERCHANT_RULE_DELETE: 'WorkspaceRules-MerchantRuleDelete', CATEGORY_SELECTOR: 'WorkspaceRules-CategorySelector', + CURRENCY_SELECTOR: 'WorkspaceRules-CurrencySelector', SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem', SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave', SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2cb9d3cb3d1b..a5ff5794173a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3185,6 +3185,10 @@ const ROUTES = { route: 'workspaces/:policyID/rules/spend-rules/:ruleID/merchants', getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID ?? ROUTES.NEW}/merchants` as const, }, + RULES_SPEND_CURRENCIES: { + route: 'workspaces/:policyID/rules/spend-rules/:ruleID/currencies', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID ?? ROUTES.NEW}/currencies` as const, + }, RULES_SPEND_MERCHANT_EDIT: { route: 'workspaces/:policyID/rules/spend-rules/:ruleID/merchants/:merchantIndex', getRoute: (policyID: string, ruleID: string, merchantIndex: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID}/merchants/${merchantIndex}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c541dd306aaa..db10436cfc36 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -896,6 +896,7 @@ const SCREENS = { RULES_SPEND_CARD: 'Rules_Spend_Card', RULES_SPEND_CATEGORY: 'Rules_Spend_Category', RULES_SPEND_MAX_AMOUNT: 'Rules_Spend_Max_Amount', + RULES_SPEND_CURRENCIES: 'Rules_Spend_Currencies', RULES_MERCHANT_MERCHANT_TO_MATCH: 'Rules_Merchant_Merchant_To_Match', RULES_MERCHANT_MATCH_TYPE: 'Rules_Merchant_Match_Type', RULES_MERCHANT_MERCHANT: 'Rules_Merchant_Merchant', diff --git a/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx b/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx new file mode 100644 index 000000000000..c10deeed51d0 --- /dev/null +++ b/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx @@ -0,0 +1,13 @@ +import {useCurrencyListState} from '@hooks/useCurrencyList'; +import useOnyx from '@hooks/useOnyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {CurrencyList} from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; + +type SpendRulesCurrencyBaseProps = {}; + +export default function SpendRulesCurrencyBase({}: SpendRulesCurrencyBaseProps) { + const [currencyList = getEmptyObject()] = useOnyx(ONYXKEYS.CURRENCY_LIST); + + return <>; +} diff --git a/src/languages/en.ts b/src/languages/en.ts index f9f5f69df835..5a69b8789c6f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7267,6 +7267,7 @@ const translations = { confirmErrorApplyAtLeastOneSpendRule: 'Apply at least one spend rule', categories: 'Categories', merchants: 'Merchants', + permittedCurrencies: 'Permitted currencies', noAvailableCards: 'All cards already have a rule', noAvailableCardsSubtitle: 'Edit an existing card rule to make changes', noCardsIssuedTitle: 'No Expensify Cards issued', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index a3f39ee3bdaf..3d595c78aa37 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1074,6 +1074,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/SpendRules/SpendRuleCardPage').default, [SCREENS.WORKSPACE.RULES_SPEND_CATEGORY]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleCategoryPage').default, [SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleMaxAmountPage').default, + [SCREENS.WORKSPACE.RULES_SPEND_CURRENCIES]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleCurrenciesPage').default, [SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleMerchantsPage').default, [SCREENS.WORKSPACE.RULES_SPEND_MERCHANT_EDIT]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantToMatchPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 9592240bad4e..8ba1094fbcaa 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -336,6 +336,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT]: { path: ROUTES.RULES_SPEND_MAX_AMOUNT.route, }, + [SCREENS.WORKSPACE.RULES_SPEND_CURRENCIES]: { + path: ROUTES.RULES_SPEND_CURRENCIES.route, + }, [SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS]: { path: ROUTES.RULES_SPEND_MERCHANTS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index dcd5071a87e0..3d7dea0b3bfa 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1503,6 +1503,10 @@ type SettingsNavigatorParamList = { policyID: string; ruleID: string; }; + [SCREENS.WORKSPACE.RULES_SPEND_CURRENCIES]: { + policyID: string; + ruleID: string; + }; [SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS]: { policyID: string; ruleID: string; diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleCurrenciesPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleCurrenciesPage.tsx new file mode 100644 index 000000000000..00f8b3c5be91 --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCurrenciesPage.tsx @@ -0,0 +1,5 @@ +type SpendRuleCurrenciesPageProps = {}; + +export default function SpendRuleCurrenciesPage({}: SpendRuleCurrenciesPageProps) { + return <>; +} diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 54bc5f09c9af..b934babd46b4 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -218,6 +218,31 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID}: SpendRulePageBa sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} /> {translate('workspace.rules.spendRules.spendRuleSectionTitle')} + + { + clearError(); + if (!selectedCurrency) { + openCurrencyMismatchModal(); + return; + } + navigation.navigate(SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT, {policyID, ruleID: currentRuleID}); + }} + shouldShowRightIcon + title={maxAmountMenuTitle} + titleStyle={styles.flex1} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} + /> + {}} + shouldShowRightIcon + title={maxAmountMenuTitle} + titleStyle={styles.flex1} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.CURRENCY_SELECTOR} + /> + - { - clearError(); - if (!selectedCurrency) { - openCurrencyMismatchModal(); - return; - } - navigation.navigate(SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT, {policyID, ruleID: currentRuleID}); - }} - shouldShowRightIcon - title={maxAmountMenuTitle} - titleStyle={styles.flex1} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} - /> Date: Tue, 2 Jun 2026 12:56:53 -0400 Subject: [PATCH 072/519] add page content --- src/CONST/index.ts | 1 + .../configuration/SpendRulesCurrencyBase.tsx | 9 +++++--- .../SpendRules/SpendRuleCurrenciesPage.tsx | 21 ++++++++++++++++--- .../rules/SpendRules/SpendRulePageBase.tsx | 5 ++++- src/types/form/SpendRuleForm.ts | 1 + 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d71c46700650..e3b4c291c9e6 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4680,6 +4680,7 @@ const CONST = { MERCHANT_MATCH_TYPES: 'merchantMatchTypes', CATEGORIES: 'categories', MAX_AMOUNT: 'maxAmount', + CURRENCIES: 'currencies', }, }, ACTION: { diff --git a/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx b/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx index c10deeed51d0..66302ad35a9c 100644 --- a/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx +++ b/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx @@ -1,12 +1,15 @@ -import {useCurrencyListState} from '@hooks/useCurrencyList'; +import React from 'react'; import useOnyx from '@hooks/useOnyx'; import ONYXKEYS from '@src/ONYXKEYS'; import {CurrencyList} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; -type SpendRulesCurrencyBaseProps = {}; +type SpendRulesCurrencyBaseProps = { + currencies: string[]; + onCurrenciesChange: (currencies: string[]) => void; +}; -export default function SpendRulesCurrencyBase({}: SpendRulesCurrencyBaseProps) { +export default function SpendRulesCurrencyBase({currencies, onCurrenciesChange}: SpendRulesCurrencyBaseProps) { const [currencyList = getEmptyObject()] = useOnyx(ONYXKEYS.CURRENCY_LIST); return <>; diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleCurrenciesPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleCurrenciesPage.tsx index 00f8b3c5be91..2d79e3f63935 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRuleCurrenciesPage.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCurrenciesPage.tsx @@ -1,5 +1,20 @@ -type SpendRuleCurrenciesPageProps = {}; +import React from 'react'; +import SpendRulesCurrencyBase from '@components/SpendRules/configuration/SpendRulesCurrencyBase'; +import useOnyx from '@hooks/useOnyx'; +import {updateDraftSpendRule} from '@libs/actions/User'; +import ONYXKEYS from '@src/ONYXKEYS'; -export default function SpendRuleCurrenciesPage({}: SpendRuleCurrenciesPageProps) { - return <>; +export default function SpendRuleCurrenciesPage() { + const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); + + const onCurrenciesChange = (currencies: string[]) => { + updateDraftSpendRule({currencies}); + }; + + return ( + + ); } diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index b934babd46b4..8dda4aec3152 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -236,7 +236,10 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID}: SpendRulePageBa /> {}} + onPress={() => { + clearError(); + navigation.navigate(SCREENS.WORKSPACE.RULES_SPEND_CURRENCIES, {policyID, ruleID: currentRuleID}); + }} shouldShowRightIcon title={maxAmountMenuTitle} titleStyle={styles.flex1} diff --git a/src/types/form/SpendRuleForm.ts b/src/types/form/SpendRuleForm.ts index e1d9568b64cc..a35a17ced33e 100644 --- a/src/types/form/SpendRuleForm.ts +++ b/src/types/form/SpendRuleForm.ts @@ -20,6 +20,7 @@ type SpendRuleForm = Form< [INPUT_IDS.MERCHANT_NAMES]: string[]; [INPUT_IDS.MERCHANT_MATCH_TYPES]: Array>; [INPUT_IDS.CATEGORIES]: SpendRuleCategory[]; + [INPUT_IDS.CURRENCIES]: string[]; [INPUT_IDS.MAX_AMOUNT]: string; } >; From 7e88101e6fa1480d7d999eab4b8c08644f56459b Mon Sep 17 00:00:00 2001 From: "Sahil (via MelvinBot)" Date: Tue, 2 Jun 2026 18:07:22 +0000 Subject: [PATCH 073/519] Remove defensive iouReportID block on destination chat in convertIOUReportToExpenseReport Co-authored-by: Sahil --- src/libs/actions/Report/index.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 24b39eb99bef..1b91d15105a8 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -6881,24 +6881,6 @@ function convertIOUReportToExpenseReport(iouReport: Report, policy: Policy, poli }, }); - // Attach the moved report to the destination policy expense chat so the expense detail view - // can resolve the report via iouReportID when navigated to offline. - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticPolicyExpenseChatReportID}`, - value: { - iouReportID: reportID, - hasOutstandingChildRequest: !isReportManuallyReimbursed(iouReport), - }, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticPolicyExpenseChatReportID}`, - value: { - iouReportID: null, - }, - }); - // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved const movedReportAction = buildOptimisticMovedReportAction(iouReport.policyID, policyID, optimisticPolicyExpenseChatReportID, reportID, policy.name); optimisticData.push({ From 04ae9274d70391ef653f1e5b820d0ea10cb3dc04 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Tue, 2 Jun 2026 14:19:57 -0400 Subject: [PATCH 074/519] create the page --- .../configuration/SpendRulesCurrencyBase.tsx | 138 +++++++++++++++++- 1 file changed, 131 insertions(+), 7 deletions(-) diff --git a/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx b/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx index 66302ad35a9c..7adb4a2ac47c 100644 --- a/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx +++ b/src/components/SpendRules/configuration/SpendRulesCurrencyBase.tsx @@ -1,16 +1,140 @@ -import React from 'react'; -import useOnyx from '@hooks/useOnyx'; -import ONYXKEYS from '@src/ONYXKEYS'; -import {CurrencyList} from '@src/types/onyx'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import React, {useState} from 'react'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import {useCurrencyListActions, useCurrencyListState} from '@components/CurrencyListContextProvider'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import SelectionList from '@components/SelectionList'; +import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; +import {ListItem} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import {getCurrencyOptions} from '@libs/SearchUIUtils'; +import variables from '@styles/variables'; +import Navigation from '@src/Navigation/Navigation'; type SpendRulesCurrencyBaseProps = { currencies: string[]; onCurrenciesChange: (currencies: string[]) => void; }; +type CurrencyListItem = ListItem & { + value: string; +}; + export default function SpendRulesCurrencyBase({currencies, onCurrenciesChange}: SpendRulesCurrencyBaseProps) { - const [currencyList = getEmptyObject()] = useOnyx(ONYXKEYS.CURRENCY_LIST); + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const [selectedCurrencies, setSelectedCurrencies] = useState(currencies); + + const {currencyList} = useCurrencyListState(); + const {getCurrencySymbol} = useCurrencyListActions(); + const currencyOptions = getCurrencyOptions(currencyList, getCurrencySymbol); + + const currencyItems: CurrencyListItem[] = currencyOptions.map((currencyDetails) => ({ + keyForList: currencyDetails.value, + text: currencyDetails.text, + value: currencyDetails.value, + isSelected: selectedCurrencies.includes(currencyDetails.value), + })); + + const filterCurrency = (item: CurrencyListItem, searchInput: string) => { + return (item.text ?? '').toLowerCase().includes(searchInput.toLowerCase()); + }; + + const sortCurrencies = (items: CurrencyListItem[]) => { + return items.sort((a, b) => localeCompare(a.text ?? '', b.text ?? '')); + }; + + const [inputValue, setInputValue, filteredCategoryItems] = useSearchResults(currencyItems, filterCurrency, sortCurrencies); + + const toggleCurrency = (item: CurrencyListItem) => { + setSelectedCurrencies((prev) => { + if (prev.includes(item.value)) { + return prev.filter((currency) => currency !== item.value); + } + return [...prev, item.value]; + }); + }; + + const toggleSelectAll = () => { + const visibleValues = filteredCategoryItems.map((item) => item.value); + const allVisibleSelected = visibleValues.length > 0 && visibleValues.every((value) => selectedCurrencies.includes(value)); + + if (allVisibleSelected) { + const visibleSet = new Set(visibleValues); + setSelectedCurrencies((prev) => prev.filter((currency) => !visibleSet.has(currency))); + return; + } + + setSelectedCurrencies((prev) => { + const next = new Set([...prev, ...visibleValues]); + return Array.from(next); + }); + }; + + const goBack = () => { + Navigation.goBack(); + }; + + const handleSave = () => { + onCurrenciesChange(selectedCurrencies); + goBack(); + }; - return <>; + return ( + + + 0 ? toggleSelectAll : undefined} + textInputOptions={{ + value: inputValue, + label: translate('common.search'), + onChangeText: setInputValue, + }} + style={{ + listHeaderWrapperStyle: [styles.pt5, styles.pb2], + listHeaderSelectAllTextStyle: [styles.textLabelSupporting], + }} + listEmptyContent={ + + + + } + footerContent={ + + } + /> + + ); } From 13dca42223d5153c54f89bae71a8faf30ee72447 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Tue, 2 Jun 2026 23:04:38 +0000 Subject: [PATCH 075/519] Split workspace_created conversion event into two events for ad platforms Splits the single `workspace_created` conversion event into two so Google, Meta, and Reddit can optimize bids toward higher-value leads: - `workspace_created_sales_eligible` fires when all of: onboarding intent is "Manage my team", company size is 5+ employees, and the email domain is private. It uses the standard "Lead" Meta/Reddit event so the platforms' pre-trained conversion models optimize toward these leads. - `workspace_created` fires for all other workspace creations and now uses a custom Meta/Reddit event, so the higher-volume, lower-value conversions don't dilute the standard "Lead" optimization. The decision lives in getWorkspaceCreatedAnalyticsEvent and is applied at both firing sites (createWorkspace and categorizeTrackedExpense). LinkedIn is paused and intentionally left unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CONST/index.ts | 11 +++++ src/libs/GoogleTagManager/index.ts | 24 +++++++--- src/libs/actions/IOU/TrackExpense.ts | 4 +- src/libs/actions/Policy/Policy.ts | 4 +- src/libs/getWorkspaceCreatedAnalyticsEvent.ts | 33 +++++++++++++ tests/unit/GoogleTagManagerTest.tsx | 44 +++++++++++++++++ .../getWorkspaceCreatedAnalyticsEventTest.ts | 48 +++++++++++++++++++ 7 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 src/libs/getWorkspaceCreatedAnalyticsEvent.ts create mode 100644 tests/unit/getWorkspaceCreatedAnalyticsEventTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 25afa6b63191..adfd72d2a24f 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9023,8 +9023,19 @@ const CONST = { REDDIT: 'SignUp', LINKEDIN: 507587661, }, + // Fired for workspace creations that don't match the "sales-eligible" profile. Uses a custom Meta/Reddit event + // so the lower-value, higher-volume conversions don't dilute the standard "Lead" optimization below. WORKSPACE_CREATED: { NAME: 'workspace_created', + META: 'workspace_created', + REDDIT: 'workspace_created', + LINKEDIN: 25474804, + IS_CUSTOM_PIXEL_EVENT: true, + }, + // Fired for workspace creations that match the "sales-eligible" profile (see getWorkspaceCreatedAnalyticsEvent). + // Uses the standard "Lead" event so Meta/Reddit can optimize bids toward these higher-value leads. + WORKSPACE_CREATED_SALES_ELIGIBLE: { + NAME: 'workspace_created_sales_eligible', META: 'Lead', REDDIT: 'Lead', LINKEDIN: 25474804, diff --git a/src/libs/GoogleTagManager/index.ts b/src/libs/GoogleTagManager/index.ts index 79db970d66df..dd75aaa1c0fb 100644 --- a/src/libs/GoogleTagManager/index.ts +++ b/src/libs/GoogleTagManager/index.ts @@ -13,7 +13,7 @@ type WindowWithPixels = Window & { push: (params: DataLayerPushParams) => void; }; fbq?: (method: string, eventName: string, params?: Record, options?: Record) => void; - rdt?: (method: string, eventType: string, params?: Record) => void; + rdt?: (method: string, eventType: string, params?: Record) => void; lintrk?: (method: string, params: Record) => void; }; @@ -25,7 +25,12 @@ type DataLayerPushParams = { declare const window: WindowWithPixels; -const PIXEL_EVENTS = [CONST.ANALYTICS.EVENT.SIGN_UP, CONST.ANALYTICS.EVENT.WORKSPACE_CREATED, CONST.ANALYTICS.EVENT.PAID_ADOPTION] as const; +const PIXEL_EVENTS = [ + CONST.ANALYTICS.EVENT.SIGN_UP, + CONST.ANALYTICS.EVENT.WORKSPACE_CREATED, + CONST.ANALYTICS.EVENT.WORKSPACE_CREATED_SALES_ELIGIBLE, + CONST.ANALYTICS.EVENT.PAID_ADOPTION, +] as const; function publishEvent(event: GoogleTagManagerEvent, accountID: number, email: string) { if (!window.dataLayer) { @@ -46,17 +51,22 @@ function publishEvent(event: GoogleTagManagerEvent, accountID: number, email: st const eventID = `${accountID}-${event}`; + // Standard events (e.g. "Lead") tap into Meta/Reddit's pre-trained conversion models, so we only mark an event as + // custom when we intentionally don't want it optimized against the standard event. + const isCustomPixelEvent = 'IS_CUSTOM_PIXEL_EVENT' in pixelEvent && pixelEvent.IS_CUSTOM_PIXEL_EVENT; + // Meta if (typeof window.fbq === 'function') { - window.fbq('track', pixelEvent.META, {em: email}, {eventID}); + window.fbq(isCustomPixelEvent ? 'trackCustom' : 'track', pixelEvent.META, {em: email}, {eventID}); } // Reddit if (typeof window.rdt === 'function') { - window.rdt('track', pixelEvent.REDDIT, { - conversionId: eventID, - email, - }); + if (isCustomPixelEvent) { + window.rdt('track', 'Custom', {customEventName: pixelEvent.REDDIT, conversionId: eventID, email}); + } else { + window.rdt('track', pixelEvent.REDDIT, {conversionId: eventID, email}); + } } // LinkedIn (uses numeric conversion IDs instead of named events) diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 2ba8d94de918..273fbcd8890a 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -16,6 +16,7 @@ import DateUtils from '@libs/DateUtils'; import {deferOrExecuteWrite} from '@libs/deferredLayoutWrite'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import getWorkspaceCreatedAnalyticsEvent from '@libs/getWorkspaceCreatedAnalyticsEvent'; import GoogleTagManager from '@libs/GoogleTagManager'; import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils} from '@libs/IOUUtils'; import isFileUploadable from '@libs/isFileUploadable'; @@ -2155,7 +2156,8 @@ function categorizeTrackedExpense(trackedExpenseParams: TrackedExpenseParams) { // If a draft policy was used, then the CategorizeTrackedExpense command will create a real one // so let's track that conversion here if (isDraftPolicy) { - GoogleTagManager.publishEvent(CONST.ANALYTICS.EVENT.WORKSPACE_CREATED.NAME, currentUserAccountID, currentUser.email ?? ''); + const workspaceCreatedEvent = getWorkspaceCreatedAnalyticsEvent(createdWorkspaceParams?.engagementChoice, createdWorkspaceParams?.companySize, currentUser.email ?? ''); + GoogleTagManager.publishEvent(workspaceCreatedEvent, currentUserAccountID, currentUser.email ?? ''); } } diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 911096b2adb1..3bfd6b6cf6df 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -83,6 +83,7 @@ import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import {createFile, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import getWorkspaceCreatedAnalyticsEvent from '@libs/getWorkspaceCreatedAnalyticsEvent'; import GoogleTagManager from '@libs/GoogleTagManager'; import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; @@ -3108,7 +3109,8 @@ function createWorkspace(options: CreateWorkspaceDataOptions): CreateWorkspacePa // Publish a workspace created event if this is their first policy if (!options.hasActiveAdminPolicies) { - GoogleTagManager.publishEvent(CONST.ANALYTICS.EVENT.WORKSPACE_CREATED.NAME, options.currentUserAccountIDParam ?? CONST.DEFAULT_NUMBER_ID, options.currentUserEmailParam); + const workspaceCreatedEvent = getWorkspaceCreatedAnalyticsEvent(optionsWithDefaults.engagementChoice, optionsWithDefaults.companySize, options.currentUserEmailParam); + GoogleTagManager.publishEvent(workspaceCreatedEvent, options.currentUserAccountIDParam ?? CONST.DEFAULT_NUMBER_ID, options.currentUserEmailParam); } return params; diff --git a/src/libs/getWorkspaceCreatedAnalyticsEvent.ts b/src/libs/getWorkspaceCreatedAnalyticsEvent.ts new file mode 100644 index 000000000000..b4ee2e81df02 --- /dev/null +++ b/src/libs/getWorkspaceCreatedAnalyticsEvent.ts @@ -0,0 +1,33 @@ +import CONST from '@src/CONST'; +import type {GoogleTagManagerEvent} from './GoogleTagManager/types'; +import {isEmailPublicDomain} from './LoginUtils'; + +// Company size ranges that represent 5 or more employees. The "1-4" range and the deprecated "1-10" range are excluded +// because they can include fewer than 5 employees. +const SALES_ELIGIBLE_COMPANY_SIZES = new Set([ + CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, + CONST.ONBOARDING_COMPANY_SIZE.SMALL, + CONST.ONBOARDING_COMPANY_SIZE.MEDIUM_SMALL, + CONST.ONBOARDING_COMPANY_SIZE.MEDIUM, + CONST.ONBOARDING_COMPANY_SIZE.LARGE, +]); + +/** + * Determines which workspace-created conversion event to publish to the ad platforms. + * + * Returns the `workspace_created_sales_eligible` event when the creator matches our higher-value "sales-eligible" + * profile, defined as all of: + * - Onboarding intent is "Manage my team" + * - Company size of 5 or more employees + * - A private (non-public/free) email domain + * + * Otherwise returns the standard `workspace_created` event. + */ +function getWorkspaceCreatedAnalyticsEvent(engagementChoice: string | undefined, companySize: string | undefined, email: string): GoogleTagManagerEvent { + const isSalesEligible = + engagementChoice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM && !!companySize && SALES_ELIGIBLE_COMPANY_SIZES.has(companySize) && !!email && !isEmailPublicDomain(email); + + return isSalesEligible ? CONST.ANALYTICS.EVENT.WORKSPACE_CREATED_SALES_ELIGIBLE.NAME : CONST.ANALYTICS.EVENT.WORKSPACE_CREATED.NAME; +} + +export default getWorkspaceCreatedAnalyticsEvent; diff --git a/tests/unit/GoogleTagManagerTest.tsx b/tests/unit/GoogleTagManagerTest.tsx index 3245b4613b99..7a8d44184b82 100644 --- a/tests/unit/GoogleTagManagerTest.tsx +++ b/tests/unit/GoogleTagManagerTest.tsx @@ -204,6 +204,50 @@ describe('GoogleTagManagerTest', () => { expect(GoogleTagManager.publishEvent).toHaveBeenCalledWith(CONST.ANALYTICS.EVENT.WORKSPACE_CREATED.NAME, 123456, email); }); + test('workspace_created_sales_eligible', async () => { + // When we create a first workspace with the "Manage my team" intent, a company of 5+ employees, and a private email domain + createWorkspace({ + policyName: '', + introSelected: undefined, + currentUserAccountIDParam: 123456, + activePolicy: undefined, + currentUserEmailParam: 'test@test.com', + currency: undefined, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, + companySize: CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, + }); + await waitForBatchedUpdatesWithAct(); + + // Then we publish the sales-eligible workspace_created event + expect(GoogleTagManager.publishEvent).toHaveBeenCalledTimes(1); + expect(GoogleTagManager.publishEvent).toHaveBeenCalledWith(CONST.ANALYTICS.EVENT.WORKSPACE_CREATED_SALES_ELIGIBLE.NAME, 123456, email); + }); + + test('workspace_created - public email domain is not sales eligible', async () => { + // When we create a first workspace that meets the intent and company size criteria but uses a public email domain + createWorkspace({ + policyName: '', + introSelected: undefined, + currentUserAccountIDParam: 123456, + activePolicy: undefined, + currentUserEmailParam: 'test@gmail.com', + currency: undefined, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, + companySize: CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, + }); + await waitForBatchedUpdatesWithAct(); + + // Then we publish the standard workspace_created event + expect(GoogleTagManager.publishEvent).toHaveBeenCalledTimes(1); + expect(GoogleTagManager.publishEvent).toHaveBeenCalledWith(CONST.ANALYTICS.EVENT.WORKSPACE_CREATED.NAME, 123456, 'test@gmail.com'); + }); + test('workspace_created - categorizeTrackedExpense', async () => { await act(async () => { await Onyx.set(ONYXKEYS.SESSION, {accountID, email}); diff --git a/tests/unit/getWorkspaceCreatedAnalyticsEventTest.ts b/tests/unit/getWorkspaceCreatedAnalyticsEventTest.ts new file mode 100644 index 000000000000..5704da7517e8 --- /dev/null +++ b/tests/unit/getWorkspaceCreatedAnalyticsEventTest.ts @@ -0,0 +1,48 @@ +import getWorkspaceCreatedAnalyticsEvent from '@libs/getWorkspaceCreatedAnalyticsEvent'; +import CONST from '@src/CONST'; + +const SALES_ELIGIBLE = CONST.ANALYTICS.EVENT.WORKSPACE_CREATED_SALES_ELIGIBLE.NAME; +const STANDARD = CONST.ANALYTICS.EVENT.WORKSPACE_CREATED.NAME; + +const PRIVATE_EMAIL = 'user@expensify.com'; +const PUBLIC_EMAIL = 'user@gmail.com'; + +describe('getWorkspaceCreatedAnalyticsEvent', () => { + it('returns the sales-eligible event when all criteria are met', () => { + expect(getWorkspaceCreatedAnalyticsEvent(CONST.ONBOARDING_CHOICES.MANAGE_TEAM, CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, PRIVATE_EMAIL)).toBe(SALES_ELIGIBLE); + }); + + it.each([ + CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, + CONST.ONBOARDING_COMPANY_SIZE.SMALL, + CONST.ONBOARDING_COMPANY_SIZE.MEDIUM_SMALL, + CONST.ONBOARDING_COMPANY_SIZE.MEDIUM, + CONST.ONBOARDING_COMPANY_SIZE.LARGE, + ])('treats company size "%s" (5+ employees) as sales eligible', (companySize) => { + expect(getWorkspaceCreatedAnalyticsEvent(CONST.ONBOARDING_CHOICES.MANAGE_TEAM, companySize, PRIVATE_EMAIL)).toBe(SALES_ELIGIBLE); + }); + + it.each([ + // The "1-4" range is below 5 employees, and the deprecated "1-10" range can include fewer than 5 employees. + CONST.ONBOARDING_COMPANY_SIZE.MICRO_SMALL, + CONST.ONBOARDING_COMPANY_SIZE.MICRO, + ])('treats company size "%s" (possibly under 5 employees) as not sales eligible', (companySize) => { + expect(getWorkspaceCreatedAnalyticsEvent(CONST.ONBOARDING_CHOICES.MANAGE_TEAM, companySize, PRIVATE_EMAIL)).toBe(STANDARD); + }); + + it('returns the standard event when the intent is not "Manage my team"', () => { + expect(getWorkspaceCreatedAnalyticsEvent(CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE, CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, PRIVATE_EMAIL)).toBe(STANDARD); + }); + + it('returns the standard event when the email domain is public', () => { + expect(getWorkspaceCreatedAnalyticsEvent(CONST.ONBOARDING_CHOICES.MANAGE_TEAM, CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, PUBLIC_EMAIL)).toBe(STANDARD); + }); + + it('returns the standard event when company size is missing', () => { + expect(getWorkspaceCreatedAnalyticsEvent(CONST.ONBOARDING_CHOICES.MANAGE_TEAM, undefined, PRIVATE_EMAIL)).toBe(STANDARD); + }); + + it('returns the standard event when the email is empty', () => { + expect(getWorkspaceCreatedAnalyticsEvent(CONST.ONBOARDING_CHOICES.MANAGE_TEAM, CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM, '')).toBe(STANDARD); + }); +}); From 67a6d959ba30ab4e711091335000a18ef3141425 Mon Sep 17 00:00:00 2001 From: aswin-s Date: Wed, 3 Jun 2026 19:23:58 +0530 Subject: [PATCH 076/519] fix: highlight split expenses after splitting an existing expense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register each new split transaction ID via addPendingNewTransactionIDs before navigating away, so MoneyRequestReportActionsList can identify and highlight them on first load via the pendingNewTransactionIDs path. Covers all three navigation branches: - dismissModalWithReport (normal workspace split) - isSelfDMSplit (selfDM → workspace split) - isSearchPageTopmostFullScreenRoute (split from search page) Skipped for isReverseSplitOperation (no new transactions) and isLastTransactionInReport (report is being emptied, fallback nav). On the read side, subscribe MoneyRequestReportActionsList to REPORT_METADATA via pendingNewTransactionIDsSelector and pass all 5 arguments to useNewTransactions, enabling the metadata fallback path that highlights transactions already present on mount. --- .../MoneyRequestReportActionsList.tsx | 9 +- .../actions/IOU/SplitTransactionUpdate.ts | 85 +++++++++++++++---- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 76b8da5183c3..9306edb4c24c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -61,6 +61,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {getStableReportSelector} from '@src/selectors/Report'; +import {pendingNewTransactionIDsSelector} from '@src/selectors/ReportMetaData'; import type * as OnyxTypes from '@src/types/onyx'; import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList'; import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; @@ -121,7 +122,9 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) () => Object.values(allReportTransactions ?? {}).some((transaction) => transaction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), [allReportTransactions], ); - const newTransactions = useNewTransactions(reportLoadingState?.hasOnceLoadedReportActions, reportTransactions); + const [pendingNewTransactionIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`, {selector: pendingNewTransactionIDsSelector}); + + const newTransactions = useNewTransactions(reportLoadingState?.hasOnceLoadedReportActions, reportTransactions, pendingNewTransactionIDs, reportIDFromRoute, isFocused); const showReportActionsLoadingState = reportLoadingState?.isLoadingInitialReportActions && !reportLoadingState?.hasOnceLoadedReportActions; const reportTransactionIDs = useMemo(() => transactions.map((transaction) => transaction.transactionID), [transactions]); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`); @@ -274,7 +277,9 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) isBackfillingRef.current = true; prevBackfillCursorRef.current = cursor; - const handle = TransitionTracker.runAfterTransitions({callback: () => getOlderActions(reportID, cursor)}); + const handle = TransitionTracker.runAfterTransitions({ + callback: () => getOlderActions(reportID, cursor), + }); return () => handle.cancel(); }, [ diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 8b1c05307ac5..4ebfb521bad0 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -63,6 +63,7 @@ import {getAllReports, getMoneyRequestPolicyTags, getPolicyTagsData} from './ind import {getMoneyRequestParticipantsFromReport} from './MoneyRequest'; import {getMoneyRequestInformation, getReportPreviewAction} from './MoneyRequestBuilder'; import type {BuildOnyxDataForMoneyRequestKeys, MoneyRequestInformationParams} from './MoneyRequestBuilder'; +import {addPendingNewTransactionIDs} from './PendingNewTransactions'; import {getDeleteTrackExpenseInformation} from './TrackExpense'; import {getUpdateMoneyRequestParams} from './UpdateMoneyRequest'; import type {UpdateMoneyRequestDataKeys} from './UpdateMoneyRequest'; @@ -78,7 +79,11 @@ type UpdateSplitTransactionsParams = { splitExpenses: SplitExpense[]; splitExpensesTotal: number | undefined; }; - searchContext?: (Partial & {activeGroupSearchHashes?: number[]}) | undefined; + searchContext?: + | (Partial & { + activeGroupSearchHashes?: number[]; + }) + | undefined; policyCategories: OnyxTypes.PolicyCategories | undefined; policy: OnyxTypes.Policy | undefined; policyRecentlyUsedCategories: OnyxTypes.RecentlyUsedCategories | undefined; @@ -660,7 +665,11 @@ function updateSplitTransactions({ parentChatReport, policyParams: { ...policyParams, - policyTagList: getMoneyRequestPolicyTags({moneyRequestReportID: splitExpense?.reportID, parentChatReport, participant: participantParams.participant}), + policyTagList: getMoneyRequestPolicyTags({ + moneyRequestReportID: splitExpense?.reportID, + parentChatReport, + participant: participantParams.participant, + }), }, transactionParams, moneyRequestReportID: moneyRequestReportIDForSplit, @@ -871,9 +880,18 @@ function updateSplitTransactions({ successDataComments.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - value: {[newReportActionID]: {pendingAction: null, isOptimisticAction: null}}, + value: { + [newReportActionID]: { + pendingAction: null, + isOptimisticAction: null, + }, + }, + }); + failureDataComments.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: {[newReportActionID]: null}, }); - failureDataComments.push({onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, value: {[newReportActionID]: null}}); if (currentSplit) { currentSplit.copiedComments ??= {}; @@ -893,7 +911,11 @@ function updateSplitTransactions({ const baseViolations = latestViolationUpdate && 'value' in latestViolationUpdate && Array.isArray(latestViolationUpdate.value) ? latestViolationUpdate.value : fallbackViolations; const nextViolations = [ ...baseViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.HOLD), - {name: CONST.VIOLATIONS.HOLD, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + { + name: CONST.VIOLATIONS.HOLD, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }, ]; if (latestViolationUpdate && 'value' in latestViolationUpdate) { @@ -1061,13 +1083,25 @@ function updateSplitTransactions({ continue; } remainingCommentActions.push(action); - optimisticActionsData[action.reportActionID] = {...action, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}; - successActionsData[action.reportActionID] = {pendingAction: null, isOptimisticAction: null}; + optimisticActionsData[action.reportActionID] = { + ...action, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + successActionsData[action.reportActionID] = { + pendingAction: null, + isOptimisticAction: null, + }; failureActionsData[action.reportActionID] = null; } if (isRemainingTransactionOnHold && remainingHoldReportAction) { - optimisticActionsData[remainingHoldReportAction.reportActionID] = {...remainingHoldReportAction, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}; - successActionsData[remainingHoldReportAction.reportActionID] = {pendingAction: null, isOptimisticAction: null}; + optimisticActionsData[remainingHoldReportAction.reportActionID] = { + ...remainingHoldReportAction, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + successActionsData[remainingHoldReportAction.reportActionID] = { + pendingAction: null, + isOptimisticAction: null, + }; failureActionsData[remainingHoldReportAction.reportActionID] = null; optimisticDataComments.push({ @@ -1173,7 +1207,9 @@ function updateSplitTransactions({ ...optimisticTransactionFromGetMoneyRequest, transactionID: snapshotTransactionID, // For edits, show a pending indicator in the snapshot while the request is in-flight. - ...(!isCreationOfSplits && {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + ...(!isCreationOfSplits && { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }), }); const reportActionsTargetReportID = selfDMReportID ?? originalSelfDMReportID; @@ -1297,7 +1333,9 @@ function updateSplitTransactions({ onyxData.failureData?.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionsReportID}`, - value: {[currentReportAction.reportActionID]: restoredActionWithoutChildMetadata}, + value: { + [currentReportAction.reportActionID]: restoredActionWithoutChildMetadata, + }, }); } } @@ -1491,7 +1529,9 @@ function updateSplitTransactions({ }, ...(whisperActionID && { [whisperActionID]: { - originalMessage: {resolution: CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING}, + originalMessage: { + resolution: CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING, + }, }, }), }; @@ -1607,12 +1647,16 @@ function updateSplitTransactions({ onyxData.optimisticData?.push({ onyxMethod: Onyx.METHOD.MERGE, key, - value: {data: {...optimisticSnapshotData, ...reportActionsMergePatch}}, + value: { + data: {...optimisticSnapshotData, ...reportActionsMergePatch}, + }, }); onyxData.failureData?.push({ onyxMethod: Onyx.METHOD.MERGE, key, - value: {data: {...failureSnapshotData, ...reportActionsFailurePatch}}, + value: { + data: {...failureSnapshotData, ...reportActionsFailurePatch}, + }, }); } } @@ -1781,7 +1825,9 @@ function updateSplitTransactions({ { onyxMethod: Onyx.METHOD.MERGE, key, - value: {errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage')}, + value: { + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), + }, }, ); } @@ -1931,6 +1977,15 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); + if (params.expenseReport?.reportID && !isReverseSplitOperation && !isLastTransactionInReport && !isSearchPageTopmostFullScreenRoute) { + for (const splitExpense of splitExpenses) { + if (!splitExpense.transactionID) { + continue; + } + addPendingNewTransactionIDs(targetReportID, splitExpense.transactionID); + } + } + if (isTracking()) { setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT, targetReportID); } From 42a0b409c81b86544304ad8f87176fb636a617e6 Mon Sep 17 00:00:00 2001 From: "Sahil (via MelvinBot)" Date: Thu, 4 Jun 2026 16:22:44 +0000 Subject: [PATCH 077/519] Extract buildDefaultTitleFieldList helper to remove duplicated fieldList construction Consolidates the identical default title report field structure built in buildPolicyData, createDraftWorkspace, and createWorkspaceFromIOUPayment into a single shared helper, also eliminating the duplicated `as unknown as` cast. Co-authored-by: Sahil --- src/libs/actions/Policy/Policy.ts | 53 ++++++++++++------------------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index cefc53361489..4f4c68d14c35 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2526,6 +2526,24 @@ function getApprovalModeForNewWorkspace( return CONST.POLICY.APPROVAL_MODE.OPTIONAL; } +/** + * Builds the default title report field (`fieldList`) used when seeding a new policy. The object intentionally omits the + * non-essential `PolicyReportField` properties (orderWeight, values, keys, etc.), so it is cast to the expected type. + */ +function buildDefaultTitleFieldList(pendingFields?: Record): Policy['fieldList'] { + return { + [CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: { + defaultValue: CONST.POLICY.DEFAULT_REPORT_NAME_PATTERN, + type: CONST.POLICY.DEFAULT_FIELD_LIST_TYPE, + target: CONST.POLICY.DEFAULT_FIELD_LIST_TARGET, + name: CONST.POLICY.DEFAULT_FIELD_LIST_NAME, + fieldID: CONST.POLICY.FIELDS.FIELD_LIST_TITLE, + deletable: true, + ...(pendingFields ? {pendingFields} : {}), + }, + } as unknown as Policy['fieldList']; +} + /** * Generates onyx data for creating a new workspace * @@ -2721,17 +2739,7 @@ function buildPolicyData(options: BuildPolicyDataOptions): OnyxData Date: Fri, 5 Jun 2026 19:13:44 +0200 Subject: [PATCH 078/519] Search selection: extract pure selection builders into selectionBuilders.ts Move mapTransactionItemToSelectedEntry, mapEmptyReportToSelectedEntry and prepareTransactionsList out of Search/index.tsx and deriveSelectedReports out of SearchSelectionProvider.tsx into a shared selectionBuilders.ts, deduping deriveSelectedReports. Pure relocation, no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Search/SearchSelectionProvider.tsx | 67 +----- src/components/Search/index.tsx | 143 +----------- src/components/Search/selectionBuilders.ts | 220 ++++++++++++++++++ 3 files changed, 223 insertions(+), 207 deletions(-) create mode 100644 src/components/Search/selectionBuilders.ts diff --git a/src/components/Search/SearchSelectionProvider.tsx b/src/components/Search/SearchSelectionProvider.tsx index a2399efd9272..f674b15be014 100644 --- a/src/components/Search/SearchSelectionProvider.tsx +++ b/src/components/Search/SearchSelectionProvider.tsx @@ -1,12 +1,9 @@ import React, {useEffect, useRef, useState} from 'react'; -import {isMoneyRequestReport} from '@libs/ReportUtils'; -import {isTransactionListItemType, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; -import {hasValidModifiedAmount} from '@libs/TransactionUtils'; -import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useSearchQueryContext, useSearchSelectionActions, useSearchSelectionContext} from './SearchContext'; import {SearchSelectionActionsContext, SearchSelectionContext} from './SearchContextDefinitions'; import type {ReportActionListItemType, TaskListItemType, TransactionGroupListItemType, TransactionListItemType} from './SearchList/ListItem/types'; +import {deriveSelectedReports} from './selectionBuilders'; import type {SearchSelectionActionsValue, SearchSelectionContextValue, SelectedReports, SelectedTransactions} from './types'; type SearchSelectionProviderProps = { @@ -29,68 +26,6 @@ const defaultSelectionState: SelectionState = { shouldTurnOffSelectionMode: false, }; -function deriveSelectedReports( - transactionIDs: SelectedTransactions, - data: TransactionListItemType[] | TransactionGroupListItemType[] | ReportActionListItemType[] | TaskListItemType[], -): SelectedReports[] { - if (data.length && data.every(isTransactionReportGroupListItemType)) { - return data - .filter((item) => { - if (!isMoneyRequestReport(item)) { - return false; - } - if (item.transactions.length === 0) { - return !!item.keyForList && transactionIDs[item.keyForList]?.isSelected; - } - return item.transactions.every(({keyForList}) => transactionIDs[keyForList]?.isSelected); - }) - .map((item) => ({ - reportID: item.reportID, - action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, - total: item.total ?? CONST.DEFAULT_NUMBER_ID, - policyID: item.policyID, - canPay: item.canPay, - canApprove: item.canApprove, - canSubmit: item.canSubmit, - canChangeApprover: item.canChangeApprover, - currency: item.currency, - chatReportID: item.chatReportID, - managerID: item.managerID, - ownerAccountID: item.ownerAccountID, - parentReportActionID: item.parentReportActionID, - parentReportID: item.parentReportID, - type: item.type, - })); - } - if (data.length && data.every(isTransactionListItemType)) { - return data - .filter(({keyForList}) => !!keyForList && transactionIDs[keyForList]?.isSelected) - .map((item) => { - const total = hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : (item.amount ?? CONST.DEFAULT_NUMBER_ID); - const action = item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW; - - return { - reportID: item.reportID, - action, - total, - policyID: item.policyID, - canPay: item.canPay, - canApprove: item.canApprove, - canSubmit: item.canSubmit, - canChangeApprover: item.canChangeApprover, - currency: item.currency, - chatReportID: item.report?.chatReportID, - managerID: item.report?.managerID, - ownerAccountID: item.report?.ownerAccountID, - parentReportActionID: item.report?.parentReportActionID, - parentReportID: item.report?.parentReportID, - type: item.report?.type, - }; - }); - } - return []; -} - function SearchSelectionProvider({children}: SearchSelectionProviderProps) { const {currentSearchHash} = useSearchQueryContext(); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 28e7a827e63c..e318610a58a8 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -80,7 +80,7 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@src/selectors/Onboarding'; -import type {OutstandingReportsByPolicyIDDerivedValue, Report, SaveSearch, Transaction} from '@src/types/onyx'; +import type {Report, SaveSearch, Transaction} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type SearchResults from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -95,6 +95,7 @@ import SearchList from './SearchList'; import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './SearchList/ListItem/types'; import {SearchScopeProvider} from './SearchScopeProvider'; import SearchTableHeader from './SearchTableHeader'; +import {mapEmptyReportToSelectedEntry, mapTransactionItemToSelectedEntry, prepareTransactionsList} from './selectionBuilders'; import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types'; type SearchProps = { @@ -118,146 +119,6 @@ type HoldMenuCallback = (item: TransactionReportGroupListItemType, requestType: const hashToString = (queryHash?: number) => (queryHash || queryHash === 0 ? String(queryHash) : undefined); -function mapTransactionItemToSelectedEntry( - item: TransactionListItemType, - itemTransaction: OnyxEntry, - originalItemTransaction: OnyxEntry, - currentUserLogin: string, - currentUserAccountID: number, - outstandingReportsByPolicyID: OutstandingReportsByPolicyIDDerivedValue | undefined, - allowNegativeAmount: boolean, - parentReport: OnyxEntry | undefined, - selfDMReport: OnyxEntry | undefined, - isProduction: boolean, -): [string, SelectedTransactionInfo] { - const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(item.report, item.reportAction, item.holdReportAction, item, item.policy, currentUserAccountID); - const canRejectRequest = item.report ? canRejectReportAction(currentUserLogin, item.report) : false; - const amount = hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : item.amount; - const isUnreported = item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; - const reportForSplit = item.report ?? (isUnreported ? selfDMReport : undefined); - - return [ - item.keyForList, - { - transaction: item, - isSelected: true, - canReject: canRejectRequest, - canHold: canHoldRequest, - isHeld: isOnHold(item), - canUnhold: canUnholdRequest, - canSplit: isSplitAction(reportForSplit, [itemTransaction], originalItemTransaction, currentUserLogin, currentUserAccountID, item.policy, parentReport, isProduction), - hasBeenSplit: getOriginalTransactionWithSplitInfo(itemTransaction, originalItemTransaction).isExpenseSplit, - canChangeReport: canEditFieldOfMoneyRequest({ - reportAction: item.reportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, - outstandingReportsByPolicyID, - transaction: item, - report: item.report, - policy: item.policy, - }), - action: item.action, - groupCurrency: item.groupCurrency, - groupExchangeRate: item.groupExchangeRate, - currencyConversionRate: item.currencyConversionRate, - reportID: item.reportID, - policyID: item.policyID, - amount: allowNegativeAmount ? amount : Math.abs(amount), - groupAmount: item.groupAmount, - currency: item.currency, - isFromOneTransactionReport: isOneTransactionReport(item.report), - ownerAccountID: item.reportAction?.actorAccountID, - reportAction: item.reportAction, - report: item.report, - }, - ]; -} - -function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType | TransactionGroupListItemType): [string, SelectedTransactionInfo] { - if (isTransactionReportGroupListItemType(item)) { - const currency = item.currency ?? ''; - return [ - item.keyForList ?? '', - { - isFromOneTransactionReport: false, - isSelected: true, - canHold: false, - canSplit: false, - canReject: false, - hasBeenSplit: false, - isHeld: false, - canUnhold: false, - canChangeReport: false, - action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, - reportID: item.reportID, - policyID: item.policyID ?? CONST.POLICY.ID_FAKE, - amount: item.totalDisplaySpend ?? item.total ?? 0, - currency, - ...(currency ? {groupCurrency: currency} : {}), - }, - ]; - } - - const currency = item.currency ?? ''; - - return [ - item.keyForList ?? '', - { - isFromOneTransactionReport: false, - isSelected: true, - canHold: false, - canSplit: false, - canReject: false, - hasBeenSplit: false, - isHeld: false, - canUnhold: false, - canChangeReport: false, - action: CONST.SEARCH.ACTION_TYPES.VIEW, - reportID: item.reportID, - policyID: item.policyID ?? CONST.POLICY.ID_FAKE, - amount: item.total ?? 0, - currency, - ...(currency ? {groupCurrency: currency} : {}), - }, - ]; -} - -function prepareTransactionsList( - item: TransactionListItemType, - itemTransaction: OnyxEntry, - originalItemTransaction: OnyxEntry, - selectedTransactions: SelectedTransactions, - currentUserLogin: string, - currentUserAccountID: number, - outstandingReportsByPolicyID: OutstandingReportsByPolicyIDDerivedValue | undefined, - parentReport: OnyxEntry | undefined, - selfDMReport: OnyxEntry | undefined, - isProduction: boolean, -) { - if (selectedTransactions[item.keyForList]?.isSelected) { - const {[item.keyForList]: omittedTransaction, ...transactions} = selectedTransactions; - - return transactions; - } - - const [key, selectedInfo] = mapTransactionItemToSelectedEntry( - item, - itemTransaction, - originalItemTransaction, - currentUserLogin, - currentUserAccountID, - outstandingReportsByPolicyID, - false, - parentReport, - selfDMReport, - isProduction, - ); - - return { - ...selectedTransactions, - [key]: selectedInfo, - }; -} - function Search({ queryJSON, hasFilterBars, diff --git a/src/components/Search/selectionBuilders.ts b/src/components/Search/selectionBuilders.ts new file mode 100644 index 000000000000..09271d7b0329 --- /dev/null +++ b/src/components/Search/selectionBuilders.ts @@ -0,0 +1,220 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; +import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isMoneyRequestReport, isOneTransactionReport} from '@libs/ReportUtils'; +import {isTransactionListItemType, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; +import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isOnHold} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import type {OutstandingReportsByPolicyIDDerivedValue, Report, Transaction} from '@src/types/onyx'; +import type {ReportActionListItemType, TaskListItemType, TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './SearchList/ListItem/types'; +import type {SelectedReports, SelectedTransactionInfo, SelectedTransactions} from './types'; + +function mapTransactionItemToSelectedEntry( + item: TransactionListItemType, + itemTransaction: OnyxEntry, + originalItemTransaction: OnyxEntry, + currentUserLogin: string, + currentUserAccountID: number, + outstandingReportsByPolicyID: OutstandingReportsByPolicyIDDerivedValue | undefined, + allowNegativeAmount: boolean, + parentReport: OnyxEntry | undefined, + selfDMReport: OnyxEntry | undefined, + isProduction: boolean, +): [string, SelectedTransactionInfo] { + const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(item.report, item.reportAction, item.holdReportAction, item, item.policy, currentUserAccountID); + const canRejectRequest = item.report ? canRejectReportAction(currentUserLogin, item.report) : false; + const amount = hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : item.amount; + const isUnreported = item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const reportForSplit = item.report ?? (isUnreported ? selfDMReport : undefined); + + return [ + item.keyForList, + { + transaction: item, + isSelected: true, + canReject: canRejectRequest, + canHold: canHoldRequest, + isHeld: isOnHold(item), + canUnhold: canUnholdRequest, + canSplit: isSplitAction(reportForSplit, [itemTransaction], originalItemTransaction, currentUserLogin, currentUserAccountID, item.policy, parentReport, isProduction), + hasBeenSplit: getOriginalTransactionWithSplitInfo(itemTransaction, originalItemTransaction).isExpenseSplit, + canChangeReport: canEditFieldOfMoneyRequest({ + reportAction: item.reportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, + outstandingReportsByPolicyID, + transaction: item, + report: item.report, + policy: item.policy, + }), + action: item.action, + groupCurrency: item.groupCurrency, + groupExchangeRate: item.groupExchangeRate, + currencyConversionRate: item.currencyConversionRate, + reportID: item.reportID, + policyID: item.policyID, + amount: allowNegativeAmount ? amount : Math.abs(amount), + groupAmount: item.groupAmount, + currency: item.currency, + isFromOneTransactionReport: isOneTransactionReport(item.report), + ownerAccountID: item.reportAction?.actorAccountID, + reportAction: item.reportAction, + report: item.report, + }, + ]; +} + +function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType | TransactionGroupListItemType): [string, SelectedTransactionInfo] { + if (isTransactionReportGroupListItemType(item)) { + const currency = item.currency ?? ''; + return [ + item.keyForList ?? '', + { + isFromOneTransactionReport: false, + isSelected: true, + canHold: false, + canSplit: false, + canReject: false, + hasBeenSplit: false, + isHeld: false, + canUnhold: false, + canChangeReport: false, + action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: item.reportID, + policyID: item.policyID ?? CONST.POLICY.ID_FAKE, + amount: item.totalDisplaySpend ?? item.total ?? 0, + currency, + ...(currency ? {groupCurrency: currency} : {}), + }, + ]; + } + + const currency = item.currency ?? ''; + + return [ + item.keyForList ?? '', + { + isFromOneTransactionReport: false, + isSelected: true, + canHold: false, + canSplit: false, + canReject: false, + hasBeenSplit: false, + isHeld: false, + canUnhold: false, + canChangeReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: item.reportID, + policyID: item.policyID ?? CONST.POLICY.ID_FAKE, + amount: item.total ?? 0, + currency, + ...(currency ? {groupCurrency: currency} : {}), + }, + ]; +} + +function prepareTransactionsList( + item: TransactionListItemType, + itemTransaction: OnyxEntry, + originalItemTransaction: OnyxEntry, + selectedTransactions: SelectedTransactions, + currentUserLogin: string, + currentUserAccountID: number, + outstandingReportsByPolicyID: OutstandingReportsByPolicyIDDerivedValue | undefined, + parentReport: OnyxEntry | undefined, + selfDMReport: OnyxEntry | undefined, + isProduction: boolean, +) { + if (selectedTransactions[item.keyForList]?.isSelected) { + const {[item.keyForList]: omittedTransaction, ...transactions} = selectedTransactions; + + return transactions; + } + + const [key, selectedInfo] = mapTransactionItemToSelectedEntry( + item, + itemTransaction, + originalItemTransaction, + currentUserLogin, + currentUserAccountID, + outstandingReportsByPolicyID, + false, + parentReport, + selfDMReport, + isProduction, + ); + + return { + ...selectedTransactions, + [key]: selectedInfo, + }; +} + +/** + * Derives `selectedReports` from the current selection + visible rows. + * + * Note: `selectedTransactionIDs` and `selectedTransactions` are two separate properties. + * Setting or clearing one of them does not influence the other. + * IDs should be used if transaction details are not required. + */ +function deriveSelectedReports( + transactionIDs: SelectedTransactions, + data: TransactionListItemType[] | TransactionGroupListItemType[] | ReportActionListItemType[] | TaskListItemType[], +): SelectedReports[] { + if (data.length && data.every(isTransactionReportGroupListItemType)) { + return data + .filter((item) => { + if (!isMoneyRequestReport(item)) { + return false; + } + if (item.transactions.length === 0) { + return !!item.keyForList && transactionIDs[item.keyForList]?.isSelected; + } + return item.transactions.every(({keyForList}) => transactionIDs[keyForList]?.isSelected); + }) + .map((item) => ({ + reportID: item.reportID, + action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, + total: item.total ?? CONST.DEFAULT_NUMBER_ID, + policyID: item.policyID, + canPay: item.canPay, + canApprove: item.canApprove, + canSubmit: item.canSubmit, + canChangeApprover: item.canChangeApprover, + currency: item.currency, + chatReportID: item.chatReportID, + managerID: item.managerID, + ownerAccountID: item.ownerAccountID, + parentReportActionID: item.parentReportActionID, + parentReportID: item.parentReportID, + type: item.type, + })); + } + if (data.length && data.every(isTransactionListItemType)) { + return data + .filter(({keyForList}) => !!keyForList && transactionIDs[keyForList]?.isSelected) + .map((item) => { + const total = hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : (item.amount ?? CONST.DEFAULT_NUMBER_ID); + const action = item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW; + + return { + reportID: item.reportID, + action, + total, + policyID: item.policyID, + canPay: item.canPay, + canApprove: item.canApprove, + canSubmit: item.canSubmit, + canChangeApprover: item.canChangeApprover, + currency: item.currency, + chatReportID: item.report?.chatReportID, + managerID: item.report?.managerID, + ownerAccountID: item.report?.ownerAccountID, + parentReportActionID: item.report?.parentReportActionID, + parentReportID: item.report?.parentReportID, + type: item.report?.type, + }; + }); + } + return []; +} + +export {mapTransactionItemToSelectedEntry, mapEmptyReportToSelectedEntry, prepareTransactionsList, deriveSelectedReports}; From 613befb50b202ca2aebbf9143e5dc90e0a28c8aa Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 5 Jun 2026 19:25:33 +0200 Subject: [PATCH 079/519] Search selection: add provider write-core (toggle/toggleAll/reconcile) Add a dedicated SearchRowSelectionActionsContext carrying stable-identity toggle/toggleAll/clearAll/reconcileSelection plus a screenContextRef populated by the upcoming SearchSelectionController. The write actions read screen-derived inputs from the ref and the latest committed selection from selectedTransactionsRef at call time, so they keep a stable identity and never close over high-churn selection state. Coordination state (isRefreshingSelection, the select-all check) lives here too. Kept on its own context rather than the shared SearchSelectionActionsValue to avoid leaking an internal ref into action payload types and churning test fixtures; the stability/separation intent is preserved. Inert until wired: still uses its own handlers. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/Search/SearchContext.tsx | 7 + .../Search/SearchContextDefinitions.ts | 44 ++- .../Search/SearchSelectionProvider.tsx | 284 +++++++++++++++++- src/components/Search/types.ts | 43 ++- 4 files changed, 369 insertions(+), 9 deletions(-) diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 0ede75ae13be..e6cd73d7e527 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -4,6 +4,7 @@ import { SearchQueryContext, SearchResultsActionsContext, SearchResultsContext, + SearchRowSelectionActionsContext, SearchSelectionActionsContext, SearchSelectionContext, } from './SearchContextDefinitions'; @@ -37,6 +38,10 @@ function useSearchSelectionActions() { return useContext(SearchSelectionActionsContext); } +function useSearchRowSelectionActions() { + return useContext(SearchRowSelectionActionsContext); +} + export { SearchQueryContext, SearchQueryActionsContext, @@ -44,10 +49,12 @@ export { SearchResultsActionsContext, SearchSelectionContext, SearchSelectionActionsContext, + SearchRowSelectionActionsContext, useSearchQueryContext, useSearchQueryActions, useSearchResultsContext, useSearchResultsActions, useSearchSelectionContext, useSearchSelectionActions, + useSearchRowSelectionActions, }; diff --git a/src/components/Search/SearchContextDefinitions.ts b/src/components/Search/SearchContextDefinitions.ts index b6c057b25af1..a9f8a748a3a3 100644 --- a/src/components/Search/SearchContextDefinitions.ts +++ b/src/components/Search/SearchContextDefinitions.ts @@ -1,7 +1,16 @@ import React from 'react'; import type {SearchKey, SearchTypeMenuItem} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; -import type {SearchQueryActionsValue, SearchQueryContextValue, SearchResultsActionsValue, SearchResultsContextValue, SearchSelectionActionsValue, SearchSelectionContextValue} from './types'; +import type { + SearchQueryActionsValue, + SearchQueryContextValue, + SearchResultsActionsValue, + SearchResultsContextValue, + SearchRowSelectionActionsValue, + SearchSelectionActionsValue, + SearchSelectionContextValue, + SearchSelectionScreenContext, +} from './types'; // This file holds the bare React.createContext() calls so they can be imported by `@hooks/useOnyx` // without triggering the SearchQueryProvider -> useCardFeedsForDisplay -> @hooks/useOnyx -> @@ -53,11 +62,42 @@ const defaultSearchSelectionActions: SearchSelectionActionsValue = { selectAllMatchingItems: () => {}, }; +const defaultSearchSelectionScreenContext: SearchSelectionScreenContext = { + filteredData: [], + transactions: undefined, + searchResultsData: undefined, + currentUserLogin: '', + currentUserAccountID: CONST.DEFAULT_NUMBER_ID, + outstandingReportsByPolicyID: undefined, + selfDMReport: undefined, + isProduction: false, + areItemsGrouped: false, + totalSelectableItemsCount: 0, +}; + +const defaultRowSelectionActions: SearchRowSelectionActionsValue = { + toggle: () => {}, + toggleAll: () => {}, + clearAll: () => {}, + reconcileSelection: () => {}, + screenContextRef: {current: defaultSearchSelectionScreenContext}, +}; + const SearchQueryContext = React.createContext(defaultSearchQueryContext); const SearchQueryActionsContext = React.createContext(defaultSearchQueryActions); const SearchResultsContext = React.createContext(defaultSearchResultsContext); const SearchResultsActionsContext = React.createContext(defaultSearchResultsActions); const SearchSelectionContext = React.createContext(defaultSearchSelectionContext); const SearchSelectionActionsContext = React.createContext(defaultSearchSelectionActions); +const SearchRowSelectionActionsContext = React.createContext(defaultRowSelectionActions); -export {SearchQueryContext, SearchQueryActionsContext, SearchResultsContext, SearchResultsActionsContext, SearchSelectionContext, SearchSelectionActionsContext}; +export { + SearchQueryContext, + SearchQueryActionsContext, + SearchResultsContext, + SearchResultsActionsContext, + SearchSelectionContext, + SearchSelectionActionsContext, + SearchRowSelectionActionsContext, + defaultSearchSelectionScreenContext, +}; diff --git a/src/components/Search/SearchSelectionProvider.tsx b/src/components/Search/SearchSelectionProvider.tsx index f674b15be014..0d5b331d047f 100644 --- a/src/components/Search/SearchSelectionProvider.tsx +++ b/src/components/Search/SearchSelectionProvider.tsx @@ -1,10 +1,24 @@ import React, {useEffect, useRef, useState} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {isReportActionListItemType, isTaskListItemType, isTransactionListItemType} from '@libs/SearchUIUtils'; +import {isTransactionPendingDelete} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, Transaction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {useSearchQueryContext, useSearchSelectionActions, useSearchSelectionContext} from './SearchContext'; -import {SearchSelectionActionsContext, SearchSelectionContext} from './SearchContextDefinitions'; +import {useSearchQueryContext, useSearchRowSelectionActions, useSearchSelectionActions, useSearchSelectionContext} from './SearchContext'; +import {defaultSearchSelectionScreenContext, SearchRowSelectionActionsContext, SearchSelectionActionsContext, SearchSelectionContext} from './SearchContextDefinitions'; import type {ReportActionListItemType, TaskListItemType, TransactionGroupListItemType, TransactionListItemType} from './SearchList/ListItem/types'; -import {deriveSelectedReports} from './selectionBuilders'; -import type {SearchSelectionActionsValue, SearchSelectionContextValue, SelectedReports, SelectedTransactions} from './types'; +import {deriveSelectedReports, mapEmptyReportToSelectedEntry, mapTransactionItemToSelectedEntry, prepareTransactionsList} from './selectionBuilders'; +import type { + SearchRowSelectionActionsValue, + SearchSelectionActionsValue, + SearchSelectionContextValue, + SearchSelectionScreenContext, + SelectedReports, + SelectedTransactionInfo, + SelectedTransactions, +} from './types'; type SearchSelectionProviderProps = { children: React.ReactNode; @@ -39,6 +53,26 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { currentSearchHashRef.current = currentSearchHash; }, [currentSearchHash]); + // When new data loads, the reconcile-with-data effect rebuilds `selectedTransactions`. This flag lets + // `updateSelectAllMatchingItemsState` skip its "are all selected?" check while that rebuild is in flight, + // so a reconcile doesn't spuriously turn select-all off. Set by `reconcileSelection`, cleared once the + // new `selectedTransactions` commits. + const isRefreshingSelection = useRef(false); + + // Screen-derived inputs for the write actions, populated by `SearchSelectionController` each render. + const screenContextRef = useRef(defaultSearchSelectionScreenContext); + + // Latest committed selection, so the stable write actions can read it at call time without closing over + // it (closing over it would give them a new identity on every selection change and re-render consumers). + const selectedTransactionsRef = useRef(selectionState.selectedTransactions); + useEffect(() => { + selectedTransactionsRef.current = selectionState.selectedTransactions; + }); + + useEffect(() => { + isRefreshingSelection.current = false; + }, [selectionState.selectedTransactions]); + const setSelectedTransactions: SearchSelectionActionsValue['setSelectedTransactions'] = (transactionIDs, data) => { if (transactionIDs instanceof Array) { if (!transactionIDs.length && areTransactionsEmpty.current) { @@ -153,6 +187,225 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { }); }; + // Turn select-all off once the selection no longer covers every selectable item. Reads the count from the + // screen-context ref so it stays in lock-step with whatever `SearchSelectionController` last reported. + const updateSelectAllMatchingItemsState = (updatedSelectedTransactions: SelectedTransactions) => { + const {totalSelectableItemsCount} = screenContextRef.current; + if (!totalSelectableItemsCount || isRefreshingSelection.current) { + return; + } + if (totalSelectableItemsCount !== Object.keys(updatedSelectedTransactions).length) { + selectAllMatchingItems(false); + } + }; + + // Atomically replace the selection (and derive `selectedReports` in the same commit) from freshly-loaded + // data, flagging the refresh so `updateSelectAllMatchingItemsState` doesn't fight it for a render. + const reconcileSelection: SearchRowSelectionActionsValue['reconcileSelection'] = (newList, data) => { + setSelectedTransactions(newList, data); + isRefreshingSelection.current = true; + }; + + const toggle: SearchRowSelectionActionsValue['toggle'] = (item, itemTransactions) => { + if (isReportActionListItemType(item)) { + return; + } + if (isTaskListItemType(item)) { + return; + } + + const {transactions, searchResultsData, currentUserLogin, currentUserAccountID, outstandingReportsByPolicyID, selfDMReport, isProduction, areItemsGrouped, filteredData} = + screenContextRef.current; + const selectedTransactions = selectedTransactionsRef.current; + + if (isTransactionListItemType(item)) { + if (!item.keyForList) { + return; + } + if (isTransactionPendingDelete(item)) { + return; + } + const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${item.transactionID}`] as OnyxEntry; + const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; + const itemParentReport = searchResultsData?.[`${ONYXKEYS.COLLECTION.REPORT}${item.report?.parentReportID}`] as OnyxEntry; + const updatedTransactions = prepareTransactionsList( + item, + itemTransaction, + originalItemTransaction, + selectedTransactions, + currentUserLogin, + currentUserAccountID, + outstandingReportsByPolicyID, + itemParentReport, + selfDMReport, + isProduction, + ); + + // Tag individual transactions with their parent group key so export filtering can derive the group when needed. + if (areItemsGrouped) { + const parentGroup = (filteredData as TransactionGroupListItemType[]).find((group) => group.transactions.some((transaction) => transaction.keyForList === item.keyForList)); + if (parentGroup?.keyForList && updatedTransactions[item.keyForList]) { + updatedTransactions[item.keyForList] = {...updatedTransactions[item.keyForList], groupKey: parentGroup.keyForList}; + } + } + + setSelectedTransactions(updatedTransactions); + updateSelectAllMatchingItemsState(updatedTransactions); + return; + } + + const currentTransactions = itemTransactions ?? item.transactions; + + if (currentTransactions.length === 0 && item.keyForList) { + const reportKey = item.keyForList; + + if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } + + if (selectedTransactions[reportKey]?.isSelected) { + const reducedSelectedTransactions: SelectedTransactions = { + ...selectedTransactions, + }; + delete reducedSelectedTransactions[reportKey]; + setSelectedTransactions(reducedSelectedTransactions); + updateSelectAllMatchingItemsState(reducedSelectedTransactions); + return; + } + + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item); + const updatedTransactions = { + ...selectedTransactions, + [reportKey]: emptyReportSelection, + }; + setSelectedTransactions(updatedTransactions); + updateSelectAllMatchingItemsState(updatedTransactions); + return; + } + + if (currentTransactions.some((transaction) => selectedTransactions[transaction.keyForList]?.isSelected)) { + const reducedSelectedTransactions: SelectedTransactions = { + ...selectedTransactions, + }; + + for (const transaction of currentTransactions) { + delete reducedSelectedTransactions[transaction.keyForList]; + } + + setSelectedTransactions(reducedSelectedTransactions); + updateSelectAllMatchingItemsState(reducedSelectedTransactions); + return; + } + + const updatedTransactions = { + ...selectedTransactions, + ...Object.fromEntries( + currentTransactions + .filter((t) => !isTransactionPendingDelete(t)) + .map((transactionItem) => { + const itemTransaction = (searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] ?? + transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`]) as OnyxEntry; + const originalItemTransaction = + searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? + transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; + const itemParentReport = searchResultsData?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; + const [key, entry] = mapTransactionItemToSelectedEntry( + transactionItem, + itemTransaction, + originalItemTransaction, + currentUserLogin, + currentUserAccountID, + outstandingReportsByPolicyID, + true, + itemParentReport, + selfDMReport, + isProduction, + ); + return [key, {...entry, groupKey: item.keyForList}]; + }), + ), + }; + setSelectedTransactions(updatedTransactions); + updateSelectAllMatchingItemsState(updatedTransactions); + }; + + const toggleAll: SearchRowSelectionActionsValue['toggleAll'] = () => { + const selectedTransactions = selectedTransactionsRef.current; + const {transactions, searchResultsData, currentUserLogin, currentUserAccountID, outstandingReportsByPolicyID, selfDMReport, isProduction, areItemsGrouped, filteredData} = + screenContextRef.current; + + const totalSelected = Object.keys(selectedTransactions).length; + + if (totalSelected > 0) { + clearSelectedTransactions(); + updateSelectAllMatchingItemsState({}); + return; + } + + let updatedTransactions: SelectedTransactions; + if (areItemsGrouped) { + const allSelections: Array<[string, SelectedTransactionInfo]> = (filteredData as TransactionGroupListItemType[]).flatMap((item) => { + if (item.transactions.length === 0 && item.keyForList) { + if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return []; + } + return [mapEmptyReportToSelectedEntry(item)]; + } + return item.transactions + .filter((t) => !isTransactionPendingDelete(t)) + .map((transactionItem) => { + const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; + const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; + const itemParentReport = searchResultsData?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; + const [key, entry] = mapTransactionItemToSelectedEntry( + transactionItem, + itemTransaction, + originalItemTransaction, + currentUserLogin, + currentUserAccountID, + outstandingReportsByPolicyID, + true, + itemParentReport, + selfDMReport, + isProduction, + ); + return [key, {...entry, groupKey: item.keyForList}] as [string, SelectedTransactionInfo]; + }); + }); + updatedTransactions = Object.fromEntries(allSelections); + } else { + // When items are not grouped, data is TransactionListItemType[] not TransactionGroupListItemType[] + updatedTransactions = Object.fromEntries( + (filteredData as TransactionListItemType[]) + .filter((t) => !isTransactionPendingDelete(t)) + .map((transactionItem) => { + const itemTransaction = searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; + const originalItemTransaction = searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; + const itemParentReport = searchResultsData?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; + return mapTransactionItemToSelectedEntry( + transactionItem, + itemTransaction, + originalItemTransaction, + currentUserLogin, + currentUserAccountID, + outstandingReportsByPolicyID, + true, + itemParentReport, + selfDMReport, + isProduction, + ); + }), + ); + } + setSelectedTransactions(updatedTransactions, filteredData); + updateSelectAllMatchingItemsState(updatedTransactions); + }; + + const clearAll: SearchRowSelectionActionsValue['clearAll'] = () => { + clearSelectedTransactions(); + updateSelectAllMatchingItemsState({}); + }; + const hasSelectedTransactions = selectionState.selectedTransactionIDs.length > 0 || Object.values(selectionState.selectedTransactions).some((t) => t.isSelected); const selectionValue: SearchSelectionContextValue = { @@ -170,9 +423,19 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { selectAllMatchingItems, }; + const rowSelectionActionsValue: SearchRowSelectionActionsValue = { + toggle, + toggleAll, + clearAll, + reconcileSelection, + screenContextRef, + }; + return ( - {children} + + {children} + ); } @@ -225,4 +488,13 @@ function useSelectionCounts(): {selected: number} { return {selected}; } -export {SearchSelectionProvider, useSyncSelectedReports, useRowSelection, useSelectionCounts}; +/** + * Stable-identity selection write actions for rows and the table header. Sourced from the dedicated + * row-selection actions context, so dispatching a toggle never re-renders the screen or the rows. + */ +function useRowSelectionActions(): Pick { + const {toggle, toggleAll, clearAll} = useSearchRowSelectionActions(); + return {toggle, toggleAll, clearAll}; +} + +export {SearchSelectionProvider, useSyncSelectedReports, useRowSelection, useSelectionCounts, useRowSelectionActions}; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 0df050ef9f68..07b96bbd0f64 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -1,13 +1,16 @@ +import type {RefObject} from 'react'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {UnitPosition, UnitWithFallback} from '@components/Charts'; import type {PaymentMethod} from '@components/KYCWall/types'; import type {SearchKey, SearchTypeMenuItem} from '@libs/SearchUIUtils'; import type CONST from '@src/CONST'; -import type {Report, ReportAction, SearchResults, Transaction} from '@src/types/onyx'; +import type {OutstandingReportsByPolicyIDDerivedValue, Report, ReportAction, SearchResults, Transaction} from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type IconAsset from '@src/types/utils/IconAsset'; import type { ReportActionListItemType, + SearchListItem, TaskListItemType, TransactionCardGroupListItemType, TransactionCategoryGroupListItemType, @@ -232,6 +235,42 @@ type SearchSelectionActionsValue = { selectAllMatchingItems: (on: boolean) => void; }; +/** + * Screen-derived inputs that the row selection write actions read at call time. Populated by + * `SearchSelectionController` (a child of ``) and read by `toggle`/`toggleAll`/`reconcileSelection`, + * so those actions can keep a stable identity instead of closing over high-churn screen state. + */ +type SearchSelectionScreenContext = { + filteredData: TransactionListItemType[] | TransactionGroupListItemType[] | ReportActionListItemType[] | TaskListItemType[]; + transactions: OnyxCollection; + searchResultsData: SearchResults['data'] | undefined; + currentUserLogin: string; + currentUserAccountID: number; + outstandingReportsByPolicyID: OutstandingReportsByPolicyIDDerivedValue | undefined; + selfDMReport: OnyxEntry; + isProduction: boolean; + areItemsGrouped: boolean; + totalSelectableItemsCount: number; +}; + +/** + * Stable-identity selection write actions consumed by rows, the table header, and `SearchSelectionController`. + * Lives on its own context (separate from both the selection state context and the bulk-action setters) so + * pressing a checkbox never re-renders consumers that only need to dispatch. + */ +type SearchRowSelectionActionsValue = { + /** Toggle selection of a single transaction row or a group (report / grouped rows). */ + toggle: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void; + /** Toggle selection of all currently selectable items. */ + toggleAll: () => void; + /** Clear the entire selection. */ + clearAll: () => void; + /** Atomically rebuild the selection from freshly-loaded data (used by the reconcile-with-data effect). */ + reconcileSelection: (newList: SelectedTransactions, data: TransactionListItemType[] | TransactionGroupListItemType[] | ReportActionListItemType[] | TaskListItemType[]) => void; + /** Mutable screen-context populated by `SearchSelectionController`; read by the write actions at call time. */ + screenContextRef: RefObject; +}; + /** Composed value of all three Search state contexts. Kept as a union for callers that need the full bag shape (e.g. test fixtures, action `searchContext` payloads). */ type SearchStateContextValue = SearchQueryContextValue & SearchResultsContextValue & SearchSelectionContextValue; @@ -431,6 +470,8 @@ export type { SearchResultsActionsValue, SearchSelectionContextValue, SearchSelectionActionsValue, + SearchSelectionScreenContext, + SearchRowSelectionActionsValue, ASTNode, QueryFilter, QueryFilters, From a489cd2ede2d5d629b4a45609afff64f929934b3 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 5 Jun 2026 19:34:12 +0200 Subject: [PATCH 080/519] Search selection: add renderless SearchSelectionController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce SearchSelectionController, a renderless child of that owns the selection-only Onyx subscriptions (OUTSTANDING_REPORTS_BY_POLICY_ID, selfDM, env, focus) and composes named hooks — useReconcileSelectionWithData, useTurnOffSelectionModeWhenEmpty, useSyncMobileSelectionModeWithScreenSize and useSyncSelectedReports — plus publishes the screen-derived inputs the provider's write actions read at call time. Returns null. Mounted in the main list branch, where selectable rows exist; selection state lives in the root provider and survives mount/unmount, and the reconcile effect re-syncs on remount. still runs its own copies of these effects in this commit (idempotent: reconcile is deepEqual-guarded, mobile-mode sync is a no-op when already settled); the next commit removes them. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Search/SearchSelectionController.tsx | 425 ++++++++++++++++++ src/components/Search/index.tsx | 12 + 2 files changed, 437 insertions(+) create mode 100644 src/components/Search/SearchSelectionController.tsx diff --git a/src/components/Search/SearchSelectionController.tsx b/src/components/Search/SearchSelectionController.tsx new file mode 100644 index 000000000000..3f42f1fe1fd0 --- /dev/null +++ b/src/components/Search/SearchSelectionController.tsx @@ -0,0 +1,425 @@ +import {useIsFocused} from '@react-navigation/native'; +import {deepEqual} from 'fast-equals'; +import {useEffect} from 'react'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useEnvironment from '@hooks/useEnvironment'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSelfDMReport from '@hooks/useSelfDMReport'; +import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; +import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isOneTransactionReport} from '@libs/ReportUtils'; +import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isOnHold} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OutstandingReportsByPolicyIDDerivedValue, Report, SearchResults, Transaction} from '@src/types/onyx'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {useSearchRowSelectionActions, useSearchSelectionContext} from './SearchContext'; +import type {ReportActionListItemType, TaskListItemType, TransactionGroupListItemType, TransactionListItemType} from './SearchList/ListItem/types'; +import {useSyncSelectedReports} from './SearchSelectionProvider'; +import {mapEmptyReportToSelectedEntry} from './selectionBuilders'; +import type {SelectedTransactions} from './types'; + +type SearchData = TransactionListItemType[] | TransactionGroupListItemType[] | ReportActionListItemType[] | TaskListItemType[]; + +type SearchSelectionControllerProps = { + /** The currently displayed (filtered, grouped) rows. Screen-derived; the controller cannot recompute it. */ + filteredData: SearchData; + + /** Number of selectable items currently in view, used by the provider's select-all coordination. */ + totalSelectableItemsCount: number; + + /** The raw search snapshot, read for denormalized transaction/report lookups during reconcile. */ + searchResults: SearchResults | undefined; + + /** The live TRANSACTION collection. Subscribed by `` (it needs it for its own rendering) and passed down. */ + transactions: OnyxCollection; + + /** Whether mobile selection mode is on. */ + isMobileSelectionModeEnabled: boolean; + + /** The search data type. */ + type: SearchDataTypes; + + /** Whether rows are grouped (group-by view or expense-report view). */ + areItemsGrouped: boolean; + + /** Whether this is the expense-report view (drives report-level selection propagation). */ + isExpenseReportType: boolean; + + /** Whether the current search produced no results. */ + isSearchResultsEmpty: boolean; +}; + +type ReconcileSelectionParams = { + isFocused: boolean; + type: SearchDataTypes; + areItemsGrouped: boolean; + isExpenseReportType: boolean; + filteredData: SearchData; + searchResultsData: SearchResults['data'] | undefined; + transactions: OnyxCollection; + currentUserEmail: string; + currentUserLogin: string; + currentUserAccountID: number; + selfDMReport: OnyxEntry; + isProduction: boolean; + outstandingReportsByPolicyID: OutstandingReportsByPolicyIDDerivedValue | undefined; +}; + +/** + * Rebuilds `selectedTransactions` whenever the underlying data changes (e.g. an Onyx push adds rows to a + * selected report) so the selection stays in sync with what is on screen, then atomically commits it via + * `reconcileSelection`. Ported verbatim from the former `` refresh-selection effect: it reads + * `selectedTransactions` from closure (not deps) on purpose so it only re-runs on data/focus/select-all + * changes, and keeps the deep-equality bail-out that prevents the #89588 infinite-update loop. + */ +function useReconcileSelectionWithData({ + isFocused, + type, + areItemsGrouped, + isExpenseReportType, + filteredData, + searchResultsData, + transactions, + currentUserEmail, + currentUserLogin, + currentUserAccountID, + selfDMReport, + isProduction, + outstandingReportsByPolicyID, +}: ReconcileSelectionParams) { + const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {reconcileSelection} = useSearchRowSelectionActions(); + + useEffect(() => { + if (!isFocused) { + return; + } + + if (type === CONST.SEARCH.DATA_TYPES.CHAT) { + return; + } + const newTransactionList: SelectedTransactions = {}; + if (areItemsGrouped) { + for (const transactionGroup of filteredData) { + if (!Object.hasOwn(transactionGroup, 'transactions') || !('transactions' in transactionGroup)) { + continue; + } + + if (transactionGroup.transactions.length === 0) { + const reportKey = transactionGroup.keyForList; + if (transactionGroup.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; + } + if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup); + newTransactionList[reportKey] = { + ...emptyReportSelection, + isSelected: areAllMatchingItemsSelected || selectedTransactions[reportKey]?.isSelected, + }; + } + continue; + } + + // For expense reports: when ANY transaction is selected, we want ALL transactions in the report selected. + // This ensures report-level selection persists when new transactions are added. + // Also check if the report itself was selected (when it was empty) by checking the reportID key + const reportKey = transactionGroup.keyForList; + const wasReportSelected = !!(reportKey && reportKey in selectedTransactions); + const hasIndividualSelectedInGroup = transactionGroup.transactions.some( + (transaction) => (!!transaction.keyForList && transaction.keyForList in selectedTransactions) || transaction.transactionID in selectedTransactions, + ); + const propagateSelectionToAllRows = (isExpenseReportType && (wasReportSelected || hasIndividualSelectedInGroup)) || (wasReportSelected && !isExpenseReportType); + + for (const transactionItem of transactionGroup.transactions) { + const listKey = transactionItem.keyForList ?? transactionItem.transactionID; + const isSelected = listKey in selectedTransactions || transactionItem.transactionID in selectedTransactions; + + // Include transaction if: already individually selected, part of select-all, or group-level propagation (expense report / empty group expanded) + const shouldInclude = isSelected || areAllMatchingItemsSelected || propagateSelectionToAllRows; + if (!shouldInclude) { + continue; + } + + const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction( + transactionItem.report, + transactionItem.reportAction, + transactionItem.holdReportAction, + transactionItem, + transactionItem.policy, + currentUserAccountID, + ); + const canRejectRequest = currentUserEmail && transactionItem.report ? canRejectReportAction(currentUserEmail, transactionItem.report) : false; + + const itemTransaction = (searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] ?? + transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`]) as OnyxEntry; + const originalItemTransaction = + searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? + transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; + const itemParentReport = searchResultsData?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; + const isItemUnreported = transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const reportForSplit = transactionItem.report ?? (isItemUnreported ? selfDMReport : undefined); + + const previousSelection = selectedTransactions[listKey] ?? selectedTransactions[transactionItem.transactionID]; + + newTransactionList[listKey] = { + transaction: transactionItem, + action: transactionItem.action, + canHold: canHoldRequest, + isHeld: isOnHold(transactionItem), + canUnhold: canUnholdRequest, + canSplit: isSplitAction( + reportForSplit, + [itemTransaction], + originalItemTransaction, + currentUserLogin, + currentUserAccountID, + transactionItem.policy, + itemParentReport, + isProduction, + ), + hasBeenSplit: getOriginalTransactionWithSplitInfo(itemTransaction, originalItemTransaction).isExpenseSplit, + canChangeReport: canEditFieldOfMoneyRequest({ + reportAction: transactionItem.reportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, + outstandingReportsByPolicyID, + transaction: transactionItem, + report: transactionItem.report, + policy: transactionItem.policy, + }), + + isSelected: areAllMatchingItemsSelected || !!previousSelection?.isSelected || propagateSelectionToAllRows, + canReject: canRejectRequest, + reportID: transactionItem.reportID, + policyID: transactionItem.report?.policyID, + amount: hasValidModifiedAmount(transactionItem) ? Number(transactionItem.modifiedAmount) : transactionItem.amount, + groupAmount: transactionItem.groupAmount, + groupCurrency: transactionItem.groupCurrency, + groupExchangeRate: transactionItem.groupExchangeRate, + currencyConversionRate: transactionItem.currencyConversionRate, + currency: transactionItem.currency, + ownerAccountID: transactionItem.reportAction?.actorAccountID, + reportAction: transactionItem.reportAction, + isFromOneTransactionReport: isOneTransactionReport(transactionItem.report), + report: transactionItem.report, + groupKey: previousSelection?.groupKey ?? (propagateSelectionToAllRows && !isExpenseReportType ? reportKey : undefined), + }; + } + } + } else { + for (const transactionItem of filteredData) { + if (!Object.hasOwn(transactionItem, 'transactionID') || !('transactionID' in transactionItem)) { + continue; + } + const listKey = transactionItem.keyForList ?? transactionItem.transactionID; + if (!(listKey in selectedTransactions) && !(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected) { + continue; + } + + const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction( + transactionItem.report, + transactionItem.reportAction, + transactionItem.holdReportAction, + transactionItem, + transactionItem.policy, + currentUserAccountID, + ); + const canRejectRequest = currentUserEmail && transactionItem.report ? canRejectReportAction(currentUserEmail, transactionItem.report) : false; + + const itemTransaction = searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; + const originalItemTransaction = searchResultsData?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; + const itemParentReport = searchResultsData?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; + const isItemUnreported = transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const reportForSplit = transactionItem.report ?? (isItemUnreported ? selfDMReport : undefined); + + const flatPreviousSelection = selectedTransactions[listKey] ?? selectedTransactions[transactionItem.transactionID]; + + newTransactionList[listKey] = { + transaction: transactionItem, + action: transactionItem.action, + canHold: canHoldRequest, + isHeld: isOnHold(transactionItem), + canUnhold: canUnholdRequest, + canSplit: isSplitAction( + reportForSplit, + [itemTransaction], + originalItemTransaction, + currentUserLogin, + currentUserAccountID, + transactionItem.policy, + itemParentReport, + isProduction, + ), + hasBeenSplit: getOriginalTransactionWithSplitInfo(itemTransaction, originalItemTransaction).isExpenseSplit, + canChangeReport: canEditFieldOfMoneyRequest({ + reportAction: transactionItem.reportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, + outstandingReportsByPolicyID, + transaction: transactionItem, + report: transactionItem.report, + policy: transactionItem.policy, + }), + + isSelected: areAllMatchingItemsSelected || !!flatPreviousSelection?.isSelected, + canReject: canRejectRequest, + reportID: transactionItem.reportID, + policyID: transactionItem.report?.policyID, + amount: hasValidModifiedAmount(transactionItem) ? Number(transactionItem.modifiedAmount) : transactionItem.amount, + groupAmount: transactionItem.groupAmount, + groupCurrency: transactionItem.groupCurrency, + groupExchangeRate: transactionItem.groupExchangeRate, + currencyConversionRate: transactionItem.currencyConversionRate, + currency: transactionItem.currency, + ownerAccountID: transactionItem.reportAction?.actorAccountID, + reportAction: transactionItem.reportAction, + isFromOneTransactionReport: isOneTransactionReport(transactionItem.report), + report: transactionItem.report, + }; + } + } + if (isEmptyObject(newTransactionList) && Object.keys(selectedTransactions).length === 0) { + return; + } + + // Bail out when the rebuilt selection is deeply equal to the current one. Without this, + // a dep that re-derives to a new reference but the same value re-runs this effect, which + // calls setSelectedTransactions with an equivalent payload and loops until React aborts + // with "Maximum update depth exceeded". See https://github.com/Expensify/App/issues/89588 + if (deepEqual(newTransactionList, selectedTransactions)) { + return; + } + + // Pass `filteredData` so `selectedReports` is updated atomically with `selectedTransactions`. + // Otherwise a stale `useSyncSelectedReports` derivation in the same commit can briefly clear + // `selectedReports` while an Onyx push expands the selection, which can close screens like + // SearchChangeApproverPage that auto-dismiss when `selectedReports` is empty. + reconcileSelection(newTransactionList, filteredData); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filteredData, reconcileSelection, areAllMatchingItemsSelected, isFocused, outstandingReportsByPolicyID, isExpenseReportType]); +} + +/** Turn mobile selection mode off once nothing is selected and the selection asked to exit the mode. */ +function useTurnOffSelectionModeWhenEmpty({isFocused, isMobileSelectionModeEnabled}: {isFocused: boolean; isMobileSelectionModeEnabled: boolean}) { + const {selectedTransactions, shouldTurnOffSelectionMode} = useSearchSelectionContext(); + + useEffect(() => { + if (!isFocused) { + return; + } + + const selectedKeys = Object.keys(selectedTransactions).filter((transactionKey) => selectedTransactions[transactionKey]); + if (selectedKeys.length === 0 && isMobileSelectionModeEnabled && shouldTurnOffSelectionMode) { + turnOffMobileSelectionMode(); + } + + // We don't want to run the effect on isFocused change as we only need it to early return when it is false. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedTransactions, isMobileSelectionModeEnabled, shouldTurnOffSelectionMode]); +} + +/** Switch mobile selection mode on/off as the screen size changes, based on whether anything is selected. */ +function useSyncMobileSelectionModeWithScreenSize({ + isFocused, + isMobileSelectionModeEnabled, + isSearchResultsEmpty, +}: { + isFocused: boolean; + isMobileSelectionModeEnabled: boolean; + isSearchResultsEmpty: boolean; +}) { + const {selectedTransactions} = useSearchSelectionContext(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + useEffect(() => { + if (!isFocused) { + return; + } + + const selectedKeys = Object.keys(selectedTransactions).filter((transactionKey) => selectedTransactions[transactionKey]); + if (!isSmallScreenWidth) { + if (selectedKeys.length === 0 && isMobileSelectionModeEnabled) { + turnOffMobileSelectionMode(); + } + return; + } + if (selectedKeys.length > 0 && !isMobileSelectionModeEnabled && !isSearchResultsEmpty) { + turnOnMobileSelectionMode(); + } + + // We only want this effect to handle the switching of mobile selection mode state when screen size changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSmallScreenWidth]); +} + +/** + * Renderless coordinator for Search selection. Owns the selection-only Onyx subscriptions and the effects + * that keep selection in sync with data and mobile selection mode, and publishes the screen-derived inputs the + * provider's stable write actions read at call time. Returns null, so its per-press re-renders are free — and + * keep `` itself out of the selection read/write path entirely. + */ +function SearchSelectionController({ + filteredData, + totalSelectableItemsCount, + searchResults, + transactions, + isMobileSelectionModeEnabled, + type, + areItemsGrouped, + isExpenseReportType, + isSearchResultsEmpty, +}: SearchSelectionControllerProps) { + const isFocused = useIsFocused(); + const {isProduction} = useEnvironment(); + const {accountID, email, login} = useCurrentUserPersonalDetails(); + const selfDMReport = useSelfDMReport(); + const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const {screenContextRef} = useSearchRowSelectionActions(); + + const searchResultsData = searchResults?.data; + + // Publish call-time inputs for the provider's stable write actions. Written in an effect (not render) so it + // never reads/writes a ref mid-render; the write actions are event handlers, so they always see the latest. + useEffect(() => { + screenContextRef.current = { + filteredData, + transactions, + searchResultsData, + currentUserLogin: email ?? '', + currentUserAccountID: accountID, + outstandingReportsByPolicyID, + selfDMReport, + isProduction, + areItemsGrouped, + totalSelectableItemsCount, + }; + }); + + useReconcileSelectionWithData({ + isFocused, + type, + areItemsGrouped, + isExpenseReportType, + filteredData, + searchResultsData, + transactions, + currentUserEmail: email ?? '', + currentUserLogin: login ?? '', + currentUserAccountID: accountID, + selfDMReport, + isProduction, + outstandingReportsByPolicyID, + }); + useTurnOffSelectionModeWhenEmpty({isFocused, isMobileSelectionModeEnabled}); + useSyncMobileSelectionModeWithScreenSize({isFocused, isMobileSelectionModeEnabled, isSearchResultsEmpty}); + useSyncSelectedReports(filteredData); + + return null; +} + +SearchSelectionController.displayName = 'SearchSelectionController'; + +export default SearchSelectionController; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index e318610a58a8..696bf740aa78 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -94,6 +94,7 @@ import {useSyncSelectedReports} from './SearchContextProvider'; import SearchList from './SearchList'; import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './SearchList/ListItem/types'; import {SearchScopeProvider} from './SearchScopeProvider'; +import SearchSelectionController from './SearchSelectionController'; import SearchTableHeader from './SearchTableHeader'; import {mapEmptyReportToSelectedEntry, mapTransactionItemToSelectedEntry, prepareTransactionsList} from './selectionBuilders'; import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types'; @@ -1659,6 +1660,17 @@ function Search({ return ( + Date: Fri, 5 Jun 2026 19:48:05 +0200 Subject: [PATCH 081/519] Search selection: move write path off onto the provider/controller no longer reads selectedTransactions or owns the selection write logic. Deleted from it: the selectedTransactions/areAllMatchingItemsSelected context read, the ~190-line refresh-selection effect, both mobile-selection-mode effects, the isRefreshingSelection ref + reset, updateSelectAllMatchingItemsState, toggleTransaction, toggleAllTransactions, the useSyncSelectedReports call, and the now-orphaned outstandingReportsByPolicyID/selfDMReport/isProduction subscriptions. onSelectRowInMobileSelectionMode now calls the stable toggle from useRowSelectionActions(). SearchList sources toggle/toggleAll from useRowSelectionActions() (passing toggle to rows via the existing onSelectionButtonPress) and reads selectedTransactions from context for its visible select-all count, so onCheckboxPress/onAllCheckboxPress/ selectedTransactions props are dropped from its public API. Updated the render-count test accordingly. This is the change that moves the metric: a checkbox press no longer re-renders or recreates its handlers. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/Search/SearchList/index.tsx | 32 +- src/components/Search/index.tsx | 499 +----------------- .../unit/Search/SearchListRenderCountTest.tsx | 6 - 3 files changed, 23 insertions(+), 514 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 836af7e5bdcd..f2b89014cb57 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -10,7 +10,9 @@ import MenuItem from '@components/MenuItem'; import Modal from '@components/Modal'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import ScrollView from '@components/ScrollView'; -import type {SearchColumnType, SearchGroupBy, SearchQueryJSON, SelectedTransactions} from '@components/Search/types'; +import {useSearchSelectionContext} from '@components/Search/SearchContext'; +import {useRowSelectionActions} from '@components/Search/SearchSelectionProvider'; +import type {SearchColumnType, SearchGroupBy, SearchQueryJSON} from '@components/Search/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import {useEditingCellState} from '@components/TransactionItemRow/EditableCell'; import useKeyboardState from '@hooks/useKeyboardState'; @@ -81,12 +83,6 @@ type SearchListProps = Pick, 'onScroll' | 'conten /** Whether this is a multi-select list */ canSelectMultiple: boolean; - /** Callback to fire when a checkbox is pressed */ - onCheckboxPress: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void; - - /** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */ - onAllCheckboxPress: () => void; - /** Styles to apply to SelectionList container */ containerStyle?: StyleProp; @@ -116,9 +112,6 @@ type SearchListProps = Pick, 'onScroll' | 'conten newTransactions?: Transaction[]; - /** Selected transactions for determining isSelected state */ - selectedTransactions: SelectedTransactions; - /** Non-personal and workspace cards (same drill path as former custom card names for rows) */ nonPersonalAndWorkspaceCards?: CardList; @@ -194,10 +187,8 @@ function SearchList({ ListItem, SearchTableHeader, onSelectRow, - onCheckboxPress, canSelectMultiple, onScroll = () => {}, - onAllCheckboxPress, contentContainerStyle, onEndReachedThreshold, onEndReached, @@ -212,7 +203,6 @@ function SearchList({ isMobileSelectionModeEnabled, newTransactions = [], nonPersonalAndWorkspaceCards, - selectedTransactions, hasLoadedAllTransactions, isAttendeesEnabledForMovingPolicy, isActionColumnWide, @@ -220,6 +210,8 @@ function SearchList({ }: SearchListProps) { const styles = useThemeStyles(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['CheckSquare']); + const {toggle, toggleAll} = useRowSelectionActions(); + const {selectedTransactions} = useSearchSelectionContext(); const {groupBy, type} = queryJSON; const flattenedItems = useMemo(() => { @@ -351,7 +343,7 @@ function SearchList({ return; } - onCheckboxPress(item, itemTransactions); + toggle(item, itemTransactions); }; const handleLongPressRow = useCallback( @@ -376,10 +368,10 @@ function SearchList({ turnOnMobileSelectionMode(); setIsModalVisible(false); - if (onCheckboxPress && longPressedItem) { - onCheckboxPress?.(longPressedItem, longPressedItemTransactions); + if (longPressedItem) { + toggle(longPressedItem, longPressedItemTransactions); } - }, [longPressedItem, onCheckboxPress, longPressedItemTransactions]); + }, [longPressedItem, toggle, longPressedItemTransactions]); /** * Scrolls to the desired item index in the section list @@ -445,7 +437,7 @@ function SearchList({ isFocused={isItemFocused} onSelectRow={onSelectRow} onLongPressRow={isMobileSelectionModeEnabled ? handleLongPressRowInMobileSelectionMode : handleLongPressRow} - onSelectionButtonPress={onCheckboxPress} + onSelectionButtonPress={toggle} canSelectMultiple={canSelectMultiple} item={item} columns={columns} @@ -481,7 +473,7 @@ function SearchList({ handleLongPressRow, handleLongPressRowInMobileSelectionMode, isMobileSelectionModeEnabled, - onCheckboxPress, + toggle, canSelectMultiple, columns, lastPaymentMethod, @@ -517,7 +509,7 @@ function SearchList({ selectedItemsLength={selectedItemsLength} totalItems={totalItems} shouldShowTextButton={selectAllButtonVisible} - onAllCheckboxPress={onAllCheckboxPress} + onAllCheckboxPress={toggleAll} /> )} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 696bf740aa78..376518f1a9d8 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,6 +1,5 @@ import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigation} from '@react-navigation/native'; import * as Sentry from '@sentry/react-native'; -import {deepEqual} from 'fast-equals'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; @@ -15,7 +14,6 @@ import useActionLoadingReportIDs from '@hooks/useActionLoadingReportIDs'; import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useEnvironment from '@hooks/useEnvironment'; import type {ActionHandledType} from '@hooks/useHoldMenuSubmit'; import useLocalize from '@hooks/useLocalize'; import useMultipleSnapshots from '@hooks/useMultipleSnapshots'; @@ -27,10 +25,9 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; -import useSelfDMReport from '@hooks/useSelfDMReport'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; -import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {saveLastSearchParams} from '@libs/actions/ReportNavigation'; import type {TransactionPreviewData} from '@libs/actions/Search'; import {setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search'; @@ -42,8 +39,7 @@ import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRout import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isCreatedTaskReportAction} from '@libs/ReportActionsUtils'; -import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; -import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isOneTransactionReport} from '@libs/ReportUtils'; +import {isOneTransactionReport} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryString, isDefaultExpensesQuery} from '@libs/SearchQueryUtils'; import { createAndOpenSearchTransactionThread, @@ -69,7 +65,7 @@ import { import {cancelSpan, endSpanWithAttributes, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import {cancelSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; -import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isOnHold, isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; +import {isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; import Navigation, {navigationRef} from '@navigation/Navigation'; import type {SearchFullscreenNavigatorParamList} from '@navigation/types'; import EmptySearchView from '@pages/Search/EmptySearchView'; @@ -80,7 +76,7 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@src/selectors/Onboarding'; -import type {Report, SaveSearch, Transaction} from '@src/types/onyx'; +import type {SaveSearch} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type SearchResults from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -89,15 +85,14 @@ import useOptimisticSearchTracking from './hooks/useOptimisticSearchTracking'; import useStableOptimisticSortedData from './hooks/useStableOptimisticSortedData'; import SearchChartView from './SearchChartView'; import SearchChartWrapper from './SearchChartWrapper'; -import {useSearchQueryActions, useSearchQueryContext, useSearchResultsActions, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from './SearchContext'; -import {useSyncSelectedReports} from './SearchContextProvider'; +import {useSearchQueryActions, useSearchQueryContext, useSearchResultsActions, useSearchResultsContext, useSearchSelectionActions} from './SearchContext'; import SearchList from './SearchList'; import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './SearchList/ListItem/types'; import {SearchScopeProvider} from './SearchScopeProvider'; import SearchSelectionController from './SearchSelectionController'; +import {useRowSelectionActions} from './SearchSelectionProvider'; import SearchTableHeader from './SearchTableHeader'; -import {mapEmptyReportToSelectedEntry, mapTransactionItemToSelectedEntry, prepareTransactionsList} from './selectionBuilders'; -import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types'; +import type {SearchColumnType, SearchParams, SearchQueryJSON, SortOrder} from './types'; type SearchProps = { queryJSON: SearchQueryJSON; @@ -136,7 +131,6 @@ function Search({ const {type, status, sortBy, sortOrder, hash, similarSearchHash, groupBy, view} = queryJSON; const {isOffline} = useNetwork(); - const {isProduction} = useEnvironment(); const prevIsOffline = usePrevious(isOffline); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth, isLargeScreenWidth, isInLandscapeMode} = useResponsiveLayout(); @@ -147,11 +141,11 @@ function Search({ const {markReportIDAsExpense, markReportIDAsMultiTransactionExpense, unmarkReportIDAsMultiTransactionExpense} = useWideRHPActions(); const {currentSearchHash, currentSearchKey, shouldResetSearchQuery, suggestedSearches} = useSearchQueryContext(); const {lastSearchType, shouldUseLiveData} = useSearchResultsContext(); - const {selectedTransactions, shouldTurnOffSelectionMode, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {setShouldResetSearchQuery} = useSearchQueryActions(); const {setShouldShowFiltersBarLoading} = useSearchResultsActions(); - const {setSelectedTransactions, clearSelectedTransactions, selectAllMatchingItems} = useSearchSelectionActions(); + const {clearSelectedTransactions} = useSearchSelectionActions(); + const {toggle} = useRowSelectionActions(); const [offset, setOffset] = useState(0); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); @@ -161,11 +155,9 @@ function Search({ const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const previousTransactions = usePrevious(transactions); const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); const [violations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const {accountID, email, login} = useCurrentUserPersonalDetails(); - const selfDMReport = useSelfDMReport(); + const {accountID, email} = useCurrentUserPersonalDetails(); const isActionLoadingSet = useActionLoadingReportIDs(); const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); @@ -226,40 +218,6 @@ function Search({ clearSelectedTransactions(); }, [validGroupBy, prevValidGroupBy, clearSelectedTransactions]); - useEffect(() => { - if (!isFocused) { - return; - } - - const selectedKeys = Object.keys(selectedTransactions).filter((transactionKey) => selectedTransactions[transactionKey]); - if (selectedKeys.length === 0 && isMobileSelectionModeEnabled && shouldTurnOffSelectionMode) { - turnOffMobileSelectionMode(); - } - - // We don't want to run the effect on isFocused change as we only need it to early return when it is false. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedTransactions, isMobileSelectionModeEnabled, shouldTurnOffSelectionMode]); - - useEffect(() => { - if (!isFocused) { - return; - } - - const selectedKeys = Object.keys(selectedTransactions).filter((transactionKey) => selectedTransactions[transactionKey]); - if (!isSmallScreenWidth) { - if (selectedKeys.length === 0 && isMobileSelectionModeEnabled) { - turnOffMobileSelectionMode(); - } - return; - } - if (selectedKeys.length > 0 && !isMobileSelectionModeEnabled && !isSearchResultsEmpty) { - turnOnMobileSelectionMode(); - } - - // We only want this effect to handle the switching of mobile selection mode state when screen size changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSmallScreenWidth]); - const {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights} = useSearchHighlightAndScroll({ searchResults, transactions, @@ -609,199 +567,6 @@ function Search({ }); }, [filteredDataLength, handleSearch, offset, queryJSON, currentSearchKey, searchResults?.search?.count, searchResults?.search?.isLoading, shouldCalculateTotals, validGroupBy]); - // When new data load, selectedTransactions is updated in next effect. We use this flag to whether selection is updated - const isRefreshingSelection = useRef(false); - - useEffect(() => { - if (!isFocused) { - return; - } - - if (type === CONST.SEARCH.DATA_TYPES.CHAT) { - return; - } - const newTransactionList: SelectedTransactions = {}; - if (validGroupBy || isExpenseReportType) { - for (const transactionGroup of filteredData) { - if (!Object.hasOwn(transactionGroup, 'transactions') || !('transactions' in transactionGroup)) { - continue; - } - - if (transactionGroup.transactions.length === 0) { - const reportKey = transactionGroup.keyForList; - if (transactionGroup.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - continue; - } - if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { - const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup); - newTransactionList[reportKey] = { - ...emptyReportSelection, - isSelected: areAllMatchingItemsSelected || selectedTransactions[reportKey]?.isSelected, - }; - } - continue; - } - - // For expense reports: when ANY transaction is selected, we want ALL transactions in the report selected. - // This ensures report-level selection persists when new transactions are added. - // Also check if the report itself was selected (when it was empty) by checking the reportID key - const reportKey = transactionGroup.keyForList; - const wasReportSelected = !!(reportKey && reportKey in selectedTransactions); - const hasIndividualSelectedInGroup = transactionGroup.transactions.some( - (transaction) => (!!transaction.keyForList && transaction.keyForList in selectedTransactions) || transaction.transactionID in selectedTransactions, - ); - const propagateSelectionToAllRows = (isExpenseReportType && (wasReportSelected || hasIndividualSelectedInGroup)) || (wasReportSelected && !isExpenseReportType); - - for (const transactionItem of transactionGroup.transactions) { - const listKey = transactionItem.keyForList ?? transactionItem.transactionID; - const isSelected = listKey in selectedTransactions || transactionItem.transactionID in selectedTransactions; - - // Include transaction if: already individually selected, part of select-all, or group-level propagation (expense report / empty group expanded) - const shouldInclude = isSelected || areAllMatchingItemsSelected || propagateSelectionToAllRows; - if (!shouldInclude) { - continue; - } - - const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction( - transactionItem.report, - transactionItem.reportAction, - transactionItem.holdReportAction, - transactionItem, - transactionItem.policy, - accountID, - ); - const canRejectRequest = email && transactionItem.report ? canRejectReportAction(email, transactionItem.report) : false; - - const itemTransaction = (searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] ?? - transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`]) as OnyxEntry; - const originalItemTransaction = - searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? - transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const itemParentReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; - const isItemUnreported = transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; - const reportForSplit = transactionItem.report ?? (isItemUnreported ? selfDMReport : undefined); - - const previousSelection = selectedTransactions[listKey] ?? selectedTransactions[transactionItem.transactionID]; - - newTransactionList[listKey] = { - transaction: transactionItem, - action: transactionItem.action, - canHold: canHoldRequest, - isHeld: isOnHold(transactionItem), - canUnhold: canUnholdRequest, - canSplit: isSplitAction(reportForSplit, [itemTransaction], originalItemTransaction, login ?? '', accountID, transactionItem.policy, itemParentReport, isProduction), - hasBeenSplit: getOriginalTransactionWithSplitInfo(itemTransaction, originalItemTransaction).isExpenseSplit, - canChangeReport: canEditFieldOfMoneyRequest({ - reportAction: transactionItem.reportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, - outstandingReportsByPolicyID, - transaction: transactionItem, - report: transactionItem.report, - policy: transactionItem.policy, - }), - - isSelected: areAllMatchingItemsSelected || !!previousSelection?.isSelected || propagateSelectionToAllRows, - canReject: canRejectRequest, - reportID: transactionItem.reportID, - policyID: transactionItem.report?.policyID, - amount: hasValidModifiedAmount(transactionItem) ? Number(transactionItem.modifiedAmount) : transactionItem.amount, - groupAmount: transactionItem.groupAmount, - groupCurrency: transactionItem.groupCurrency, - groupExchangeRate: transactionItem.groupExchangeRate, - currencyConversionRate: transactionItem.currencyConversionRate, - currency: transactionItem.currency, - ownerAccountID: transactionItem.reportAction?.actorAccountID, - reportAction: transactionItem.reportAction, - isFromOneTransactionReport: isOneTransactionReport(transactionItem.report), - report: transactionItem.report, - groupKey: previousSelection?.groupKey ?? (propagateSelectionToAllRows && !isExpenseReportType ? reportKey : undefined), - }; - } - } - } else { - for (const transactionItem of filteredData) { - if (!Object.hasOwn(transactionItem, 'transactionID') || !('transactionID' in transactionItem)) { - continue; - } - const listKey = transactionItem.keyForList ?? transactionItem.transactionID; - if (!(listKey in selectedTransactions) && !(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected) { - continue; - } - - const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction( - transactionItem.report, - transactionItem.reportAction, - transactionItem.holdReportAction, - transactionItem, - transactionItem.policy, - accountID, - ); - const canRejectRequest = email && transactionItem.report ? canRejectReportAction(email, transactionItem.report) : false; - - const itemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; - const originalItemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const itemParentReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; - const isItemUnreported = transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; - const reportForSplit = transactionItem.report ?? (isItemUnreported ? selfDMReport : undefined); - - const flatPreviousSelection = selectedTransactions[listKey] ?? selectedTransactions[transactionItem.transactionID]; - - newTransactionList[listKey] = { - transaction: transactionItem, - action: transactionItem.action, - canHold: canHoldRequest, - isHeld: isOnHold(transactionItem), - canUnhold: canUnholdRequest, - canSplit: isSplitAction(reportForSplit, [itemTransaction], originalItemTransaction, login ?? '', accountID, transactionItem.policy, itemParentReport, isProduction), - hasBeenSplit: getOriginalTransactionWithSplitInfo(itemTransaction, originalItemTransaction).isExpenseSplit, - canChangeReport: canEditFieldOfMoneyRequest({ - reportAction: transactionItem.reportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, - outstandingReportsByPolicyID, - transaction: transactionItem, - report: transactionItem.report, - policy: transactionItem.policy, - }), - - isSelected: areAllMatchingItemsSelected || !!flatPreviousSelection?.isSelected, - canReject: canRejectRequest, - reportID: transactionItem.reportID, - policyID: transactionItem.report?.policyID, - amount: hasValidModifiedAmount(transactionItem) ? Number(transactionItem.modifiedAmount) : transactionItem.amount, - groupAmount: transactionItem.groupAmount, - groupCurrency: transactionItem.groupCurrency, - groupExchangeRate: transactionItem.groupExchangeRate, - currencyConversionRate: transactionItem.currencyConversionRate, - currency: transactionItem.currency, - ownerAccountID: transactionItem.reportAction?.actorAccountID, - reportAction: transactionItem.reportAction, - isFromOneTransactionReport: isOneTransactionReport(transactionItem.report), - report: transactionItem.report, - }; - } - } - if (isEmptyObject(newTransactionList) && Object.keys(selectedTransactions).length === 0) { - return; - } - - // Bail out when the rebuilt selection is deeply equal to the current one. Without this, - // a dep that re-derives to a new reference but the same value re-runs this effect, which - // calls setSelectedTransactions with an equivalent payload and loops until React aborts - // with "Maximum update depth exceeded". See https://github.com/Expensify/App/issues/89588 - if (deepEqual(newTransactionList, selectedTransactions)) { - return; - } - - // Pass `filteredData` so `selectedReports` is updated atomically with `selectedTransactions`. - // Otherwise a stale `useSyncSelectedReports` derivation in the same commit can briefly clear - // `selectedReports` while an Onyx push expands the selection, which can close screens like - // SearchChangeApproverPage that auto-dismiss when `selectedReports` is empty. - setSelectedTransactions(newTransactionList, filteredData); - - isRefreshingSelection.current = true; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filteredData, setSelectedTransactions, areAllMatchingItemsSelected, isFocused, outstandingReportsByPolicyID, isExpenseReportType]); - useEffect(() => { if (!isSearchResultsEmpty || prevIsSearchResultEmpty) { return; @@ -845,13 +610,6 @@ function Search({ [isFocused, clearSelectedTransactions, hash, currentSearchHash], ); - // When selectedTransactions is updated, we confirm that selection is refreshed - useEffect(() => { - isRefreshingSelection.current = false; - }, [selectedTransactions]); - - useSyncSelectedReports(filteredData); - const areItemsGrouped = !!validGroupBy || isExpenseReportType; const totalSelectableItemsCount = useMemo(() => { if (!areItemsGrouped) { @@ -874,162 +632,12 @@ function Search({ }, 0); }, [areItemsGrouped, filteredData]); - const updateSelectAllMatchingItemsState = useCallback( - (updatedSelectedTransactions: SelectedTransactions) => { - if (!totalSelectableItemsCount || isRefreshingSelection.current) { - return; - } - const areAllItemsSelected = totalSelectableItemsCount === Object.keys(updatedSelectedTransactions).length; - - if (!areAllItemsSelected) { - selectAllMatchingItems(false); - } - }, - [totalSelectableItemsCount, selectAllMatchingItems], - ); - - const toggleTransaction = useCallback( - (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => { - if (isReportActionListItemType(item)) { - return; - } - if (isTaskListItemType(item)) { - return; - } - if (isTransactionListItemType(item)) { - if (!item.keyForList) { - return; - } - if (isTransactionPendingDelete(item)) { - return; - } - const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${item.transactionID}`] as OnyxEntry; - const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const itemParentReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${item.report?.parentReportID}`] as OnyxEntry; - const updatedTransactions = prepareTransactionsList( - item, - itemTransaction, - originalItemTransaction, - selectedTransactions, - email ?? '', - accountID, - outstandingReportsByPolicyID, - itemParentReport, - selfDMReport, - isProduction, - ); - - // Tag individual transactions with their parent group key so export filtering can derive the group when needed. - if (areItemsGrouped) { - const parentGroup = (filteredData as TransactionGroupListItemType[]).find((group) => - group.transactions.some((transaction) => transaction.keyForList === item.keyForList), - ); - if (parentGroup?.keyForList && updatedTransactions[item.keyForList]) { - updatedTransactions[item.keyForList] = {...updatedTransactions[item.keyForList], groupKey: parentGroup.keyForList}; - } - } - - setSelectedTransactions(updatedTransactions); - updateSelectAllMatchingItemsState(updatedTransactions); - return; - } - - const currentTransactions = itemTransactions ?? item.transactions; - - if (currentTransactions.length === 0 && item.keyForList) { - const reportKey = item.keyForList; - - if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - return; - } - - if (selectedTransactions[reportKey]?.isSelected) { - const reducedSelectedTransactions: SelectedTransactions = { - ...selectedTransactions, - }; - delete reducedSelectedTransactions[reportKey]; - setSelectedTransactions(reducedSelectedTransactions); - updateSelectAllMatchingItemsState(reducedSelectedTransactions); - return; - } - - const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item); - const updatedTransactions = { - ...selectedTransactions, - [reportKey]: emptyReportSelection, - }; - setSelectedTransactions(updatedTransactions); - updateSelectAllMatchingItemsState(updatedTransactions); - return; - } - - if (currentTransactions.some((transaction) => selectedTransactions[transaction.keyForList]?.isSelected)) { - const reducedSelectedTransactions: SelectedTransactions = { - ...selectedTransactions, - }; - - for (const transaction of currentTransactions) { - delete reducedSelectedTransactions[transaction.keyForList]; - } - - setSelectedTransactions(reducedSelectedTransactions); - updateSelectAllMatchingItemsState(reducedSelectedTransactions); - return; - } - - const updatedTransactions = { - ...selectedTransactions, - ...Object.fromEntries( - currentTransactions - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => { - const itemTransaction = (searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] ?? - transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`]) as OnyxEntry; - const originalItemTransaction = - searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? - transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const itemParentReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; - const [key, entry] = mapTransactionItemToSelectedEntry( - transactionItem, - itemTransaction, - originalItemTransaction, - email ?? '', - accountID, - outstandingReportsByPolicyID, - true, - itemParentReport, - selfDMReport, - isProduction, - ); - return [key, {...entry, groupKey: item.keyForList}]; - }), - ), - }; - setSelectedTransactions(updatedTransactions); - updateSelectAllMatchingItemsState(updatedTransactions); - }, - [ - selectedTransactions, - setSelectedTransactions, - updateSelectAllMatchingItemsState, - transactions, - searchResults?.data, - email, - accountID, - outstandingReportsByPolicyID, - selfDMReport, - isProduction, - areItemsGrouped, - filteredData, - ], - ); - const onSelectRowInMobileSelectionMode = (item: SearchListItem) => { if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return; } - toggleTransaction(item); + toggle(item); }; const onSelectRow = useCallback( @@ -1301,88 +909,6 @@ function Search({ setOffset((prev) => prev + CONST.SEARCH.RESULTS_PAGE_SIZE); }, [isFocused, searchResults?.search?.hasMoreResults, shouldShowLoadingMoreItems, shouldShowLoadingState, offset, allDataLength]); - const toggleAllTransactions = useCallback(() => { - const totalSelected = Object.keys(selectedTransactions).length; - - if (totalSelected > 0) { - clearSelectedTransactions(); - updateSelectAllMatchingItemsState({}); - return; - } - - let updatedTransactions: SelectedTransactions; - if (areItemsGrouped) { - const allSelections: Array<[string, SelectedTransactionInfo]> = (filteredData as TransactionGroupListItemType[]).flatMap((item) => { - if (item.transactions.length === 0 && item.keyForList) { - if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - return []; - } - return [mapEmptyReportToSelectedEntry(item)]; - } - return item.transactions - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => { - const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; - const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const itemParentReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; - const [key, entry] = mapTransactionItemToSelectedEntry( - transactionItem, - itemTransaction, - originalItemTransaction, - email ?? '', - accountID, - outstandingReportsByPolicyID, - true, - itemParentReport, - selfDMReport, - isProduction, - ); - return [key, {...entry, groupKey: item.keyForList}] as [string, SelectedTransactionInfo]; - }); - }); - updatedTransactions = Object.fromEntries(allSelections); - } else { - // When items are not grouped, data is TransactionListItemType[] not TransactionGroupListItemType[] - updatedTransactions = Object.fromEntries( - (filteredData as TransactionListItemType[]) - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => { - const itemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; - const originalItemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const itemParentReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; - return mapTransactionItemToSelectedEntry( - transactionItem, - itemTransaction, - originalItemTransaction, - email ?? '', - accountID, - outstandingReportsByPolicyID, - true, - itemParentReport, - selfDMReport, - isProduction, - ); - }), - ); - } - setSelectedTransactions(updatedTransactions, filteredData); - updateSelectAllMatchingItemsState(updatedTransactions); - }, [ - areItemsGrouped, - selectedTransactions, - setSelectedTransactions, - filteredData, - updateSelectAllMatchingItemsState, - clearSelectedTransactions, - transactions, - email, - accountID, - outstandingReportsByPolicyID, - searchResults?.data, - selfDMReport, - isProduction, - ]); - const onLayoutBase = useCallback(() => { hasHadFirstLayout.current = true; onDestinationVisible?.(isSearchResultsEmptyRef.current, 'layout'); @@ -1677,10 +1203,7 @@ function Search({ data={stableSortedData} ListItem={ListItem} onSelectRow={isMobileSelectionModeEnabled ? onSelectRowInMobileSelectionMode : onSelectRow} - onCheckboxPress={toggleTransaction} - onAllCheckboxPress={toggleAllTransactions} canSelectMultiple={canSelectMultiple} - selectedTransactions={selectedTransactions} shouldPreventLongPressRow={isChat || isTask} SearchTableHeader={ !shouldShowTableHeader ? undefined : ( diff --git a/tests/unit/Search/SearchListRenderCountTest.tsx b/tests/unit/Search/SearchListRenderCountTest.tsx index 7e257b7e6ff4..40bbb1a49344 100644 --- a/tests/unit/Search/SearchListRenderCountTest.tsx +++ b/tests/unit/Search/SearchListRenderCountTest.tsx @@ -181,15 +181,12 @@ describe('SearchList render count', () => { function SearchListWrapper({onRenderCount}: {onRenderCount: () => void}) { const onSelectRow = useCallback(() => {}, []); - const onCheckboxPress = useCallback(() => {}, []); - const onAllCheckboxPress = useCallback(() => {}, []); const onEndReached = useCallback(() => {}, []); const onLayout = useCallback(() => {}, []); const queryJSON = useMemo(() => STABLE_QUERY_JSON, []); const columns = useMemo(() => STABLE_COLUMNS, []); const data = useMemo(() => MOCK_DATA, []); - const selectedTransactions = useMemo(() => ({}), []); const contentContainerStyle = useMemo(() => ({}), []); const containerStyle = useMemo(() => ({}), []); @@ -202,10 +199,7 @@ describe('SearchList render count', () => { data={data} ListItem={MockListItem as never} onSelectRow={onSelectRow} - onCheckboxPress={onCheckboxPress} - onAllCheckboxPress={onAllCheckboxPress} canSelectMultiple={false} - selectedTransactions={selectedTransactions} queryJSON={queryJSON} columns={columns} isMobileSelectionModeEnabled={false} From 159b162fc8969095ba47e7e0042e5b1de00ea1b2 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 5 Jun 2026 19:50:44 +0200 Subject: [PATCH 082/519] Search selection: drop selectedTransactions from FlashList extraData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows self-hydrate their selected state via useRowSelection(keyForList) (read side, already shipped), so the row re-renders on its own context subscription when selection changes. The selectedTransactions entry in BaseSearchList's FlashList extraData is therefore redundant and only forced extra row diffing — remove it, along with the now-unused prop on BaseSearchList and the prop pass from SearchList. SearchList keeps reading selectedTransactions from context for its visible select-all count. Isolated commit: it is the one piece with an independent row re-render surface, so it reverts cleanly if a platform regresses. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/Search/SearchList/BaseSearchList/index.tsx | 5 ++--- src/components/Search/SearchList/BaseSearchList/types.ts | 5 +---- src/components/Search/SearchList/index.tsx | 1 - 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index d71c45e3d070..3526d582c3cc 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -34,7 +34,6 @@ function BaseSearchList({ contentContainerStyle, flattenedItemsLength, newTransactions, - selectedTransactions, isAttendeesEnabledForMovingPolicy, nonPersonalAndWorkspaceCards, }: BaseSearchListProps) { @@ -133,8 +132,8 @@ function BaseSearchList({ }, [setHasKeyBeenPressed]); const extraData = useMemo( - () => [focusedIndex, columns, newTransactions, selectedTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], - [focusedIndex, columns, newTransactions, selectedTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], + () => [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], + [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], ); return ( diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index 63f60b5da280..4e7adb5c948d 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -2,7 +2,7 @@ import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; import type {RefObject} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; -import type {SearchColumnType, SelectedTransactions} from '@components/Search/types'; +import type {SearchColumnType} from '@components/Search/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import type {CardList, Transaction} from '@src/types/onyx'; @@ -42,9 +42,6 @@ type BaseSearchListProps = Pick< /** The function to scroll to an index */ scrollToIndex?: (index: number, animated?: boolean) => void; - /** Selected transactions for triggering re-render via extraData */ - selectedTransactions?: SelectedTransactions; - /** Precomputed attendee-tracking boolean (derived from policy-for-moving-expenses) */ isAttendeesEnabledForMovingPolicy?: boolean; diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index f2b89014cb57..3edd323bdcc1 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -534,7 +534,6 @@ function SearchList({ onLayout={onLayout} contentContainerStyle={contentContainerStyle} newTransactions={newTransactions} - selectedTransactions={selectedTransactions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} /> From 458b1dc1f45c50ffbe766b8f6e730514f7e2c302 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 6 Jun 2026 01:33:00 +0500 Subject: [PATCH 083/519] Derive the label text from the current options matched by value --- src/pages/workspace/WorkspaceMembersPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index f2af0be231b8..98eddd427691 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -672,7 +672,11 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers /> ); - const roleFilterDropdownLabel = `${translate('common.role')}: ${selectedRoleFilters.length > 0 ? selectedRoleFilters.map(({text}) => text).join(', ') : translate('common.all')}`; + const selectedRoleFilterLabels = selectedRoleFilters + .map(({value}) => roleFilterOptions.find((option) => option.value === value)?.text) + .filter((text): text is string => !!text) + .join(', '); + const roleFilterDropdownLabel = `${translate('common.role')}: ${selectedRoleFilters.length > 0 ? selectedRoleFilterLabels : translate('common.all')}`; const roleFilterDropdown = shouldShowRoleFilter ? ( Date: Sat, 6 Jun 2026 02:45:42 +0530 Subject: [PATCH 084/519] Fix stuck search sidebar peek after modal delete --- src/components/Navigation/SearchSidebar.tsx | 70 +++++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/src/components/Navigation/SearchSidebar.tsx b/src/components/Navigation/SearchSidebar.tsx index ecdbbaca5420..f985519ca152 100644 --- a/src/components/Navigation/SearchSidebar.tsx +++ b/src/components/Navigation/SearchSidebar.tsx @@ -1,5 +1,6 @@ import type {ParamListBase} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import {isModalActiveSelector} from '@selectors/Modal'; +import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; import SidebarLeftIcon from '@assets/images/sidebar-left.svg'; @@ -12,6 +13,7 @@ import Tooltip from '@components/Tooltip'; import useLoadingBarVisibility from '@hooks/useLoadingBarVisibility'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -19,6 +21,7 @@ import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackN import SearchTypeMenuWide from '@pages/Search/SearchTypeMenuWide'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import { useSearchSidebarCollapse, @@ -40,7 +43,11 @@ function SearchSidebar({state}: SearchSidebarProps) { const {isOffline} = useNetwork(); const shouldShowLoadingBarForReports = useLoadingBarVisibility(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {isCollapsed, toggleSidebar, startPeek, endPeek} = useSearchSidebarCollapse(); + const {isCollapsed, isPeeking, toggleSidebar, startPeek, endPeek} = useSearchSidebarCollapse(); + const [isAnyModalActive = false] = useOnyx(ONYXKEYS.MODAL, {selector: isModalActiveSelector}); + const isAnyModalActiveRef = useRef(false); + const wasAnyModalActiveRef = useRef(false); + const sidebarRef = useRef(null); const layoutSpacerStyle = useSearchSidebarLayoutWidthStyle(); const visualSidebarWidthStyle = useSearchSidebarVisualWidthStyle(); const breadcrumbAnimatedStyle = useSearchSidebarCollapseFadeStyle(); @@ -70,6 +77,58 @@ function SearchSidebar({state}: SearchSidebarProps) { return endPeek; }, [endPeek, shouldUseNarrowLayout]); + const endPeekIfPointerIsOutsideSidebar = useCallback(() => { + if (!isPeeking || isAnyModalActiveRef.current || typeof document === 'undefined') { + return; + } + + const sidebarElement = sidebarRef.current as unknown as HTMLElement | null; + if (!sidebarElement || sidebarElement.matches?.(':hover')) { + return; + } + + endPeek(); + }, [endPeek, isPeeking]); + + const endPeekWhenNoModalIsActive = useCallback(() => { + if (isAnyModalActiveRef.current) { + return; + } + + endPeek(); + }, [endPeek]); + + useEffect(() => { + isAnyModalActiveRef.current = isAnyModalActive; + }, [isAnyModalActive]); + + useEffect(() => { + if (!isPeeking || typeof document === 'undefined') { + return; + } + + document.addEventListener('pointermove', endPeekIfPointerIsOutsideSidebar); + + return () => { + document.removeEventListener('pointermove', endPeekIfPointerIsOutsideSidebar); + }; + }, [endPeekIfPointerIsOutsideSidebar, isPeeking]); + + useEffect(() => { + const wasAnyModalActive = wasAnyModalActiveRef.current; + wasAnyModalActiveRef.current = isAnyModalActive; + + if (!wasAnyModalActive || isAnyModalActive || typeof window === 'undefined') { + return; + } + + const animationFrame = window.requestAnimationFrame(endPeekIfPointerIsOutsideSidebar); + + return () => { + window.cancelAnimationFrame(animationFrame); + }; + }, [endPeekIfPointerIsOutsideSidebar, isAnyModalActive]); + const shouldShowLoadingState = route?.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT ? false : !isOffline && !!isSearchLoading; if (shouldUseNarrowLayout) { @@ -99,8 +158,11 @@ function SearchSidebar({state}: SearchSidebarProps) { return ( - - + + Date: Sat, 6 Jun 2026 03:02:51 +0530 Subject: [PATCH 085/519] Fix search sidebar ref type --- src/components/Navigation/SearchSidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Navigation/SearchSidebar.tsx b/src/components/Navigation/SearchSidebar.tsx index f985519ca152..da5b5d1bb266 100644 --- a/src/components/Navigation/SearchSidebar.tsx +++ b/src/components/Navigation/SearchSidebar.tsx @@ -47,7 +47,7 @@ function SearchSidebar({state}: SearchSidebarProps) { const [isAnyModalActive = false] = useOnyx(ONYXKEYS.MODAL, {selector: isModalActiveSelector}); const isAnyModalActiveRef = useRef(false); const wasAnyModalActiveRef = useRef(false); - const sidebarRef = useRef(null); + const sidebarRef = useRef>(null); const layoutSpacerStyle = useSearchSidebarLayoutWidthStyle(); const visualSidebarWidthStyle = useSearchSidebarVisualWidthStyle(); const breadcrumbAnimatedStyle = useSearchSidebarCollapseFadeStyle(); From 6c83f92e21f5b9e6b0dbdcac5d3800a32760008c Mon Sep 17 00:00:00 2001 From: KJ21-ENG Date: Sat, 6 Jun 2026 03:17:33 +0530 Subject: [PATCH 086/519] Address search sidebar compiler review --- src/components/Navigation/SearchSidebar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Navigation/SearchSidebar.tsx b/src/components/Navigation/SearchSidebar.tsx index da5b5d1bb266..048cb115ac1c 100644 --- a/src/components/Navigation/SearchSidebar.tsx +++ b/src/components/Navigation/SearchSidebar.tsx @@ -1,6 +1,6 @@ import type {ParamListBase} from '@react-navigation/native'; import {isModalActiveSelector} from '@selectors/Modal'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; import SidebarLeftIcon from '@assets/images/sidebar-left.svg'; @@ -77,7 +77,7 @@ function SearchSidebar({state}: SearchSidebarProps) { return endPeek; }, [endPeek, shouldUseNarrowLayout]); - const endPeekIfPointerIsOutsideSidebar = useCallback(() => { + const endPeekIfPointerIsOutsideSidebar = () => { if (!isPeeking || isAnyModalActiveRef.current || typeof document === 'undefined') { return; } @@ -88,15 +88,15 @@ function SearchSidebar({state}: SearchSidebarProps) { } endPeek(); - }, [endPeek, isPeeking]); + }; - const endPeekWhenNoModalIsActive = useCallback(() => { + const endPeekWhenNoModalIsActive = () => { if (isAnyModalActiveRef.current) { return; } endPeek(); - }, [endPeek]); + }; useEffect(() => { isAnyModalActiveRef.current = isAnyModalActive; From 85792040c2f1b3bcb013b6ef2f79629121501384 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Sat, 6 Jun 2026 01:31:20 +0300 Subject: [PATCH 087/519] fix dew submit button animation --- src/CONST/index.ts | 1 + src/components/AnimatedSubmitButton/index.tsx | 36 ++++++++++++++++--- .../SubmitPrimaryAction.tsx | 4 +++ .../SubmitActionButton.tsx | 4 +++ src/libs/actions/IOU/ReportWorkflow.ts | 10 +++++- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ef6a35a1b1fc..b4a09f2753ea 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5033,6 +5033,7 @@ const CONST = { }, EXPENSE_PENDING_ACTION: { SUBMIT: 'SUBMIT', + SUBMIT_FAILED: 'SUBMIT_FAILED', APPROVE: 'APPROVE', }, BRICK_ROAD_INDICATOR_STATUS: { diff --git a/src/components/AnimatedSubmitButton/index.tsx b/src/components/AnimatedSubmitButton/index.tsx index 945d5333fc9b..0c9b53822afd 100644 --- a/src/components/AnimatedSubmitButton/index.tsx +++ b/src/components/AnimatedSubmitButton/index.tsx @@ -5,9 +5,12 @@ import {scheduleOnRN} from 'react-native-worklets'; import Button from '@components/Button'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import {clearPendingExpenseAction} from '@userActions/IOU/ReportWorkflow'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; type AnimatedSubmitButtonProps = WithSentryLabel & { @@ -28,9 +31,15 @@ type AnimatedSubmitButtonProps = WithSentryLabel & { // Whether the button should be disabled isDisabled?: boolean; + + // Whether this is a DEW submission that needs backend validation before showing "Submitted" + isDEWSubmission?: boolean; + + // The report id for which the button is displayed + reportID?: string; }; -function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunning, onAnimationFinish, isDisabled, sentryLabel}: AnimatedSubmitButtonProps) { +function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunning, onAnimationFinish, isDisabled, isDEWSubmission, sentryLabel, reportID}: AnimatedSubmitButtonProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const isAnimationRunning = isSubmittingAnimationRunning; @@ -42,6 +51,8 @@ function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunn const [minWidth, setMinWidth] = useState(0); const [isShowingLoading, setIsShowingLoading] = useState(false); const viewRef = useRef(null); + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`); + const isDEWSubmissionComplete = isDEWSubmission && isSubmittingAnimationRunning && !reportMetadata?.pendingExpenseAction; const containerStyles = useAnimatedStyle(() => ({ height: height.get(), @@ -87,6 +98,11 @@ function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunn return; } + // For DEW submission the animation is controlled by the BE response. + if (isDEWSubmission) { + return; + } + setMinWidth(viewRef.current?.getBoundingClientRect?.().width ?? 0); setIsShowingLoading(true); @@ -95,19 +111,29 @@ function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunn }, CONST.ANIMATION_SUBMIT_LOADING_STATE_DURATION); return () => clearTimeout(timer); - }, [buttonMarginTop, gap, height, isAnimationRunning]); + }, [buttonMarginTop, gap, height, isAnimationRunning, isDEWSubmission]); useEffect(() => { - if (!isAnimationRunning || isShowingLoading) { + if (!isAnimationRunning || isShowingLoading || (isDEWSubmission && !isDEWSubmissionComplete)) { return; } const timer = setTimeout(() => setCanShow(false), CONST.ANIMATION_SUBMIT_SUBMITTED_STATE_VISIBLE_DURATION); return () => clearTimeout(timer); - }, [isAnimationRunning, isShowingLoading]); + }, [isAnimationRunning, isShowingLoading, isDEWSubmissionComplete, isDEWSubmission]); + + useEffect(() => { + if (!isSubmittingAnimationRunning || !isDEWSubmission || reportMetadata?.pendingExpenseAction !== CONST.EXPENSE_PENDING_ACTION.SUBMIT_FAILED) { + return; + } + // When pending submission fails we quit to avoid showing submitted animation. + // eslint-disable-next-line react-hooks/set-state-in-effect + setCanShow(false); + clearPendingExpenseAction(reportID); + }, [isSubmittingAnimationRunning, reportMetadata?.pendingExpenseAction, reportID, isDEWSubmission]); - const showLoading = isShowingLoading || (!viewRef.current && isAnimationRunning); + const showLoading = isShowingLoading || (isAnimationRunning && (!viewRef.current || (isDEWSubmission && !isDEWSubmissionComplete))); return ( diff --git a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx index f788022b2b1e..0d1555d4ce74 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx @@ -16,6 +16,7 @@ import useStrictPolicyRules from '@hooks/useStrictPolicyRules'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; import {getFilteredReportActionsForReportView} from '@libs/ReportActionsUtils'; import {hasViolations as hasViolationsReportUtils, shouldBlockSubmitDueToPreventSelfApproval, shouldBlockSubmitDueToStrictPolicyRules} from '@libs/ReportUtils'; import {hasAnyPendingRTERViolation as hasAnyPendingRTERViolationTransactionUtils, hasOnlyPendingCardTransactions, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils'; @@ -52,6 +53,7 @@ function SubmitPrimaryAction({reportID}: SubmitPrimaryActionProps) { const transactions = Object.values(reportTransactions); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); const hasAnyPendingRTERViolation = hasAnyPendingRTERViolationTransactionUtils(transactions, allTransactionViolations, email ?? '', accountID, moneyRequestReport, policy); + const isDEWSubmission = hasDynamicExternalWorkflow(policy); const handleMarkPendingRTERTransactionsAsCash = () => { markPendingRTERTransactionsAsCash(transactions, allTransactionViolations, reportActions); @@ -121,6 +123,8 @@ function SubmitPrimaryAction({reportID}: SubmitPrimaryActionProps) { isSubmittingAnimationRunning={isSubmittingAnimationRunning} onAnimationFinish={stopAnimation} isDisabled={shouldBlockSubmit} + isDEWSubmission={isDEWSubmission} + reportID={reportID} /> ); } diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx index c9d7b8684d70..f73ef54a7159 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx @@ -9,6 +9,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import {hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; import {hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import {hasAnyPendingRTERViolation as hasAnyPendingRTERViolationTransactionUtils, hasOnlyPendingCardTransactions, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils'; import {submitReport} from '@userActions/IOU/ReportWorkflow'; @@ -42,6 +43,7 @@ function SubmitActionButton({iouReportID, isSubmittingAnimationRunning, stopAnim const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const {isOffline} = useNetwork(); + const isDEWSubmission = hasDynamicExternalWorkflow(policy); const reportTransactionsCollection = useReportTransactionsCollection(iouReportID); const transactions = Object.values(reportTransactionsCollection ?? {}).filter( (t): t is Transaction => !!t && (isOffline || t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), @@ -85,6 +87,8 @@ function SubmitActionButton({iouReportID, isSubmittingAnimationRunning, stopAnim }} isSubmittingAnimationRunning={isSubmittingAnimationRunning} onAnimationFinish={stopAnimation} + isDEWSubmission={isDEWSubmission} + reportID={iouReportID} sentryLabel={CONST.SENTRY_LABEL.REPORT_PREVIEW.SUBMIT_BUTTON} /> ); diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 1978be33d565..b5389616dcfa 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -1528,7 +1528,7 @@ function submitReport({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, value: { - pendingExpenseAction: null, + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT_FAILED, }, }); } @@ -1789,6 +1789,13 @@ function addReportApprover( API.write(WRITE_COMMANDS.ADD_REPORT_APPROVER, params, onyxData); } +function clearPendingExpenseAction(reportID: string | undefined) { + if (!reportID) { + return; + } + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {pendingExpenseAction: null}); +} + export { addReportApprover, approveMoneyRequest, @@ -1798,6 +1805,7 @@ export { canIOUBePaid, canSubmitReport, canUnapproveIOU, + clearPendingExpenseAction, getBadgeFromIOUReport, getIOUReportActionWithBadge, getReportOriginalCreationTimestamp, From 67cb8b57e2669c1bdc915c5d29f8926778860409 Mon Sep 17 00:00:00 2001 From: Scott Deeter Date: Fri, 5 Jun 2026 17:30:16 -0700 Subject: [PATCH 088/519] ReportActionsUtils: add shouldUseRealActor param to getReportActionActorAccountID --- src/libs/ReportActionsUtils.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 4b34fbaca017..cbccb0ed0e72 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -960,6 +960,9 @@ function getReportActionActorAccountID( iouReport: OnyxEntry, report: OnyxEntry, delegatePersonalDetails?: PersonalDetails | undefined | null, + // When true (e.g. in search results) the actual author is returned instead of the Concierge + // display override used in inbox timelines for automated actions. + shouldUseRealActor = false, ): number | undefined { switch (reportAction?.actionName) { case CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW: { @@ -975,7 +978,7 @@ function getReportActionActorAccountID( case CONST.REPORT.ACTIONS.TYPE.CREATED: { const reportNameValuePairs = allReportNameValuePair?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${iouReport?.reportID}`]; - if (isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID)) { + if (!shouldUseRealActor && isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID)) { return CONST.ACCOUNT_ID.CONCIERGE; } return reportAction?.actorAccountID; @@ -1000,7 +1003,7 @@ function getReportActionActorAccountID( // - Harvesting (delayed submissions) // - Automatic approvals/forwards via workspace rules // - Automatic payments via workspace rules - if (wasSubmittedViaHarvesting || (wasAutomatic && actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) || (wasAutomatic && isPayment)) { + if (!shouldUseRealActor && (wasSubmittedViaHarvesting || (wasAutomatic && actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) || (wasAutomatic && isPayment))) { return CONST.ACCOUNT_ID.CONCIERGE; } From bc204c45f1a1ea95f3ed20c92f38937911924e29 Mon Sep 17 00:00:00 2001 From: Scott Deeter Date: Fri, 5 Jun 2026 17:30:20 -0700 Subject: [PATCH 089/519] useReportActionAvatars: thread shouldUseRealActor to actor resolution --- src/components/ReportActionAvatars/useReportActionAvatars.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/ReportActionAvatars/useReportActionAvatars.ts b/src/components/ReportActionAvatars/useReportActionAvatars.ts index c89ec71ce0af..b539adb2143a 100644 --- a/src/components/ReportActionAvatars/useReportActionAvatars.ts +++ b/src/components/ReportActionAvatars/useReportActionAvatars.ts @@ -48,6 +48,7 @@ function useReportActionAvatars({ invitedEmailsToAccountIDs, shouldUseCustomFallbackAvatar = false, chatReportID: passedChatReportID, + shouldUseRealActor = false, }: { report: OnyxEntry; action: OnyxEntry; @@ -60,6 +61,8 @@ function useReportActionAvatars({ invitedEmailsToAccountIDs?: InvitedEmailsToAccountIDs; shouldUseCustomFallbackAvatar?: boolean; chatReportID?: string; + /** When true, returns the action's real author instead of the Concierge display override used in inbox timelines. */ + shouldUseRealActor?: boolean; }) { const defaultAvatars = useDefaultAvatars(); /* Get avatar type */ @@ -119,7 +122,7 @@ function useReportActionAvatars({ const delegateAccountID = getDelegateAccountIDFromReportAction(action); const delegatePersonalDetails = delegateAccountID ? personalDetails?.[delegateAccountID] : undefined; - const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); + const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails, shouldUseRealActor); const humanAgentAccountID = getHumanAgentAccountIDFromReportAction(action); const isAInvoiceReport = isInvoiceReport(iouReport ?? null); From e01a38c72064955138ed4e56e0e37a17ea302e58 Mon Sep 17 00:00:00 2001 From: Scott Deeter Date: Fri, 5 Jun 2026 17:30:24 -0700 Subject: [PATCH 090/519] ReportActionItemSingle: show real author in search results --- src/pages/inbox/report/ReportActionItemSingle.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionItemSingle.tsx b/src/pages/inbox/report/ReportActionItemSingle.tsx index ad55749a8bde..d26ae4f340c9 100644 --- a/src/pages/inbox/report/ReportActionItemSingle.tsx +++ b/src/pages/inbox/report/ReportActionItemSingle.tsx @@ -6,6 +6,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ReportActionAvatars from '@components/ReportActionAvatars'; import useReportActionAvatars from '@components/ReportActionAvatars/useReportActionAvatars'; +import {useIsOnSearch} from '@components/Search/SearchScopeProvider'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -78,8 +79,9 @@ function ReportActionItemSingle({ const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); + const isOnSearch = useIsOnSearch(); - const {avatarType, avatars, details, source, reportPreviewSenderID} = useReportActionAvatars({report: potentialIOUReport ?? report, action}); + const {avatarType, avatars, details, source, reportPreviewSenderID} = useReportActionAvatars({report: potentialIOUReport ?? report, action, shouldUseRealActor: isOnSearch}); const reportID = source.chatReport?.reportID; const iouReportID = source.iouReport?.reportID; From 7d84302a91f602d6350eeb4eab5581aa240e0820 Mon Sep 17 00:00:00 2001 From: Scott Deeter Date: Fri, 5 Jun 2026 17:30:27 -0700 Subject: [PATCH 091/519] tests: cover shouldUseRealActor in getReportActionActorAccountID --- tests/unit/ReportActionsUtilsTest.ts | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 51d41614c46a..7769dad604a3 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -4252,6 +4252,57 @@ describe('ReportActionsUtils', () => { const actorAccountID = getReportActionActorAccountID(reportAction, iouReport, report); expect(actorAccountID).toBe(9999); }); + + it('returns CONCIERGE for automatic DEW_APPROVE_FAILED when shouldUseRealActor is false', () => { + const reportAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED, + reportActionID: '1', + created: '1', + message: [], + actorAccountID: 2701545, + originalMessage: {automaticAction: true, message: 'failed'}, + }; + const iouReport: Report = {...createRandomReport(0, undefined)}; + const report: Report = {...createRandomReport(1, undefined)}; + + const actorAccountID = getReportActionActorAccountID(reportAction, iouReport, report, undefined, false); + expect(actorAccountID).toBe(CONST.ACCOUNT_ID.CONCIERGE); + }); + + it('returns real actorAccountID for automatic DEW_APPROVE_FAILED when shouldUseRealActor is true', () => { + const reportAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED, + reportActionID: '1', + created: '1', + message: [], + actorAccountID: 2701545, + originalMessage: {automaticAction: true, message: 'failed'}, + }; + const iouReport: Report = {...createRandomReport(0, undefined)}; + const report: Report = {...createRandomReport(1, undefined)}; + + const actorAccountID = getReportActionActorAccountID(reportAction, iouReport, report, undefined, true); + expect(actorAccountID).toBe(2701545); + }); + + it('returns real actorAccountID for harvest-created CREATED action when shouldUseRealActor is true', async () => { + const reportAction: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + actorAccountID: 9999, + }; + const iouReport: Report = {...createRandomReport(0, undefined)}; + const report: Report = {...createRandomReport(3, undefined), reportID: 'harvest-report-shouldUseRealActor'}; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${iouReport.reportID}`, { + origin: 'harvest', + originalID: 'orig-456', + }); + await waitForBatchedUpdates(); + + const actorAccountID = getReportActionActorAccountID(reportAction, iouReport, report, undefined, true); + expect(actorAccountID).toBe(9999); + }); }); describe('getInvoiceCompanyNameUpdateMessage', () => { From a02590595b30aa99001a806165fb4f92a73b88e2 Mon Sep 17 00:00:00 2001 From: aswin-s Date: Mon, 8 Jun 2026 00:11:35 +0530 Subject: [PATCH 092/519] Remove unrelated formatting changes --- .../MoneyRequestReportActionsList.tsx | 4 +- .../actions/IOU/SplitTransactionUpdate.ts | 75 ++++--------------- 2 files changed, 16 insertions(+), 63 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index c8763c70a9ae..e9532d27727c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -277,9 +277,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) isBackfillingRef.current = true; prevBackfillCursorRef.current = cursor; - const handle = TransitionTracker.runAfterTransitions({ - callback: () => getOlderActions(reportID, cursor), - }); + const handle = TransitionTracker.runAfterTransitions({callback: () => getOlderActions(reportID, cursor)}); return () => handle.cancel(); }, [ diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 4ebfb521bad0..65c1ea15c046 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -79,11 +79,7 @@ type UpdateSplitTransactionsParams = { splitExpenses: SplitExpense[]; splitExpensesTotal: number | undefined; }; - searchContext?: - | (Partial & { - activeGroupSearchHashes?: number[]; - }) - | undefined; + searchContext?: (Partial & {activeGroupSearchHashes?: number[]}) | undefined; policyCategories: OnyxTypes.PolicyCategories | undefined; policy: OnyxTypes.Policy | undefined; policyRecentlyUsedCategories: OnyxTypes.RecentlyUsedCategories | undefined; @@ -665,11 +661,7 @@ function updateSplitTransactions({ parentChatReport, policyParams: { ...policyParams, - policyTagList: getMoneyRequestPolicyTags({ - moneyRequestReportID: splitExpense?.reportID, - parentChatReport, - participant: participantParams.participant, - }), + policyTagList: getMoneyRequestPolicyTags({moneyRequestReportID: splitExpense?.reportID, parentChatReport, participant: participantParams.participant}), }, transactionParams, moneyRequestReportID: moneyRequestReportIDForSplit, @@ -880,18 +872,9 @@ function updateSplitTransactions({ successDataComments.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - value: { - [newReportActionID]: { - pendingAction: null, - isOptimisticAction: null, - }, - }, - }); - failureDataComments.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - value: {[newReportActionID]: null}, + value: {[newReportActionID]: {pendingAction: null, isOptimisticAction: null}}, }); + failureDataComments.push({onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, value: {[newReportActionID]: null}}); if (currentSplit) { currentSplit.copiedComments ??= {}; @@ -911,11 +894,7 @@ function updateSplitTransactions({ const baseViolations = latestViolationUpdate && 'value' in latestViolationUpdate && Array.isArray(latestViolationUpdate.value) ? latestViolationUpdate.value : fallbackViolations; const nextViolations = [ ...baseViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.HOLD), - { - name: CONST.VIOLATIONS.HOLD, - type: CONST.VIOLATION_TYPES.VIOLATION, - showInReview: true, - }, + {name: CONST.VIOLATIONS.HOLD, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, ]; if (latestViolationUpdate && 'value' in latestViolationUpdate) { @@ -1083,25 +1062,13 @@ function updateSplitTransactions({ continue; } remainingCommentActions.push(action); - optimisticActionsData[action.reportActionID] = { - ...action, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }; - successActionsData[action.reportActionID] = { - pendingAction: null, - isOptimisticAction: null, - }; + optimisticActionsData[action.reportActionID] = {...action, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}; + successActionsData[action.reportActionID] = {pendingAction: null, isOptimisticAction: null}; failureActionsData[action.reportActionID] = null; } if (isRemainingTransactionOnHold && remainingHoldReportAction) { - optimisticActionsData[remainingHoldReportAction.reportActionID] = { - ...remainingHoldReportAction, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }; - successActionsData[remainingHoldReportAction.reportActionID] = { - pendingAction: null, - isOptimisticAction: null, - }; + optimisticActionsData[remainingHoldReportAction.reportActionID] = {...remainingHoldReportAction, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}; + successActionsData[remainingHoldReportAction.reportActionID] = {pendingAction: null, isOptimisticAction: null}; failureActionsData[remainingHoldReportAction.reportActionID] = null; optimisticDataComments.push({ @@ -1207,9 +1174,7 @@ function updateSplitTransactions({ ...optimisticTransactionFromGetMoneyRequest, transactionID: snapshotTransactionID, // For edits, show a pending indicator in the snapshot while the request is in-flight. - ...(!isCreationOfSplits && { - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }), + ...(!isCreationOfSplits && {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }); const reportActionsTargetReportID = selfDMReportID ?? originalSelfDMReportID; @@ -1333,9 +1298,7 @@ function updateSplitTransactions({ onyxData.failureData?.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionsReportID}`, - value: { - [currentReportAction.reportActionID]: restoredActionWithoutChildMetadata, - }, + value: {[currentReportAction.reportActionID]: restoredActionWithoutChildMetadata}, }); } } @@ -1529,9 +1492,7 @@ function updateSplitTransactions({ }, ...(whisperActionID && { [whisperActionID]: { - originalMessage: { - resolution: CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING, - }, + originalMessage: {resolution: CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING}, }, }), }; @@ -1647,16 +1608,12 @@ function updateSplitTransactions({ onyxData.optimisticData?.push({ onyxMethod: Onyx.METHOD.MERGE, key, - value: { - data: {...optimisticSnapshotData, ...reportActionsMergePatch}, - }, + value: {data: {...optimisticSnapshotData, ...reportActionsMergePatch}}, }); onyxData.failureData?.push({ onyxMethod: Onyx.METHOD.MERGE, key, - value: { - data: {...failureSnapshotData, ...reportActionsFailurePatch}, - }, + value: {data: {...failureSnapshotData, ...reportActionsFailurePatch}}, }); } } @@ -1825,9 +1782,7 @@ function updateSplitTransactions({ { onyxMethod: Onyx.METHOD.MERGE, key, - value: { - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), - }, + value: {errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage')}, }, ); } From 2e3a5084d652fbb346e086c8670dc2681be058cb Mon Sep 17 00:00:00 2001 From: aswin-s Date: Mon, 8 Jun 2026 01:44:36 +0530 Subject: [PATCH 093/519] Only register newly-created split transaction IDs as pending-new --- src/libs/actions/IOU/SplitTransactionUpdate.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 65c1ea15c046..1eaa643b1b70 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1933,8 +1933,9 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); if (params.expenseReport?.reportID && !isReverseSplitOperation && !isLastTransactionInReport && !isSearchPageTopmostFullScreenRoute) { + const existingChildTransactionIDs = new Set(allChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); for (const splitExpense of splitExpenses) { - if (!splitExpense.transactionID) { + if (!splitExpense.transactionID || existingChildTransactionIDs.has(splitExpense.transactionID)) { continue; } addPendingNewTransactionIDs(targetReportID, splitExpense.transactionID); From e9738ef72c6486c50149e027c821b4f8b5fb12ef Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:21:06 +0700 Subject: [PATCH 094/519] improve navigation guard to account for explanation modal --- .../Navigation/guards/AIFeaturesPromoGuard.ts | 20 ++++++++++++++----- src/libs/Navigation/guards/index.ts | 6 +++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts index 3a8df06ead97..041234a4053c 100644 --- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts +++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts @@ -6,6 +6,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import isProductTrainingElementDismissed from '@libs/TooltipUtils'; +import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -21,6 +22,7 @@ let dismissedProductTraining: OnyxEntry; let isDismissedProductTrainingLoaded = false; let hasBeenAddedToNudgeMigration = false; +let isHybridAppOnboardingCompleted: boolean | undefined; let isTryNewDotLoaded = false; let onboarding: OnyxEntry; @@ -32,18 +34,20 @@ let hasRedirectedToAIFeaturesPromoModal = false; * Same-session protection. * * Per the issue requirements, the AI features promo modal must not appear in the same - * session as the migration welcome modal or the onboarding flow. These flags trip when - * we observe either of those modals being "active" (pending dismissal / not yet - * completed) at any point during this process lifetime, and suppress the AI promo for - * the rest of the session. + * session as the migration welcome modal, the onboarding flow, or the HybridApp + * explanation modal. These flags trip when we observe any of those modals being + * "active" (pending dismissal / not yet completed) at any point during this process + * lifetime, and suppress the AI promo for the rest of the session. */ let observedActiveMigrationModalThisSession = false; let observedActiveOnboardingThisSession = false; +let observedActiveExplanationModalThisSession = false; function resetSessionFlag() { hasRedirectedToAIFeaturesPromoModal = false; observedActiveMigrationModalThisSession = false; observedActiveOnboardingThisSession = false; + observedActiveExplanationModalThisSession = false; } /** @@ -60,7 +64,8 @@ function navigateToAIFeaturesPromoModalIfReady() { !isOnboardingLoaded || isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining) || observedActiveMigrationModalThisSession || - observedActiveOnboardingThisSession + observedActiveOnboardingThisSession || + observedActiveExplanationModalThisSession ) { return; } @@ -101,10 +106,15 @@ Onyx.connectWithoutView({ callback: (value) => { const result = value ? tryNewDotOnyxSelector(value) : undefined; hasBeenAddedToNudgeMigration = result?.hasBeenAddedToNudgeMigration ?? false; + isHybridAppOnboardingCompleted = result?.isHybridAppOnboardingCompleted; isTryNewDotLoaded = true; if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, dismissedProductTraining)) { observedActiveMigrationModalThisSession = true; } + // The HybridApp explanation modal shows when the user is transitioning from OldDot to NewDot. + if (CONFIG.IS_HYBRID_APP && isHybridAppOnboardingCompleted === false) { + observedActiveExplanationModalThisSession = true; + } navigateToAIFeaturesPromoModalIfReady(); }, }); diff --git a/src/libs/Navigation/guards/index.ts b/src/libs/Navigation/guards/index.ts index e60ba997d016..783b8be533b3 100644 --- a/src/libs/Navigation/guards/index.ts +++ b/src/libs/Navigation/guards/index.ts @@ -5,7 +5,7 @@ import getCurrentUrl from '@libs/Navigation/currentUrl'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Session} from '@src/types/onyx'; import AIFeaturesPromoGuard, {onSessionOrLoadingAppChanged as onAIFeaturesPromoSessionOrLoadingAppChanged} from './AIFeaturesPromoGuard'; -import MigratedUserWelcomeModalGuard, {onSessionOrLoadingAppChanged} from './MigratedUserWelcomeModalGuard'; +import MigratedUserWelcomeModalGuard, {onSessionOrLoadingAppChanged as onMigratedUserWelcomeModalSessionOrLoadingAppChanged} from './MigratedUserWelcomeModalGuard'; import OnboardingGuard from './OnboardingGuard'; import type {GuardContext, GuardResult, NavigationGuard} from './types'; @@ -20,7 +20,7 @@ Onyx.connectWithoutView({ key: ONYXKEYS.SESSION, callback: (value) => { session = value; - onSessionOrLoadingAppChanged(session, isLoadingApp); + onMigratedUserWelcomeModalSessionOrLoadingAppChanged(session, isLoadingApp); onAIFeaturesPromoSessionOrLoadingAppChanged(session, isLoadingApp); }, }); @@ -29,7 +29,7 @@ Onyx.connectWithoutView({ key: ONYXKEYS.IS_LOADING_APP, callback: (value) => { isLoadingApp = value ?? true; - onSessionOrLoadingAppChanged(session, isLoadingApp); + onMigratedUserWelcomeModalSessionOrLoadingAppChanged(session, isLoadingApp); onAIFeaturesPromoSessionOrLoadingAppChanged(session, isLoadingApp); }, }); From d78c768b2a85f891ec954deed77a87427d7a584b Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:21:43 +0700 Subject: [PATCH 095/519] add lottie files --- assets/animations/CustomAgents.lottie | Bin 0 -> 38277 bytes assets/animations/ExpenseAssistant.lottie | Bin 0 -> 28766 bytes assets/animations/SpendAnalysis.lottie | Bin 0 -> 20112 bytes src/components/LottieAnimations/index.tsx | 16 ++++++++++++++++ 4 files changed, 16 insertions(+) create mode 100644 assets/animations/CustomAgents.lottie create mode 100644 assets/animations/ExpenseAssistant.lottie create mode 100644 assets/animations/SpendAnalysis.lottie diff --git a/assets/animations/CustomAgents.lottie b/assets/animations/CustomAgents.lottie new file mode 100644 index 0000000000000000000000000000000000000000..8210c757e7a027bfb36841b1b60c0024149afc06 GIT binary patch literal 38277 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 literal 0 HcmV?d00001 diff --git a/assets/animations/ExpenseAssistant.lottie b/assets/animations/ExpenseAssistant.lottie new file mode 100644 index 0000000000000000000000000000000000000000..99e64a64cede3e17f2b38cc4c4285971d86cc8c2 GIT binary patch literal 28766 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'@assets/animations/SpendAnalysis.lottie'), + w: 440, + h: 334, + backgroundColor: colors.pink700, + }, + ExpenseAssistant: { + file: require('@assets/animations/ExpenseAssistant.lottie'), + w: 440, + h: 334, + }, + CustomAgents: { + file: require('@assets/animations/CustomAgents.lottie'), + w: 440, + h: 334, + }, } satisfies Record; export default DotLottieAnimations; From 07bdc5abdf91b5d2da4f08b5ae911f0875cc6413 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:23:25 +0700 Subject: [PATCH 096/519] implement AI features promo modal UI --- src/components/AIFeaturesPromoModal/index.tsx | 61 ++- .../FeatureTrainingModalBody.tsx | 282 ++++++++++++ .../index.tsx} | 403 ++++++++---------- src/styles/index.ts | 6 + src/styles/variables.ts | 1 + 5 files changed, 528 insertions(+), 225 deletions(-) create mode 100644 src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx rename src/components/{FeatureTrainingModal.tsx => FeatureTrainingModal/index.tsx} (56%) diff --git a/src/components/AIFeaturesPromoModal/index.tsx b/src/components/AIFeaturesPromoModal/index.tsx index 1dda5ecb0ca6..08bbdc3553c2 100644 --- a/src/components/AIFeaturesPromoModal/index.tsx +++ b/src/components/AIFeaturesPromoModal/index.tsx @@ -1,32 +1,75 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import FeatureTrainingModal from '@components/FeatureTrainingModal'; +import type {FeatureTrainingModalPageProps} from '@components/FeatureTrainingModal'; import LottieAnimations from '@components/LottieAnimations'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; import {dismissProductTraining} from '@libs/actions/Welcome'; import Log from '@libs/Log'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; function AIFeaturesPromoModal() { const {translate} = useLocalize(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {isBetaEnabled} = usePermissions(); + const canUseCustomAgent = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); - const onClose = () => { - Log.hmmm('[AIFeaturesPromoModal] onClose called, dismissing product training'); - dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, true); - }; + const pages = useMemo( + () => [ + { + animation: LottieAnimations.SpendAnalysis, + title: translate('aiFeaturesPromoModal.spendAnalysis.title'), + subtitle: translate('aiFeaturesPromoModal.subtitle'), + description: translate('aiFeaturesPromoModal.spendAnalysis.description'), + confirmText: translate('common.next'), + }, + { + animation: LottieAnimations.ExpenseAssistant, + title: translate('aiFeaturesPromoModal.expenseAssistant.title'), + subtitle: translate('aiFeaturesPromoModal.subtitle'), + description: translate('aiFeaturesPromoModal.expenseAssistant.description'), + confirmText: canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText'), + }, + ...(canUseCustomAgent + ? [ + { + animation: LottieAnimations.CustomAgents, + title: translate('aiFeaturesPromoModal.customAgents.title'), + subtitle: translate('aiFeaturesPromoModal.subtitle'), + description: translate('aiFeaturesPromoModal.customAgents.description'), + confirmText: translate('aiFeaturesPromoModal.confirmText'), + }, + ] + : []), + ], + [translate], + ); const onConfirm = () => { Log.hmmm('[AIFeaturesPromoModal] onConfirm called, dismissing product training'); dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL); }; + const onClose = () => { + Log.hmmm('[AIFeaturesPromoModal] onClose called, dismissing product training'); + dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, true); + }; + return ( ); } diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx new file mode 100644 index 000000000000..1055dcb2263e --- /dev/null +++ b/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx @@ -0,0 +1,282 @@ +import type {SourceLoadEventPayload} from 'expo-video'; +import React, {useEffect, useState} from 'react'; +import {Image, View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {ImageResizeMode, ImageSourcePropType} from 'react-native'; +import {GestureHandlerRootView} from 'react-native-gesture-handler'; +import Button from '@components/Button'; +import CheckboxWithLabel from '@components/CheckboxWithLabel'; +import type {BaseFeatureTrainingModalProps, FeatureTrainingModalPageProps} from '@components/FeatureTrainingModal'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import ImageSVG from '@components/ImageSVG'; +import Lottie from '@components/Lottie'; +import LottieAnimations from '@components/LottieAnimations'; +import OfflineIndicator from '@components/OfflineIndicator'; +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; +import VideoPlayer from '@components/VideoPlayer'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import Accessibility from '@libs/Accessibility'; +import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; +import CONST from '@src/CONST'; + +// Aspect ratio and height of the video. +// Useful before video loads to reserve space. +const VIDEO_ASPECT_RATIO = 1280 / 960; + +type VideoStatus = 'video' | 'animation'; + +const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7; + +type FeatureTrainingModalBodyProps = BaseFeatureTrainingModalProps & + FeatureTrainingModalPageProps & { + /** Padding for the modal */ + modalPadding: number; + + /** Whether the modal should be shown again */ + willShowAgain: boolean; + + /** A callback to call when the modal should be shown again */ + toggleWillShowAgain: () => void; + + /** A callback to call when we want to close the modal */ + closeModal: (didPressHelpButton?: boolean) => void; + + /** A callback to call when we want to close the modal and confirm */ + confirmModal: () => void; + + /** Whether to show the back button to navigate back to the previous page in carousel mode */ + shouldShowBackButton?: boolean; + + /** A callback to call when we want to navigate back to the previous page in carousel mode */ + onBack?: () => void; + }; + +function FeatureTrainingModalBody({ + animation, + animationStyle, + illustrationInnerContainerStyle, + illustrationOuterContainerStyle, + videoURL, + illustrationAspectRatio: illustrationAspectRatioProp, + image, + contentFitImage, + width, + title = '', + subtitle = '', + description = '', + secondaryDescription = '', + titleStyles, + shouldShowDismissModalOption = false, + confirmText = '', + helpText = '', + onHelp = () => {}, + children, + contentInnerContainerStyles, + contentOuterContainerStyles, + imageWidth, + imageHeight, + shouldRenderSVG = true, + shouldRenderHTMLDescription = false, + shouldShowConfirmationLoader = false, + canConfirmWhileOffline = true, + shouldCallOnHelpWhenModalHidden = false, + helpSentryLabel, + confirmSentryLabel, + modalPadding, + willShowAgain = true, + toggleWillShowAgain, + closeModal, + confirmModal, + shouldShowBackButton = false, + onBack, +}: FeatureTrainingModalBodyProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const isReduceMotionEnabled = Accessibility.useReducedMotion(); + const illustrations = useMemoizedLazyIllustrations(['Hands']); + const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); + const {windowHeight, windowWidth} = useWindowDimensions(); + const [videoStatus, setVideoStatus] = useState('video'); + const [isVideoStatusLocked, setIsVideoStatusLocked] = useState(false); + const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isOffline} = useNetwork(); + const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); + + useEffect(() => { + if (isVideoStatusLocked) { + return; + } + + if (isOffline) { + setVideoStatus('animation'); + } else if (!isOffline) { + setVideoStatus('video'); + setIsVideoStatusLocked(true); + } + }, [isOffline, isVideoStatusLocked]); + + const setAspectRatio = (event: SourceLoadEventPayload) => { + const track = event.availableVideoTracks.at(0); + + if (!track) { + return; + } + + setIllustrationAspectRatio(track.size.width / track.size.height); + }; + + const renderIllustration = () => { + const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO; + + return ( + + {!!image && + (shouldRenderSVG ? ( + + ) : ( + + ))} + {!!videoURL && videoStatus === 'video' && ( + + + + )} + {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && ( + + {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? ( + + ) : ( + + )} + + )} + + ); + }; + + return ( + + + {renderIllustration()} + + + {!!title && !!description && ( + + {subtitle && {subtitle}} + {typeof title === 'string' ? {title} : title} + {shouldRenderHTMLDescription ? ( + + + + ) : ( + {description} + )} + {secondaryDescription.length > 0 && {secondaryDescription}} + {children} + + )} + {shouldShowDismissModalOption && ( + + )} + {!!helpText && ( +