diff --git a/.vscode/settings.json b/.vscode/settings.json index 8b591448ff..725c5c2aa2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,6 +16,11 @@ ["[a-zA-Z]+[cC]lass[nN]ame[\"'`]?:\\s*[\"'`]([^\"'`]*)[\"'`]", "([^\"'`]*)"], ["[a-zA-Z]+[cC]lass[nN]ame\\s*=\\s*[\"'`]([^\"'`]*)[\"'`]", "([^\"'`]*)"] ], + "tailwindCSS.experimental.configFile": { + "apps/mobile/tailwind.config.ts": "apps/mobile/**", + "apps/server/tailwind.config.ts": "apps/server/**", + "tailwind.config.ts": ["!apps/mobile/**", "!apps/server/**", "**"] + }, "typescript.tsserver.maxTsServerMemory": 8096, "typescript.tsserver.nodePath": "node", // If you do not want to autofix some rules on save diff --git a/CHANGELOG.md b/CHANGELOG.md index 9480e2f354..0798978108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # CHANGELOG -# [0.3.0-beta.0](https://github.com/RSSNext/follow/compare/v0.2.7-beta.0...v0.3.0-beta.0) (2025-01-02) +## [0.3.1-beta.0](https://github.com/RSSNext/follow/compare/v0.2.7-beta.0...v0.3.1-beta.0) (2025-01-09) ### Bug Fixes @@ -44,6 +44,7 @@ * audio player on mobile ([1e936e2](https://github.com/RSSNext/follow/commit/1e936e2c593adafa92d54ddc542ff30688c0e3bc)) * audio player width, fixed [#1934](https://github.com/RSSNext/follow/issues/1934) ([238938a](https://github.com/RSSNext/follow/commit/238938a4da5b854f9f3422b93059b9f82f60d09b)) * auth in electron ([405ee42](https://github.com/RSSNext/follow/commit/405ee42e8a1232424bb667cd56fffd3d0b463a4e)) +* authentication style on mobile ([58e796e](https://github.com/RSSNext/follow/commit/58e796e3df1844ccb2c4cadb6578051d1114c628)) * **auth:** handle optional icon class names for providers in login components ([de8d337](https://github.com/RSSNext/follow/commit/de8d3376713be0b696579e16395b887b2658d59f)) * **auth:** redirect url ([e7d5576](https://github.com/RSSNext/follow/commit/e7d5576b63a77cd4f4e25a3124f9685f408a73de)) * auto archived list flash ([#1269](https://github.com/RSSNext/follow/issues/1269)) ([8d2478c](https://github.com/RSSNext/follow/commit/8d2478c6a93aa9f5a99fddc55bbb8e532e1a0fe1)) @@ -103,6 +104,7 @@ * detecting windows11 ([#1170](https://github.com/RSSNext/follow/issues/1170)) ([2813f1d](https://github.com/RSSNext/follow/commit/2813f1d6540fcb215287dd32cbc0f8cf87cb1cb0)) * **devtool:** fix devtool font in windows ([2b64659](https://github.com/RSSNext/follow/commit/2b6465921c23d2357413423c27da89fe7bd06568)) * dialog did not close after confirmation ([#1628](https://github.com/RSSNext/follow/issues/1628)) ([6a5ec1b](https://github.com/RSSNext/follow/commit/6a5ec1b044f35e7ffa537436d0a7caba2f8d488e)) +* dialog for update password ([78df5b9](https://github.com/RSSNext/follow/commit/78df5b9073988f7046362aa4e819f79c0eb9f2f0)) * disable `pointerDownOutside` trigger `onDismiss` ([#1215](https://github.com/RSSNext/follow/issues/1215)) ([2bf5245](https://github.com/RSSNext/follow/commit/2bf5245126bc9ef890c06afe5a825d1e1ab4059c)) * disable auto load archive for inbox and list ([7825e16](https://github.com/RSSNext/follow/commit/7825e16b6d1230ba2c13e5ef00474fc05cc510e5)) * disable horizontal auto-scroll ([8666b41](https://github.com/RSSNext/follow/commit/8666b41f2dded5b6b5cb86b7824683b48582cf1d)), closes [#1512](https://github.com/RSSNext/follow/issues/1512) @@ -130,6 +132,7 @@ * entry title can selectable, fixed [#1428](https://github.com/RSSNext/follow/issues/1428) ([6320c00](https://github.com/RSSNext/follow/commit/6320c00373a98846cdd92235b044fa1fd1b8266e)) * entry title truncate ([685ce3c](https://github.com/RSSNext/follow/commit/685ce3cc4165c35918e92b06951d2479ce5adb37)) * **entry-column:** pre-render cached entries ([5065c8e](https://github.com/RSSNext/follow/commit/5065c8e52dcba1a8d8f16ce3729b7c2eb7f5a192)) +* error and warning in mobile ([#2475](https://github.com/RSSNext/follow/issues/2475)) ([ff1dc03](https://github.com/RSSNext/follow/commit/ff1dc03304e1d4911c5ae7df34b793dc4132bce3)) * escape for seo meta tags ([9564257](https://github.com/RSSNext/follow/commit/9564257564e417e18bdeedded9397d469e6daf14)), closes [#1232](https://github.com/RSSNext/follow/issues/1232) * explicitly assign a value to `cancelId` for ‘Clear All Data' dialog ([#1624](https://github.com/RSSNext/follow/issues/1624)) ([fdb87cf](https://github.com/RSSNext/follow/commit/fdb87cf5cdf9623f16eafa7e9ae25f4fc7950e05)) * **external:** add global 404 page ([5e49b81](https://github.com/RSSNext/follow/commit/5e49b8114e0446b734a54eea3291721d8f9656b3)) @@ -150,6 +153,7 @@ * fill missing userId when open tip modal, close [#2248](https://github.com/RSSNext/follow/issues/2248) ([53ad291](https://github.com/RSSNext/follow/commit/53ad291820ccefc6badee8d3dc6af276c31c3cc6)) * filter no picture breaking logic ([9a84ed4](https://github.com/RSSNext/follow/commit/9a84ed40a16ea58c2c1cdc8cf9c46009fe46faea)) * firefix icon, fix [#2370](https://github.com/RSSNext/follow/issues/2370) ([bbf7acf](https://github.com/RSSNext/follow/commit/bbf7acfd3dddbcd4ee25fea640b12be194ee7596)) +* fix decoding error(utf-8,gbk,iso-8859 and other charsets) in readability (issue [#2435](https://github.com/RSSNext/follow/issues/2435)) ([#2449](https://github.com/RSSNext/follow/issues/2449)) ([1de1cd9](https://github.com/RSSNext/follow/commit/1de1cd9ae18d89e88805816b7fdea4e1438cd39f)) * flickering issue when FAB button disappears ([#2390](https://github.com/RSSNext/follow/issues/2390)) ([2bc8968](https://github.com/RSSNext/follow/commit/2bc89685b5abee504f6c4dc0aad479a5b931266e)) * float sidebar missing background ([b9982e0](https://github.com/RSSNext/follow/commit/b9982e0a6b44f476621daf30f93f8e740a544a02)) * follow external server fetch ua ([072dec0](https://github.com/RSSNext/follow/commit/072dec02b787bcdd8af7f5c0bf25398e2da8c15e)) @@ -173,11 +177,13 @@ * **i18n:** update Japanese translations for better clarity ([#1842](https://github.com/RSSNext/follow/issues/1842)) ([bf9fb22](https://github.com/RSSNext/follow/commit/bf9fb2289ae7d6c553ec10ba28cab9f04e9f1bcd)) * **i18n:** update label for notification badge settings in zh-CN locale and others ([#1455](https://github.com/RSSNext/follow/issues/1455)) ([ec82f03](https://github.com/RSSNext/follow/commit/ec82f036b44a3d0d463c48c1d793fc517def1d07)) * ignore `file` and editor protocols ([2badb9b](https://github.com/RSSNext/follow/commit/2badb9b1c0bf6628d2a2fc177063fd0c7c7fb08b)) +* ignore `node_modules` for tailwindcss to avoid warning ([#2425](https://github.com/RSSNext/follow/issues/2425)) ([1e8f80e](https://github.com/RSSNext/follow/commit/1e8f80ef158ce1e59af7ae106b871a60742038f3)) * image blurhash out when image loaded ([5b237ca](https://github.com/RSSNext/follow/commit/5b237caf803ed5a2cb95a7fb6e0f2fb7940f5da9)) * image blurhash placeholder ([3ca9b5a](https://github.com/RSSNext/follow/commit/3ca9b5a65978e854dafdfe73d81c873379c82c42)) * image error ui ([#2124](https://github.com/RSSNext/follow/issues/2124)) ([008cc98](https://github.com/RSSNext/follow/commit/008cc98f8a2624d4360e7c6bb8ba7176436f8579)) * immer object extensible ([6ad35ad](https://github.com/RSSNext/follow/commit/6ad35ad6712a68db9e231bf5733a2956ccecda90)) * import ([9d6e7d4](https://github.com/RSSNext/follow/commit/9d6e7d44363efc50a592e13e7c20aa6eca2e03d0)) +* improve Markdown rendering and UI adjustments in various components ([1439d87](https://github.com/RSSNext/follow/commit/1439d87f071dd6153519f56091e73f3fabf817b4)) * improve multi select behavior ([24017df](https://github.com/RSSNext/follow/commit/24017dfb3190c91969ec68d3d88544e051880469)) * improve perform for feed column ([#1708](https://github.com/RSSNext/follow/issues/1708)) ([1f349ed](https://github.com/RSSNext/follow/commit/1f349ed73e912825fb34877f5db9df13c6c6f879)) * improve protocol handling ([0a0b39b](https://github.com/RSSNext/follow/commit/0a0b39b5f8581250c7146cd5cd078b9a0cec89d2)) @@ -194,6 +200,7 @@ * lock ([97c1ece](https://github.com/RSSNext/follow/commit/97c1ece9e3d855c7dbcf7238e7953e9bd206811c)) * login override provider icon ([96b8929](https://github.com/RSSNext/follow/commit/96b892923ab1e791d29f0464b847a6873c61f075)) * login page redirection ([fedf2af](https://github.com/RSSNext/follow/commit/fedf2af98459c5e0a50678fcc9923dc288190662)) +* login page styles ([e77a0e1](https://github.com/RSSNext/follow/commit/e77a0e1043bb59439c009daff4e4fa1bc7df3677)) * mark as read when navigating ([423777e](https://github.com/RSSNext/follow/commit/423777e1372631b8ea8d06913ce55fbdf97f1f07)) * mark feed unread dirty, refetch unread next time, fixed [#1830](https://github.com/RSSNext/follow/issues/1830) ([58d0e9c](https://github.com/RSSNext/follow/commit/58d0e9c1902157c2e9fb1444bc1c0db0b775485b)) * mark single feed as all read ([a4acd2f](https://github.com/RSSNext/follow/commit/a4acd2fb65335f228875ed5b38f03a9c71b76e1b)) @@ -204,6 +211,9 @@ * missing site url in feed selector ([ea677ac](https://github.com/RSSNext/follow/commit/ea677ac6a3a43489a9508a32ecb22fa38ecf4f27)) * mobile need login modal ([0fb16f2](https://github.com/RSSNext/follow/commit/0fb16f2352e9776731522204b838be59f66127a4)) * mobile pop back to entry list will refresh data ([3c18768](https://github.com/RSSNext/follow/commit/3c18768968aeb4635933ae616c52de55b6fad2ab)) +* **mobile:** background color ([9f6934f](https://github.com/RSSNext/follow/commit/9f6934f844c995a1dcbb326123420e6ee9b05a96)) +* **mobile:** improve responsive design in CornerPlayer ([949a07e](https://github.com/RSSNext/follow/commit/949a07ed0e3cb8b8ea582ebd926f143da7386736)) +* **mobile:** update email login style ([9019e9b](https://github.com/RSSNext/follow/commit/9019e9bbc6ebcdcbd8a0f21844e04f1df17dd84a)) * modal bottom buttons align ([#1216](https://github.com/RSSNext/follow/issues/1216)) ([b97096d](https://github.com/RSSNext/follow/commit/b97096dbd06bb7687b6c1313de45d8e91dee25af)) * modal close button overlaps the select content ([#1166](https://github.com/RSSNext/follow/issues/1166)) ([3c103ed](https://github.com/RSSNext/follow/commit/3c103ed15daeb1f57a001d615c047c303087ba46)) * modal exiting transition type ([5bb0100](https://github.com/RSSNext/follow/commit/5bb01006ad04f8e5ed93076905b78b74ce293f79)) @@ -239,6 +249,7 @@ * prevent default click behavior in Media ([#1905](https://github.com/RSSNext/follow/issues/1905)) ([97dee6e](https://github.com/RSSNext/follow/commit/97dee6eda40e63dec2e66aa20da31a5d1ceed875)) * prevent default for cmd+k ([81d49f0](https://github.com/RSSNext/follow/commit/81d49f0893100de85a22d114765f76e7c9dea36c)) * prevent default scrolling behavior while using arrow keys to switch between entries ([#1447](https://github.com/RSSNext/follow/issues/1447)) ([ed5ee50](https://github.com/RSSNext/follow/commit/ed5ee50fe2676309e5451d73531baf59bbf0d746)) +* prevent media overflow ([5a7160d](https://github.com/RSSNext/follow/commit/5a7160d5af8e86c4b78e46bd2a5c141f3004c9f0)) * prevent overscroll bounce in some scene ([11803c8](https://github.com/RSSNext/follow/commit/11803c84b38a219d40cc0c97a0e6ec2c826dbd81)) * prevent right cilck on content menu ([#1525](https://github.com/RSSNext/follow/issues/1525)) ([ca6428f](https://github.com/RSSNext/follow/commit/ca6428f21926524d08c8aae121e9c705a147540e)) * prevent withdrawal of zero amount to avoid unnecessary fees ([#1422](https://github.com/RSSNext/follow/issues/1422)) ([6584526](https://github.com/RSSNext/follow/commit/65845268f9940247d3b645b1588d1564ec0b4cff)) @@ -255,6 +266,7 @@ * remove hardcode minfest ([0432618](https://github.com/RSSNext/follow/commit/04326186b71dc840e1454e6a1573257c97db32c8)) * remove immer set to avoid object extensible ([7e5a791](https://github.com/RSSNext/follow/commit/7e5a79155a083a8242579dee18d019049b7ad6f5)) * remove mouse enter event on mobile ([279e402](https://github.com/RSSNext/follow/commit/279e4025a5b14ad5cf5972bf439d2a30ce7d7d79)) +* remove noScale arg ([ca4d3ea](https://github.com/RSSNext/follow/commit/ca4d3ea8b41a706743be0884558adf116c70ca60)) * remove prev entries ids ref ([996629c](https://github.com/RSSNext/follow/commit/996629c14c752f76b65d423ebc23d60b315dee77)) * remove skeleton when app load but in 404 ([54d4e52](https://github.com/RSSNext/follow/commit/54d4e529c796e6cc22c36b2a9a1e949b1dad5b37)) * remove stack when close sheet ([eeb785d](https://github.com/RSSNext/follow/commit/eeb785de3f822166a04b3e6916fc107fb600db25)), closes [#1910](https://github.com/RSSNext/follow/issues/1910) @@ -268,6 +280,10 @@ * revert electron bump ([#1991](https://github.com/RSSNext/follow/issues/1991)) ([200415c](https://github.com/RSSNext/follow/commit/200415c77f52d11a43c2610c5c58c61fbbfb23f8)) * revert merge chunk ([4a9679b](https://github.com/RSSNext/follow/commit/4a9679bad109a786b80939ca9ead32f1505be186)) * **rn:** adjust ui colors ([33d9e14](https://github.com/RSSNext/follow/commit/33d9e14d7c377c76dc349c1fc6ae901da2fe30c7)) +* **rn:** check tab current index frequency ([fc3a3ce](https://github.com/RSSNext/follow/commit/fc3a3cef23532143853625a1af6cc2bbe3c28a3d)) +* **rn:** delete subscribe in db persist ([e2c9459](https://github.com/RSSNext/follow/commit/e2c9459db8cc0137bb63ea83b1cd7f2cb2a3dab4)) +* **rn:** discover form ([4f3d990](https://github.com/RSSNext/follow/commit/4f3d9902af372dd849f02a20dc1ae644d309fd73)) +* **rn:** rsshub form styles ([8583210](https://github.com/RSSNext/follow/commit/85832105b4e6a860d34374cfa7e6c01a06cc54a3)) * **rsshub:** adjust table cell width ([ca8d19d](https://github.com/RSSNext/follow/commit/ca8d19dc394d7f9907a40834abcdd0e14ddc2b47)) * scroll bar z index ([5057999](https://github.com/RSSNext/follow/commit/50579995367b871e84768c5983b3302f75f8e077)), closes [#1233](https://github.com/RSSNext/follow/issues/1233) * scroll out mark read logic ([eaecb25](https://github.com/RSSNext/follow/commit/eaecb252d322b5f2e8e89873523dfbe8d213848c)) @@ -288,6 +304,7 @@ * set windows env ([1ea103b](https://github.com/RSSNext/follow/commit/1ea103b7ee1689ba9683c341a2afacacf2884fbd)) * setting loader type error ([da8aad7](https://github.com/RSSNext/follow/commit/da8aad7327d45cd899f8091cb0315ff2497b2ab9)) * setting loader type error ([#1469](https://github.com/RSSNext/follow/issues/1469)) ([6ef5622](https://github.com/RSSNext/follow/commit/6ef5622a32eaaebfeec32cca1000bf908f742e34)) +* **settings:** align submit button in ExportFeedsForm for better layout ([15feb5a](https://github.com/RSSNext/follow/commit/15feb5a35ac6db4ff5c12ff4d1f46c25632d7693)) * **share:** normal list item layout ([3a0c039](https://github.com/RSSNext/follow/commit/3a0c0393e01e09ebf86ab1415b3d84bca6d2b882)) * **share:** og image grid width and image align top to description ([14e37da](https://github.com/RSSNext/follow/commit/14e37da0cebbdb1b283c562c21acc2f8ba385bf9)) * sheet stack dismiss transition ([6a30f23](https://github.com/RSSNext/follow/commit/6a30f2363e3d99c95b3b7a5415c4fd898d1c12a4)) @@ -325,6 +342,7 @@ * **toc:** should focus when toc item clicked ([f51dfdd](https://github.com/RSSNext/follow/commit/f51dfddc9ef40f4a7f226f32f9357e03a04f6ee0)) * translate form style ([6f3bb61](https://github.com/RSSNext/follow/commit/6f3bb61023dde1d58164425d8649c90bb4f680c6)), closes [#1184](https://github.com/RSSNext/follow/issues/1184) * **translations:** update zh-CN locale files for accuracy ([#1739](https://github.com/RSSNext/follow/issues/1739)) ([83c9f71](https://github.com/RSSNext/follow/commit/83c9f717dd7092dc4a4b1106961d746988066fd9)) +* tray icon appears small and blurry on Windows, Close [#2077](https://github.com/RSSNext/follow/issues/2077) ([#2461](https://github.com/RSSNext/follow/issues/2461)) ([b4edc42](https://github.com/RSSNext/follow/commit/b4edc426df34549dbb870b13ab3a8ca949eedd6b)) * trending api based on language ([83ca873](https://github.com/RSSNext/follow/commit/83ca87349598acc361c8128bbcb014d89fcb56cf)) * trending in mobile ([128c8ca](https://github.com/RSSNext/follow/commit/128c8ca44d059120e89fd40b4b0d76063114b30e)) * trial limit ([eee2bf8](https://github.com/RSSNext/follow/commit/eee2bf8771d2d30bba1f534984e3ca8d5e26cf04)) @@ -371,6 +389,7 @@ * vercel rewrite config ([#1203](https://github.com/RSSNext/follow/issues/1203)) ([c954d61](https://github.com/RSSNext/follow/commit/c954d61f074e282a9a86cbf1d1546748871c20e7)) * video media can not play in video view ([4cbf8a5](https://github.com/RSSNext/follow/commit/4cbf8a54fcf11fb71ff935baee52c79a83f5035c)), closes [#1645](https://github.com/RSSNext/follow/issues/1645) * **video:** open entry url directly on mobile ([0eed6d1](https://github.com/RSSNext/follow/commit/0eed6d1b237a612981938b32ce884b5f0c1bd13b)) +* View freeze while using swipe gesture to go back in safari ([#2412](https://github.com/RSSNext/follow/issues/2412)) ([9ee3d3d](https://github.com/RSSNext/follow/commit/9ee3d3d8a3a34d922e7ed34428aedd110f56b5bf)) * When pasting the entire URL, auto adjust the URL prefix ([#1762](https://github.com/RSSNext/follow/issues/1762)) ([3c7aeac](https://github.com/RSSNext/follow/commit/3c7aeacc90cbdcbe6bf4b06572d74f269f2c8854)), closes [#1761](https://github.com/RSSNext/follow/issues/1761) * window titlebar maximize state sync, fixed [#1982](https://github.com/RSSNext/follow/issues/1982) ([#1989](https://github.com/RSSNext/follow/issues/1989)) ([7ad1e30](https://github.com/RSSNext/follow/commit/7ad1e305c3167633d58b6b621dba2cd63d1cf528)) * wrap with Ellipsis ([3ba9093](https://github.com/RSSNext/follow/commit/3ba90936931ad36790a68ee90a2c1582054bb90a)) @@ -393,6 +412,7 @@ * add hydrate data type-safe helper ([cb6a65b](https://github.com/RSSNext/follow/commit/cb6a65b7cc1651d86b83b5b4089a7145cd59b0be)) * add image preview in picture gallery images ([59728a8](https://github.com/RSSNext/follow/commit/59728a89295c2a9c6130651d06699eef027fef5e)) * add list madeby ([03858b1](https://github.com/RSSNext/follow/commit/03858b18b8de5cffb2add05d72fdbf5e229e6cf4)) +* add loading indicator and UI improvements in transaction ([#2480](https://github.com/RSSNext/follow/issues/2480)) ([6c3e8e8](https://github.com/RSSNext/follow/commit/6c3e8e82c64005466f21c3ee2680574da43fd604)) * add media session action handlers for play and pause ([#2394](https://github.com/RSSNext/follow/issues/2394)) ([e7a1da3](https://github.com/RSSNext/follow/commit/e7a1da32be21849f30c085046983efed0b4654ad)) * add mobile top timeline setting ([fe48d7c](https://github.com/RSSNext/follow/commit/fe48d7cf02a202a1f14bbfe3abe4c813629ac968)) * add more actions to entry header ([#1993](https://github.com/RSSNext/follow/issues/1993)) ([5c4b5f0](https://github.com/RSSNext/follow/commit/5c4b5f0095cff53f1ce70d66f2275d0a9c73e985)) @@ -420,6 +440,7 @@ * copy button for ai summary ([b3ee572](https://github.com/RSSNext/follow/commit/b3ee5727e29d809092a8f4631d3e13c8d81095b9)) * copy profile email ([1e12588](https://github.com/RSSNext/follow/commit/1e1258811163d72ae36f29714eef5d50834c348b)) * customizable columns for masonry view, closed [#1749](https://github.com/RSSNext/follow/issues/1749) ([0e0ce84](https://github.com/RSSNext/follow/commit/0e0ce843235f01f33f4c5b9708aa67dac5901b46)) +* customize toolbar ([#2468](https://github.com/RSSNext/follow/issues/2468)) ([38fe110](https://github.com/RSSNext/follow/commit/38fe11075424a31a60c9db1c2758980f957c19c2)) * discover rsshub card background use single color ([7eeea5e](https://github.com/RSSNext/follow/commit/7eeea5e694c142803a37564ef8886d4fc4d2dab4)) * **discover:** enhance RSSHub recommendations with filters ([#1481](https://github.com/RSSNext/follow/issues/1481)) ([eb70126](https://github.com/RSSNext/follow/commit/eb70126b8283b6e0b246f86751e588a37cb34902)) * **discover:** implement searchable header and enhance UI with animated search bar ([2300996](https://github.com/RSSNext/follow/commit/2300996b87c2850f8f496bdd982e7ca8e5938d7f)) @@ -429,7 +450,9 @@ * edit rsshub instance ([7f080aa](https://github.com/RSSNext/follow/commit/7f080aa00b36d42f614bce6408a4400748ee3fb4)) * email verification ([3497623](https://github.com/RSSNext/follow/commit/3497623b853f8321ed81788e32d43846be6d6135)) * email verification ([d4905fd](https://github.com/RSSNext/follow/commit/d4905fd0082b41de3f76afa597d67030761a4ae8)) +* enhance deep link handling in FollowWebView component to support new routes for adding and following items ([b46e178](https://github.com/RSSNext/follow/commit/b46e1780f783c24e5706b9c350b980c62a3cd9c6)) * enhance SocialMediaItem component with dynamic title styling and action bar positioning ([5d2fe70](https://github.com/RSSNext/follow/commit/5d2fe70b52a857c16ed7220fc36ecd5111f8cd4d)) +* enhance table with new translations and tooltip for descriptions ([#2471](https://github.com/RSSNext/follow/issues/2471)) ([99011eb](https://github.com/RSSNext/follow/commit/99011ebcf2cd3c4096520394537bb97b5ea08865)) * entry image gallery modal ([e0d3e17](https://github.com/RSSNext/follow/commit/e0d3e17da4ee17217d7b78871b546f43af87d893)) * export database ([85b4502](https://github.com/RSSNext/follow/commit/85b4502f9c113b8de73ccf4a167aa514a3c149ea)) * **external:** move `login` and `redirect` route to external ([7916803](https://github.com/RSSNext/follow/commit/791680332d5e1c2c52eda792dd7ff69281f25adb)) @@ -447,7 +470,9 @@ * **i18n:** translations (zh-TW) ([#2166](https://github.com/RSSNext/follow/issues/2166)) ([45e37a0](https://github.com/RSSNext/follow/commit/45e37a07e8eee65ae5b02b524b5e09185c804c08)) * **icon:** use gradient fallback background ([e827002](https://github.com/RSSNext/follow/commit/e8270025e469d9a0463d267d3da591a2991951bd)) * image zoom ([1e47ba2](https://github.com/RSSNext/follow/commit/1e47ba25671000408e69fc3bae2c4626d0bd664e)), closes [#1183](https://github.com/RSSNext/follow/issues/1183) +* improve email verification display ([#2424](https://github.com/RSSNext/follow/issues/2424)) ([ebc6c4f](https://github.com/RSSNext/follow/commit/ebc6c4fdb6773f2b7d94b81b839a3a338787c4b3)) * **infra:** electron app can hot update renderer layer ([#1209](https://github.com/RSSNext/follow/issues/1209)) ([ca4751a](https://github.com/RSSNext/follow/commit/ca4751acd275579614a477d133ed643fca3fbf1a)) +* integrate LoadingContainer ([834dff8](https://github.com/RSSNext/follow/commit/834dff8d91406eeb56b035e2da9f9d6e3a853850)) * integrate react-query for fetching unread feed items by view ([d4dd4fb](https://github.com/RSSNext/follow/commit/d4dd4fb68d5a1cebdaeeea1aff88c2fbe2fba5e9)) * **integration:** add outline integration ([#1229](https://github.com/RSSNext/follow/issues/1229)) ([0d0266b](https://github.com/RSSNext/follow/commit/0d0266b25189a74efdc63ce0062f3a379d4a0729)) * **integration:** Add readeck integration ([#1972](https://github.com/RSSNext/follow/issues/1972)) ([1ce3f5b](https://github.com/RSSNext/follow/commit/1ce3f5b8ba95b16ad1b12e702027cede3590491a)) @@ -455,7 +480,9 @@ * list preview ([ae390f7](https://github.com/RSSNext/follow/commit/ae390f7afabf3e312217bf13a175e2036c2c2763)) * load archived entries automatically ([5fe9e0c](https://github.com/RSSNext/follow/commit/5fe9e0c0e24460ca0fe435db03b753e9dcd3df17)) * **locales:** enhance zh-HK translations ([#2208](https://github.com/RSSNext/follow/issues/2208)) ([2ecd89f](https://github.com/RSSNext/follow/commit/2ecd89ffde7a24580fb879192abdfa7ff95c6988)) +* **locales:** update Japanese translations for RSSHub and related settings ([#2474](https://github.com/RSSNext/follow/issues/2474)) ([6ed05ce](https://github.com/RSSNext/follow/commit/6ed05cea8bff558930a94c0742abb3d350580e54)) * **locales:** update zh-TW translations ([#2290](https://github.com/RSSNext/follow/issues/2290)) ([f734e6c](https://github.com/RSSNext/follow/commit/f734e6cfa3348f8906b48cbae8188531fd266e5e)) +* **locales:** update zh-TW translations ([#2446](https://github.com/RSSNext/follow/issues/2446)) ([dbae887](https://github.com/RSSNext/follow/commit/dbae887df1fd23a92593682d74907d3851fdf183)) * manual action ([#1867](https://github.com/RSSNext/follow/issues/1867)) ([0eedbba](https://github.com/RSSNext/follow/commit/0eedbbadc263b474e2d4bfd5b6c498ac39c16c34)) * manually link social account ([fd373fb](https://github.com/RSSNext/follow/commit/fd373fb5271e4d9a31e37d49830d3fe3d4927922)) * **mark-all-button:** add countdown to auto-confirm message ([#1414](https://github.com/RSSNext/follow/issues/1414)) ([e1a5fc6](https://github.com/RSSNext/follow/commit/e1a5fc63f20c941140071789c9b67685da19ea5c)) @@ -463,6 +490,8 @@ * migrate to better auth ([#1951](https://github.com/RSSNext/follow/issues/1951)) ([9102203](https://github.com/RSSNext/follow/commit/910220395631bcf86f56bfc2b2168ac95852444c)) * mobile app architecture and initial screens ([#2144](https://github.com/RSSNext/follow/issues/2144)) ([4b6a0fc](https://github.com/RSSNext/follow/commit/4b6a0fca2e3243061c7befc837e705262f17b664)) * mobile design ([#1568](https://github.com/RSSNext/follow/issues/1568)) ([edd4f9e](https://github.com/RSSNext/follow/commit/edd4f9e5a6dc041e9eae2cee5ddf4eb624527ef5)), closes [#1575](https://github.com/RSSNext/follow/issues/1575) +* **mobile:** introduce expo image ([9ea93b8](https://github.com/RSSNext/follow/commit/9ea93b80238f47788e0f984f03faf3c9a3434a81)) +* **modal:** enhance Follow modal with improved navigation and loading state ([cf31c0f](https://github.com/RSSNext/follow/commit/cf31c0f5fae3fd6dac1e1e571e870ea57e05ed0b)) * move feed to new category in context menu ([#2072](https://github.com/RSSNext/follow/issues/2072)) ([d684e14](https://github.com/RSSNext/follow/commit/d684e14056581744ac78ca697fa5ca059294245e)) * move hideExtraBadge ([2f14c30](https://github.com/RSSNext/follow/commit/2f14c30307a895bc866ac57989df1dc624eab4e6)) * move replaceImgUrlIfNeed ([4a4ecee](https://github.com/RSSNext/follow/commit/4a4ecee7f199ea292a6da7293388ef249e91c766)) @@ -487,8 +516,14 @@ * **renderer:** prevent currently executing async entry action from being executed again ([#1348](https://github.com/RSSNext/follow/issues/1348)) ([be82fe2](https://github.com/RSSNext/follow/commit/be82fe2a8ce37bac7205a1a43abeca396fdadba6)) * replace twMacro with unplugin-ast ([#1462](https://github.com/RSSNext/follow/issues/1462)) ([05da9ca](https://github.com/RSSNext/follow/commit/05da9ca7d3d1f66bd22250599df8b66ea0fd3a43)) * reset feed ([#1419](https://github.com/RSSNext/follow/issues/1419)) ([9066758](https://github.com/RSSNext/follow/commit/9066758c322b8b31c1a9a137be017283fa92bea8)) +* **rn-component:** implement toast manager ([950b5af](https://github.com/RSSNext/follow/commit/950b5af8ec0316875ed3ce5a99baaaecb56c6d37)) +* **rn:** feed form and subscribe ([f9d5d76](https://github.com/RSSNext/follow/commit/f9d5d7698c0a3b498684468abf4ce50b3c223390)) +* **rn:** fix dismiss modal when follow done ([468a3e4](https://github.com/RSSNext/follow/commit/468a3e490a7c0c3424b721011f2dc72503ba479f)) +* **rn:** follow modal form ([9beff37](https://github.com/RSSNext/follow/commit/9beff379f621e7756d55c4b6b7c0d3a9a6b1fecd)) * **rn:** impl markdown component for rn ([93b38a7](https://github.com/RSSNext/follow/commit/93b38a7c70dda2e934a424995bb07e24bdefd2ac)) * **rn:** implements discover page ([#2385](https://github.com/RSSNext/follow/issues/2385)) ([66f97c0](https://github.com/RSSNext/follow/commit/66f97c0057f571717b3b33929baf5a13fc7e677c)) +* **rn:** init follow feed modal ([1e8196a](https://github.com/RSSNext/follow/commit/1e8196aad4037af6c9d5d29bb6361bbb19b5d00c)) +* **rn:** subscription list item transition ([9745f4d](https://github.com/RSSNext/follow/commit/9745f4d9d0248073e15658ef710c5b2083b273ba)) * **rn:** TabView component ([#2358](https://github.com/RSSNext/follow/issues/2358)) ([b0f7e82](https://github.com/RSSNext/follow/commit/b0f7e82ad3ee73d26f7d3a1cfb825848e8c1efc3)) * rsshub add modal content ([613b3b5](https://github.com/RSSNext/follow/commit/613b3b5c1978a656023a6596c7d421be66211aed)) * rsshub add modal loading status ([b044713](https://github.com/RSSNext/follow/commit/b044713528f3f17780ef019c6218535e35255cf4)) @@ -513,6 +548,7 @@ * unified feed title ([b86afe5](https://github.com/RSSNext/follow/commit/b86afe5a9789ad13c5a026adf553d2c6bc333bff)) * uniq macos entry column position ([4b63023](https://github.com/RSSNext/follow/commit/4b63023389e125353de343b679840e5fbca1a4d3)) * unlink account ([6691cb2](https://github.com/RSSNext/follow/commit/6691cb2367e59cbbd3d0176c10fa3f57865ee39d)) +* update mobile app structure and enhance loading functionality ([3cca62c](https://github.com/RSSNext/follow/commit/3cca62c26f83aa99c2cd1793f17f8b929b46b588)) * update og image ([c58df35](https://github.com/RSSNext/follow/commit/c58df35d1f48e7374973a393c9b7efea38d12494)) * update og image ([142f9f5](https://github.com/RSSNext/follow/commit/142f9f57ce2697ab82fd5e1dd2cee7d0f7500721)) * update server hono ([5beaca3](https://github.com/RSSNext/follow/commit/5beaca3c772d7bc097fd53779c581eb6b41c9eaa)) diff --git a/CONTRIBUTE.md b/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTE.md rename to CONTRIBUTING.md diff --git a/apps/main/package.json b/apps/main/package.json index 4df5592b97..d7aab971b1 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -31,6 +31,7 @@ "@openpanel/web": "1.0.1", "@sentry/electron": "5.7.0", "builder-util-runtime": "9.2.10", + "chardet": "^2.0.0", "cookie-es": "^1.2.2", "dompurify": "~3.2.2", "electron-context-menu": "4.0.4", diff --git a/apps/main/src/helper.ts b/apps/main/src/helper.ts index 28bf1885aa..618fe834ec 100644 --- a/apps/main/src/helper.ts +++ b/apps/main/src/helper.ts @@ -15,7 +15,7 @@ export const getTrayIconPath = () => { } if (isWindows) { // https://www.electronjs.org/docs/latest/api/tray#:~:text=Windows,best%20visual%20effects. - return path.join(__dirname, "../../resources/icon.ico") + return path.join(__dirname, "../../resources/icon-no-padding.ico") } return getIconPath() } diff --git a/apps/main/src/lib/readability.ts b/apps/main/src/lib/readability.ts index 3dfa9e177e..a67d15ac0f 100644 --- a/apps/main/src/lib/readability.ts +++ b/apps/main/src/lib/readability.ts @@ -1,5 +1,6 @@ import { Readability } from "@mozilla/readability" import { name, version } from "@pkg" +import chardet from "chardet" import DOMPurify from "dompurify" import { parseHTML } from "linkedom" import { fetch } from "ofetch" @@ -21,24 +22,34 @@ function sanitizeHTMLString(dirtyDocumentString: string) { return sanitizedDocumentString } +/** + * Decodes the response body of a `fetch` request into a string, ensuring proper character set handling. + * @throws Will return "Failed to decode response content." if the decoding process encounters any errors. + */ +async function decodeResponseBodyChars(res: Response) { + // Read the response body as an ArrayBuffer + const buffer = await res.arrayBuffer() + // Step 1: Get charset from Content-Type header + const contentType = res.headers.get("content-type") + const httpCharset = contentType?.match(/charset=([\w-]+)/i)?.[1] + // Step 2: Use charset from Content-Type header or fall back to chardet + const detectedCharset = httpCharset || chardet.detect(Buffer.from(buffer)) || "utf-8" + // Step 3: Decode the response body using the detected charset + try { + const decodedText = new TextDecoder(detectedCharset, { fatal: false }).decode(buffer) + return decodedText + } catch { + return "Failed to decode response content." + } +} + export async function readability(url: string) { const dirtyDocumentString = await fetch(url, { headers: { "User-Agent": userAgents, Accept: "text/html", }, - }).then(async (res) => { - const contentType = res.headers.get("content-type") - // text/html; charset=GBK - if (!contentType) return res.text() - const charset = contentType.match(/charset=([a-zA-Z-\d]+)/)?.[1] - if (charset) { - const blob = await res.blob() - const buffer = await blob.arrayBuffer() - return new TextDecoder(charset).decode(buffer) - } - return res.text() - }) + }).then(decodeResponseBodyChars) const sanitizedDocumentString = sanitizeHTMLString(dirtyDocumentString) const baseUrl = new URL(url).origin diff --git a/apps/main/src/lib/tray.ts b/apps/main/src/lib/tray.ts index 43683dc3b2..60c83612bb 100644 --- a/apps/main/src/lib/tray.ts +++ b/apps/main/src/lib/tray.ts @@ -20,7 +20,7 @@ export const registerAppTray = () => { const icon = nativeImage.createFromPath(getTrayIconPath()) // See https://stackoverflow.com/questions/41664208/electron-tray-icon-change-depending-on-dark-theme/41998326#41998326 - const trayIcon = icon.resize({ width: 16 }) + const trayIcon = isMacOS ? icon.resize({ width: 16 }) : icon trayIcon.setTemplateImage(true) tray = new Tray(trayIcon) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c4c809891f..790d8dfa54 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -2,7 +2,7 @@ "name": "@follow/mobile", "version": "1.0.0", "private": true, - "main": "src/main.ts", + "main": "src/main.tsx", "scripts": { "android": "expo run:android", "db:generate": "drizzle-kit generate", @@ -43,6 +43,7 @@ "expo-file-system": "~18.0.6", "expo-font": "~13.0.1", "expo-haptics": "~14.0.0", + "expo-image": "~2.0.3", "expo-linear-gradient": "~14.0.1", "expo-linking": "~7.0.3", "expo-router": "4.0.11", @@ -57,6 +58,7 @@ "hono": "4.6.13", "immer": "10.1.1", "jotai": "2.10.3", + "nanoid": "5.0.9", "nativewind": "4.1.23", "ofetch": "1.4.1", "react": "^18.3.1", @@ -68,6 +70,7 @@ "react-native-keyboard-controller": "^1.15.0", "react-native-pager-view": "6.6.1", "react-native-reanimated": "~3.16.5", + "react-native-root-siblings": "5.0.1", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.1.0", "react-native-svg": "15.8.0", diff --git a/apps/mobile/src/atoms/app.ts b/apps/mobile/src/atoms/app.ts new file mode 100644 index 0000000000..8cd6b4801b --- /dev/null +++ b/apps/mobile/src/atoms/app.ts @@ -0,0 +1,17 @@ +import { atom } from "jotai" + +export const loadingVisibleAtom = atom(false) + +export const loadingAtom = atom<{ + finish?: null | (() => any) + cancel?: null | (() => any) + error?: null | ((err: any) => any) + done?: null | ((r: unknown) => any) + thenable: null | Promise +}>({ + finish: null, + cancel: null, + error: null, + done: null, + thenable: null, +}) diff --git a/apps/mobile/src/components/common/FollowWebView.tsx b/apps/mobile/src/components/common/FollowWebView.tsx index f960cfcec6..474f2fba20 100644 --- a/apps/mobile/src/components/common/FollowWebView.tsx +++ b/apps/mobile/src/components/common/FollowWebView.tsx @@ -117,24 +117,35 @@ const useDeepLink = ({ }) => { const handleDeepLink = useCallback( async (url: string) => { - const { queryParams } = Linking.parse(url) - if (!queryParams) { - console.error("Invalid URL! queryParams is not available", url) - return - } - const id = queryParams["id"] ?? undefined - const isList = queryParams["type"] === "list" - // const urlParam = queryParams["url"] ?? undefined - if (!id || typeof id !== "string") { - console.error("Invalid URL! id is not a string", url) - return - } - const injectJavaScript = webViewRef.current?.injectJavaScript - if (!injectJavaScript) { - console.error("injectJavaScript is not available") - return + const { queryParams, path, hostname } = Linking.parse(url) + + const pathname = (hostname || "") + (path || "") + const pathnameTrimmed = pathname?.endsWith("/") ? pathname.slice(0, -1) : pathname + + switch (pathnameTrimmed) { + case "/add": + case "/follow": { + if (!queryParams) { + console.error("Invalid URL! queryParams is not available", url) + return + } + + const id = queryParams["id"] ?? undefined + const isList = queryParams["type"] === "list" + // const urlParam = queryParams["url"] ?? undefined + if (!id || typeof id !== "string") { + console.error("Invalid URL! id is not a string", url) + return + } + const injectJavaScript = webViewRef.current?.injectJavaScript + if (!injectJavaScript) { + console.error("injectJavaScript is not available") + return + } + callWebviewExpose(injectJavaScript).follow({ id, isList }) + return + } } - callWebviewExpose(injectJavaScript).follow({ id, isList }) }, [webViewRef], ) diff --git a/apps/mobile/src/components/common/FullWindowOverlay.ios.tsx b/apps/mobile/src/components/common/FullWindowOverlay.ios.tsx new file mode 100644 index 0000000000..2bcdac7b69 --- /dev/null +++ b/apps/mobile/src/components/common/FullWindowOverlay.ios.tsx @@ -0,0 +1 @@ +export { FullWindowOverlay } from "react-native-screens" diff --git a/apps/mobile/src/components/common/FullWindowOverlay.tsx b/apps/mobile/src/components/common/FullWindowOverlay.tsx new file mode 100644 index 0000000000..391ba102e7 --- /dev/null +++ b/apps/mobile/src/components/common/FullWindowOverlay.tsx @@ -0,0 +1 @@ +export { Fragment as FullWindowOverlay } from "react" diff --git a/apps/mobile/src/components/common/HeaderTitleExtra.tsx b/apps/mobile/src/components/common/HeaderTitleExtra.tsx new file mode 100644 index 0000000000..6ab1ea7970 --- /dev/null +++ b/apps/mobile/src/components/common/HeaderTitleExtra.tsx @@ -0,0 +1,61 @@ +import { cn } from "@follow/utils" +import { useTheme } from "@react-navigation/native" +import type { StyleProp, TextProps, TextStyle } from "react-native" +import { Animated, Platform, StyleSheet, Text, View } from "react-native" + +type Props = Omit & { + tintColor?: string + children?: string + style?: Animated.WithAnimatedValue> + subText?: string + subTextStyle?: StyleProp + subTextClassName?: string +} + +export function HeaderTitleExtra({ + tintColor, + style, + subText, + subTextStyle, + subTextClassName, + ...rest +}: Props) { + const { colors, fonts } = useTheme() + + return ( + + + + {subText} + + + ) +} + +const styles = StyleSheet.create({ + title: Platform.select({ + ios: { + fontSize: 17, + }, + android: { + fontSize: 20, + }, + default: { + fontSize: 18, + }, + }), +}) diff --git a/apps/mobile/src/components/common/ModalSharedComponents.tsx b/apps/mobile/src/components/common/ModalSharedComponents.tsx index 4ad15e3793..f6dcc31603 100644 --- a/apps/mobile/src/components/common/ModalSharedComponents.tsx +++ b/apps/mobile/src/components/common/ModalSharedComponents.tsx @@ -1,18 +1,61 @@ +import { withOpacity } from "@follow/utils" import { router } from "expo-router" import { TouchableOpacity } from "react-native" +import { useIsRouteOnlyOne } from "@/src/hooks/useIsRouteOnlyOne" +import { CheckLineIcon } from "@/src/icons/check_line" import { CloseCuteReIcon } from "@/src/icons/close_cute_re" +import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line" import { useColor } from "@/src/theme/colors" +import { RotateableLoading } from "./RotateableLoading" + export const ModalHeaderCloseButton = () => { return } const ModalHeaderCloseButtonImpl = () => { const label = useColor("label") + + const routeOnlyOne = useIsRouteOnlyOne() + return ( router.dismiss()}> - + {routeOnlyOne ? ( + + ) : ( + + )} + + ) +} + +export interface ModalHeaderShubmitButtonProps { + isValid: boolean + onPress: () => void + isLoading?: boolean +} +export const ModalHeaderShubmitButton = ({ + isValid, + onPress, + isLoading, +}: ModalHeaderShubmitButtonProps) => { + return +} + +const ModalHeaderShubmitButtonImpl = ({ + isValid, + onPress, + isLoading, +}: ModalHeaderShubmitButtonProps) => { + const label = useColor("label") + return ( + + {isLoading ? ( + + ) : ( + + )} ) } diff --git a/apps/mobile/src/components/common/RotateableLoading.tsx b/apps/mobile/src/components/common/RotateableLoading.tsx new file mode 100644 index 0000000000..805ab1f79d --- /dev/null +++ b/apps/mobile/src/components/common/RotateableLoading.tsx @@ -0,0 +1,39 @@ +import type { FC } from "react" +import { useEffect } from "react" +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated" + +import { Loading3CuteReIcon } from "@/src/icons/loading_3_cute_re" + +export interface RotateableLoadingProps { + size?: number + color?: string +} +export const RotateableLoading: FC = ({ size = 36, color = "#fff" }) => { + const rotate = useSharedValue(0) + useEffect(() => { + rotate.value = withRepeat( + withTiming(360, { duration: 1000, easing: Easing.linear }), + Infinity, + false, + ) + return () => { + rotate.value = 0 + } + }, [rotate]) + + const rotateStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotate.value}deg` }], + })) + + return ( + + + + ) +} diff --git a/apps/mobile/src/components/ui/accordion/index.tsx b/apps/mobile/src/components/ui/accordion/AccordionItem.tsx similarity index 100% rename from apps/mobile/src/components/ui/accordion/index.tsx rename to apps/mobile/src/components/ui/accordion/AccordionItem.tsx diff --git a/apps/mobile/src/components/ui/form/FormProvider.tsx b/apps/mobile/src/components/ui/form/FormProvider.tsx new file mode 100644 index 0000000000..ec8dfc1f97 --- /dev/null +++ b/apps/mobile/src/components/ui/form/FormProvider.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react" +import type { FieldValues, UseFormReturn } from "react-hook-form" + +const FormContext = createContext | null>(null) + +export function FormProvider(props: { + form: UseFormReturn + children: React.ReactNode +}) { + return {props.children} +} + +export function useFormContext() { + const context = useContext(FormContext) + if (!context) { + throw new Error("useFormContext must be used within a FormProvider") + } + return context as UseFormReturn +} diff --git a/apps/mobile/src/components/ui/form/PickerIos.tsx b/apps/mobile/src/components/ui/form/PickerIos.tsx new file mode 100644 index 0000000000..5c871f276e --- /dev/null +++ b/apps/mobile/src/components/ui/form/PickerIos.tsx @@ -0,0 +1,94 @@ +/* eslint-disable @eslint-react/no-array-index-key */ +import { cn } from "@follow/utils" +import { Portal } from "@gorhom/portal" +import { Picker } from "@react-native-picker/picker" +import { useMemo, useState } from "react" +import type { StyleProp, ViewStyle } from "react-native" +import { Pressable, Text, View } from "react-native" +import Animated, { SlideOutDown } from "react-native-reanimated" +import { useEventCallback } from "usehooks-ts" + +import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line" +import { useColor } from "@/src/theme/colors" + +import { BlurEffect } from "../../common/HeaderBlur" + +interface PickerIosProps { + options: { label: string; value: T }[] + + value: T + onValueChange: (value: T) => void + + wrapperClassName?: string + wrapperStyle?: StyleProp +} +export function PickerIos({ + options, + value, + onValueChange, + wrapperClassName, + wrapperStyle, +}: PickerIosProps) { + const [isOpen, setIsOpen] = useState(false) + + const [currentValue, setCurrentValue] = useState(() => { + if (!value) { + return options[0].value + } + return value + }) + + const valueToLabelMap = useMemo(() => { + return options.reduce((acc, option) => { + acc.set(option.value, option.label) + return acc + }, new Map()) + }, [options]) + + const handleChangeValue = useEventCallback((value: T) => { + setCurrentValue(value) + onValueChange(value) + }) + + const systemFill = useColor("text") + + return ( + <> + {/* Trigger */} + setIsOpen(!isOpen)}> + + {valueToLabelMap.get(currentValue)} + + + + + + {/* Picker */} + {isOpen && ( + + setIsOpen(false)} + className="absolute inset-0 flex flex-row items-end" + > + + + e.stopPropagation()}> + + {options.map((option, index) => ( + + ))} + + + + + + )} + + ) +} diff --git a/apps/mobile/src/components/ui/form/Select.tsx b/apps/mobile/src/components/ui/form/Select.tsx index e894d8afae..0f2798fd08 100644 --- a/apps/mobile/src/components/ui/form/Select.tsx +++ b/apps/mobile/src/components/ui/form/Select.tsx @@ -1,17 +1,14 @@ -/* eslint-disable @eslint-react/no-array-index-key */ import { cn } from "@follow/utils" -import { Portal } from "@gorhom/portal" -import { Picker } from "@react-native-picker/picker" -import { useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import type { StyleProp, ViewStyle } from "react-native" -import { Pressable, Text, View } from "react-native" -import Animated, { SlideOutDown } from "react-native-reanimated" +import { Text, View } from "react-native" +import ContextMenu from "react-native-context-menu-view" import { useEventCallback } from "usehooks-ts" import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line" import { useColor } from "@/src/theme/colors" -import { BlurEffect } from "../../common/HeaderBlur" +import { FormLabel } from "./Label" interface SelectProps { options: { label: string; value: T }[] @@ -21,6 +18,8 @@ interface SelectProps { wrapperClassName?: string wrapperStyle?: StyleProp + + label?: string } export function Select({ options, @@ -28,9 +27,8 @@ export function Select({ onValueChange, wrapperClassName, wrapperStyle, + label, }: SelectProps) { - const [isOpen, setIsOpen] = useState(false) - const [currentValue, setCurrentValue] = useState(() => { if (!value) { return options[0].value @@ -50,44 +48,42 @@ export function Select({ onValueChange(value) }) + useEffect(() => { + onValueChange(currentValue) + }, []) + const systemFill = useColor("text") + return ( - <> + + {!!label && } + {/* Trigger */} - setIsOpen(!isOpen)}> + ({ + title: option.label, + selected: option.value === currentValue, + }))} + onPress={(e) => { + const { index } = e.nativeEvent + handleChangeValue(options[index].value) + }} + > {valueToLabelMap.get(currentValue)} - + - - {/* Picker */} - {isOpen && ( - - setIsOpen(false)} - className="absolute inset-0 flex flex-row items-end" - > - - - e.stopPropagation()}> - - {options.map((option, index) => ( - - ))} - - - - - - )} - + + ) } diff --git a/apps/mobile/src/components/ui/form/Switch.tsx b/apps/mobile/src/components/ui/form/Switch.tsx new file mode 100644 index 0000000000..bfb706ca12 --- /dev/null +++ b/apps/mobile/src/components/ui/form/Switch.tsx @@ -0,0 +1,31 @@ +import { forwardRef } from "react" +import type { StyleProp, SwitchProps, ViewStyle } from "react-native" +import { Switch, Text, View } from "react-native" + +import { accentColor } from "@/src/theme/colors" + +import { FormLabel } from "./Label" + +interface Props { + wrapperClassName?: string + wrapperStyle?: StyleProp + + label?: string + description?: string +} + +export const FormSwitch = forwardRef( + ({ wrapperClassName, wrapperStyle, label, description, ...rest }, ref) => { + return ( + + + {!!label && } + {!!description && ( + {description} + )} + + + + ) + }, +) diff --git a/apps/mobile/src/components/ui/form/TextField.tsx b/apps/mobile/src/components/ui/form/TextField.tsx index 41ce189803..4895a8cad7 100644 --- a/apps/mobile/src/components/ui/form/TextField.tsx +++ b/apps/mobile/src/components/ui/form/TextField.tsx @@ -1,35 +1,50 @@ import { cn } from "@follow/utils/src/utils" -import type { FC } from "react" +import { forwardRef } from "react" import type { StyleProp, TextInputProps, ViewStyle } from "react-native" -import { StyleSheet, TextInput, View } from "react-native" +import { StyleSheet, Text, TextInput, View } from "react-native" + +import { FormLabel } from "./Label" interface TextFieldProps { wrapperClassName?: string wrapperStyle?: StyleProp + + label?: string + description?: string + required?: boolean } -export const TextField: FC = ({ - className, - style, - wrapperClassName, - wrapperStyle, - ...rest -}) => { - return ( - - - - ) -} + +export const TextField = forwardRef( + ( + { className, style, wrapperClassName, wrapperStyle, label, description, required, ...rest }, + ref, + ) => { + return ( + <> + {!!label && } + {!!description && ( + + {description} + + )} + + + + + ) + }, +) const styles = StyleSheet.create({ textField: { diff --git a/apps/mobile/src/components/ui/icon/feed-icon.tsx b/apps/mobile/src/components/ui/icon/feed-icon.tsx index 48a4db177a..50258aadec 100644 --- a/apps/mobile/src/components/ui/icon/feed-icon.tsx +++ b/apps/mobile/src/components/ui/icon/feed-icon.tsx @@ -1,9 +1,9 @@ import type { FeedViewType } from "@follow/constants" import { getUrlIcon } from "@follow/utils/src/utils" +import type { ImageProps } from "expo-image" +import { Image } from "expo-image" import type { ReactNode } from "react" import { useMemo } from "react" -import type { ImageProps } from "react-native" -import { Image } from "react-native" import type { FeedSchema } from "@/src/database/schemas/types" @@ -57,5 +57,5 @@ export function FeedIcon({ } }, [fallback, feed, siteUrl]) - return + return } diff --git a/apps/mobile/src/components/ui/loading/index.tsx b/apps/mobile/src/components/ui/loading/index.tsx index bd9f645e44..fc5b3f03cc 100644 --- a/apps/mobile/src/components/ui/loading/index.tsx +++ b/apps/mobile/src/components/ui/loading/index.tsx @@ -16,8 +16,9 @@ import { Loading3CuteLiIcon } from "@/src/icons/loading_3_cute_li" export const LoadingIndicator: FC< { size?: number + color?: string } & PropsWithChildren -> = ({ size = 60, children }) => { +> = ({ size = 60, color, children }) => { const rotateValue = useSharedValue(0) const rotation = useDerivedValue(() => { @@ -41,7 +42,7 @@ export const LoadingIndicator: FC< return ( - + {children} diff --git a/apps/mobile/src/components/ui/tabview/TabBar.tsx b/apps/mobile/src/components/ui/tabview/TabBar.tsx new file mode 100644 index 0000000000..e2b9faf70a --- /dev/null +++ b/apps/mobile/src/components/ui/tabview/TabBar.tsx @@ -0,0 +1,211 @@ +import { cn } from "@follow/utils" +import { debounce } from "es-toolkit/compat" +import type { FC } from "react" +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react" +import type { + Animated as AnimatedNative, + StyleProp, + TouchableOpacityProps, + ViewStyle, +} from "react-native" +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native" +import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated" + +import { accentColor } from "@/src/theme/colors" + +import type { Tab } from "./types" + +interface TabBarProps { + tabs: Tab[] + + tabbarClassName?: string + tabbarStyle?: StyleProp + + TabItem?: FC<{ isSelected: boolean; tab: Tab } & Pick> + + onTabItemPress?: (index: number) => void + currentTab?: number + + tabScrollContainerAnimatedX?: AnimatedNative.Value +} + +const springConfig = { + stiffness: 100, + damping: 10, +} + +export const TabBar = forwardRef( + ( + { + tabs, + TabItem = Pressable, + tabbarClassName, + tabbarStyle, + + onTabItemPress, + currentTab: tab, + tabScrollContainerAnimatedX: pagerOffsetX, + }, + ref, + ) => { + const [currentTab, setCurrentTab] = useState(tab || 0) + const [tabWidths, setTabWidths] = useState([]) + const [tabPositions, setTabPositions] = useState([]) + const indicatorPosition = useSharedValue(0) + + useEffect(() => { + if (typeof tab === "number") { + setCurrentTab(tab) + } + }, [tab]) + + const sharedPagerOffsetX = useSharedValue(0) + const [tabBarWidth, setTabBarWidth] = useState(0) + useEffect(() => { + if (pagerOffsetX) { + return + } + sharedPagerOffsetX.value = withSpring(currentTab * tabBarWidth, springConfig) + }, [currentTab, pagerOffsetX, sharedPagerOffsetX, tabBarWidth]) + useEffect(() => { + if (!pagerOffsetX) return + const id = pagerOffsetX.addListener(({ value }) => { + sharedPagerOffsetX.value = value + }) + return () => { + pagerOffsetX.removeListener(id) + } + }, [pagerOffsetX, sharedPagerOffsetX]) + const tabRef = useRef(null) + + const handleChangeTabIndex = useCallback((index: number) => { + setCurrentTab(index) + onTabItemPress?.(index) + }, []) + useEffect(() => { + if (!pagerOffsetX) return + const listener = pagerOffsetX.addListener( + debounce(({ value }) => { + // Calculate which tab should be active based on scroll position + const tabIndex = Math.round(value / tabBarWidth) + if (tabIndex !== currentTab) { + handleChangeTabIndex(tabIndex) + } + }, 36), + ) + + return () => pagerOffsetX.removeListener(listener) + }, [currentTab, handleChangeTabIndex, onTabItemPress, pagerOffsetX, tabBarWidth]) + + useImperativeHandle(ref, () => tabRef.current!) + useEffect(() => { + if (tabWidths.length > 0) { + indicatorPosition.value = withSpring(tabPositions[currentTab] || 0, springConfig) + + if (tabRef.current) { + const x = currentTab > 0 ? tabPositions[currentTab - 1] + tabWidths[currentTab - 1] : 0 + + const isCurrentTabVisible = + sharedPagerOffsetX.value < tabPositions[currentTab] && + sharedPagerOffsetX.value + tabWidths[currentTab] > tabPositions[currentTab] + + if (!isCurrentTabVisible) { + tabRef.current.scrollTo({ x, y: 0, animated: true }) + } + } + } + }, [currentTab, indicatorPosition, sharedPagerOffsetX.value, tabPositions, tabWidths]) + + const indicatorStyle = useAnimatedStyle(() => { + const scrollProgress = sharedPagerOffsetX.value / tabBarWidth + + const currentIndex = Math.floor(scrollProgress) + const nextIndex = Math.min(currentIndex + 1, tabs.length - 1) + const progress = scrollProgress - currentIndex + + // Interpolate between current and next tab positions + const xPosition = + tabPositions[currentIndex] + + (tabPositions[nextIndex] - tabPositions[currentIndex]) * progress + + // Interpolate between current and next tab widths + const width = + tabWidths[currentIndex] + (tabWidths[nextIndex] - tabWidths[currentIndex]) * progress + + return { + transform: [{ translateX: xPosition }], + width, + backgroundColor: tabs[currentTab].activeColor || accentColor, + } + }) + + return ( + { + setTabBarWidth(event.nativeEvent.layout.width) + }} + showsHorizontalScrollIndicator={false} + className={cn( + "border-tertiary-system-background relative shrink-0 grow-0", + tabbarClassName, + )} + horizontal + ref={tabRef} + contentContainerStyle={styles.tabScroller} + style={[styles.root, tabbarStyle]} + > + {tabs.map((tab, index) => ( + { + handleChangeTabIndex(index) + }} + key={tab.value} + isSelected={index === currentTab} + onLayout={(event) => { + const { width, x } = event.nativeEvent.layout + setTabWidths((prev) => { + const newWidths = [...prev] + newWidths[index] = width + return newWidths + }) + setTabPositions((prev) => { + const newPositions = [...prev] + newPositions[index] = x + return newPositions + }) + }} + tab={tab} + > + + + ))} + + + + ) + }, +) + +const styles = StyleSheet.create({ + tabScroller: { + alignItems: "center", + flexDirection: "row", + paddingHorizontal: 4, + }, + root: { paddingHorizontal: 6 }, + + indicator: { + position: "absolute", + bottom: 0, + height: 2, + borderRadius: 1, + }, +}) + +const TabItemInner = ({ tab, isSelected }: { tab: Tab; isSelected: boolean }) => { + return ( + + {tab.name} + + ) +} diff --git a/apps/mobile/src/components/ui/tabview/TabView.tsx b/apps/mobile/src/components/ui/tabview/TabView.tsx new file mode 100644 index 0000000000..b2e8dfc9fe --- /dev/null +++ b/apps/mobile/src/components/ui/tabview/TabView.tsx @@ -0,0 +1,126 @@ +import { cn } from "@follow/utils" +import type { FC } from "react" +import { useCallback, useEffect, useRef, useState } from "react" +import type { ScrollView, StyleProp, TouchableOpacityProps, ViewStyle } from "react-native" +import { + Animated as RnAnimated, + Pressable, + useAnimatedValue, + useWindowDimensions, + View, +} from "react-native" +import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils" + +import { AnimatedScrollView } from "../../common/AnimatedComponents" +import { TabBar } from "./TabBar" + +type Tab = { + name: string + activeColor?: string + value: string +} + +export type TabComponent = FC<{ isSelected: boolean; tab: Tab } & Pick> +interface TabViewProps { + tabs: Tab[] + Tab?: TabComponent + TabItem?: FC<{ isSelected: boolean; tab: Tab } & Pick> + initialTab?: number + onTabChange?: (tab: number) => void + + // styles + tabbarClassName?: string + tabbarStyle?: StyleProp + scrollerStyle?: StyleProp + scrollerContainerStyle?: StyleProp + scrollerContainerClassName?: string + scrollerClassName?: string + + lazyTab?: boolean + lazyOnce?: boolean +} + +export const TabView: FC = ({ + tabs, + Tab = View, + TabItem = Pressable, + initialTab, + onTabChange, + + tabbarClassName, + tabbarStyle, + scrollerClassName, + scrollerStyle, + scrollerContainerStyle, + scrollerContainerClassName, + + lazyOnce, + lazyTab, +}) => { + const [currentTab, setCurrentTab] = useState(initialTab ?? 0) + + const pagerOffsetX = useAnimatedValue(0) + + const { width: windowWidth } = useWindowDimensions() + + const [lazyTabSet, setLazyTabSet] = useState(() => new Set()) + + const shouldRenderCurrentTab = (index: number) => { + if (!lazyTab) return true + if (index === currentTab) return true + if (lazyOnce && lazyTabSet.has(index)) return true + return lazyTabSet.has(index) + } + + useEffect(() => { + setLazyTabSet((prev) => { + const newSet = new Set(prev) + newSet.add(currentTab) + return newSet + }) + }, [currentTab]) + + const contentScrollerRef = useRef(null) + + return ( + <> + { + contentScrollerRef.current?.scrollTo({ x: index * windowWidth, y: 0, animated: true }) + setCurrentTab(index) + onTabChange?.(index) + }, + [onTabChange, windowWidth], + )} + tabs={tabs} + currentTab={currentTab} + tabbarClassName={tabbarClassName} + tabbarStyle={tabbarStyle} + TabItem={TabItem} + tabScrollContainerAnimatedX={pagerOffsetX} + /> + + + {tabs.map((tab, index) => ( + + {shouldRenderCurrentTab(index) && } + + ))} + + + ) +} diff --git a/apps/mobile/src/components/ui/tabview/index.tsx b/apps/mobile/src/components/ui/tabview/index.tsx deleted file mode 100644 index 4295478584..0000000000 --- a/apps/mobile/src/components/ui/tabview/index.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { cn } from "@follow/utils" -import type { FC } from "react" -import { useEffect, useRef, useState } from "react" -import type { StyleProp, TouchableOpacityProps, ViewStyle } from "react-native" -import { - Animated as RnAnimated, - Pressable, - ScrollView, - StyleSheet, - Text, - useAnimatedValue, - useWindowDimensions, - View, -} from "react-native" -import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated" -import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils" - -import { accentColor } from "@/src/theme/colors" - -import { AnimatedScrollView } from "../../common/AnimatedComponents" - -type Tab = { - name: string - activeColor?: string - value: string -} - -export type TabComponent = FC<{ isSelected: boolean; tab: Tab } & Pick> -interface TabViewProps { - tabs: Tab[] - Tab?: TabComponent - TabItem?: FC<{ isSelected: boolean; tab: Tab } & Pick> - initialTab?: number - onTabChange?: (tab: number) => void - - // styles - tabbarClassName?: string - tabbarStyle?: StyleProp - scrollerStyle?: StyleProp - scrollerContainerStyle?: StyleProp - scrollerContainerClassName?: string - scrollerClassName?: string - - lazyTab?: boolean - lazyOnce?: boolean -} - -const springConfig = { - stiffness: 100, - damping: 10, -} - -export const TabView: FC = ({ - tabs, - Tab = View, - TabItem = Pressable, - initialTab, - onTabChange, - - tabbarClassName, - tabbarStyle, - scrollerClassName, - scrollerStyle, - scrollerContainerStyle, - scrollerContainerClassName, - - lazyOnce, - lazyTab, -}) => { - const tabRef = useRef(null) - - const [tabWidths, setTabWidths] = useState([]) - const [tabPositions, setTabPositions] = useState([]) - - const [currentTab, setCurrentTab] = useState(initialTab ?? 0) - - const pagerOffsetX = useAnimatedValue(0) - const sharedPagerOffsetX = useSharedValue(0) - useEffect(() => { - const id = pagerOffsetX.addListener(({ value }) => { - sharedPagerOffsetX.value = value - }) - return () => { - pagerOffsetX.removeListener(id) - } - }, [pagerOffsetX, sharedPagerOffsetX]) - - const indicatorPosition = useSharedValue(0) - const { width: windowWidth } = useWindowDimensions() - - useEffect(() => { - if (tabWidths.length > 0) { - indicatorPosition.value = withSpring(tabPositions[currentTab] || 0, springConfig) - - if (tabRef.current) { - const x = currentTab > 0 ? tabPositions[currentTab - 1] + tabWidths[currentTab - 1] : 0 - - const isCurrentTabVisible = - sharedPagerOffsetX.value < tabPositions[currentTab] && - sharedPagerOffsetX.value + tabWidths[currentTab] > tabPositions[currentTab] - - if (!isCurrentTabVisible) { - tabRef.current.scrollTo({ x, y: 0, animated: true }) - } - } - } - }, [currentTab, indicatorPosition, sharedPagerOffsetX.value, tabPositions, tabWidths]) - - const indicatorStyle = useAnimatedStyle(() => { - const scrollProgress = sharedPagerOffsetX.value / windowWidth - - const currentIndex = Math.floor(scrollProgress) - const nextIndex = Math.min(currentIndex + 1, tabs.length - 1) - const progress = scrollProgress - currentIndex - - // Interpolate between current and next tab positions - const xPosition = - tabPositions[currentIndex] + (tabPositions[nextIndex] - tabPositions[currentIndex]) * progress - - // Interpolate between current and next tab widths - const width = - tabWidths[currentIndex] + (tabWidths[nextIndex] - tabWidths[currentIndex]) * progress - - return { - transform: [{ translateX: xPosition }], - width, - backgroundColor: tabs[currentTab].activeColor || accentColor, - } - }) - - useEffect(() => { - const listener = pagerOffsetX.addListener(({ value }) => { - // Calculate which tab should be active based on scroll position - const tabIndex = Math.round(value / windowWidth) - if (tabIndex !== currentTab) { - setCurrentTab(tabIndex) - onTabChange?.(tabIndex) - } - }) - - return () => pagerOffsetX.removeListener(listener) - }, [tabWidths, tabPositions, currentTab, pagerOffsetX, windowWidth, onTabChange]) - - const [lazyTabSet, setLazyTabSet] = useState(() => new Set()) - - const shouldRenderCurrentTab = (index: number) => { - if (!lazyTab) return true - if (index === currentTab) return true - if (lazyOnce && lazyTabSet.has(index)) return true - return lazyTabSet.has(index) - } - - useEffect(() => { - setLazyTabSet((prev) => { - const newSet = new Set(prev) - newSet.add(currentTab) - return newSet - }) - }, [currentTab]) - - const contentScrollerRef = useRef(null) - - return ( - <> - - {tabs.map((tab, index) => ( - { - // setCurrentTab(index) - contentScrollerRef.current?.scrollTo({ x: index * windowWidth, y: 0, animated: true }) - onTabChange?.(index) - }} - key={tab.value} - isSelected={index === currentTab} - onLayout={(event) => { - const { width, x } = event.nativeEvent.layout - setTabWidths((prev) => { - const newWidths = [...prev] - newWidths[index] = width - return newWidths - }) - setTabPositions((prev) => { - const newPositions = [...prev] - newPositions[index] = x - return newPositions - }) - }} - tab={tab} - > - - - ))} - - - - - - {tabs.map((tab, index) => ( - - {shouldRenderCurrentTab(index) && } - - ))} - - - ) -} - -const TabItemInner = ({ tab, isSelected }: { tab: Tab; isSelected: boolean }) => { - return ( - - {tab.name} - - ) -} - -const styles = StyleSheet.create({ - tabScroller: { - alignItems: "center", - flexDirection: "row", - paddingHorizontal: 4, - }, - - root: { paddingHorizontal: 6 }, - indicator: { - position: "absolute", - bottom: 0, - height: 2, - borderRadius: 1, - }, -}) diff --git a/apps/mobile/src/components/ui/tabview/types.ts b/apps/mobile/src/components/ui/tabview/types.ts new file mode 100644 index 0000000000..2e6dca7686 --- /dev/null +++ b/apps/mobile/src/components/ui/tabview/types.ts @@ -0,0 +1,5 @@ +export type Tab = { + name: string + activeColor?: string + value: string +} diff --git a/apps/mobile/src/components/ui/toast/CenteredToast.tsx b/apps/mobile/src/components/ui/toast/CenteredToast.tsx new file mode 100644 index 0000000000..b64321776e --- /dev/null +++ b/apps/mobile/src/components/ui/toast/CenteredToast.tsx @@ -0,0 +1,74 @@ +import { withOpacity } from "@follow/utils" +import { createElement, useContext, useEffect, useState } from "react" +import { StyleSheet, Text, View } from "react-native" +import Animated, { FadeOut } from "react-native-reanimated" + +import { toastTypeToIcons } from "./constants" +import { ToastActionContext } from "./ctx" +import type { ToastProps } from "./types" + +export const CenteredToast = (props: ToastProps) => { + const renderMessage = props.render ? null : props.message ? ( + {props.message} + ) : null + const { register } = useContext(ToastActionContext) + useEffect(() => { + const disposer = register(props.currentIndex, { + dimiss: async () => {}, + }) + return () => { + disposer() + } + }, [props.currentIndex, register]) + const renderIcon = + props.icon === false + ? null + : (props.icon ?? ( + + {createElement(toastTypeToIcons[props.type], { + color: "white", + height: 20, + width: 20, + })} + + )) + + const [measureHeight, setMeasureHeight] = useState(-1) + return ( + { + setMeasureHeight(nativeEvent.layout.height) + }} + exiting={FadeOut} + style={StyleSheet.flatten([ + styles.toast, + measureHeight === -1 ? styles.hidden : {}, + measureHeight > 50 ? styles.rounded : styles.roundedFull, + ])} + > + {renderIcon} + {renderMessage} + + ) +} + +const styles = StyleSheet.create({ + toast: { + borderWidth: StyleSheet.hairlineWidth, + flexDirection: "row", + paddingHorizontal: 16, + paddingVertical: 12, + borderColor: withOpacity("#ffffff", 0.3), + backgroundColor: withOpacity("#000000", 0.9), + }, + + hidden: { + opacity: 0, + }, + rounded: { + borderRadius: 16, + }, + roundedFull: { + borderRadius: 9999, + }, +}) diff --git a/apps/mobile/src/components/ui/toast/ToastContainer.tsx b/apps/mobile/src/components/ui/toast/ToastContainer.tsx new file mode 100644 index 0000000000..ee6dff9d97 --- /dev/null +++ b/apps/mobile/src/components/ui/toast/ToastContainer.tsx @@ -0,0 +1,50 @@ +import { useAtomValue } from "jotai" +import { useContext, useMemo } from "react" +import { View } from "react-native" + +import { CenteredToast } from "./CenteredToast" +import { ToastContainerContext } from "./ctx" +import type { ToastProps } from "./types" + +export const ToastContainer = () => { + const stackAtom = useContext(ToastContainerContext) + const stack = useAtomValue(stackAtom) + + const { renderCenterReplaceToast, renderBottomStackToasts } = useMemo(() => { + const { centerToasts, bottomToasts } = stack.reduce( + (acc, toast) => { + if (toast.variant === "center-replace") { + acc.centerToasts.push(toast) + } else if (toast.variant === "bottom-stack") { + acc.bottomToasts.push(toast) + } + return acc + }, + { centerToasts: [] as ToastProps[], bottomToasts: [] as ToastProps[] }, + ) + + const renderCenterReplaceToast = + centerToasts.length > 0 + ? centerToasts.reduce((latest, toast) => + latest.currentIndex > toast.currentIndex ? latest : toast, + ) + : null + + const renderBottomStackToasts = bottomToasts.sort((a, b) => a.currentIndex - b.currentIndex) + + return { renderCenterReplaceToast, renderBottomStackToasts } + }, [stack]) + + void renderBottomStackToasts + + return ( + + {/* Center replace container */} + + {renderCenterReplaceToast && } + + {/* Bottom stack */} + {/* */} + + ) +} diff --git a/apps/mobile/src/components/ui/toast/constants.ts b/apps/mobile/src/components/ui/toast/constants.ts new file mode 100644 index 0000000000..1d1dc5af8d --- /dev/null +++ b/apps/mobile/src/components/ui/toast/constants.ts @@ -0,0 +1,9 @@ +import { CheckCircleFilledIcon } from "@/src/icons/check_circle_filled" +import { CloseCircleFillIcon } from "@/src/icons/close_circle_fill" +import { InfoCircleFillIcon } from "@/src/icons/info_circle_fill" + +export const toastTypeToIcons = { + success: CheckCircleFilledIcon, + error: CloseCircleFillIcon, + info: InfoCircleFillIcon, +} as const diff --git a/apps/mobile/src/components/ui/toast/ctx.tsx b/apps/mobile/src/components/ui/toast/ctx.tsx new file mode 100644 index 0000000000..18e2a156bf --- /dev/null +++ b/apps/mobile/src/components/ui/toast/ctx.tsx @@ -0,0 +1,13 @@ +import type { PrimitiveAtom } from "jotai" +import { createContext } from "react" + +import type { ToastProps, ToastRef } from "./types" + +export const ToastContainerContext = createContext>(null!) + +type Disposer = () => void +interface ToastActionContext { + register: (currentIndex: number, ref: ToastRef) => Disposer +} + +export const ToastActionContext = createContext(null!) diff --git a/apps/mobile/src/components/ui/toast/manager.tsx b/apps/mobile/src/components/ui/toast/manager.tsx new file mode 100644 index 0000000000..8c10e7f614 --- /dev/null +++ b/apps/mobile/src/components/ui/toast/manager.tsx @@ -0,0 +1,108 @@ +import { jotaiStore } from "@follow/utils" +import { atom, Provider } from "jotai" +import RootSiblings from "react-native-root-siblings" + +import { FullWindowOverlay } from "../../common/FullWindowOverlay" +import { ToastActionContext, ToastContainerContext } from "./ctx" +import { ToastContainer } from "./ToastContainer" +import type { BottomToastProps, CenterToastProps, ToastProps, ToastRef } from "./types" + +export class ToastManager { + private stackAtom = atom([]) + private portal: RootSiblings | null = null + + private propsMap = {} as Record + private currentIndex = 0 + + private defaultProps: Omit = { + duration: 3000, + action: [], + type: "info", + variant: "bottom-stack", + message: "", + render: null, + icon: null, + canClose: true, + } + + private toastRefs = {} as Record + + private register(currentIndex: number, ref: ToastRef) { + this.toastRefs[currentIndex] = ref + return () => { + delete this.toastRefs[currentIndex] + } + } + + mount() { + this.portal = new RootSiblings( + ( + + + + + + + + + + ), + ) + } + + private push(props: ToastProps) { + this.propsMap[props.currentIndex] = props + jotaiStore.set(this.stackAtom, [...jotaiStore.get(this.stackAtom), props]) + } + + private remove(index: number) { + delete this.propsMap[index] + jotaiStore.set( + this.stackAtom, + jotaiStore.get(this.stackAtom).filter((toast) => toast.currentIndex !== index), + ) + } + + private scheduleDismiss(index: number) { + const props = this.propsMap[index] + + if (props.duration === Infinity) { + return + } + + setTimeout(async () => { + await this.toastRefs[index].dimiss() + this.remove(index) + }, props.duration) + } + + // @ts-expect-error + show(props: CenterToastProps): Promise<() => void> + show(props: BottomToastProps): Promise<() => void> + show(props: Omit, "currentIndex">) { + if (!this.portal) { + this.mount() + } + + const nextProps = { ...this.defaultProps, ...props } + + if (nextProps.canClose === false) { + nextProps.duration = Infinity + } + + if (nextProps.variant === "center-replace") { + // Find and remove the toast if it exists + const index = jotaiStore + .get(this.stackAtom) + .findIndex((toast) => toast.variant === "center-replace") + if (index !== -1) { + this.remove(index) + } + } + + const currentIndex = ++this.currentIndex + this.push({ ...nextProps, currentIndex }) + this.scheduleDismiss(currentIndex) + return () => this.remove(currentIndex) + } +} diff --git a/apps/mobile/src/components/ui/toast/types.ts b/apps/mobile/src/components/ui/toast/types.ts new file mode 100644 index 0000000000..d176d94ccb --- /dev/null +++ b/apps/mobile/src/components/ui/toast/types.ts @@ -0,0 +1,31 @@ +export interface ToastProps { + currentIndex: number + variant: "bottom-stack" | "center-replace" + type: "success" | "error" | "info" + message: string + render: React.ReactNode + + action: { + label: React.ReactNode + onPress: () => void + variant?: "normal" | "destructive" + }[] + duration: number + icon?: React.ReactNode | false + canClose?: boolean +} + +export type CenterToastProps = Partial< + Pick +> & { + variant: "center-replace" +} + +export type BottomToastProps = Partial & { + variant: "bottom-stack" + canClose?: boolean +} + +export interface ToastRef { + dimiss: () => Promise +} diff --git a/apps/mobile/src/components/common/Html.tsx b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx similarity index 90% rename from apps/mobile/src/components/common/Html.tsx rename to apps/mobile/src/components/ui/typography/HtmlWeb.tsx index c0b45a735b..d152110cb1 100644 --- a/apps/mobile/src/components/common/Html.tsx +++ b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx @@ -5,7 +5,7 @@ import "@follow/components/assets/tailwind.css" import type { HtmlProps } from "@follow/components" import { Html } from "@follow/components" -export default function HtmlRender({ +export default function HtmlWeb({ content, dom, ...options diff --git a/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx b/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx index d77421ad26..47833fad01 100644 --- a/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx +++ b/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx @@ -3,6 +3,7 @@ import "@/src/global.css" import { parseMarkdown } from "@follow/components/src/utils/parse-markdown" import { cn } from "@follow/utils" +import { useMemo } from "react" import { useDarkMode } from "usehooks-ts" import { useCSSInjection } from "@/src/theme/web" @@ -13,7 +14,7 @@ const MarkdownWeb: WebComponent<{ value: string }> = ({ value }) => { const { isDarkMode } = useDarkMode() return (
- {parseMarkdown(value).content} + {useMemo(() => parseMarkdown(value).content, [value])}
) } diff --git a/apps/mobile/src/constants/ui.ts b/apps/mobile/src/constants/ui.ts deleted file mode 100644 index caa3ddddd9..0000000000 --- a/apps/mobile/src/constants/ui.ts +++ /dev/null @@ -1 +0,0 @@ -export const bottomViewTabHeight = 35 diff --git a/apps/mobile/src/hooks/useIsRouteOnlyOne.ts b/apps/mobile/src/hooks/useIsRouteOnlyOne.ts new file mode 100644 index 0000000000..4f50c82524 --- /dev/null +++ b/apps/mobile/src/hooks/useIsRouteOnlyOne.ts @@ -0,0 +1,10 @@ +import { useNavigation } from "expo-router" + +export const useIsRouteOnlyOne = () => { + const navigation = useNavigation() + const state = navigation.getState() + + const routeOnlyOne = state.routes.length === 1 + + return routeOnlyOne +} diff --git a/apps/mobile/src/hooks/useLoadingCallback.tsx b/apps/mobile/src/hooks/useLoadingCallback.tsx new file mode 100644 index 0000000000..08178d6508 --- /dev/null +++ b/apps/mobile/src/hooks/useLoadingCallback.tsx @@ -0,0 +1,31 @@ +import { useSetAtom } from "jotai" +import { useCallback } from "react" + +import { loadingAtom, loadingVisibleAtom } from "../atoms/app" + +export const useLoadingCallback = () => { + const setLoadingCaller = useSetAtom(loadingAtom) + const setVisible = useSetAtom(loadingVisibleAtom) + + return useCallback( + ( + thenable: Promise, + options: Partial<{ + finish: () => any + cancel: () => any + done: (r: unknown) => any + error: (err: any) => any + }>, + ) => { + setLoadingCaller({ + thenable, + finish: options.finish, + cancel: options.cancel, + done: options.done, + error: options.error, + }) + setVisible(true) + }, + [setLoadingCaller, setVisible], + ) +} diff --git a/apps/mobile/src/icons/check_line.tsx b/apps/mobile/src/icons/check_line.tsx new file mode 100644 index 0000000000..49b194185b --- /dev/null +++ b/apps/mobile/src/icons/check_line.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface CheckLineIconProps { + width?: number + height?: number + color?: string +} + +export const CheckLineIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: CheckLineIconProps) => { + return ( + + + + + ) +} diff --git a/apps/mobile/src/icons/close_circle_fill.tsx b/apps/mobile/src/icons/close_circle_fill.tsx new file mode 100644 index 0000000000..8597960ba3 --- /dev/null +++ b/apps/mobile/src/icons/close_circle_fill.tsx @@ -0,0 +1,14 @@ +import type { SvgProps } from "react-native-svg" +import { G, Path, Svg } from "react-native-svg" + +export const CloseCircleFillIcon = (props: SvgProps & { color?: string }) => ( + + + + + + +) diff --git a/apps/mobile/src/icons/info_circle_fill.tsx b/apps/mobile/src/icons/info_circle_fill.tsx new file mode 100644 index 0000000000..22381e0b17 --- /dev/null +++ b/apps/mobile/src/icons/info_circle_fill.tsx @@ -0,0 +1,14 @@ +import type { SvgProps } from "react-native-svg" +import { G, Path, Svg } from "react-native-svg" + +export const InfoCircleFillIcon = (props: SvgProps & { color?: string }) => ( + + + + + + +) diff --git a/apps/mobile/src/icons/mingcute_left_line.tsx b/apps/mobile/src/icons/mingcute_left_line.tsx new file mode 100644 index 0000000000..841a3b1a29 --- /dev/null +++ b/apps/mobile/src/icons/mingcute_left_line.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import Svg, { G, Path } from "react-native-svg" + +interface MingcuteLeftLineIconProps { + width?: number + height?: number + color?: string +} + +export const MingcuteLeftLineIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: MingcuteLeftLineIconProps) => { + return ( + + + + + + + ) +} diff --git a/apps/mobile/src/lib/loading.tsx b/apps/mobile/src/lib/loading.tsx new file mode 100644 index 0000000000..a90defc168 --- /dev/null +++ b/apps/mobile/src/lib/loading.tsx @@ -0,0 +1,60 @@ +import type { FC } from "react" +import { useEffect, useRef, useState } from "react" +import { Pressable, StyleSheet, TouchableOpacity, View } from "react-native" +import RootSiblings from "react-native-root-siblings" + +import { FullWindowOverlay } from "../components/common/FullWindowOverlay" +import { RotateableLoading } from "../components/common/RotateableLoading" +import { CloseCuteReIcon } from "../icons/close_cute_re" + +class LoadingStatic { + async start(promise: Promise) { + const siblings = new RootSiblings( siblings.destroy()} />) + + try { + return await promise + } finally { + siblings.destroy() + } + } +} + +export const loading = new LoadingStatic() + +const LoadingContainer: FC<{ + cancel: () => void +}> = ({ cancel }) => { + const cancelTimerRef = useRef(null) + + const [showCancelButton, setShowCancelButton] = useState(false) + useEffect(() => { + cancelTimerRef.current = setTimeout(() => { + setShowCancelButton(true) + }, 3000) + return () => { + if (cancelTimerRef.current) { + clearTimeout(cancelTimerRef.current) + } + } + }, []) + + return ( + + {/* Pressable to prevent the overlay from being clicked */} + + + + + {showCancelButton && ( + + + + + + + + )} + + + ) +} diff --git a/apps/mobile/src/lib/toast.tsx b/apps/mobile/src/lib/toast.tsx new file mode 100644 index 0000000000..31c500b1de --- /dev/null +++ b/apps/mobile/src/lib/toast.tsx @@ -0,0 +1,26 @@ +import { ToastManager } from "../components/ui/toast/manager" +import type { ToastProps } from "../components/ui/toast/types" + +export const toastInstance = new ToastManager() + +type CommandToastOptions = Partial> +type Toast = { + // [key in "error" | "success" | "info"]: (message: string) => void; + show: typeof toastInstance.show + error: (message: string, options?: CommandToastOptions) => void + success: (message: string, options?: CommandToastOptions) => void + info: (message: string, options?: CommandToastOptions) => void +} +export const toast = { + show: toastInstance.show.bind(toastInstance), +} as Toast +;(["error", "success", "info"] as const).forEach((type) => { + toast[type] = (message: string, options: CommandToastOptions = {}) => { + toastInstance.show({ + type, + message, + variant: "center-replace", + ...options, + }) + } +}) diff --git a/apps/mobile/src/main.ts b/apps/mobile/src/main.ts deleted file mode 100644 index 15ceea7665..0000000000 --- a/apps/mobile/src/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { initializeApp } from "./initialize" - -initializeApp().then(() => { - require("expo-router/entry") -}) diff --git a/apps/mobile/src/main.tsx b/apps/mobile/src/main.tsx new file mode 100644 index 0000000000..1fddfc654e --- /dev/null +++ b/apps/mobile/src/main.tsx @@ -0,0 +1,24 @@ +import "@expo/metro-runtime" + +import { registerRootComponent } from "expo" +import { App } from "expo-router/build/qualified-entry" +import { RootSiblingParent } from "react-native-root-siblings" + +// import { renderRootComponent } from "expo" +import { initializeApp } from "./initialize" + +initializeApp().then(() => { + // This file should only import and register the root. No components or exports + // should be added here. + // renderRootComponent(App) +}) + +const MApp = () => { + return ( + + + + ) +} + +registerRootComponent(MApp) diff --git a/apps/mobile/src/modules/discover/recommendation-item.tsx b/apps/mobile/src/modules/discover/RecommendationListItem.tsx similarity index 100% rename from apps/mobile/src/modules/discover/recommendation-item.tsx rename to apps/mobile/src/modules/discover/RecommendationListItem.tsx diff --git a/apps/mobile/src/modules/discover/recommendations.tsx b/apps/mobile/src/modules/discover/Recommendations.tsx similarity index 96% rename from apps/mobile/src/modules/discover/recommendations.tsx rename to apps/mobile/src/modules/discover/Recommendations.tsx index fda55fbe1f..2eae69b2eb 100644 --- a/apps/mobile/src/modules/discover/recommendations.tsx +++ b/apps/mobile/src/modules/discover/Recommendations.tsx @@ -11,12 +11,12 @@ import { Text, TouchableOpacity, View } from "react-native" import type { PanGestureHandlerGestureEvent } from "react-native-gesture-handler" import { PanGestureHandler } from "react-native-gesture-handler" -import type { TabComponent } from "@/src/components/ui/tabview" -import { TabView } from "@/src/components/ui/tabview" +import type { TabComponent } from "@/src/components/ui/tabview/TabView" +import { TabView } from "@/src/components/ui/tabview/TabView" import { apiClient } from "@/src/lib/api-fetch" import { RSSHubCategoryCopyMap } from "./copy" -import { RecommendationListItem } from "./recommendation-item" +import { RecommendationListItem } from "./RecommendationListItem" export const Recommendations = () => { const headerHeight = useHeaderHeight() @@ -152,11 +152,13 @@ const Tab: TabComponent = ({ tab }) => { return ( diff --git a/apps/mobile/src/modules/discover/SearchTabBar.tsx b/apps/mobile/src/modules/discover/SearchTabBar.tsx new file mode 100644 index 0000000000..57040028f0 --- /dev/null +++ b/apps/mobile/src/modules/discover/SearchTabBar.tsx @@ -0,0 +1,27 @@ +import { useAtom } from "jotai" +import type { FC } from "react" +import type { Animated } from "react-native" + +import { TabBar } from "@/src/components/ui/tabview/TabBar" + +import type { SearchType } from "./constants" +import { SearchTabs } from "./constants" +import { useSearchPageContext } from "./ctx" + +export const SearchTabBar: FC<{ + animatedX: Animated.Value +}> = ({ animatedX }) => { + const { searchTypeAtom } = useSearchPageContext() + const [searchType, setSearchType] = useAtom(searchTypeAtom) + + return ( + tab.value === searchType)} + onTabItemPress={(index) => { + setSearchType(SearchTabs[index].value as SearchType) + }} + /> + ) +} diff --git a/apps/mobile/src/modules/discover/constants.ts b/apps/mobile/src/modules/discover/constants.ts new file mode 100644 index 0000000000..0c2f22c960 --- /dev/null +++ b/apps/mobile/src/modules/discover/constants.ts @@ -0,0 +1,13 @@ +export enum SearchType { + Feed = "feed", + List = "list", + User = "user", + RSSHub = "rsshub", +} + +export const SearchTabs = [ + { name: "Feed", value: SearchType.Feed }, + { name: "List", value: SearchType.List }, + { name: "User", value: SearchType.User }, + { name: "RSSHub", value: SearchType.RSSHub }, +] diff --git a/apps/mobile/src/modules/discover/content-selector.tsx b/apps/mobile/src/modules/discover/content-selector.tsx deleted file mode 100644 index a5625cb290..0000000000 --- a/apps/mobile/src/modules/discover/content-selector.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Recommendations } from "./recommendations" - -export const DiscoverContentSelector = () => { - return -} diff --git a/apps/mobile/src/modules/discover/ctx.tsx b/apps/mobile/src/modules/discover/ctx.tsx index 482bfd7478..77a9720d08 100644 --- a/apps/mobile/src/modules/discover/ctx.tsx +++ b/apps/mobile/src/modules/discover/ctx.tsx @@ -1,28 +1,73 @@ import type { PrimitiveAtom } from "jotai" import { atom } from "jotai" +import type { Dispatch, SetStateAction } from "react" import { createContext, useContext, useState } from "react" +import type { Animated } from "react-native" +import { useAnimatedValue } from "react-native" -interface DiscoverPageContextType { +import { SearchType } from "./constants" + +interface SearchPageContextType { searchFocusedAtom: PrimitiveAtom searchValueAtom: PrimitiveAtom + + searchTypeAtom: PrimitiveAtom +} +export const SearchPageContext = createContext(null!) + +const SearchBarHeightContext = createContext(0) +const setSearchBarHeightContext = createContext>>(() => {}) +export const SearchBarHeightProvider = ({ children }: { children: React.ReactNode }) => { + const [searchBarHeight, setSearchBarHeight] = useState(0) + return ( + + + {children} + + + ) } -export const DiscoverPageContext = createContext(null!) -export const DiscoverPageProvider = ({ children }: { children: React.ReactNode }) => { - const [atomRefs] = useState((): DiscoverPageContextType => { +export const useSearchBarHeight = () => { + return useContext(SearchBarHeightContext) +} +export const useSetSearchBarHeight = () => { + return useContext(setSearchBarHeightContext) +} + +export const SearchPageProvider = ({ children }: { children: React.ReactNode }) => { + const [atomRefs] = useState((): SearchPageContextType => { const searchFocusedAtom = atom(true) const searchValueAtom = atom("") - + const searchTypeAtom = atom(SearchType.Feed) return { searchFocusedAtom, searchValueAtom, + searchTypeAtom, } }) - return {children} + return {children} } -export const useDiscoverPageContext = () => { - const ctx = useContext(DiscoverPageContext) +const SearchPageScrollContainerAnimatedXContext = createContext(null!) +export const SearchPageScrollContainerAnimatedXProvider = ({ + children, +}: { + children: React.ReactNode +}) => { + const scrollContainerAnimatedX = useAnimatedValue(0) + return ( + + {children} + + ) +} + +export const useSearchPageScrollContainerAnimatedX = () => { + return useContext(SearchPageScrollContainerAnimatedXContext) +} +export const useSearchPageContext = () => { + const ctx = useContext(SearchPageContext) if (!ctx) throw new Error("useDiscoverPageContext must be used within a DiscoverPageProvider") return ctx } diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx new file mode 100644 index 0000000000..bc4de97881 --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx @@ -0,0 +1,56 @@ +import { useQuery } from "@tanstack/react-query" +import { useAtomValue } from "jotai" +import { memo } from "react" +import { Text } from "react-native" + +import { LoadingIndicator } from "@/src/components/ui/loading" +import { apiClient } from "@/src/lib/api-fetch" + +import { useSearchPageContext } from "../ctx" +import { BaseSearchPageFlatList, BaseSearchPageRootView, BaseSearchPageScrollView } from "./__base" + +type SearchResultItem = Awaited>["data"][number] + +export const SearchFeed = () => { + const { searchValueAtom } = useSearchPageContext() + const searchValue = useAtomValue(searchValueAtom) + + const { data, isLoading } = useQuery({ + queryKey: ["searchFeed", searchValue], + queryFn: () => { + return apiClient.discover.$post({ + json: { + keyword: searchValue, + target: "feeds", + }, + }) + }, + enabled: !!searchValue, + }) + + if (isLoading) { + return ( + + + + ) + } + + return ( + } + data={data?.data} + renderItem={renderItem} + /> + ) +} +const keyExtractor = (item: SearchResultItem) => item.feed?.id ?? Math.random().toString() + +const renderItem = ({ item }: { item: SearchResultItem }) => ( + +) + +const SearchFeedItem = memo(({ item }: { item: SearchResultItem }) => { + return {item.feed?.title} +}) diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx new file mode 100644 index 0000000000..244064eb60 --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx @@ -0,0 +1,16 @@ +import { useAtomValue } from "jotai" +import { Text } from "react-native" + +import { useSearchPageContext } from "../ctx" +import { BaseSearchPageScrollView } from "./__base" + +export const SearchList = () => { + const { searchValueAtom } = useSearchPageContext() + const searchValue = useAtomValue(searchValueAtom) + + return ( + + {searchValue} + + ) +} diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchRSSHub.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchRSSHub.tsx new file mode 100644 index 0000000000..7083fa8aea --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/SearchRSSHub.tsx @@ -0,0 +1,11 @@ +import { Text } from "react-native" + +import { BaseSearchPageScrollView } from "./__base" + +export const SearchRSSHub = () => { + return ( + + RSSHub + + ) +} diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchUser.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchUser.tsx new file mode 100644 index 0000000000..3d0ca7c2eb --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/SearchUser.tsx @@ -0,0 +1,11 @@ +import { Text } from "react-native" + +import { BaseSearchPageScrollView } from "./__base" + +export const SearchUser = () => { + return ( + + User + + ) +} diff --git a/apps/mobile/src/modules/discover/search-tabs/__base.tsx b/apps/mobile/src/modules/discover/search-tabs/__base.tsx new file mode 100644 index 0000000000..e482077e60 --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/__base.tsx @@ -0,0 +1,61 @@ +import { forwardRef } from "react" +import type { ScrollViewProps } from "react-native" +import { ScrollView, useWindowDimensions, View } from "react-native" +import type { FlatListPropsWithLayout } from "react-native-reanimated" +import Animated, { LinearTransition } from "react-native-reanimated" +import { useSafeAreaInsets } from "react-native-safe-area-context" + +import { useSearchBarHeight } from "../ctx" + +export const BaseSearchPageScrollView = forwardRef( + ({ children, ...props }, ref) => { + const searchBarHeight = useSearchBarHeight() + const insets = useSafeAreaInsets() + const windowWidth = useWindowDimensions().width + const offsetTop = searchBarHeight - insets.top + return ( + + {children} + + ) + }, +) + +export const BaseSearchPageRootView = ({ children }: { children: React.ReactNode }) => { + const windowWidth = useWindowDimensions().width + const insets = useSafeAreaInsets() + const searchBarHeight = useSearchBarHeight() + const offsetTop = searchBarHeight - insets.top + return ( + + {children} + + ) +} + +export function BaseSearchPageFlatList({ ...props }: FlatListPropsWithLayout) { + const insets = useSafeAreaInsets() + const searchBarHeight = useSearchBarHeight() + const offsetTop = searchBarHeight - insets.top + const windowWidth = useWindowDimensions().width + return ( + + ) +} diff --git a/apps/mobile/src/modules/discover/search.tsx b/apps/mobile/src/modules/discover/search.tsx index 9e786b0608..04f6da96f4 100644 --- a/apps/mobile/src/modules/discover/search.tsx +++ b/apps/mobile/src/modules/discover/search.tsx @@ -1,8 +1,9 @@ import { getDefaultHeaderHeight } from "@react-navigation/elements" -import { useTheme } from "@react-navigation/native" import { router } from "expo-router" import { useAtom, useAtomValue, useSetAtom } from "jotai" -import { useEffect, useRef } from "react" +import type { FC } from "react" +import { useEffect, useRef, useState } from "react" +import type { LayoutChangeEvent } from "react-native" import { Animated, Easing, @@ -20,24 +21,36 @@ import { BlurEffect } from "@/src/components/common/HeaderBlur" import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re" import { accentColor, useColor } from "@/src/theme/colors" -import { useDiscoverPageContext } from "./ctx" +import { useSearchPageContext } from "./ctx" +import { SearchTabBar } from "./SearchTabBar" -export const SearchHeader = () => { +export const SearchHeader: FC<{ + animatedX: Animated.Value + onLayout: (e: LayoutChangeEvent) => void +}> = ({ animatedX, onLayout }) => { const frame = useSafeAreaFrame() const insets = useSafeAreaInsets() const headerHeight = getDefaultHeaderHeight(frame, false, insets.top) return ( - + + ) } export const DiscoverHeader = () => { + return +} +const DiscoverHeaderImpl = () => { const frame = useSafeAreaFrame() const insets = useSafeAreaInsets() const headerHeight = getDefaultHeaderHeight(frame, false, insets.top) @@ -76,34 +89,32 @@ const PlaceholerSearchBar = () => { } const ComposeSearchBar = () => { - const { searchFocusedAtom, searchValueAtom } = useDiscoverPageContext() - const [isFocused, setIsFocused] = useAtom(searchFocusedAtom) + const { searchFocusedAtom, searchValueAtom } = useSearchPageContext() + const setIsFocused = useSetAtom(searchFocusedAtom) const setSearchValue = useSetAtom(searchValueAtom) return ( <> - {isFocused && ( - { - setIsFocused(false) - setSearchValue("") - - if (router.canGoBack()) { - router.back() - } - }} - > - Cancel - - )} + + { + setIsFocused(false) + setSearchValue("") + + if (router.canGoBack()) { + router.back() + } + }} + > + Cancel + ) } const SearchInput = () => { - const { colors } = useTheme() - const { searchFocusedAtom, searchValueAtom } = useDiscoverPageContext() + const { searchFocusedAtom, searchValueAtom } = useSearchPageContext() const [isFocused, setIsFocused] = useAtom(searchFocusedAtom) const placeholderTextColor = useColor("placeholderText") const searchValue = useAtomValue(searchValueAtom) @@ -114,7 +125,9 @@ const SearchInput = () => { const skeletonTranslateXValue = useAnimatedValue(0) const placeholderOpacityValue = useAnimatedValue(1) - const focusOrHasValue = isFocused || searchValue + const [tempSearchValue, setTempSearchValue] = useState(searchValue) + + const focusOrHasValue = isFocused || searchValue || tempSearchValue useEffect(() => { if (focusOrHasValue) { @@ -170,7 +183,7 @@ const SearchInput = () => { }, [isFocused]) return ( - + {focusOrHasValue && ( { className="absolute inset-y-0 left-3 flex flex-row items-center justify-center" > - {!searchValue && ( + {!searchValue && !tempSearchValue && ( Search @@ -190,13 +203,20 @@ const SearchInput = () => { enterKeyHint="search" autoFocus={isFocused} ref={inputRef} - value={searchValue} + onSubmitEditing={() => { + setSearchValue(tempSearchValue) + setTempSearchValue("") + }} + defaultValue={searchValue} cursorColor={accentColor} selectionColor={accentColor} style={styles.searchInput} + className="text-text" onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} - onChangeText={(text) => setSearchValue(text)} + onChangeText={(text) => { + setTempSearchValue(text) + }} /> { const styles = StyleSheet.create({ header: { flex: 1, - alignItems: "center", marginTop: -3, flexDirection: "row", @@ -226,15 +245,15 @@ const styles = StyleSheet.create({ marginHorizontal: 16, position: "relative", }, + searchbar: { flex: 1, display: "flex", flexDirection: "row", alignItems: "center", justifyContent: "center", - borderRadius: 50, - height: "100%", + height: 32, position: "relative", }, searchInput: { diff --git a/apps/mobile/src/modules/feed/view-selector.tsx b/apps/mobile/src/modules/feed/view-selector.tsx new file mode 100644 index 0000000000..7c5f6858e3 --- /dev/null +++ b/apps/mobile/src/modules/feed/view-selector.tsx @@ -0,0 +1,38 @@ +import type { FeedViewType } from "@follow/constants" +import { cn } from "@follow/utils" +import { Text, TouchableOpacity, View } from "react-native" + +import { Grid } from "@/src/components/ui/grid" +import { views } from "@/src/constants/views" + +interface Props { + value: FeedViewType + onChange: (value: FeedViewType) => void + + className?: string +} + +export const FeedViewSelector = ({ value, onChange, className }: Props) => { + return ( + + {views.map((view) => { + const isSelected = +value === +view.view + return ( + onChange(view.view)}> + + + + {view.name} + + + + ) + })} + + ) +} diff --git a/apps/mobile/src/modules/login/email.tsx b/apps/mobile/src/modules/login/email.tsx index 7b44837959..ed567994e5 100644 --- a/apps/mobile/src/modules/login/email.tsx +++ b/apps/mobile/src/modules/login/email.tsx @@ -54,26 +54,35 @@ export function EmailLogin() { return ( - - + + + Account + + + + + Password + + + ) : ( - Continue with Email + Continue )} diff --git a/apps/mobile/src/modules/subscription/CategoryGrouped.tsx b/apps/mobile/src/modules/subscription/CategoryGrouped.tsx new file mode 100644 index 0000000000..c3b715e1e9 --- /dev/null +++ b/apps/mobile/src/modules/subscription/CategoryGrouped.tsx @@ -0,0 +1,69 @@ +import { memo, useState } from "react" +import { Text, TouchableOpacity, View } from "react-native" +import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated" + +import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" +import { MingcuteRightLine } from "@/src/icons/mingcute_right_line" +import { useUnreadCounts } from "@/src/store/unread/hooks" + +import { SubscriptionFeedCategoryContextMenu } from "../context-menu/feeds" +import { GroupedContext } from "./ctx" +import { UnGroupedList } from "./UnGroupedList" + +// const CategoryList: FC<{ +// grouped: Record +// }> = ({ grouped }) => { +// const sortedGrouped = useSortedGroupedSubscription(grouped, "alphabet") +// return sortedGrouped.map(({ category, subscriptionIds }) => { +// return +// }) +// } +export const CategoryGrouped = memo( + ({ category, subscriptionIds }: { category: string; subscriptionIds: string[] }) => { + const unreadCounts = useUnreadCounts(subscriptionIds) + const [expanded, setExpanded] = useState(false) + const rotateSharedValue = useSharedValue(0) + const rotateStyle = useAnimatedStyle(() => { + return { + transform: [{ rotate: `${rotateSharedValue.value}deg` }], + } + }, [rotateSharedValue]) + return ( + <> + + { + // TODO navigate to category + }} + className="border-item-pressed h-12 flex-row items-center border-b px-3" + > + { + rotateSharedValue.value = withSpring(expanded ? 0 : 90, {}) + setExpanded(!expanded) + }} + className="size-5 flex-row items-center justify-center" + > + + + + + {category} + {!!unreadCounts && ( + {unreadCounts} + )} + + + + {expanded && ( + + + + + + )} + + ) + }, +) diff --git a/apps/mobile/src/modules/subscription/SubscriptionLists.tsx b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx new file mode 100644 index 0000000000..a353c8620d --- /dev/null +++ b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx @@ -0,0 +1,219 @@ +import { FeedViewType } from "@follow/constants" +import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs" +import { useHeaderHeight } from "@react-navigation/elements" +import { useAtom } from "jotai" +import { memo, useEffect, useMemo, useRef, useState } from "react" +import { RefreshControl, StyleSheet, Text, View } from "react-native" +import PagerView from "react-native-pager-view" +import ReAnimated, { LinearTransition } from "react-native-reanimated" +import { useSafeAreaInsets } from "react-native-safe-area-context" +import { useEventCallback } from "usehooks-ts" + +import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" +import { StarCuteFiIcon } from "@/src/icons/star_cute_fi" +import { + useGroupedSubscription, + useInboxSubscription, + useListSubscription, + usePrefetchSubscription, + useSortedGroupedSubscription, + useSortedListSubscription, + useSortedUngroupedSubscription, +} from "@/src/store/subscription/hooks" +import { subscriptionSyncService } from "@/src/store/subscription/store" + +import { useFeedListSortMethod, useFeedListSortOrder, viewAtom } from "./atoms" +import { CategoryGrouped } from "./CategoryGrouped" +import { ViewTabHeight } from "./constants" +import { useViewPageCurrentView, ViewPageCurrentViewProvider } from "./ctx" +import { InboxItem } from "./items/InboxItem" +import { ListSubscriptionItem } from "./items/ListSubscriptionItem" +import { SubscriptionItem } from "./items/SubscriptionItem" + +export const SubscriptionLists = memo(() => { + const [currentView, setCurrentView] = useAtom(viewAtom) + + const pagerRef = useRef(null) + + useEffect(() => { + pagerRef.current?.setPage(currentView) + }, [currentView]) + + return ( + { + setCurrentView(nativeEvent.position) + }} + scrollEnabled + style={style.flex} + initialPage={0} + ref={pagerRef} + offscreenPageLimit={3} + > + {[ + FeedViewType.Articles, + FeedViewType.SocialMedia, + FeedViewType.Pictures, + FeedViewType.Videos, + FeedViewType.Audios, + FeedViewType.Notifications, + ].map((view) => { + return ( + + + + ) + })} + + ) +}) +const keyExtractor = (item: string | { category: string; subscriptionIds: string[] }) => { + if (typeof item === "string") { + return item + } + return item.category +} +const SubscriptionList = ({ view }: { view: FeedViewType }) => { + const headerHeight = useHeaderHeight() + const insets = useSafeAreaInsets() + const tabHeight = useBottomTabBarHeight() + + usePrefetchSubscription(view) + const { grouped, unGrouped } = useGroupedSubscription(view) + + const sortBy = useFeedListSortMethod() + const sortOrder = useFeedListSortOrder() + const sortedGrouped = useSortedGroupedSubscription(grouped, sortBy, sortOrder) + const sortedUnGrouped = useSortedUngroupedSubscription(unGrouped, sortBy, sortOrder) + const data = useMemo( + () => [...sortedGrouped, ...sortedUnGrouped], + [sortedGrouped, sortedUnGrouped], + ) + + const [refreshing, setRefreshing] = useState(false) + const onRefresh = useEventCallback(() => { + return subscriptionSyncService.fetch(view) + }) + + const offsetTop = headerHeight - insets.top + ViewTabHeight * 2 + 23 + + return ( + { + setRefreshing(true) + onRefresh().finally(() => { + setRefreshing(false) + }) + }} + refreshing={refreshing} + /> + } + contentInsetAdjustmentBehavior="automatic" + scrollIndicatorInsets={{ + bottom: tabHeight - insets.bottom, + top: offsetTop, + }} + contentContainerStyle={{ + paddingTop: offsetTop, + paddingBottom: tabHeight, + }} + data={data} + ListHeaderComponent={ListHeaderComponent} + renderItem={ItemRender} + keyExtractor={keyExtractor} + itemLayoutAnimation={LinearTransition} + extraData={{ + total: data.length, + }} + /> + ) +} + +const ItemRender = ({ + item, + index, + extraData, +}: { + item: string | { category: string; subscriptionIds: string[] } + index: number + extraData?: { + total: number + } +}) => { + if (typeof item === "string") { + return ( + + ) + } + const { category, subscriptionIds } = item + + return +} + +const ListHeaderComponent = () => { + const view = useViewPageCurrentView() + + return ( + <> + + {view === FeedViewType.Articles && } + + Feeds + + ) +} + +const InboxList = () => { + const inboxes = useInboxSubscription(FeedViewType.Articles) + if (inboxes.length === 0) return null + return ( + + Inboxes + {inboxes.map((id) => { + return + })} + + ) +} + +const StarItem = () => { + return ( + { + // TODO + }} + className="mt-4 h-12 w-full flex-row items-center px-3" + > + + Collections + + ) +} + +const ListList = () => { + const currentView = useViewPageCurrentView() + const listIds = useListSubscription(currentView) + const sortedListIds = useSortedListSubscription(listIds, "alphabet") + if (sortedListIds.length === 0) return null + return ( + + Lists + {sortedListIds.map((id) => { + return + })} + + ) +} + +const style = StyleSheet.create({ + flex: { + flex: 1, + }, +}) diff --git a/apps/mobile/src/modules/subscription/UnGroupedList.tsx b/apps/mobile/src/modules/subscription/UnGroupedList.tsx new file mode 100644 index 0000000000..129a7f2949 --- /dev/null +++ b/apps/mobile/src/modules/subscription/UnGroupedList.tsx @@ -0,0 +1,25 @@ +import type { FC } from "react" + +import { useSortedUngroupedSubscription } from "@/src/store/subscription/hooks" + +import { useFeedListSortMethod, useFeedListSortOrder } from "./atoms" +import { SubscriptionItem } from "./items/SubscriptionItem" + +export const UnGroupedList: FC<{ + subscriptionIds: string[] +}> = ({ subscriptionIds }) => { + const sortBy = useFeedListSortMethod() + const sortOrder = useFeedListSortOrder() + const sortedSubscriptionIds = useSortedUngroupedSubscription(subscriptionIds, sortBy, sortOrder) + const lastSubscriptionId = sortedSubscriptionIds.at(-1) + + return sortedSubscriptionIds.map((id) => { + return ( + + ) + }) +} diff --git a/apps/mobile/src/modules/subscription/ViewTab.tsx b/apps/mobile/src/modules/subscription/ViewTab.tsx index 173d1d9426..dab6189f6b 100644 --- a/apps/mobile/src/modules/subscription/ViewTab.tsx +++ b/apps/mobile/src/modules/subscription/ViewTab.tsx @@ -8,13 +8,13 @@ import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-na import { ThemedBlurView } from "@/src/components/common/ThemedBlurView" import { ContextMenu } from "@/src/components/ui/context-menu" -import { bottomViewTabHeight } from "@/src/constants/ui" import type { ViewDefinition } from "@/src/constants/views" import { views } from "@/src/constants/views" import { useUnreadCountByView } from "@/src/store/unread/hooks" import { unreadSyncService } from "@/src/store/unread/store" import { offsetAtom, setCurrentView, viewAtom } from "./atoms" +import { ViewTabHeight } from "./constants" const springConfig: WithSpringConfig = { damping: 20, @@ -65,10 +65,10 @@ export const ViewTab = () => { return ( - + { scrollOffsetX.current = event.nativeEvent.contentOffset.x diff --git a/apps/mobile/src/modules/subscription/constants.ts b/apps/mobile/src/modules/subscription/constants.ts new file mode 100644 index 0000000000..73a0d731ce --- /dev/null +++ b/apps/mobile/src/modules/subscription/constants.ts @@ -0,0 +1 @@ +export const ViewTabHeight = 35 diff --git a/apps/mobile/src/modules/subscription/ctx.ts b/apps/mobile/src/modules/subscription/ctx.ts index 70d178c998..7e7cb1a5b7 100644 --- a/apps/mobile/src/modules/subscription/ctx.ts +++ b/apps/mobile/src/modules/subscription/ctx.ts @@ -4,3 +4,4 @@ import { createContext, useContext } from "react" const ViewPageCurrentViewContext = createContext(null!) export const ViewPageCurrentViewProvider = ViewPageCurrentViewContext.Provider export const useViewPageCurrentView = () => useContext(ViewPageCurrentViewContext) +export const GroupedContext = createContext(null) diff --git a/apps/mobile/src/modules/subscription/items/InboxItem.tsx b/apps/mobile/src/modules/subscription/items/InboxItem.tsx new file mode 100644 index 0000000000..625ad1b1eb --- /dev/null +++ b/apps/mobile/src/modules/subscription/items/InboxItem.tsx @@ -0,0 +1,35 @@ +import { useColorScheme } from "nativewind" +import { memo } from "react" +import { Text, View } from "react-native" +import Animated, { FadeOutUp } from "react-native-reanimated" + +import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" +import { InboxCuteFiIcon } from "@/src/icons/inbox_cute_fi" +import { useSubscription } from "@/src/store/subscription/hooks" +import { getInboxStoreId } from "@/src/store/subscription/utils" +import { useUnreadCount } from "@/src/store/unread/hooks" + +export const InboxItem = memo(({ id }: { id: string }) => { + const subscription = useSubscription(getInboxStoreId(id)) + const unreadCount = useUnreadCount(id) + const { colorScheme } = useColorScheme() + if (!subscription) return null + return ( + + + + + + + {subscription.title} + {!!unreadCount && ( + {unreadCount} + )} + + + ) +}) diff --git a/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx b/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx new file mode 100644 index 0000000000..5a77b06f0d --- /dev/null +++ b/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx @@ -0,0 +1,33 @@ +import { memo } from "react" +import { Image, Text, View } from "react-native" +import Animated, { FadeOutUp } from "react-native-reanimated" + +import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon" +import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" +import { useList } from "@/src/store/list/hooks" +import { useUnreadCount } from "@/src/store/unread/hooks" + +import { SubscriptionListItemContextMenu } from "../../context-menu/lists" + +export const ListSubscriptionItem = memo(({ id }: { id: string; className?: string }) => { + const list = useList(id) + const unreadCount = useUnreadCount(id) + if (!list) return null + return ( + + + + + {!!list.image && ( + + )} + {!list.image && } + + + {list.title} + {!!unreadCount && } + + + + ) +}) diff --git a/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx new file mode 100644 index 0000000000..fba7c030dc --- /dev/null +++ b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx @@ -0,0 +1,100 @@ +import { cn } from "@follow/utils" +import { router } from "expo-router" +import { memo, useContext } from "react" +import { Text, View } from "react-native" +import Animated, { FadeOutUp } from "react-native-reanimated" + +import { FeedIcon } from "@/src/components/ui/icon/feed-icon" +import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" +import { useFeed } from "@/src/store/feed/hooks" +import { useSubscription } from "@/src/store/subscription/hooks" +import { useUnreadCount } from "@/src/store/unread/hooks" + +import { SubscriptionFeedItemContextMenu } from "../../context-menu/feeds" +import { GroupedContext, useViewPageCurrentView } from "../ctx" + +// const renderRightActions = () => { +// return ( +// +// { +// // TODO: Handle unsubscribe +// }} +// > +// Unsubscribe +// +// +// ) +// } +// const renderLeftActions = () => { +// return ( +// +// { +// // TODO: Handle unsubscribe +// }} +// > +// Read +// +// +// ) +// } +// let prevOpenedRow: SwipeableMethods | null = null +export const SubscriptionItem = memo(({ id, className }: { id: string; className?: string }) => { + const subscription = useSubscription(id) + const unreadCount = useUnreadCount(id) + const feed = useFeed(id) + const inGrouped = !!useContext(GroupedContext) + const view = useViewPageCurrentView() + // const swipeableRef: SwipeableRef = useRef(null) + if (!subscription || !feed) return null + + return ( + // FIXME: Here leads to very serious performance issues, the frame rate of both the UI and JS threads has dropped + // { + // if (prevOpenedRow && prevOpenedRow !== swipeableRef.current) { + // prevOpenedRow.close() + // } + // prevOpenedRow = swipeableRef.current + // }} + // > + // + + + { + router.push({ + pathname: `/feeds/[feedId]`, + params: { + feedId: id, + }, + }) + }} + > + + + + {subscription.title || feed.title} + {!!unreadCount && ( + {unreadCount} + )} + + + + // + ) +}) diff --git a/apps/mobile/src/modules/subscription/list.tsx b/apps/mobile/src/modules/subscription/list.tsx deleted file mode 100644 index 55b8723648..0000000000 --- a/apps/mobile/src/modules/subscription/list.tsx +++ /dev/null @@ -1,459 +0,0 @@ -import { FeedViewType } from "@follow/constants" -import { cn } from "@follow/utils" -import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs" -import { useHeaderHeight } from "@react-navigation/elements" -import { FlashList } from "@shopify/flash-list" -import { router } from "expo-router" -import { useAtom } from "jotai" -import { useColorScheme } from "nativewind" -import type { FC } from "react" -import { createContext, memo, useContext, useEffect, useMemo, useRef } from "react" -import { - Animated, - Easing, - Image, - StyleSheet, - Text, - TouchableOpacity, - useAnimatedValue, - View, -} from "react-native" -import PagerView from "react-native-pager-view" -import { useSharedValue } from "react-native-reanimated" -import { useSafeAreaInsets } from "react-native-safe-area-context" - -import { AccordionItem } from "@/src/components/ui/accordion" -import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon" -import { FeedIcon } from "@/src/components/ui/icon/feed-icon" -import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" -import { bottomViewTabHeight } from "@/src/constants/ui" -import { InboxCuteFiIcon } from "@/src/icons/inbox_cute_fi" -import { MingcuteRightLine } from "@/src/icons/mingcute_right_line" -import { StarCuteFiIcon } from "@/src/icons/star_cute_fi" -import { useFeed } from "@/src/store/feed/hooks" -import { useList } from "@/src/store/list/hooks" -import { - useGroupedSubscription, - useInboxSubscription, - useListSubscription, - usePrefetchSubscription, - useSortedGroupedSubscription, - useSortedListSubscription, - useSortedUngroupedSubscription, - useSubscription, -} from "@/src/store/subscription/hooks" -import { getInboxStoreId } from "@/src/store/subscription/utils" -import { useUnreadCount, useUnreadCounts } from "@/src/store/unread/hooks" - -import { - SubscriptionFeedCategoryContextMenu, - SubscriptionFeedItemContextMenu, -} from "../context-menu/feeds" -import { SubscriptionListItemContextMenu } from "../context-menu/lists" -import { useFeedListSortMethod, useFeedListSortOrder, viewAtom } from "./atoms" -import { useViewPageCurrentView, ViewPageCurrentViewProvider } from "./ctx" - -export const SubscriptionList = memo(() => { - const [currentView, setCurrentView] = useAtom(viewAtom) - - const pagerRef = useRef(null) - - useEffect(() => { - pagerRef.current?.setPage(currentView) - }, [currentView]) - - return ( - <> - - - { - setCurrentView(nativeEvent.position) - }} - scrollEnabled - style={style.flex} - initialPage={0} - ref={pagerRef} - offscreenPageLimit={3} - > - {[ - FeedViewType.Articles, - FeedViewType.SocialMedia, - FeedViewType.Pictures, - FeedViewType.Videos, - FeedViewType.Audios, - FeedViewType.Notifications, - ].map((view) => { - return ( - - - - ) - })} - - - ) -}) -const RecycleList = ({ view }: { view: FeedViewType }) => { - const headerHeight = useHeaderHeight() - const insets = useSafeAreaInsets() - const tabHeight = useBottomTabBarHeight() - - usePrefetchSubscription(view) - const { grouped, unGrouped } = useGroupedSubscription(view) - - const sortBy = useFeedListSortMethod() - const sortOrder = useFeedListSortOrder() - const sortedGrouped = useSortedGroupedSubscription(grouped, sortBy, sortOrder) - const sortedUnGrouped = useSortedUngroupedSubscription(unGrouped, sortBy, sortOrder) - const data = useMemo( - () => [...sortedGrouped, ...sortedUnGrouped], - [sortedGrouped, sortedUnGrouped], - ) - - return ( - - ) -} - -const ItemRender = ({ - item, - index, - extraData, -}: { - item: string | { category: string; subscriptionIds: string[] } - index: number - extraData?: { - total: number - } -}) => { - if (typeof item === "string") { - return ( - - ) - } - const { category, subscriptionIds } = item - - return -} - -const ListHeaderComponent = () => { - const view = useViewPageCurrentView() - - return ( - <> - - {view === FeedViewType.Articles && } - - Feeds - - ) -} - -// This not used FlashList -// const ViewPage = memo(({ view }: { view: FeedViewType }) => { -// const { grouped, unGrouped } = useGroupedSubscription(view) -// usePrefetchSubscription(view) - -// return ( -// -// -// {view === FeedViewType.Articles && } -// -// Feeds -// -// -// -// ) -// }) - -const InboxList = () => { - const inboxes = useInboxSubscription(FeedViewType.Articles) - if (inboxes.length === 0) return null - return ( - - Inboxes - {inboxes.map((id) => { - return - })} - - ) -} - -const InboxItem = memo(({ id }: { id: string }) => { - const subscription = useSubscription(getInboxStoreId(id)) - const unreadCount = useUnreadCount(id) - const { colorScheme } = useColorScheme() - if (!subscription) return null - return ( - - - - - - {subscription.title} - {!!unreadCount && {unreadCount}} - - ) -}) - -const StarItem = () => { - return ( - { - // TODO - }} - className="mt-4 h-12 w-full flex-row items-center px-3" - > - - Collections - - ) -} - -const ListList = () => { - const currentView = useViewPageCurrentView() - const listIds = useListSubscription(currentView) - const sortedListIds = useSortedListSubscription(listIds, "alphabet") - if (sortedListIds.length === 0) return null - return ( - - Lists - {sortedListIds.map((id) => { - return - })} - - ) -} - -const ListSubscriptionItem = memo(({ id }: { id: string; className?: string }) => { - const list = useList(id) - const unreadCount = useUnreadCount(id) - if (!list) return null - return ( - - - - {!!list.image && ( - - )} - {!list.image && } - - - {list.title} - {!!unreadCount && } - - - ) -}) - -const UnGroupedList: FC<{ - subscriptionIds: string[] -}> = ({ subscriptionIds }) => { - const sortBy = useFeedListSortMethod() - const sortOrder = useFeedListSortOrder() - const sortedSubscriptionIds = useSortedUngroupedSubscription(subscriptionIds, sortBy, sortOrder) - const lastSubscriptionId = sortedSubscriptionIds.at(-1) - - return sortedSubscriptionIds.map((id) => { - return ( - - ) - }) -} - -const GroupedContext = createContext(null) - -const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) - -// const CategoryList: FC<{ -// grouped: Record -// }> = ({ grouped }) => { -// const sortedGrouped = useSortedGroupedSubscription(grouped, "alphabet") - -// return sortedGrouped.map(({ category, subscriptionIds }) => { -// return -// }) -// } - -const CategoryGrouped = memo( - ({ category, subscriptionIds }: { category: string; subscriptionIds: string[] }) => { - const unreadCounts = useUnreadCounts(subscriptionIds) - const isExpanded = useSharedValue(false) - const rotateValue = useAnimatedValue(1) - return ( - - { - // TODO navigate to category - }} - className="border-item-pressed h-12 flex-row items-center border-b px-3" - > - { - Animated.timing(rotateValue, { - toValue: isExpanded.value ? 1 : 0, - easing: Easing.linear, - - useNativeDriver: true, - }).start() - isExpanded.value = !isExpanded.value - }} - style={[ - { - transform: [ - { - rotate: rotateValue.interpolate({ - inputRange: [0, 1], - outputRange: ["90deg", "0deg"], - }), - }, - ], - }, - style.accordionIcon, - ]} - > - - - {category} - {!!unreadCounts && ( - {unreadCounts} - )} - - - - - - - - ) - }, -) - -// const renderRightActions = () => { -// return ( -// -// { -// // TODO: Handle unsubscribe -// }} -// > -// Unsubscribe -// -// -// ) -// } - -// const renderLeftActions = () => { -// return ( -// -// { -// // TODO: Handle unsubscribe -// }} -// > -// Read -// -// -// ) -// } - -// let prevOpenedRow: SwipeableMethods | null = null -const SubscriptionItem = memo(({ id, className }: { id: string; className?: string }) => { - const subscription = useSubscription(id) - const unreadCount = useUnreadCount(id) - const feed = useFeed(id) - const inGrouped = !!useContext(GroupedContext) - const view = useViewPageCurrentView() - // const swipeableRef: SwipeableRef = useRef(null) - - if (!subscription || !feed) return null - - return ( - // FIXME: Here leads to very serious performance issues, the frame rate of both the UI and JS threads has dropped - // { - // if (prevOpenedRow && prevOpenedRow !== swipeableRef.current) { - // prevOpenedRow.close() - // } - // prevOpenedRow = swipeableRef.current - // }} - // > - - { - router.push({ - pathname: `/feeds/[feedId]`, - params: { - feedId: id, - }, - }) - }} - > - - - - {subscription.title || feed.title} - {!!unreadCount && ( - {unreadCount} - )} - - - // - ) -}) - -const style = StyleSheet.create({ - flex: { - flex: 1, - }, - accordionIcon: { - height: 20, - width: 20, - alignItems: "center", - justifyContent: "center", - }, -}) diff --git a/apps/mobile/src/screens/(headless)/debug.tsx b/apps/mobile/src/screens/(headless)/debug.tsx index 41da66a11b..13cfca3c14 100644 --- a/apps/mobile/src/screens/(headless)/debug.tsx +++ b/apps/mobile/src/screens/(headless)/debug.tsx @@ -1,6 +1,9 @@ +import { sleep } from "@follow/utils" import * as Clipboard from "expo-clipboard" import * as FileSystem from "expo-file-system" import { Sitemap } from "expo-router/build/views/Sitemap" +import type { FC } from "react" +import * as React from "react" import { useRef, useState } from "react" import { Alert, @@ -16,110 +19,135 @@ import { useSafeAreaInsets } from "react-native-safe-area-context" import { getDbPath } from "@/src/database" import { clearSessionToken, getSessionToken, setSessionToken } from "@/src/lib/cookie" +import { loading } from "@/src/lib/loading" +import { toast } from "@/src/lib/toast" -export default function DebugPanel() { - const insets = useSafeAreaInsets() - // const isExpanded = useSharedValue(false) - return ( - - Users - - {/*