From 60e2dd319825181df8070a885d7e33a192e42a2f Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 12 Mar 2026 17:37:56 +0800 Subject: [PATCH 01/39] release(desktop): release v1.4.0 --- apps/desktop/changelog/1.4.0.md | 23 +++++++++++++++++++++++ apps/desktop/changelog/next.md | 14 +------------- apps/desktop/package.json | 4 ++-- 3 files changed, 26 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/changelog/1.4.0.md diff --git a/apps/desktop/changelog/1.4.0.md b/apps/desktop/changelog/1.4.0.md new file mode 100644 index 00000000000..c0c5ad22f6e --- /dev/null +++ b/apps/desktop/changelog/1.4.0.md @@ -0,0 +1,23 @@ +# What's new in v1.4.0 + +## Shiny new things + +- Added the Folo CLI with browser-based sign-in +- Added desktop-to-CLI session sync and install management +- Added in-app review prompts + +## Improvements + +- Improved account and auth flows to support CLI sign-in and session recovery +- Added a dedicated CLI settings page and install controls +- Expanded desktop end-to-end coverage for auth and user flows + +## No longer broken + +- Removed the unwanted text selection toolbar +- Fixed AI onboarding asset loading by switching the spline asset domain +- Hardened setting sync authentication lifecycle + +## Thanks + +Special thanks to volunteer contributors for their valuable contributions diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 417be58557f..8f5eac449a4 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -2,22 +2,10 @@ ## Shiny new things -- Added the Folo CLI with browser-based sign-in -- Added desktop-to-CLI session sync and install management -- Added in-app review prompts - ## Improvements -- Improved account and auth flows to support CLI sign-in and session recovery -- Added a dedicated CLI settings page and install controls -- Expanded desktop end-to-end coverage for auth and user flows - ## No longer broken -- Removed the unwanted text selection toolbar -- Fixed AI onboarding asset loading by switching the spline asset domain -- Hardened setting sync authentication lifecycle - ## Thanks -Special thanks to volunteer contributors for their valuable contributions +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cdd006ddeaa..468c9ae3c49 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "Folo", "type": "module", - "version": "1.3.1", + "version": "1.4.0", "private": true, "description": "Follow everything in one place", "author": "Folo Team", @@ -95,5 +95,5 @@ "vite-tsconfig-paths": "6.1.1" }, "productName": "Folo", - "mainHash": "906a0b82e00829115764f36a38132f3774120c4bea764e3b1be8949f447ccb06" + "mainHash": "0c464fca7c98fd4b42abba743abb1cd590e216043987acf4f6d1e10392ce0e57" } From 94f90873d587e8fdff6259dd65a845e638d11c0a Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 12 Mar 2026 20:35:26 +0800 Subject: [PATCH 02/39] fix(desktop): restore renderer api requests --- .../layer/main/src/ipc/services/auth.ts | 44 --------------- .../layer/renderer/src/lib/api-client.ts | 56 ++----------------- apps/desktop/layer/renderer/src/main.tsx | 32 +---------- 3 files changed, 6 insertions(+), 126 deletions(-) diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index aae7c1813cc..fb677877469 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -5,7 +5,6 @@ import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app" -import { apiClient } from "~/lib/api-client" import { WindowManager } from "~/manager/window" import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../../lib/cli-session-sync" @@ -135,49 +134,6 @@ export class AuthService extends IpcService { await this.clearSessionToken() } - @IpcMethod() - async getSession(_context: IpcContext) { - return apiClient.auth.getSession() - } - - @IpcMethod() - async getSessionByToken(_context: IpcContext, token: string) { - const response = await fetch(`${env.VITE_API_URL}/better-auth/get-session`, { - headers: { - ...createDesktopAPIHeaders({ version: PKG.version }), - Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`, - }, - }) - - return response.json().catch(async () => ({ message: await response.text() })) - } - - @IpcMethod() - async request( - _context: IpcContext, - payload: { - input: string - init?: { - method?: string - headers?: Record - body?: string - } - }, - ) { - const response = await fetch(payload.input, { - method: payload.init?.method, - headers: payload.init?.headers, - body: payload.init?.body, - cache: "no-store", - }) - - return { - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - body: await response.text(), - } - } - @IpcMethod() async signInWithCredential( _context: IpcContext, diff --git a/apps/desktop/layer/renderer/src/lib/api-client.ts b/apps/desktop/layer/renderer/src/lib/api-client.ts index aed9d5d0f63..25102bef8e6 100644 --- a/apps/desktop/layer/renderer/src/lib/api-client.ts +++ b/apps/desktop/layer/renderer/src/lib/api-client.ts @@ -1,4 +1,3 @@ -import { IN_ELECTRON } from "@follow/shared/constants" import { env } from "@follow/shared/env.desktop" import { whoami } from "@follow/store/user/getters" import { userActions } from "@follow/store/user/store" @@ -9,53 +8,17 @@ import PKG from "@pkg" import { NetworkStatus, setApiStatus } from "~/atoms/network" import { setLoginModalShow } from "~/atoms/user" -import { ipcServices } from "./client" -import { getAuthSessionToken, getClientId, getSessionId } from "./client-session" - -const electronFetch = async (input: string | URL | Request, options: RequestInit = {}) => { - const authService = ipcServices?.auth as - | (NonNullable["auth"] & { - request?: (payload: { - input: string - init?: { method?: string; headers?: Record; body?: string } - }) => Promise<{ status: number; headers: Record; body: string }> - }) - | undefined - - if (!authService?.request) { - return fetch(input.toString(), { - ...options, - cache: "no-store", - }) - } - - const headers = new Headers(options.headers) - const response = await authService.request({ - input: input.toString(), - init: { - method: options.method, - headers: Object.fromEntries(headers.entries()), - body: typeof options.body === "string" ? options.body : undefined, - }, - }) - - return new Response(response.body, { - status: response.status, - headers: response.headers, - }) -} +import { getClientId, getSessionId } from "./client-session" export const followClient = new FollowClient({ credentials: "include", timeout: 30000, baseURL: env.VITE_API_URL, fetch: async (input, options = {}) => - IN_ELECTRON - ? electronFetch(input, options) - : fetch(input.toString(), { - ...options, - cache: "no-store", - }), + fetch(input.toString(), { + ...options, + cache: "no-store", + }), }) export const followApi = followClient.api @@ -65,11 +28,6 @@ followClient.addRequestInterceptor(async (ctx) => { header["X-Client-Id"] = getClientId() header["X-Session-Id"] = getSessionId() - const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null - if (authSessionToken) { - header.Cookie = `__Secure-better-auth.session_token=${authSessionToken}; better-auth.session_token=${authSessionToken}` - } - const apiHeader = createDesktopAPIHeaders({ version: PKG.version }) options.headers = { @@ -107,10 +65,6 @@ followClient.addResponseInterceptor(async ({ response }) => { return response } - if (IN_ELECTRON && getAuthSessionToken()) { - return response - } - // Or we can present LoginModal here. // router.navigate("/login") // If any response status is 401, we can set auth fail. Maybe some bug, but if navigate to login page, had same issues diff --git a/apps/desktop/layer/renderer/src/main.tsx b/apps/desktop/layer/renderer/src/main.tsx index d22eb2127bd..e09c49b0cd7 100644 --- a/apps/desktop/layer/renderer/src/main.tsx +++ b/apps/desktop/layer/renderer/src/main.tsx @@ -11,8 +11,6 @@ import ReactDOM from "react-dom/client" import { RouterProvider } from "react-router/dom" import { authClient } from "~/lib/auth" -import { ipcServices } from "~/lib/client" -import { getAuthSessionToken } from "~/lib/client-session" import { setAppIsReady } from "./atoms/app" import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "./constants" @@ -24,35 +22,7 @@ import { router } from "./router" authClientContext.provide(authClient) queryClientContext.provide(queryClient) - -const providedApi = IN_ELECTRON - ? { - ...followApi, - auth: { - ...followApi.auth, - getSession: async (...args: Parameters) => { - const authService = ipcServices?.auth as - | (typeof followApi.auth & { - getSession?: () => ReturnType - getSessionByToken?: (token: string) => ReturnType - }) - | undefined - const authSessionToken = getAuthSessionToken() - const session = authSessionToken - ? await authService?.getSessionByToken?.(authSessionToken) - : await authService?.getSession?.() - - if (session) { - return session - } - - return followApi.auth.getSession(...args) - }, - }, - } - : followApi - -apiContext.provide(providedApi) +apiContext.provide(followApi) initializeApp().finally(() => { import("./push-notification").then(({ registerWebPushNotifications }) => { From 96b6eac16abca8b6af57b53822f9bd935dc168d5 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 12 Mar 2026 20:36:34 +0800 Subject: [PATCH 03/39] fix(desktop): hide cli settings tab for release --- .../layer/renderer/src/pages/settings/(settings)/cli.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx index a64966178f8..cf7efab9fd7 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx @@ -6,12 +6,13 @@ import { defineSettingPageData } from "~/modules/settings/utils" const iconName = "i-mgc-terminal-cute-re" const priority = (1000 << 1) + 25 +const CLI_SETTINGS_DISABLED_FOR_THIS_RELEASE = true export const loader = defineSettingPageData({ icon: iconName, name: "titles.cli", priority, - hideIf: () => !IN_ELECTRON, + hideIf: () => CLI_SETTINGS_DISABLED_FOR_THIS_RELEASE || !IN_ELECTRON, }) export function Component() { From ff1cf29d130a6d2a6143613d9c6818cdbf590fd0 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 12 Mar 2026 20:52:07 +0800 Subject: [PATCH 04/39] docs(release): remove cli notes from desktop changelog --- apps/desktop/changelog/1.4.0.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/desktop/changelog/1.4.0.md b/apps/desktop/changelog/1.4.0.md index c0c5ad22f6e..5b4d2febe1e 100644 --- a/apps/desktop/changelog/1.4.0.md +++ b/apps/desktop/changelog/1.4.0.md @@ -2,14 +2,10 @@ ## Shiny new things -- Added the Folo CLI with browser-based sign-in -- Added desktop-to-CLI session sync and install management - Added in-app review prompts ## Improvements -- Improved account and auth flows to support CLI sign-in and session recovery -- Added a dedicated CLI settings page and install controls - Expanded desktop end-to-end coverage for auth and user flows ## No longer broken From df83cf62eaf8b1215dc62074b030dd88b23d3c9b Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 13 Mar 2026 10:14:58 +0800 Subject: [PATCH 05/39] fix(mobile): polish auth and settings ux --- .../mobile/src/modules/context-menu/feeds.tsx | 4 +- .../mobile/src/modules/context-menu/lists.tsx | 4 +- apps/mobile/src/modules/discover/search.tsx | 2 +- apps/mobile/src/modules/feed/FollowFeed.tsx | 10 +- apps/mobile/src/modules/login/index.tsx | 146 +++++++++--------- .../src/modules/settings/SettingsList.tsx | 11 +- .../src/modules/settings/UserHeaderBanner.tsx | 8 +- .../modules/settings/routes/2FASetting.tsx | 13 +- .../src/modules/settings/routes/Account.tsx | 57 ++++--- .../modules/settings/routes/Appearance.tsx | 111 +++++++------ .../modules/settings/routes/EditProfile.tsx | 2 +- .../screens/(modal)/ForgetPasswordScreen.tsx | 99 ++++++------ .../src/screens/(modal)/ProfileScreen.tsx | 4 +- locales/mobile/default/en.json | 11 ++ locales/mobile/default/ja.json | 11 ++ locales/mobile/default/zh-CN.json | 11 ++ locales/settings/en.json | 14 ++ locales/settings/ja.json | 14 ++ locales/settings/zh-CN.json | 14 ++ 19 files changed, 318 insertions(+), 228 deletions(-) diff --git a/apps/mobile/src/modules/context-menu/feeds.tsx b/apps/mobile/src/modules/context-menu/feeds.tsx index e681b185ec8..9cf7767161d 100644 --- a/apps/mobile/src/modules/context-menu/feeds.tsx +++ b/apps/mobile/src/modules/context-menu/feeds.tsx @@ -231,9 +231,9 @@ const generateSubscriptionContextMenu = (navigation: Navigation, id: string) => destructive onSelect={() => { // unsubscribe - Alert.alert("Unsubscribe?", "This will remove the feed from your subscriptions", [ + Alert.alert(t("feed.unfollow.confirm_title"), t("feed.unfollow.confirm_description"), [ { - text: "Cancel", + text: t("words.cancel", { ns: "common" }), style: "cancel", }, { diff --git a/apps/mobile/src/modules/context-menu/lists.tsx b/apps/mobile/src/modules/context-menu/lists.tsx index ed6d172064b..ce4483f2628 100644 --- a/apps/mobile/src/modules/context-menu/lists.tsx +++ b/apps/mobile/src/modules/context-menu/lists.tsx @@ -52,9 +52,9 @@ export const SubscriptionListItemContextMenu: FC< title: t("operation.unfollow"), destructive: true, onSelect: () => { - Alert.alert(t("operation.unfollow"), "Are you sure you want to unsubscribe?", [ + Alert.alert(t("feed.unfollow.confirm_title"), t("feed.unfollow.confirm_description"), [ { - text: "Cancel", + text: t("words.cancel", { ns: "common" }), style: "cancel", }, { diff --git a/apps/mobile/src/modules/discover/search.tsx b/apps/mobile/src/modules/discover/search.tsx index f90869ee7aa..d1e125aa553 100644 --- a/apps/mobile/src/modules/discover/search.tsx +++ b/apps/mobile/src/modules/discover/search.tsx @@ -231,7 +231,7 @@ const SearchInput = () => { allowFontScaling={false} className="text-[16px] font-medium text-accent" > - Cancel + {t("words.cancel")} diff --git a/apps/mobile/src/modules/feed/FollowFeed.tsx b/apps/mobile/src/modules/feed/FollowFeed.tsx index 8bf93b5cd6e..b8ec45b1d3c 100644 --- a/apps/mobile/src/modules/feed/FollowFeed.tsx +++ b/apps/mobile/src/modules/feed/FollowFeed.tsx @@ -128,7 +128,7 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) { } else { await subscriptionSyncService.subscribe(body) } - toast.success(isSubscribed ? "Feed updated" : "Feed followed") + toast.success(t(isSubscribed ? "feed.follow.update_success" : "feed.follow.success")) if (canDismiss) { navigate.dismiss() } else { @@ -144,9 +144,9 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) { const handleUnfollow = () => { if (!subscription?.feedId || isLoading) return - Alert.alert("Unsubscribe?", "This will remove the feed from your subscriptions", [ + Alert.alert(t("feed.unfollow.confirm_title"), t("feed.unfollow.confirm_description"), [ { - text: "Cancel", + text: tCommon("words.cancel"), style: "cancel", }, { @@ -156,7 +156,7 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) { try { setIsLoading(true) await subscriptionSyncService.unsubscribe(subscription.feedId) - toast.success("Feed unfollowed") + toast.success(t("feed.unfollow.success")) if (canDismiss) { navigate.dismiss() } else { @@ -181,7 +181,7 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) { }) }, [isDirty, setScreenOptions]) if (!feed?.id) { - return Feed ({id}) not found + return {t("feed.not_found", { id })} } return ( - - { - KeyboardController.dismiss() - }} - accessible={false} + + + - - + - - {`${isRegister ? t("signin.sign_up_to") : t("signin.sign_in_to")} `} - Folo - - {isEmail ? ( - isRegister ? ( - + + + {`${isRegister ? t("signin.sign_up_to") : t("signin.sign_in_to")} `} + Folo + + {isEmail ? ( + isRegister ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( - setIsEmail(true)} isRegister={isRegister} /> - )} + setIsEmail(true)} isRegister={isRegister} /> + )} + + + + + {isEmail ? ( + setIsEmail(false)} + > + {t("login.back")} + + ) : ( + setIsRegister(!isRegister)}> + + , + }} + /> + + + )} + + - - - - {isEmail ? ( - setIsEmail(false)} - > - {t("login.back")} - - ) : ( - setIsRegister(!isRegister)}> - - , - }} - /> - - - )} - + ) @@ -107,10 +110,7 @@ const TermsCheckBox = () => { ], })) return ( - + ) @@ -118,16 +118,16 @@ const TermsCheckBox = () => { const TermsText = () => { const { t } = useTranslation() return ( - + {t("login.agree_to")} - + Linking.openURL("https://folo.is/terms-of-service")} className="text-secondary-label" > {t("login.terms")} -  &  + / Linking.openURL("https://folo.is/privacy-policy")} className="text-secondary-label" diff --git a/apps/mobile/src/modules/settings/SettingsList.tsx b/apps/mobile/src/modules/settings/SettingsList.tsx index 4c25e1b30f6..821306141a1 100644 --- a/apps/mobile/src/modules/settings/SettingsList.tsx +++ b/apps/mobile/src/modules/settings/SettingsList.tsx @@ -1,6 +1,7 @@ import { UserRole } from "@follow/constants" import { useUserRole, useWhoami } from "@follow/store/user/hooks" import type { StatusConfigs as ServerConfigs } from "@follow-app/client-sdk" +import i18next from "i18next" import type { FC } from "react" import { Fragment, useMemo } from "react" import { useTranslation } from "react-i18next" @@ -203,15 +204,17 @@ const ActionGroupNavigationLinks: GroupNavigationLink[] = [ onPress: () => { Dialog.show({ id: "settings-sign-out-dialog", - title: "Confirm sign out", + title: i18next.t("profile.sign_out.confirm_title", { ns: "settings" }), content: ( - Are you sure you want to sign out? + + {i18next.t("profile.sign_out.confirm_message", { ns: "settings" })} + ), variant: "destructive", - confirmText: "Sign out", - cancelText: "Cancel", + confirmText: i18next.t("titles.sign_out", { ns: "settings" }), + cancelText: i18next.t("words.cancel", { ns: "common" }), onConfirm: async () => { await signOut() }, diff --git a/apps/mobile/src/modules/settings/UserHeaderBanner.tsx b/apps/mobile/src/modules/settings/UserHeaderBanner.tsx index 2a73334641d..a119411ebb9 100644 --- a/apps/mobile/src/modules/settings/UserHeaderBanner.tsx +++ b/apps/mobile/src/modules/settings/UserHeaderBanner.tsx @@ -5,6 +5,7 @@ import { cn, getLuminance } from "@follow/utils" import { LinearGradient } from "expo-linear-gradient" import type { FC } from "react" import { useMemo } from "react" +import { useTranslation } from "react-i18next" import { Linking, Pressable, StyleSheet, View } from "react-native" import type { SharedValue } from "react-native-reanimated" import ReAnimated, { FadeIn, FadeOut, interpolate, useAnimatedStyle } from "react-native-reanimated" @@ -91,6 +92,7 @@ export const UserHeaderBanner = ({ userId?: string showRoleBadge?: boolean }) => { + const { t } = useTranslation() const serverConfigs = useServerConfigs() const bgColor = useColor("systemGroupedBackground") const avatarIconColor = useColor("secondaryLabel") @@ -237,9 +239,9 @@ export const UserHeaderBanner = ({ {user?.name ? ( @@ -287,7 +289,7 @@ export const UserHeaderBanner = ({ testID="settings-sign-in" onPress={() => navigation.presentControllerView(LoginScreen)} > - Sign in to your account + {t("settings.sign_in_cta")} ) : null} diff --git a/apps/mobile/src/modules/settings/routes/2FASetting.tsx b/apps/mobile/src/modules/settings/routes/2FASetting.tsx index 3ba677ceffe..0756e16c3f3 100644 --- a/apps/mobile/src/modules/settings/routes/2FASetting.tsx +++ b/apps/mobile/src/modules/settings/routes/2FASetting.tsx @@ -1,6 +1,7 @@ import { whoamiQueryKey } from "@follow/store/user/hooks" import { useMutation } from "@tanstack/react-query" import { useRef } from "react" +import { useTranslation } from "react-i18next" import { KeyboardAvoidingView, View } from "react-native" import type { OtpInputRef } from "react-native-otp-entry" import { OtpInput } from "react-native-otp-entry" @@ -24,6 +25,7 @@ const isAuthCodeValid = (code: string) => { export const TwoFASetting: NavigationControllerView<{ totpURI: string }> = ({ totpURI }) => { + const { t } = useTranslation("settings") const label = useColor("label") const tertiaryLabel = useColor("tertiaryLabel") const navigation = useNavigation() @@ -40,17 +42,19 @@ export const TwoFASetting: NavigationControllerView<{ }) }, onError(error) { - toast.error(`Failed to verify: ${error.message}`) + toast.error(`${t("profile.two_factor.verify_failed")}: ${error.message}`) }, onSuccess() { navigation.back() - toast.success("2FA enabled!") + toast.success(t("profile.two_factor.enabled")) }, }) const otpInputRef = useRef(null) return ( - }> + } + > - Scan the QR code above with your authenticator app, and then enter the 6-digit code - which will be displayed in your authenticator app. + {t("profile.two_factor.setup.description")} diff --git a/apps/mobile/src/modules/settings/routes/Account.tsx b/apps/mobile/src/modules/settings/routes/Account.tsx index f1ead175d0e..69fd23c25df 100644 --- a/apps/mobile/src/modules/settings/routes/Account.tsx +++ b/apps/mobile/src/modules/settings/routes/Account.tsx @@ -104,6 +104,7 @@ const AccountLinker: FC<{ provider: keyof typeof provider2IconMap account?: Account }> = ({ provider, account }) => { + const { t } = useTranslation(["settings", "common"]) const queryClient = useQueryClient() const unlinkAccountMutation = useMutation({ mutationFn: async () => { @@ -115,7 +116,7 @@ const AccountLinker: FC<{ if (res.error) throw new Error(res.error.message) }, onSuccess: () => { - toast.success("Unlinked account success") + toast.success(t("profile.link_social.unlink.success")) queryClient.invalidateQueries({ queryKey: accountInfoKey, }) @@ -146,7 +147,7 @@ const AccountLinker: FC<{ linkSocial({ provider: provider as any }) .then((res) => { if (!res.data?.url) { - toast.error("Failed to link account") + toast.error(t("profile.link_social.link_failed")) return } openLink(res.data.url, () => { @@ -159,21 +160,27 @@ const AccountLinker: FC<{ }) }) .catch((error) => { - toast.error(error instanceof Error ? error.message : "Failed to link account") + toast.error( + error instanceof Error ? error.message : t("profile.link_social.link_failed"), + ) }) return } - Alert.alert("Unlink account", "Are you sure you want to unlink your account?", [ - { - text: "Cancel", - style: "cancel", - }, - { - text: "Unlink", - style: "destructive", - onPress: () => unlinkAccountMutation.mutate(), - }, - ]) + Alert.alert( + t("profile.link_social.unlink.title"), + t("profile.link_social.unlink.confirm"), + [ + { + text: t("words.cancel", { ns: "common" }), + style: "cancel", + }, + { + text: t("profile.link_social.unlink.action"), + style: "destructive", + onPress: () => unlinkAccountMutation.mutate(), + }, + ], + ) }} /> ) @@ -215,7 +222,7 @@ const AuthenticationSection = () => { ) } const SecuritySection = () => { - const { t } = useTranslation("settings") + const { t } = useTranslation(["settings", "common"]) const { data: account } = useAccount() const hasPassword = account?.data?.find((account) => account.provider === "credential") const whoAmI = useWhoami() @@ -231,14 +238,14 @@ const SecuritySection = () => { onPress={() => { const email = whoAmI?.email || "" if (!email) { - toast.error("You need to login with email first") + toast.error(t("profile.change_password.email_required")) return } if (!hasPassword) { forgetPassword({ email, }) - toast.success("We have sent you an email with instructions to reset your password.") + toast.success(t("profile.reset_password_mail_sent")) } else { navigation.pushControllerView(ResetPassword) } @@ -260,10 +267,10 @@ const SecuritySection = () => { .updateTwoFactor(false, ctx.password) .finally(() => done()) if (res.error?.message) { - toast.error("Invalid password or something went wrong") + toast.error(t("profile.two_factor.invalid_password")) return } - toast.success("2FA disabled") + toast.success(t("profile.two_factor.disabled")) return } const { password } = ctx @@ -271,7 +278,7 @@ const SecuritySection = () => { .updateTwoFactor(true, password) .finally(() => done()) if (res.error?.message) { - toast.error("Invalid password or something went wrong") + toast.error(t("profile.two_factor.invalid_password")) return } if (res.data && "totpURI" in res.data) { @@ -279,7 +286,7 @@ const SecuritySection = () => { totpURI: res.data.totpURI, }) } else { - toast.error("Failed to enable 2FA") + toast.error(t("profile.two_factor.enable_failed")) } }, }, @@ -291,15 +298,15 @@ const SecuritySection = () => { textClassName="text-red text-left" onPress={async () => { Alert.alert( - "Delete account", - "Are you sure you want to delete your account? \nThis action is irreversible and may take up to two days to take effect.", + t("profile.delete_account.confirm_title"), + t("profile.delete_account.confirm_description"), [ { - text: "Cancel", + text: t("words.cancel", { ns: "common" }), style: "cancel", }, { - text: "Delete", + text: t("words.delete", { ns: "common" }), style: "destructive", onPress: async () => { // await signOut() diff --git a/apps/mobile/src/modules/settings/routes/Appearance.tsx b/apps/mobile/src/modules/settings/routes/Appearance.tsx index 31b41da5e11..e40dcc0037b 100644 --- a/apps/mobile/src/modules/settings/routes/Appearance.tsx +++ b/apps/mobile/src/modules/settings/routes/Appearance.tsx @@ -1,7 +1,7 @@ import { getUnreadAll } from "@follow/store/unread/getters" import { themeNames } from "@shikijs/themes" import { useTranslation } from "react-i18next" -import { useColorScheme, View } from "react-native" +import { useColorScheme } from "react-native" import { setUISetting, useUISettingKey } from "@/src/atoms/settings/ui" import { @@ -84,6 +84,7 @@ export const AppearanceScreen = () => { const useSystemFontScaling = useUISettingKey("useSystemFontScaling") const useDifferentFontSizeForContent = useUISettingKey("useDifferentFontSizeForContent") const mobileContentFontSize = useUISettingKey("mobileContentFontSize") + const selectWrapperClassName = "w-auto min-w-[96px] max-w-[44vw] shrink-0" return ( { label={t("appearance.thumbnail_ratio.title")} description={t("appearance.thumbnail_ratio.description")} > - - { + setUISetting("thumbnailRatio", val as "square" | "original") + }} + /> @@ -178,19 +178,18 @@ export const AppearanceScreen = () => { label={t("appearance.font_scaling.scale.label")} description={t("appearance.font_scaling.scale.description")} > - - ({ + label: t(`appearance.font_scaling.size.${preset.key}`), + value: preset.value.toString(), + }))} + value={fontScale.toString()} + onValueChange={(val) => { + setUISetting("fontScale", Number.parseFloat(val)) + }} + disabled={useSystemFontScaling} + /> { label={t("appearance.font_scaling.content_size.label")} description={t("appearance.font_scaling.content_size.description")} > - - ({ + label: t(`appearance.font_scaling.content_size.${preset.key}`), + value: preset.value.toString(), + }))} + value={mobileContentFontSize.toString()} + onValueChange={(val) => { + setUISetting("mobileContentFontSize", Number.parseInt(val)) + }} + /> )} - + - - ({ + label: theme, + value: theme, + }))} + value={colorScheme === "dark" ? codeThemeDark : codeThemeLight} + onValueChange={(val) => { + setUISetting(`codeHighlightTheme${colorScheme === "dark" ? "Dark" : "Light"}`, val) + }} + /> { await userSyncService.updateProfile(dirtyFields) }, onSuccess: () => { - toast.success("Profile updated") + toast.success(t("profile.updateSuccess")) setDirtyFields({}) }, onError: (error) => { diff --git a/apps/mobile/src/screens/(modal)/ForgetPasswordScreen.tsx b/apps/mobile/src/screens/(modal)/ForgetPasswordScreen.tsx index 4bdf7ee7514..a127a9d3968 100644 --- a/apps/mobile/src/screens/(modal)/ForgetPasswordScreen.tsx +++ b/apps/mobile/src/screens/(modal)/ForgetPasswordScreen.tsx @@ -1,6 +1,7 @@ import { useMutation } from "@tanstack/react-query" import { useState } from "react" -import { StyleSheet, TouchableWithoutFeedback, View } from "react-native" +import { useTranslation } from "react-i18next" +import { ScrollView, StyleSheet, View } from "react-native" import { KeyboardAvoidingView, KeyboardController } from "react-native-keyboard-controller" import { useSafeAreaInsets } from "react-native-safe-area-context" @@ -16,8 +17,15 @@ import { getTokenHeaders } from "@/src/lib/token" export const ForgetPasswordScreen: NavigationControllerView = () => { const insets = useSafeAreaInsets() + const { t } = useTranslation() const [email, setEmail] = useState("") const navigation = useNavigation() + const contentContainerStyle = { + flexGrow: 1, + justifyContent: "space-between" as const, + paddingTop: insets.top + 56, + paddingBottom: insets.bottom + 24, + } const forgetPasswordMutation = useMutation({ mutationFn: async (email: string) => { const res = await forgetPassword( @@ -36,61 +44,56 @@ export const ForgetPasswordScreen: NavigationControllerView = () => { toast.error(error.message) }, onSuccess: () => { - toast.success("We have sent you an email with instructions to reset your password.") + toast.success(t("login.forgot_password.success")) navigation.back() }, }) return ( - { - KeyboardController.dismiss() - }} - accessible={false} - > - - - - - Forgot password? - - Enter your email address that you use with your account to continue. - + + + { + KeyboardController.dismiss() + }} + > + + + {t("login.forgot_password.title")} + + + {t("login.forgot_password.description")} + - - - forgetPasswordMutation.mutate(email)} - /> - + + + forgetPasswordMutation.mutate(email)} + /> - - forgetPasswordMutation.mutate(email)} - /> - - + + forgetPasswordMutation.mutate(email)} + /> + + ) } diff --git a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx index 02d50eec082..99dba97db18 100644 --- a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx +++ b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx @@ -381,9 +381,9 @@ const MaybeSwipeable = ({ id, children }: { id: string; children: React.ReactNod { onPress: () => { // unsubscribe - Alert.alert("Unsubscribe?", "This will remove the feed from your subscriptions", [ + Alert.alert(t("feed.unfollow.confirm_title"), t("feed.unfollow.confirm_description"), [ { - text: "Cancel", + text: t("words.cancel", { ns: "common" }), style: "cancel", }, { diff --git a/locales/mobile/default/en.json b/locales/mobile/default/en.json index 735dd208fd3..0bf8564a339 100644 --- a/locales/mobile/default/en.json +++ b/locales/mobile/default/en.json @@ -11,12 +11,22 @@ "entry_content.no_content": "No media available", "entry_content.no_video_url": "No video URL found", "entry_list.zero_unread": "Zero Unread", + "feed.follow.success": "Feed followed.", + "feed.follow.update_success": "Feed updated.", + "feed.not_found": "Feed ({{id}}) not found.", + "feed.unfollow.confirm_description": "This will remove the feed from your subscriptions.", + "feed.unfollow.confirm_title": "Unsubscribe?", + "feed.unfollow.success": "Feed unfollowed.", "login.agree_to": "By continuing, you agree to our", "login.back": "Back", "login.confirm_password.label": "Confirm Password", "login.continueWith": "Continue with {{provider}}", "login.email": "Email", "login.forget_password.note": "Forgot your password?", + "login.forgot_password.continue": "Continue", + "login.forgot_password.description": "Enter the email address associated with your account to continue.", + "login.forgot_password.success": "We sent you an email with instructions to reset your password.", + "login.forgot_password.title": "Forgot password?", "login.have_account": "Already have an account? Sign in", "login.invalid_email_or_password": "Invalid email or password", "login.no_account": "Don't have an account? Sign up", @@ -80,6 +90,7 @@ "operation.unstar": "Unstar", "profile.title": "{{name}}'s Profile", "profile.uncategorized_feeds": "Uncategorized feeds", + "settings.sign_in_cta": "Sign in to your account", "signin.sign_in_to": "Sign in to", "signin.sign_up_to": "Sign up to", "subscription_form.category": "Category", diff --git a/locales/mobile/default/ja.json b/locales/mobile/default/ja.json index 93491faa5e1..3dc4bcc9542 100644 --- a/locales/mobile/default/ja.json +++ b/locales/mobile/default/ja.json @@ -11,12 +11,22 @@ "entry_content.no_content": "コンテンツがありません", "entry_content.no_video_url": "動画 URL が見つかりません", "entry_list.zero_unread": "未読ゼロ", + "feed.follow.success": "フィードをフォローしました。", + "feed.follow.update_success": "フィードを更新しました。", + "feed.not_found": "フィード({{id}})が見つかりません。", + "feed.unfollow.confirm_description": "このフィードは購読一覧から削除されます。", + "feed.unfollow.confirm_title": "購読を解除しますか?", + "feed.unfollow.success": "フィードのフォローを解除しました。", "login.agree_to": " 続行することで、あなたは私たちの", "login.back": "戻る", "login.confirm_password.label": "パスワードの確認", "login.continueWith": "{{provider}} で続行", "login.email": "Email", "login.forget_password.note": "パスワードをお忘れですか?", + "login.forgot_password.continue": "続行", + "login.forgot_password.description": "続行するには、アカウントに紐づくメールアドレスを入力してください。", + "login.forgot_password.success": "パスワード再設定手順を記載したメールを送信しました。", + "login.forgot_password.title": "パスワードをお忘れですか?", "login.have_account": "すでにアカウントをお持ちですか? サインイン", "login.invalid_email_or_password": "メールアドレスまたはパスワードが正しくありません", "login.no_account": "アカウントをお持ちでないですか? サインアップ", @@ -80,6 +90,7 @@ "operation.unstar": "スター解除", "profile.title": "{{name}}のプロフィール", "profile.uncategorized_feeds": "未分類のフィード", + "settings.sign_in_cta": "アカウントにサインイン", "signin.sign_in_to": "サインインする", "signin.sign_up_to": "サインアップする", "subscription_form.category": "カテゴリ", diff --git a/locales/mobile/default/zh-CN.json b/locales/mobile/default/zh-CN.json index 54e0e23b611..a61170ce723 100644 --- a/locales/mobile/default/zh-CN.json +++ b/locales/mobile/default/zh-CN.json @@ -11,12 +11,22 @@ "entry_content.no_content": "没有内容", "entry_content.no_video_url": "未找到视频链接", "entry_list.zero_unread": "全部已读", + "feed.follow.success": "已关注此订阅源。", + "feed.follow.update_success": "订阅源已更新。", + "feed.not_found": "未找到订阅源({{id}})。", + "feed.unfollow.confirm_description": "此操作会将该订阅源从你的订阅列表中移除。", + "feed.unfollow.confirm_title": "取消订阅?", + "feed.unfollow.success": "已取消订阅该订阅源。", "login.agree_to": "继续即表示您同意我们的", "login.back": "返回", "login.confirm_password.label": "确认密码", "login.continueWith": "使用 {{provider}} 继续", "login.email": "邮件地址", "login.forget_password.note": "忘记了密码?", + "login.forgot_password.continue": "继续", + "login.forgot_password.description": "输入与你账户关联的邮箱地址以继续。", + "login.forgot_password.success": "我们已向你发送重置密码说明邮件。", + "login.forgot_password.title": "忘记密码?", "login.have_account": "已有账户?登录", "login.invalid_email_or_password": "邮箱或密码无效", "login.no_account": "没有账户?注册", @@ -80,6 +90,7 @@ "operation.unstar": "取消收藏", "profile.title": "{{name}}的个人资料", "profile.uncategorized_feeds": "未分类的订阅源", + "settings.sign_in_cta": "登录你的账户", "signin.sign_in_to": "登录", "signin.sign_up_to": "注册", "subscription_form.category": "分类", diff --git a/locales/settings/en.json b/locales/settings/en.json index 895ee864c9a..9bc23987ad0 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -622,10 +622,13 @@ "profile.avatar.uploadError": "Failed to upload avatar", "profile.avatar.uploadSuccess": "Avatar uploaded successfully", "profile.avatar.uploadTitle": "Upload Avatar", + "profile.change_password.email_required": "You need to sign in with email first.", "profile.change_password.label": "Change Password", "profile.confirm_password.label": "Confirm Password", "profile.current_password.label": "Current Password", "profile.danger_zone": "Danger Zone", + "profile.delete_account.confirm_description": "Are you sure you want to delete your account?\nThis action is irreversible and may take up to two days to take effect.", + "profile.delete_account.confirm_title": "Delete account", "profile.delete_account.label": "Delete Account", "profile.edit_email": "Edit Email", "profile.edit_profile": "Edit Profile", @@ -644,7 +647,11 @@ "profile.handle.label": "Handle", "profile.link_social.authentication": "Authentication", "profile.link_social.link": "Link", + "profile.link_social.link_failed": "Failed to link account.", + "profile.link_social.unlink.action": "Unlink", + "profile.link_social.unlink.confirm": "Are you sure you want to unlink your account?", "profile.link_social.unlink.success": "Social account unlinked.", + "profile.link_social.unlink.title": "Unlink account", "profile.name.description": "Your public display name.", "profile.name.label": "Display Name", "profile.new_password.label": "New Password", @@ -666,6 +673,8 @@ "profile.security": "Security", "profile.set_avatar": "Set Avatar", "profile.sidebar_title": "Profile", + "profile.sign_out.confirm_message": "Are you sure you want to sign out?", + "profile.sign_out.confirm_title": "Confirm sign out", "profile.submit": "Submit", "profile.title": "Profile Settings", "profile.totp_code.init": "Scan the QR code with your TOTP app", @@ -675,10 +684,15 @@ "profile.two_factor.disable": "Disable 2FA", "profile.two_factor.disabled": "Two-factor authentication disabled.", "profile.two_factor.enable": "Enable 2FA", + "profile.two_factor.enable_failed": "Failed to enable 2FA.", "profile.two_factor.enable_notice": "You need to enable two-factor authentication to perform this action.", "profile.two_factor.enabled": "Two-factor authentication enabled.", + "profile.two_factor.invalid_password": "Invalid password or something went wrong.", "profile.two_factor.label": "Two Factor", "profile.two_factor.no_password": "You need to set a password before enabling 2FA.", + "profile.two_factor.setup.description": "Scan the QR code above with your authenticator app, then enter the 6-digit code shown in the app.", + "profile.two_factor.setup.title": "2FA Setup", + "profile.two_factor.verify_failed": "Failed to verify code", "profile.updateSuccess": "Profile updated.", "profile.update_password_success": "Password updated.", "referral.description": "Share Folo with a friend! Extend your Pro Preview and future benefits, and your friends can also get a 45-day trial period. Learn more.", diff --git a/locales/settings/ja.json b/locales/settings/ja.json index 46a681d10a4..7e48d80f446 100644 --- a/locales/settings/ja.json +++ b/locales/settings/ja.json @@ -622,10 +622,13 @@ "profile.avatar.uploadError": "アバターのアップロードに失敗しました", "profile.avatar.uploadSuccess": "アバターが正常にアップロードされました", "profile.avatar.uploadTitle": "アバターをアップロード", + "profile.change_password.email_required": "最初にメールアドレスでサインインしてください。", "profile.change_password.label": "パスワードを変更", "profile.confirm_password.label": "パスワードの確認", "profile.current_password.label": "現在のパスワード", "profile.danger_zone": "危険ゾーン", + "profile.delete_account.confirm_description": "アカウントを削除してもよろしいですか?\nこの操作は元に戻せず、反映まで最大 2 日かかる場合があります。", + "profile.delete_account.confirm_title": "アカウントを削除", "profile.delete_account.label": "アカウントを削除", "profile.edit_email": "メールを編集", "profile.edit_profile": "プロフィールを編集", @@ -644,7 +647,11 @@ "profile.handle.label": "ハンドル", "profile.link_social.authentication": "認証", "profile.link_social.link": "リンク", + "profile.link_social.link_failed": "アカウントの連携に失敗しました。", + "profile.link_social.unlink.action": "連携を解除", + "profile.link_social.unlink.confirm": "このアカウント連携を解除してもよろしいですか?", "profile.link_social.unlink.success": "ソーシャルアカウントのリンクを解除しました。", + "profile.link_social.unlink.title": "アカウント連携を解除", "profile.name.description": "公開表示名", "profile.name.label": "表示名", "profile.new_password.label": "新しいパスワード", @@ -666,6 +673,8 @@ "profile.security": "セキュリティ", "profile.set_avatar": "アバターを設定", "profile.sidebar_title": "プロフィール", + "profile.sign_out.confirm_message": "サインアウトしてもよろしいですか?", + "profile.sign_out.confirm_title": "サインアウトを確認", "profile.submit": "送信", "profile.title": "プロフィール設定", "profile.totp_code.init": "TOTP アプリで QR コードをスキャンしてください", @@ -675,10 +684,15 @@ "profile.two_factor.disable": "2FA を無効にする", "profile.two_factor.disabled": "2FA を無効化しました", "profile.two_factor.enable": "2FA を有効にする ", + "profile.two_factor.enable_failed": "2FA を有効化できませんでした。", "profile.two_factor.enable_notice": "このアクションを実行するには 2FA の有効化が必要です。", "profile.two_factor.enabled": "2FA が有効になりました", + "profile.two_factor.invalid_password": "パスワードが正しくないか、別の問題が発生しました。", "profile.two_factor.label": "2FA", "profile.two_factor.no_password": "2FA を有効化する前にパスワードの 設定 が必要です。", + "profile.two_factor.setup.description": "認証アプリで上の QR コードをスキャンし、アプリに表示された 6 桁のコードを入力してください。", + "profile.two_factor.setup.title": "2FA の設定", + "profile.two_factor.verify_failed": "コードを検証できませんでした", "profile.updateSuccess": "プロフィールが更新されました。", "profile.update_password_success": "パスワードが更新されました。", "referral.description": "Folo を友達と共有しましょう! Pro Preview と今後の特典を延長でき、友達も 45 日間のトライアル期間を得ることができます。 詳細はこちら。", diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index b98aeaa48d0..889f67f623b 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -622,10 +622,13 @@ "profile.avatar.uploadError": "头像上传失败", "profile.avatar.uploadSuccess": "头像上传成功", "profile.avatar.uploadTitle": "上传头像", + "profile.change_password.email_required": "你需要先使用邮箱登录。", "profile.change_password.label": "更改密码", "profile.confirm_password.label": "确认密码", "profile.current_password.label": "当前密码", "profile.danger_zone": "危险区", + "profile.delete_account.confirm_description": "确定要删除你的账户吗?\n此操作不可逆,且可能需要最多两天生效。", + "profile.delete_account.confirm_title": "删除账户", "profile.delete_account.label": "注销账户", "profile.edit_email": "编辑邮件地址", "profile.edit_profile": "编辑个人资料", @@ -644,7 +647,11 @@ "profile.handle.label": "唯一标识", "profile.link_social.authentication": "身份验证", "profile.link_social.link": "连接", + "profile.link_social.link_failed": "关联账户失败。", + "profile.link_social.unlink.action": "解除关联", + "profile.link_social.unlink.confirm": "确定要解除当前账户关联吗?", "profile.link_social.unlink.success": "已解除社交账户连接。", + "profile.link_social.unlink.title": "解除账户关联", "profile.name.description": "你的公开显示名称。", "profile.name.label": "显示名称", "profile.new_password.label": "新密码", @@ -666,6 +673,8 @@ "profile.security": "安全", "profile.set_avatar": "设置头像", "profile.sidebar_title": "个人资料", + "profile.sign_out.confirm_message": "确定要退出登录吗?", + "profile.sign_out.confirm_title": "确认退出登录", "profile.submit": "提交", "profile.title": "个人资料设置", "profile.totp_code.init": "使用身份验证器应用扫描二维码", @@ -675,10 +684,15 @@ "profile.two_factor.disable": "停用双重身份验证", "profile.two_factor.disabled": "双重身份验证已停用。", "profile.two_factor.enable": "启用双重身份验证", + "profile.two_factor.enable_failed": "启用双重身份验证失败。", "profile.two_factor.enable_notice": "需要启用双重身份验证才能执行此操作。", "profile.two_factor.enabled": "双重身份验证已启用。", + "profile.two_factor.invalid_password": "密码无效或发生了其他错误。", "profile.two_factor.label": "双重身份验证", "profile.two_factor.no_password": "启用双重身份验证之前需要设置密码。", + "profile.two_factor.setup.description": "使用身份验证器应用扫描上方二维码,然后输入应用中显示的 6 位验证码。", + "profile.two_factor.setup.title": "设置双重身份验证", + "profile.two_factor.verify_failed": "验证码验证失败", "profile.updateSuccess": "个人资料已更新。", "profile.update_password_success": "密码已更新。", "referral.description": "与朋友分享 Folo!延长你的专业版试用期和未来权益,你的朋友也可以获得 45 天的试用期。了解更多。", From aea75fe57b2f906f805ca8931d7c76ba7261b960 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 13 Mar 2026 10:19:42 +0800 Subject: [PATCH 06/39] fix(desktop): improve self-test coverage and ui polish --- apps/desktop/e2e/scripts/capture-ui-audit.ts | 178 ++++++++++++++ apps/desktop/e2e/support/app.ts | 10 +- apps/desktop/e2e/support/auth-bootstrap.ts | 224 ++++++++++++++++++ .../layer/main/src/ipc/services/auth.ts | 34 ++- .../src/modules/action/action-setting.tsx | 51 ++-- .../layer/renderer/src/modules/auth/Form.tsx | 106 ++++++--- .../src/modules/auth/LoginModalContent.tsx | 42 ++-- .../renderer/src/modules/auth/TokenModal.tsx | 3 + .../src/modules/discover/DiscoveryContent.tsx | 38 ++- .../modules/discover/UnifiedDiscoverForm.tsx | 2 +- .../power/my-wallet-section/create-wallet.tsx | 42 ++-- .../modules/power/my-wallet-section/index.tsx | 11 +- .../TransactionsSection.tsx | 28 ++- .../settings/modal/SettingModalContent.tsx | 34 +-- .../src/modules/settings/modal/layout.tsx | 1 + .../modules/settings/tabs/notifications.tsx | 10 +- .../SubscriptionColumnHeader.tsx | 20 +- .../SubscriptionTabButton.tsx | 2 + .../TimelineTabsSettingsModal.tsx | 71 ++++-- .../(layer)/(subview)/discover/index.tsx | 28 +-- locales/app/en.json | 7 + locales/app/fr-FR.json | 7 + locales/app/ja.json | 7 + locales/app/zh-CN.json | 7 + locales/app/zh-TW.json | 7 + locales/settings/en.json | 5 + locales/settings/fr-FR.json | 5 + locales/settings/ja.json | 5 + locales/settings/zh-CN.json | 5 + locales/settings/zh-TW.json | 5 + .../src/ui/button/action-button.tsx | 3 + 31 files changed, 801 insertions(+), 197 deletions(-) create mode 100644 apps/desktop/e2e/scripts/capture-ui-audit.ts create mode 100644 apps/desktop/e2e/support/auth-bootstrap.ts diff --git a/apps/desktop/e2e/scripts/capture-ui-audit.ts b/apps/desktop/e2e/scripts/capture-ui-audit.ts new file mode 100644 index 00000000000..b1b16e2e1da --- /dev/null +++ b/apps/desktop/e2e/scripts/capture-ui-audit.ts @@ -0,0 +1,178 @@ +import { mkdir } from "node:fs/promises" + +import { chromium } from "@playwright/test" +import { join } from "pathe" + +import { createTestAccount, tryDeleteCurrentUser } from "../support/account" +import { + closeSettings, + dismissFeedForm, + followOnboardingFeed, + openSettings, + openWebApp, +} from "../support/app" +import { bootstrapAuthenticatedWebSession } from "../support/auth-bootstrap" +import { buildWebAppURL, resolveDesktopE2EEnv } from "../support/env" + +const SETTING_TABS = [ + "general", + "appearance", + "notifications", + "shortcuts", + "ai", + "integration", + "feeds", + "list", + "profile", + "data-control", + "cli", + "plan", + "about", +] as const + +const SUBVIEW_ROUTES = ["discover", "power", "action", "rsshub", "ai"] as const + +const waitForUiSettled = async (page: import("@playwright/test").Page, delay = 1200) => { + await page.waitForLoadState("domcontentloaded") + await page.waitForTimeout(delay) +} + +const waitForRouteReady = async ( + page: import("@playwright/test").Page, + route: (typeof SUBVIEW_ROUTES)[number], +) => { + await waitForUiSettled(page, route === "power" ? 3500 : 1200) + + if (route === "power") { + await page + .waitForFunction( + () => + document.body.textContent?.includes("Your Balance") || + document.body.textContent?.includes("Transactions") || + document.body.textContent?.includes("Create Wallet"), + undefined, + { timeout: 15_000 }, + ) + .catch(() => {}) + } +} + +async function main() { + const env = resolveDesktopE2EEnv() + const outputDir = join( + env.desktopAppDir, + "e2e", + "artifacts", + "ui-audit", + `run-${new Date().toISOString().replaceAll(":", "-")}`, + ) + + await mkdir(outputDir, { recursive: true }) + + const browser = await chromium.launch({ + channel: "chromium", + headless: true, + args: ["--disable-web-security"], + }) + + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + viewport: { + width: 1440, + height: 980, + }, + colorScheme: "light", + }) + + let page = await context.newPage() + const account = createTestAccount("ui-audit") + + let screenshotIndex = 1 + const capture = async (name: string) => { + const path = join(outputDir, `${String(screenshotIndex).padStart(2, "0")}-${name}.png`) + screenshotIndex += 1 + await page.screenshot({ path, fullPage: false }) + console.info(path) + } + + const bootstrapAccount = async () => { + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + await bootstrapAuthenticatedWebSession(page, env, account) + return + } catch (error) { + await capture(`auth-bootstrap-attempt-${attempt}-failed`) + if (attempt === 3) { + throw error + } + + await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" }) + await waitForUiSettled(page) + } + } + } + + try { + await openWebApp(page, env) + await waitForUiSettled(page) + await capture("00-login-modal") + await page.close() + page = await context.newPage() + + await bootstrapAccount() + await waitForUiSettled(page) + await capture("01-home-articles") + + await followOnboardingFeed(page, env) + await waitForUiSettled(page) + await capture("02-discover-follow") + await dismissFeedForm(page) + + const timelineTabs = await page.locator('[data-testid^="timeline-tab-"]').all() + for (const tab of timelineTabs) { + const testId = await tab.getAttribute("data-testid") + if (!testId) continue + await tab.click() + await waitForUiSettled(page) + await capture(`timeline-${testId.replace("timeline-tab-", "")}`) + } + + for (const route of SUBVIEW_ROUTES) { + await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" }) + await waitForRouteReady(page, route) + await capture(`subview-${route}`) + } + + await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" }) + await waitForUiSettled(page) + + await openSettings(page) + await waitForUiSettled(page) + + for (const tab of SETTING_TABS) { + if (tab === "general") { + await capture("settings-general") + continue + } + + const tabTrigger = page.getByTestId(`settings-tab-${tab}`) + if (!(await tabTrigger.isVisible().catch(() => false))) { + continue + } + + await tabTrigger.click() + await waitForUiSettled(page) + await capture(`settings-${tab}`) + } + + await closeSettings(page) + await waitForUiSettled(page) + await capture("home-after-settings") + } finally { + await tryDeleteCurrentUser(page, env).catch(() => null) + await context.close().catch(() => {}) + await browser.close().catch(() => {}) + } +} + +void main() diff --git a/apps/desktop/e2e/support/app.ts b/apps/desktop/e2e/support/app.ts index 2e2b6f3851d..8ace2e89130 100644 --- a/apps/desktop/e2e/support/app.ts +++ b/apps/desktop/e2e/support/app.ts @@ -549,10 +549,14 @@ export const expectOnboardingFeedUnsubscribed = async ( export const expectTimelineSwitchAndEntryReadFlow = async (page: Page) => { await returnToMainShell(page) - await page.getByTestId("timeline-tab-videos").click() - await expect.poll(async () => page.locator("[data-entry-id]").count()).toBe(0) + const videosTab = page.getByTestId("timeline-tab-videos") + await videosTab.click() + await expect(videosTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 }) + await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0) - await page.getByTestId("timeline-tab-articles").click() + const articlesTab = page.getByTestId("timeline-tab-articles") + await articlesTab.click() + await expect(articlesTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 }) await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0) const unreadOnboardingEntry = page diff --git a/apps/desktop/e2e/support/auth-bootstrap.ts b/apps/desktop/e2e/support/auth-bootstrap.ts new file mode 100644 index 00000000000..3ee0d623ddf --- /dev/null +++ b/apps/desktop/e2e/support/auth-bootstrap.ts @@ -0,0 +1,224 @@ +import type { BrowserContext, Page } from "@playwright/test" +import { nanoid } from "nanoid" + +import type { TestAccount } from "./account" +import { injectRecaptchaToken, waitForAuthenticated } from "./app" +import type { DesktopE2EEnv } from "./env" +import { buildWebAppURL } from "./env" + +type AuthBootstrapResponse = { + token?: string | null + error?: { + message?: string + } | null +} + +type ParsedCookie = { + expires?: number + httpOnly: boolean + name: string + path: string + sameSite: "Lax" | "None" | "Strict" + secure: boolean + value: string +} + +const splitSetCookieHeader = (header: string) => { + const parts: string[] = [] + let buffer = "" + + for (const char of header) { + if (char === ",") { + const recent = buffer.toLowerCase() + const hasExpires = recent.includes("expires=") + const hasGmt = /gmt/i.test(recent) + + if (hasExpires && !hasGmt) { + buffer += char + continue + } + + if (buffer.trim()) { + parts.push(buffer.trim()) + } + buffer = "" + continue + } + + buffer += char + } + + if (buffer.trim()) { + parts.push(buffer.trim()) + } + + return parts +} + +const parseSetCookieHeader = (header: string): ParsedCookie[] => { + return splitSetCookieHeader(header) + .map((cookie) => { + const [nameValue, ...attributes] = cookie.split(";").map((part) => part.trim()) + const [name, ...valueParts] = nameValue?.split("=") ?? [] + if (!name) { + return null + } + + const parsedCookie: ParsedCookie = { + name, + value: valueParts.join("="), + path: "/", + httpOnly: false, + secure: false, + sameSite: "Lax", + } + + for (const attribute of attributes) { + const [rawKey, ...rawValueParts] = attribute.split("=") + const key = rawKey?.toLowerCase() + const value = rawValueParts.join("=") + + switch (key) { + case "expires": { + const expires = new Date(value) + if (!Number.isNaN(expires.getTime())) { + parsedCookie.expires = expires.getTime() / 1000 + } + break + } + case "httponly": { + parsedCookie.httpOnly = true + break + } + case "path": { + parsedCookie.path = value || "/" + break + } + case "samesite": { + if (value === "None" || value === "Strict" || value === "Lax") { + parsedCookie.sameSite = value + } + break + } + case "secure": { + parsedCookie.secure = true + break + } + } + } + + return parsedCookie + }) + .filter(Boolean) +} + +const requestAuth = async ({ + apiURL, + path, + body, +}: { + apiURL: string + body: Record + path: string +}) => { + const response = await fetch(new URL(path, apiURL), { + method: "POST", + headers: { + "Cache-Control": "no-store", + "content-type": "application/json", + "x-app-name": "Folo Web", + "x-app-platform": "desktop/web", + "x-app-version": "1.4.0", + "x-client-id": nanoid(), + "x-session-id": nanoid(), + "x-token": "ac:fallback", + }, + body: JSON.stringify(body), + }) + + return { + response, + body: (await response.json().catch(() => null)) as AuthBootstrapResponse | null, + setCookie: response.headers.get("set-cookie"), + } +} + +const signIn = (env: DesktopE2EEnv, account: TestAccount) => + requestAuth({ + apiURL: env.apiURL, + path: "/better-auth/sign-in/email", + body: { + email: account.email, + password: account.password, + rememberMe: true, + }, + }) + +const signUp = (env: DesktopE2EEnv, account: TestAccount) => + requestAuth({ + apiURL: env.apiURL, + path: "/better-auth/sign-up/email", + body: { + email: account.email, + password: account.password, + name: account.email.split("@")[0] ?? account.email, + callbackURL: `${env.webURL}/login`, + }, + }) + +const applyCookiesToContext = async ( + context: BrowserContext, + env: DesktopE2EEnv, + setCookieHeader: string, +) => { + const cookies = parseSetCookieHeader(setCookieHeader) + await context.addCookies( + cookies.map((cookie) => ({ + url: env.apiURL, + name: cookie.name, + value: cookie.value, + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, + expires: cookie.expires, + })), + ) +} + +export const bootstrapAuthenticatedWebSession = async ( + page: Page, + env: DesktopE2EEnv, + account: TestAccount, +) => { + let signInResult = await signIn(env, account) + + if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) { + const signUpResult = await signUp(env, account) + const signUpError = signUpResult.body?.error?.message?.toLowerCase() ?? "" + const isExistingAccount = + signUpError.includes("exist") || + signUpError.includes("already") || + signUpError.includes("taken") + + if ((!signUpResult.response.ok || signUpResult.body?.error) && !isExistingAccount) { + throw new Error( + signUpResult.body?.error?.message || + signInResult.body?.error?.message || + `auth bootstrap failed with ${signUpResult.response.status}`, + ) + } + + signInResult = await signIn(env, account) + } + + if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) { + throw new Error( + signInResult.body?.error?.message || `sign in failed with ${signInResult.response.status}`, + ) + } + + await applyCookiesToContext(page.context(), env, signInResult.setCookie) + await injectRecaptchaToken(page, env) + await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" }) + await waitForAuthenticated(page) +} diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index fb677877469..42958d6ad1f 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -24,18 +24,26 @@ export class AuthService extends IpcService { const url = new URL(apiURL) const isSecure = url.protocol === "https:" const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1" - - await mainWindow.webContents.session.cookies.set({ - url: apiURL, - name: BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN, - value: token, - ...(isLocalhost ? {} : { domain: url.hostname }), - path: "/", - httpOnly: true, - secure: isSecure, - sameSite: "no_restriction", - expirationDate: new Date().setDate(new Date().getDate() + 30), - }) + const cookieNames = [ + BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN, + ...(isSecure && !isLocalhost ? ["__Secure-better-auth.session_token"] : []), + ] + + await Promise.all( + cookieNames.map((name) => + mainWindow.webContents.session.cookies.set({ + url: apiURL, + name, + value: token, + ...(isLocalhost ? {} : { domain: url.hostname }), + path: "/", + httpOnly: true, + secure: isSecure, + sameSite: "no_restriction", + expirationDate: new Date().setDate(new Date().getDate() + 30), + }), + ), + ) } private async clearSessionToken(): Promise { @@ -78,7 +86,7 @@ export class AuthService extends IpcService { const token = typeof data.token === "string" ? data.token : null const persistedSessionToken = sessionToken ?? token if (response.ok && persistedSessionToken) { - void this.applySessionToken(persistedSessionToken).catch(() => {}) + await this.applySessionToken(persistedSessionToken) } if (sessionToken) { diff --git a/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx b/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx index 7fd1ab10d03..ebd9ccaa759 100644 --- a/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx +++ b/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx @@ -12,8 +12,8 @@ import { actionActions } from "@follow/store/action/store" import { nextFrame } from "@follow/utils" import { JsonObfuscatedCodec } from "@follow/utils/json-codec" import { cn } from "@follow/utils/utils" +import { repository } from "@pkg" import { useQueryClient } from "@tanstack/react-query" -import { m } from "motion/react" import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useBlocker } from "react-router" @@ -44,15 +44,14 @@ import { import { useSetSubViewRightView } from "../app-layout/subview/hooks" import { generateExportFilename } from "./utils" -const EmptyActionPlaceholder = () => { - const { t } = useTranslation("settings") +const EmptyActionPlaceholder = ({ onCreateRule }: { onCreateRule: () => void }) => { + const { t } = useTranslation(["settings", "common"]) return ( -
-
- {/* Simple icon */} -
- +
+
+
+
@@ -63,25 +62,23 @@ const EmptyActionPlaceholder = () => { {t("actions.action_card.empty.description")}

-
- -
- {t("actions.action_card.empty.start")} - + +
+ + + + {t("words.documentation", { ns: "common" })} +
- +
) } @@ -164,7 +161,7 @@ export const ActionSetting = () => {
) : ( - + )} ) diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index 52724d90538..b0cda5452f1 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -14,7 +14,7 @@ import { IN_ELECTRON } from "@follow/shared/constants" import { env } from "@follow/shared/env.desktop" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { useTranslation } from "react-i18next" +import { Trans, useTranslation } from "react-i18next" import { toast } from "sonner" import { z } from "zod" @@ -217,7 +217,16 @@ export function LoginWithPassword({ {t("login.email")} - + @@ -245,7 +254,15 @@ export function LoginWithPassword({ - + @@ -268,17 +285,21 @@ export function LoginWithPassword({ -
- If you don't have an account,{" "} - +
+ onLoginStateChange("register")} + /> + ), + }} + />
) @@ -396,7 +417,16 @@ export function RegisterForm({ {t("register.email")} - + @@ -413,7 +443,15 @@ export function RegisterForm({ : `${t("register.password")} (${t("register.password_optional")})`} - + @@ -430,7 +468,15 @@ export function RegisterForm({ : `${t("register.confirm_password")} (${t("register.password_optional")})`} - + @@ -452,17 +498,21 @@ export function RegisterForm({ -
- If you already have an account,{" "} - +
+ onLoginStateChange("login")} + /> + ), + }} + />
) diff --git a/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx b/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx index 4db95789fbd..081805adc37 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx @@ -31,7 +31,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { const { canClose = true, runtime } = props - const { t } = useTranslation() + const { t } = useTranslation(["app", "common"]) const { data: authProviders, isLoading } = useAuthProviders() const { status } = useSession() @@ -159,10 +159,11 @@ export const LoginModalContent = (props: LoginModalContentProps) => { {!IN_ELECTRON && ( )} {isEmail ? ( @@ -181,13 +182,8 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
{/* Login Providers */}
- {visibleProviders.map(([key, provider], index) => ( - + {visibleProviders.map(([key, provider]) => ( +
- +
))}
@@ -238,9 +238,9 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
@@ -249,7 +249,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { @@ -257,7 +257,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { diff --git a/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx b/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx index c89db4384cf..7f567b3309c 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx @@ -69,6 +69,9 @@ export const TokenModalContent = () => { autoFocus className="mt-1 dark:text-zinc-200" placeholder="folo://auth?token=xxx" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} {...field} /> diff --git a/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx b/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx index c8fc8549844..76bfd021b91 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx @@ -42,9 +42,8 @@ export function DiscoveryContent() { } return ( -
- {/* Segment Toggle - Centered */} -
+
+
setActiveView(value as DiscoveryView)} @@ -70,28 +69,23 @@ export function DiscoveryContent() { /> - {/* Filters Bar - Inside Content Area */} -
-
- - {t("words.language")}: - - tCommon(item.label as any)} - renderValue={(item) => tCommon(item.label as any)} - /> -
+
+ + {t("words.language")}: + + tCommon(item.label as any)} + renderValue={(item) => tCommon(item.label as any)} + />
- {/* Content Area with Filters */} -
- {/* Content */} +
{activeView === "trending" ? ( ) : ( diff --git a/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx b/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx index b1ee06e27ac..f58750bb6c5 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx @@ -301,7 +301,7 @@ export function UnifiedDiscoverForm() { className="w-full max-w-2xl" data-testid="discover-form" > -
+
{ const { t } = useTranslation("settings") return ( -
-

- , - strong: , - }} - /> -

-
- +
+
+
+
+ +
+

+ , + strong: , + }} + /> +

+
+ +
+ +
) diff --git a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx index b642754657a..7a5d3ddca78 100644 --- a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx @@ -36,9 +36,14 @@ export const MyWalletSection = ({ className }: { className?: string }) => { return } return ( -
+
-
+
@@ -46,7 +51,7 @@ export const MyWalletSection = ({ className }: { className?: string }) => {
- +
{t("wallet.balance.withdrawable")} diff --git a/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx b/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx index a2a7226b99f..44cfc23c2ce 100644 --- a/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx @@ -1,6 +1,7 @@ import { LoadingCircle } from "@follow/components/ui/loading/index.js" import { Tabs, TabsList, TabsTrigger } from "@follow/components/ui/tabs/index.jsx" import { useWhoami } from "@follow/store/user/hooks" +import { cn } from "@follow/utils/utils" import { TransactionTypes } from "@follow-app/client-sdk" import { useState } from "react" import { useTranslation } from "react-i18next" @@ -28,8 +29,15 @@ export const TransactionsSection: Component = ({ className }) => { if (!myWallet) return null + const hasTransactions = Boolean(transactions.data?.length) + return ( -
+
setType(val)}> @@ -40,8 +48,8 @@ export const TransactionsSection: Component = ({ className }) => { ))} - - {!!transactions.data?.length && ( + {hasTransactions ? : null} + {hasTransactions && ( { )} - {(transactions.isFetching || !transactions.data?.length) && ( -
+ {(transactions.isFetching || !hasTransactions) && ( +
{transactions.isFetching ? ( ) : ( - t("wallet.transactions.noTransactions") +
+ +

+ {t("wallet.transactions.empty.title")} +

+

+ {t("wallet.transactions.empty.description")} +

+
)}
)} diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx index 0c88bcaaef6..67f2783b391 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx @@ -195,22 +195,24 @@ const Content: FC<{
-

- - ), - HeartIcon: , - }} - /> -

+ {activeSetting.path === "about" && ( +

+ + ), + HeartIcon: , + }} + /> +

+ )} ) diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx index a7a89c39f39..9824612210c 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx @@ -183,6 +183,7 @@ const SettingItemButtonImpl = (props: { "my-0.5 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-text", isActive && "!bg-theme-item-active !text-text", !IN_ELECTRON && "duration-200 hover:bg-theme-item-hover", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30", disabled && "opacity-50", disabledByConfig && "cursor-not-allowed", )} diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx index 4953469a26a..32bd69f74f9 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx @@ -60,7 +60,8 @@ export const SettingNotifications = () => {

{t.settings("notifications.channel")}

- {data?.data?.length || 0} {t.common("words.items")} + {data?.data?.length || 0}{" "} + {t.common("words.items", { count: data?.data?.length || 0 })}
@@ -74,7 +75,12 @@ export const SettingNotifications = () => { {!isLoading && (!data?.data || data.data.length === 0) ? (
-

No notification channels

+

+ {t.settings("notifications.empty.title")} +

+

+ {t.settings("notifications.empty.description")} +

) : ( diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx index 6693a23e3ee..c9ed343a3b4 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx @@ -7,7 +7,7 @@ import { m } from "motion/react" import type { FC, PropsWithChildren } from "react" import { memo, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" -import { Link } from "react-router" +import { useNavigate } from "react-router" import { toast } from "sonner" import { setTimelineColumnShow, useSubscriptionColumnShow } from "~/atoms/sidebar" @@ -27,6 +27,7 @@ import { ProfileButton } from "~/modules/user/ProfileButton" export const SubscriptionColumnHeader = memo(() => { const timelineId = useRouteParamsSelector((s) => s.timelineId) const navigateBackHome = useBackHome(timelineId) + const navigate = useNavigate() const normalStyle = !window.electron || window.electron.process.platform !== "darwin" const { t } = useTranslation() return ( @@ -52,15 +53,14 @@ export const SubscriptionColumnHeader = memo(() => { )}
- - - - - + navigate("/discover")} + > + + diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx index e9241bc5d91..ae4d2d0755b 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx @@ -157,6 +157,7 @@ const ViewAllSwitchButton: FC<{ return ( - {children} + {hasItems ? ( + children + ) : ( +

{emptyLabel}

+ )}
) } @@ -55,7 +71,7 @@ function TabItem({ id }: { id: UniqueIdentifier }) { const meta = getViewMeta(String(id)) const { t } = useTranslation() return ( -
+
{meta.icon}
{t(meta.name as any, { ns: "common" })} @@ -65,6 +81,8 @@ function TabItem({ id }: { id: UniqueIdentifier }) { } function SortableTabItem({ id }: { id: UniqueIdentifier }) { + const { t } = useTranslation("app") + const meta = getViewMeta(String(id)) const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, }) @@ -79,7 +97,11 @@ function SortableTabItem({ id }: { id: UniqueIdentifier }) {
@@ -96,6 +118,7 @@ function useResolvedTimelineTabs() { } const TimelineTabsSettings = () => { + const { t } = useTranslation(["app", "common", "settings"]) const { visible, hidden } = useResolvedTimelineTabs() const commitTimelineTabs = useCallback( @@ -175,6 +198,12 @@ const TimelineTabsSettings = () => { className="mx-auto w-[600px] max-w-full space-y-4 overflow-hidden pt-2" onPointerDown={(e) => e.stopPropagation()} > +
+

+ {t("appearance.customize_sub_tabs.description", { ns: "settings" })} +

+

{t("sidebar.timeline_tabs.instructions")}

+
{ >
-

Visible

- +

+ {t("sidebar.timeline_tabs.visible")} +

+ 0} + > {visible.map((id) => ( @@ -194,8 +229,14 @@ const TimelineTabsSettings = () => {
-

Hidden

- +

+ {t("sidebar.timeline_tabs.hidden")} +

+ 0} + > {hidden.map((id) => ( @@ -209,6 +250,7 @@ const TimelineTabsSettings = () => {
@@ -225,13 +267,14 @@ const TimelineTabsSettings = () => { export const useShowTimelineTabsSettingsModal = () => { const { present } = useModalStack() + const { t } = useTranslation("settings") return useCallback(() => { present({ id: "timeline-tabs-settings", - title: "Customize View Tabs", + title: t("appearance.customize_sub_tabs.label"), content: () => , overlay: true, clickOutsideToDismiss: true, }) - }, [present]) + }, [present, t]) } diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx index 35cbae22626..34a6de5edc0 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx @@ -19,7 +19,7 @@ interface SectionProps { } function Section({ children, className }: SectionProps) { - return
{children}
+ return
{children}
} // ============================================================================ @@ -33,25 +33,21 @@ export function Component() { const hasSearchData = useHasDiscoverSearchData() return ( -
- {/* Hero Section */} -
-
-

{t("words.discover")}

-

{t("discover.tips.search_keyword")}

+
+
+
+
+

{t("words.discover")}

+

{t("discover.tips.search_keyword")}

+
+
+ +
- {/* Search Section */} -
-
- -
-
- - {/* Discovery Section - Hide when searching */} {!hasSearchData && ( -
+
diff --git a/locales/app/en.json b/locales/app/en.json index aabf420ae2c..b1568b40b1e 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -474,7 +474,14 @@ "sidebar.feed_column.context_menu.unsubscribe_category": "Unsubscribe All in Category", "sidebar.select_sort_method": "Select a sort method", "sidebar.timeline_tabs.customize": "Customize View Tabs...", + "sidebar.timeline_tabs.drag_tab": "Drag timeline tab", + "sidebar.timeline_tabs.empty_hidden": "Drag tabs here to hide them from the sidebar.", + "sidebar.timeline_tabs.empty_visible": "Drag tabs here to show them in the sidebar.", + "sidebar.timeline_tabs.hidden": "Hidden", "sidebar.timeline_tabs.hide_tab": "Hide this view", + "sidebar.timeline_tabs.instructions": "Drag tabs between sections to reorder, show, or hide them.", + "sidebar.timeline_tabs.reset": "Reset to default", + "sidebar.timeline_tabs.visible": "Visible", "signin.continue_with": "Continue with {{provider}}", "signin.sign_in_to": "Sign in to", "signin.sign_up_to": "Sign up to", diff --git a/locales/app/fr-FR.json b/locales/app/fr-FR.json index 08c2eede780..cba7b105dc3 100644 --- a/locales/app/fr-FR.json +++ b/locales/app/fr-FR.json @@ -473,7 +473,14 @@ "sidebar.feed_column.context_menu.unsubscribe_category": "Se désabonner de tout dans la catégorie", "sidebar.select_sort_method": "Sélectionner une méthode de tri", "sidebar.timeline_tabs.customize": "Personnaliser les onglets de vue...", + "sidebar.timeline_tabs.drag_tab": "Faire glisser l'onglet de la chronologie", + "sidebar.timeline_tabs.empty_hidden": "Faites glisser des onglets ici pour les masquer de la barre latérale.", + "sidebar.timeline_tabs.empty_visible": "Faites glisser des onglets ici pour les afficher dans la barre latérale.", + "sidebar.timeline_tabs.hidden": "Masqué", "sidebar.timeline_tabs.hide_tab": "Masquer cette vue", + "sidebar.timeline_tabs.instructions": "Faites glisser les onglets entre les sections pour les réorganiser, les afficher ou les masquer.", + "sidebar.timeline_tabs.reset": "Réinitialiser par défaut", + "sidebar.timeline_tabs.visible": "Visible", "signin.continue_with": "Continuer avec {{provider}}", "signin.sign_in_to": "Se connecter à", "signin.sign_up_to": "S'inscrire à", diff --git a/locales/app/ja.json b/locales/app/ja.json index 5e957d2f790..1d6afbcd609 100644 --- a/locales/app/ja.json +++ b/locales/app/ja.json @@ -474,7 +474,14 @@ "sidebar.feed_column.context_menu.unsubscribe_category": "カテゴリー内の購読をすべて解除", "sidebar.select_sort_method": "並べ替え方法を選択", "sidebar.timeline_tabs.customize": "ビュータブをカスタマイズ...", + "sidebar.timeline_tabs.drag_tab": "タイムラインタブをドラッグ", + "sidebar.timeline_tabs.empty_hidden": "ここにドラッグするとサイドバーから非表示になります。", + "sidebar.timeline_tabs.empty_visible": "ここにドラッグするとサイドバーに表示されます。", + "sidebar.timeline_tabs.hidden": "非表示", "sidebar.timeline_tabs.hide_tab": "このビューを非表示にする", + "sidebar.timeline_tabs.instructions": "セクション間でタブをドラッグして、並び替え、表示、非表示を切り替えます。", + "sidebar.timeline_tabs.reset": "デフォルトに戻す", + "sidebar.timeline_tabs.visible": "表示中", "signin.continue_with": "{{provider}} で続ける", "signin.sign_in_to": "サインイン", "signin.sign_up_to": "サインアップ", diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json index 2ea6d51f55a..e26bf395ee9 100644 --- a/locales/app/zh-CN.json +++ b/locales/app/zh-CN.json @@ -474,7 +474,14 @@ "sidebar.feed_column.context_menu.unsubscribe_category": "取消分类内所有订阅", "sidebar.select_sort_method": "选择排序方法", "sidebar.timeline_tabs.customize": "自定义视图标签...", + "sidebar.timeline_tabs.drag_tab": "拖动时间线标签", + "sidebar.timeline_tabs.empty_hidden": "将标签拖到这里即可从侧栏隐藏。", + "sidebar.timeline_tabs.empty_visible": "将标签拖到这里即可在侧栏显示。", + "sidebar.timeline_tabs.hidden": "已隐藏", "sidebar.timeline_tabs.hide_tab": "隐藏此视图", + "sidebar.timeline_tabs.instructions": "在两个区域之间拖动标签,即可重新排序、显示或隐藏它们。", + "sidebar.timeline_tabs.reset": "恢复默认", + "sidebar.timeline_tabs.visible": "已显示", "signin.continue_with": "使用 {{provider}} 登录", "signin.sign_in_to": "登录", "signin.sign_up_to": "注册", diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json index e2eb02f531f..d70ca8a5944 100644 --- a/locales/app/zh-TW.json +++ b/locales/app/zh-TW.json @@ -474,7 +474,14 @@ "sidebar.feed_column.context_menu.unsubscribe_category": "取消分類內所有訂閱", "sidebar.select_sort_method": "選擇排序方式", "sidebar.timeline_tabs.customize": "自訂檢視分頁...", + "sidebar.timeline_tabs.drag_tab": "拖曳時間軸標籤", + "sidebar.timeline_tabs.empty_hidden": "將標籤拖曳到這裡即可從側欄隱藏。", + "sidebar.timeline_tabs.empty_visible": "將標籤拖曳到這裡即可在側欄顯示。", + "sidebar.timeline_tabs.hidden": "已隱藏", "sidebar.timeline_tabs.hide_tab": "隱藏此檢視", + "sidebar.timeline_tabs.instructions": "在兩個區域之間拖曳標籤,即可重新排序、顯示或隱藏它們。", + "sidebar.timeline_tabs.reset": "恢復預設", + "sidebar.timeline_tabs.visible": "已顯示", "signin.continue_with": "透過 {{provider}} 登入", "signin.sign_in_to": "登入", "signin.sign_up_to": "註冊", diff --git a/locales/settings/en.json b/locales/settings/en.json index 9bc23987ad0..bfd1370f5e6 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -36,6 +36,7 @@ "actions.action_card.block": "Block", "actions.action_card.block_rules": "Block Rules", "actions.action_card.custom_filters": "Custom Filters", + "actions.action_card.empty.cta": "Create your first rule", "actions.action_card.empty.description": "Create your first action rule to automatically process your feeds.", "actions.action_card.empty.start": "Start here!", "actions.action_card.empty.title": "No Actions Yet", @@ -573,6 +574,8 @@ "lists.view": "View", "notifications.channel": "Channel", "notifications.current": "(current client)", + "notifications.empty.description": "Notification channels will appear here after you enable notifications on this device.", + "notifications.empty.title": "No notification channels", "notifications.info": "Folo offers robust and versatile notification features through Actions. You can customize notification for specific feeds, views, or keywords. Below are your registered notification channels.", "notifications.test": "Test Notification", "notifications.test_success": "Test notification sent successfully.", @@ -816,6 +819,8 @@ "wallet.sidebar_title": "Power", "wallet.transactions.amount": "Amount", "wallet.transactions.date": "Date", + "wallet.transactions.empty.description": "Tips, purchases, withdrawals, and airdrops will appear here once they happen.", + "wallet.transactions.empty.title": "No transactions yet", "wallet.transactions.from": "From", "wallet.transactions.more": "View more through the blockchain explorer.", "wallet.transactions.noTransactions": "No transactions", diff --git a/locales/settings/fr-FR.json b/locales/settings/fr-FR.json index cd698d4ce06..8e11ad0ac3f 100644 --- a/locales/settings/fr-FR.json +++ b/locales/settings/fr-FR.json @@ -36,6 +36,7 @@ "actions.action_card.block": "Bloquer", "actions.action_card.block_rules": "Règles de blocage", "actions.action_card.custom_filters": "Filtres personnalisés", + "actions.action_card.empty.cta": "Créer votre première règle", "actions.action_card.empty.description": "Créez votre première règle d'action pour traiter automatiquement vos flux.", "actions.action_card.empty.start": "Commencez ici !", "actions.action_card.empty.title": "Aucune action pour le moment", @@ -573,6 +574,8 @@ "lists.view": "Vue", "notifications.channel": "Canal", "notifications.current": "(client actuel)", + "notifications.empty.description": "Les canaux de notification apparaîtront ici une fois les notifications activées sur cet appareil.", + "notifications.empty.title": "Aucun canal de notification", "notifications.info": "Folo offre des fonctionnalités de notification robustes et polyvalentes via Actions. Vous pouvez personnaliser la notification pour des flux, des vues ou des mots-clés spécifiques. Ci-dessous vos canaux de notification enregistrés.", "notifications.test": "Notification de test", "notifications.test_success": "Notification de test envoyée avec succès.", @@ -797,6 +800,8 @@ "wallet.sidebar_title": "Puissance", "wallet.transactions.amount": "Montant", "wallet.transactions.date": "Date", + "wallet.transactions.empty.description": "Les pourboires, achats, retraits et airdrops apparaîtront ici lorsqu'ils auront lieu.", + "wallet.transactions.empty.title": "Aucune transaction pour le moment", "wallet.transactions.from": "De", "wallet.transactions.more": "Voir plus via l'explorateur de blockchain.", "wallet.transactions.noTransactions": "Aucune transaction", diff --git a/locales/settings/ja.json b/locales/settings/ja.json index 7e48d80f446..cacd2a8a557 100644 --- a/locales/settings/ja.json +++ b/locales/settings/ja.json @@ -36,6 +36,7 @@ "actions.action_card.block": "ブロック", "actions.action_card.block_rules": "ブロックルール", "actions.action_card.custom_filters": "カスタムフィルター", + "actions.action_card.empty.cta": "最初のルールを作成", "actions.action_card.empty.description": "最初のアクションルールを作成して、フィードを自動的に処理します。", "actions.action_card.empty.start": "ここから始めましょう!", "actions.action_card.empty.title": "アクションはまだありません", @@ -573,6 +574,8 @@ "lists.view": "表示", "notifications.channel": "チャンネル", "notifications.current": "(現在のクライアント)", + "notifications.empty.description": "このデバイスで通知を有効にすると、通知チャンネルがここに表示されます。", + "notifications.empty.title": "通知チャンネルはありません", "notifications.info": "Foloはアクションを通じて堅牢で多機能な通知機能を提供します。特定のフィード、ビュー、キーワードの通知をカスタマイズできます。以下は登録された通知チャンネルです。", "notifications.test": "テスト通知", "notifications.test_success": "テスト通知が正常に送信されました。", @@ -816,6 +819,8 @@ "wallet.sidebar_title": "Power", "wallet.transactions.amount": "金額", "wallet.transactions.date": "日付", + "wallet.transactions.empty.description": "チップ、購入、出金、エアドロップの履歴が発生するとここに表示されます。", + "wallet.transactions.empty.title": "まだ取引はありません", "wallet.transactions.from": "送信元", "wallet.transactions.more": "blockchain explorerで詳細を表示する", "wallet.transactions.noTransactions": "トランザクションなし", diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index 889f67f623b..a4950ef6577 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -36,6 +36,7 @@ "actions.action_card.block": "屏蔽", "actions.action_card.block_rules": "阻止规则", "actions.action_card.custom_filters": "指定条件", + "actions.action_card.empty.cta": "创建第一条规则", "actions.action_card.empty.description": "创建首个自动化规则以自动处理你的订阅源", "actions.action_card.empty.start": "从此处开始!", "actions.action_card.empty.title": "尚无自动化规则", @@ -573,6 +574,8 @@ "lists.view": "视图", "notifications.channel": "渠道", "notifications.current": "(当前客户端)", + "notifications.empty.description": "当你在当前设备上启用通知后,通知渠道会显示在这里。", + "notifications.empty.title": "暂无通知渠道", "notifications.info": "Folo 通过自动化提供强大且灵活的通知功能。你可以为特定的订阅源、视图或关键字自定义通知。以下是已注册的通知渠道。", "notifications.test": "测试通知", "notifications.test_success": "测试通知发送成功。", @@ -816,6 +819,8 @@ "wallet.sidebar_title": "Power", "wallet.transactions.amount": "数额", "wallet.transactions.date": "日期", + "wallet.transactions.empty.description": "打赏、购买、提现和空投等记录发生后会显示在这里。", + "wallet.transactions.empty.title": "暂无交易记录", "wallet.transactions.from": "发送者", "wallet.transactions.more": "通过区块链浏览器查看更多交易…", "wallet.transactions.noTransactions": "无交易记录", diff --git a/locales/settings/zh-TW.json b/locales/settings/zh-TW.json index 4afb1e831e0..ce0726ecce2 100644 --- a/locales/settings/zh-TW.json +++ b/locales/settings/zh-TW.json @@ -36,6 +36,7 @@ "actions.action_card.block": "封鎖", "actions.action_card.block_rules": "封鎖規則", "actions.action_card.custom_filters": "自訂過濾條件", + "actions.action_card.empty.cta": "建立第一條規則", "actions.action_card.empty.description": "建立首個自動化規則以自動處理您的訂閱內容。", "actions.action_card.empty.start": "從此處開始!", "actions.action_card.empty.title": "尚無自動化規則", @@ -573,6 +574,8 @@ "lists.view": "查看", "notifications.channel": "管道", "notifications.current": "(當前客户端)", + "notifications.empty.description": "當您在這台裝置上啟用通知後,通知管道會顯示在這裡。", + "notifications.empty.title": "尚無通知管道", "notifications.info": "Folo 通過自動化提供強大且靈活的通知功能。你可以為特定的 RSS 摘要、視圖或關鍵字自定義通知。以下是已註冊的通知管道。", "notifications.test": "測試通知", "notifications.test_success": "測試通知發送成功。", @@ -797,6 +800,8 @@ "wallet.sidebar_title": "Power", "wallet.transactions.amount": "額度", "wallet.transactions.date": "日期", + "wallet.transactions.empty.description": "當打賞、購買、提領與空投等記錄發生後,會顯示在這裡。", + "wallet.transactions.empty.title": "尚無交易紀錄", "wallet.transactions.from": "發送者", "wallet.transactions.more": "通過區塊鏈瀏覽器查看更多交易…", "wallet.transactions.noTransactions": "無交易紀錄", diff --git a/packages/internal/components/src/ui/button/action-button.tsx b/packages/internal/components/src/ui/button/action-button.tsx index 3414aed6c97..bdbc5fb9762 100644 --- a/packages/internal/components/src/ui/button/action-button.tsx +++ b/packages/internal/components/src/ui/button/action-button.tsx @@ -109,6 +109,7 @@ export const ActionButton = ({ "no-drag-region pointer-events-auto inline-flex items-center justify-center", active && typeof icon !== "function" && "bg-zinc-500/15 hover:bg-zinc-500/20", "hover:bg-theme-item-hover data-[state=open]:bg-theme-item-active rounded-md duration-200", + "focus-visible:ring-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", clickableDisabled && "cursor-not-allowed opacity-50", shouldHighlightMotion && @@ -127,6 +128,8 @@ export const ActionButton = ({ }} type="button" disabled={disabled} + aria-busy={loading || undefined} + aria-disabled={disabled || clickableDisabled || undefined} onClick={ onClick ? async (e) => { From a2a88d9fd4ad04b219f4687f41016a02d7cb0da6 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 13 Mar 2026 13:17:24 +0800 Subject: [PATCH 07/39] fix(mobile): improve ipad layouts and detail rendering --- .../ios/Modules/SharedWebView/FOWebView.swift | 40 ++++ .../Modules/SharedWebView/SharedWebView.swift | 14 +- .../ios/Modules/TabBar/TabBarRootView.swift | 3 +- .../src/components/common/NoLoginInfo.tsx | 12 +- .../views/SafeNavigationScrollView.tsx | 18 +- .../native/webview/EntryContentWebView.tsx | 19 +- .../src/components/ui/grouped/GroupedList.tsx | 13 ++ apps/mobile/src/lib/platform.ts | 3 +- apps/mobile/src/lib/responsive.ts | 41 +++- apps/mobile/src/modules/ai/summary.tsx | 2 +- .../src/modules/entry-content/EntryTitle.tsx | 2 +- .../entry-list/EntryListContentArticle.tsx | 24 ++- .../entry-list/EntryListContentPicture.tsx | 9 +- .../entry-list/EntryListContentVideo.tsx | 9 +- apps/mobile/src/modules/login/index.tsx | 180 +++++++++++------- apps/mobile/src/modules/login/social.tsx | 6 +- apps/mobile/src/modules/onboarding/shared.tsx | 7 +- .../screen/TimelineSelectorProvider.tsx | 5 +- .../modules/screen/TimelineViewSelector.tsx | 71 ++++--- .../modules/subscription/CategoryGrouped.tsx | 11 +- .../modules/subscription/ItemSeparator.tsx | 53 +++--- .../subscription/SubscriptionLists.tsx | 17 +- .../modules/subscription/items/InboxItem.tsx | 11 +- .../items/ListSubscriptionItem.tsx | 4 +- .../subscription/items/SubscriptionItem.tsx | 11 +- .../src/screens/(modal)/LoginScreen.tsx | 18 +- .../entries/[entryId]/EntryDetailScreen.tsx | 15 +- apps/mobile/src/screens/OnboardingScreen.tsx | 5 +- .../mobile/web-app/html-renderer/src/HTML.tsx | 10 +- .../src/common/WrappedElementProvider.tsx | 2 +- .../html-renderer/src/components/image.tsx | 5 +- .../web-app/html-renderer/src/parser.tsx | 7 +- 32 files changed, 456 insertions(+), 191 deletions(-) diff --git a/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift b/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift index 0520f5ffda4..7be6c06122c 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift @@ -57,12 +57,52 @@ private class FOWKWebViewConfiguration: WKWebViewConfiguration { let css = """ :root { overflow: hidden !important; overflow-behavior: none !important; } body { + margin: 0 !important; overflow-y: visible !important; position: absolute !important; width: 100% !important; + max-width: 100% !important; height: auto !important; -webkit-overflow-scrolling: touch !important; } + #root, + article { + width: 100% !important; + max-width: 100% !important; + margin-left: 0 !important; + margin-right: 0 !important; + box-sizing: border-box !important; + } + article > p, + article > div, + article > ul, + article > ol, + article > pre, + article > table, + article > blockquote, + article > h1, + article > h2, + article > h3, + article > h4, + article > h5, + article > h6, + article > figure { + width: 100% !important; + max-width: 100% !important; + margin-left: 0 !important; + margin-right: 0 !important; + box-sizing: border-box !important; + } + article figure { + margin-top: 0 !important; + margin-bottom: 1rem !important; + } + article button[data-image-width], + article figure img, + article > img { + margin-left: auto !important; + margin-right: auto !important; + } ::selection { background-color: \(hexAccentColor) !important; } diff --git a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift index 9cbfef20512..7e6d670081e 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift @@ -6,6 +6,7 @@ import WebKit class WebViewView: ExpoView { private var cancellable: AnyCancellable? + private var lastReportedHeight: CGFloat = 0 required init(appContext: AppContext? = nil) { super.init(appContext: appContext) @@ -14,7 +15,7 @@ class WebViewView: ExpoView { cancellable = WebViewManager.state.$contentHeight .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.layoutSubviews() + self?.setNeedsLayout() } } @@ -25,15 +26,18 @@ class WebViewView: ExpoView { private let onContentHeightChange = ExpoModulesCore.EventDispatcher() override func layoutSubviews() { + super.layoutSubviews() let rect = CGRect( - x: bounds.origin.x, - y: bounds.origin.y, + x: 0, + y: 0, width: bounds.width, height: WebViewManager.state.contentHeight ) WebViewManager.updateFrame(rect) - frame = rect - onContentHeightChange(["height": Float(rect.height)]) + if abs(lastReportedHeight - rect.height) > 0.5 { + lastReportedHeight = rect.height + onContentHeightChange(["height": Float(rect.height)]) + } } diff --git a/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift b/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift index d732f2d319c..28e2c5c18cc 100644 --- a/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift +++ b/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift @@ -14,6 +14,7 @@ import UIKit enum CustomTabbarController { static var tabBarController = { let tabBarController = UITabBarController() + let isPad = UIDevice.current.userInterfaceIdiom == .pad if #available(iOS 16.0, *), UIDevice.current.userInterfaceIdiom == .pad { tabBarController.tabBar.isTranslucent = false tabBarController.tabBar.barStyle = .default @@ -25,7 +26,7 @@ enum CustomTabbarController { tabBarController.isTabBarHidden = true } - if #available(iOS 26.0, *) { + if #available(iOS 26.0, *), !isPad { tabBarController.isTabBarHidden = false tabBarController.tabBarMinimizeBehavior = .onScrollDown diff --git a/apps/mobile/src/components/common/NoLoginInfo.tsx b/apps/mobile/src/components/common/NoLoginInfo.tsx index 76e1d1042ca..e24c08022a0 100644 --- a/apps/mobile/src/components/common/NoLoginInfo.tsx +++ b/apps/mobile/src/components/common/NoLoginInfo.tsx @@ -1,20 +1,26 @@ -import { Pressable } from "react-native" +import { Pressable, View } from "react-native" import { Text } from "@/src/components/ui/typography/Text" import { destination } from "@/src/lib/navigation/biz/Destination" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { accentColor } from "@/src/theme/colors" import { Logo } from "../ui/logo" export function NoLoginInfo({ target }: { target: "timeline" | "subscriptions" }) { + const readableContainerStyle = useReadableContainerStyle(420) return ( destination.Login()} > - - {`Sign in to see your ${target}`} + + + + {`Sign in to see your ${target}`} + + ) } diff --git a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx index 4ebfe33fa29..bf212dc76e1 100644 --- a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx +++ b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx @@ -38,6 +38,7 @@ type SafeNavigationScrollViewProps = Omit & { contentViewStyle?: StyleProp contentViewClassName?: string + contentContainerMaxWidth?: number Header?: React.ReactNode ScrollViewBottom?: React.ReactNode @@ -55,6 +56,7 @@ export const SafeNavigationScrollView = ({ reanimatedScrollY, contentViewClassName, contentViewStyle, + contentContainerMaxWidth, Header, ScrollViewBottom, ...props @@ -63,6 +65,9 @@ export const SafeNavigationScrollView = ({ const tabBarHeight = useBottomTabBarHeight() const frame = useSafeAreaFrame() + const resolvedContentContainerWidth = contentContainerMaxWidth + ? Math.min(frame.width, contentContainerMaxWidth) + : undefined const sheetModal = useScreenIsInSheetModal() const [headerHeight, setHeaderHeight] = useState( () => @@ -140,7 +145,18 @@ export const SafeNavigationScrollView = ({ {...props} > - + {children} {ScrollViewBottom} diff --git a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx index e6475d47352..c298d310bca 100644 --- a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx +++ b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx @@ -3,7 +3,7 @@ import { Portal } from "@gorhom/portal" import { useAtom } from "jotai" import * as React from "react" import { useCallback, useEffect } from "react" -import { View } from "react-native" +import { Dimensions, StyleSheet, View } from "react-native" import { runOnJS, runOnUI } from "react-native-reanimated" import TrackPlayer from "react-native-track-player" @@ -99,16 +99,22 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { handleModeSwitch(nextMode) }, [mode, handleModeSwitch]) + useEffect(() => { + // Reset the shared container height before the next entry content arrives. + setContentHeight(Dimensions.get("window").height) + }, [props.entryId, props.showReadability, props.showTranslation, setContentHeight]) + return ( <> { WebViewManager.setEntry(entryInWebview) }} > { setContentHeight(e.nativeEvent.height) }} @@ -127,3 +133,10 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { ) } + +const styles = StyleSheet.create({ + webView: { + width: "100%", + height: "100%", + }, +}) diff --git a/apps/mobile/src/components/ui/grouped/GroupedList.tsx b/apps/mobile/src/components/ui/grouped/GroupedList.tsx index 90b25166b12..58538a4aa49 100644 --- a/apps/mobile/src/components/ui/grouped/GroupedList.tsx +++ b/apps/mobile/src/components/ui/grouped/GroupedList.tsx @@ -12,6 +12,7 @@ import { titleCase } from "title-case" import { Text } from "@/src/components/ui/typography/Text" import { CheckFilledIcon } from "@/src/icons/check_filled" import { MingcuteRightLine } from "@/src/icons/mingcute_right_line" +import { useIsTabletLayout } from "@/src/lib/responsive" import { accentColor, useColor } from "@/src/theme/colors" import { PlatformActivityIndicator } from "../loading/PlatformActivityIndicator" @@ -24,6 +25,8 @@ import { } from "./constants" import { GroupedInsetListCardItemStyle } from "./GroupedInsetListCardItemStyle" +const GROUPED_TABLET_MAX_WIDTH = 760 + interface GroupedInsetListCardProps { showSeparator?: boolean SeparatorComponent?: FC @@ -53,10 +56,16 @@ export const GroupedInsetListCard: FC< () => React.Children.toArray(children).filter(Boolean), [children], ) + const isTablet = useIsTabletLayout() return ( = ({ label, marginSize = "normal" }) => { + const isTablet = useIsTabletLayout() return ( { - return useDeviceType() && isIOS + return useDeviceType() === DeviceType.TABLET && isIOS } export const isIos26 = Number.parseFloat(String(Platform.Version)) >= 26 diff --git a/apps/mobile/src/lib/responsive.ts b/apps/mobile/src/lib/responsive.ts index 886ecb5d497..e0241fcaa3a 100644 --- a/apps/mobile/src/lib/responsive.ts +++ b/apps/mobile/src/lib/responsive.ts @@ -1,8 +1,15 @@ -import { useCallback } from "react" +import { DeviceType } from "expo-device" +import { useCallback, useMemo } from "react" +import type { ViewStyle } from "react-native" import { Dimensions, useWindowDimensions } from "react-native" +import { useDeviceType } from "../atoms/hooks/useDeviceType" +import { isIOS } from "./platform" + const baseWidth = 375 const baseHeight = 812 +const maxResponsiveHeight = 932 +const tabletMinLength = 744 const windowDim = Dimensions.get("window") Dimensions.addEventListener("change", ({ window }) => { @@ -22,7 +29,7 @@ export const scaleWidth = (size: number) => { * @returns */ export const scaleHeight = (size: number) => { - return (size / baseHeight) * windowDim.height + return (size / baseHeight) * Math.min(windowDim.height, maxResponsiveHeight) } export const useScaleWidth = () => { const windowDim = useWindowDimensions() @@ -40,8 +47,36 @@ export const useScaleHeight = () => { return useCallback( (size: number) => { - return (size / baseHeight) * windowDim.height + return (size / baseHeight) * Math.min(windowDim.height, maxResponsiveHeight) }, [windowDim.height], ) } + +export const useIsTabletLayout = () => { + const deviceType = useDeviceType() + const { width, height } = useWindowDimensions() + + if (!isIOS) { + return false + } + + return deviceType === DeviceType.TABLET || Math.min(width, height) >= tabletMinLength +} + +export const useReadableContainerStyle = (maxWidth: number, gutter = 24) => { + const isTablet = useIsTabletLayout() + const { width } = useWindowDimensions() + + return useMemo(() => { + if (!isTablet) { + return + } + + return { + width: "100%", + maxWidth: Math.max(Math.min(maxWidth, width - gutter * 2), 0), + alignSelf: "center", + } + }, [gutter, isTablet, maxWidth, width]) +} diff --git a/apps/mobile/src/modules/ai/summary.tsx b/apps/mobile/src/modules/ai/summary.tsx index 530624ac36f..14fa8f7ef32 100644 --- a/apps/mobile/src/modules/ai/summary.tsx +++ b/apps/mobile/src/modules/ai/summary.tsx @@ -160,7 +160,7 @@ export const AISummary: FC<{ const mainContent = ( | null> }) => { const extraData: EntryExtraData = useMemo(() => ({ entryIds }), [entryIds]) + const readableItemStyle = useReadableContainerStyle(860, 16) const { fetchNextPage, isFetching, refetch, isRefetching, hasNextPage, fetchedTime, isReady } = useEntries({ viewId: view, active }) const renderItem = useCallback( ({ item: id, extraData, index }: ListRenderItemInfo) => ( - 0} - testID={index === 0 ? "timeline-entry-first" : undefined} - /> + + 0} + testID={index === 0 ? "timeline-entry-first" : undefined} + /> + ), - [view], + [readableItemStyle, view], ) const ListFooterComponent = useMemo( @@ -89,7 +93,9 @@ export const EntryListContentArticle = ({ return ( {ARTICLE_SKELETON_KEYS.map((key) => ( - + + + ))} ) diff --git a/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx b/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx index 40ce82797b7..c65f30e8eac 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx @@ -11,6 +11,7 @@ import { StyleSheet, View } from "react-native" import { useActionLanguage, useGeneralSettingKey } from "@/src/atoms/settings/general" import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks" import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator" +import { useIsTabletLayout } from "@/src/lib/responsive" import { useEntries } from "@/src/modules/screen/atoms" import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight" @@ -38,6 +39,7 @@ export const EntryListContentPicture = ({ "data" | "renderItem" > & { ref?: React.Ref | null> }) => { const ref = useRef>(null) + const isTablet = useIsTabletLayout() useImperativeHandle(forwardRef, () => ref.current!) const { fetchNextPage, refetch, isRefetching, hasNextPage, isFetching, isReady } = useEntries({ @@ -103,8 +105,8 @@ export const EntryListContentPicture = ({ onViewableItemsChanged={onViewableItemsChanged} onScroll={onScroll} onEndReached={fetchNextPage} - numColumns={2} - contentContainerStyle={styles.contentContainer} + numColumns={isTablet ? 3 : 2} + contentContainerStyle={isTablet ? styles.tabletContentContainer : styles.contentContainer} ListFooterComponent={ hasNextPage ? ( @@ -164,6 +166,9 @@ const styles = StyleSheet.create({ contentContainer: { paddingHorizontal: 8, }, + tabletContentContainer: { + paddingHorizontal: 16, + }, skeletonHeight120: { height: 120, }, diff --git a/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx b/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx index 5c8869d8df0..deebe1d0593 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx @@ -10,6 +10,7 @@ import { StyleSheet, View } from "react-native" import { useActionLanguage, useGeneralSettingKey } from "@/src/atoms/settings/general" import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks" +import { useIsTabletLayout } from "@/src/lib/responsive" import { useEntries } from "@/src/modules/screen/atoms" import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight" @@ -32,6 +33,7 @@ export const EntryListContentVideo = ({ > & { ref?: React.Ref | null> }) => { const ref = useRef>(null) useImperativeHandle(forwardRef, () => ref.current!) + const isTablet = useIsTabletLayout() const { fetchNextPage, refetch, isRefetching, isFetching, hasNextPage, isReady } = useEntries({ viewId: view, active, @@ -106,11 +108,11 @@ export const EntryListContentVideo = ({ onViewableItemsChanged={onViewableItemsChanged} onScroll={onScroll} onEndReached={fetchNextPage} - numColumns={2} + numColumns={isTablet ? 3 : 2} ListFooterComponent={ListFooterComponent} {...rest} onRefresh={refetch} - contentContainerStyle={styles.contentContainer} + contentContainerStyle={isTablet ? styles.tabletContentContainer : styles.contentContainer} /> ) } @@ -119,6 +121,9 @@ const styles = StyleSheet.create({ contentContainer: { paddingHorizontal: 8, }, + tabletContentContainer: { + paddingHorizontal: 16, + }, }) const defaultKeyExtractor = (item: string) => { diff --git a/apps/mobile/src/modules/login/index.tsx b/apps/mobile/src/modules/login/index.tsx index de58e8d3123..6d1aab1825d 100644 --- a/apps/mobile/src/modules/login/index.tsx +++ b/apps/mobile/src/modules/login/index.tsx @@ -1,13 +1,14 @@ +import { cn } from "@follow/utils" import { useState } from "react" import { Trans, useTranslation } from "react-i18next" -import { Linking, Pressable, ScrollView, View } from "react-native" -import { KeyboardAvoidingView } from "react-native-keyboard-controller" +import { Linking, Pressable, TouchableWithoutFeedback, View } from "react-native" +import { KeyboardAvoidingView, KeyboardController } from "react-native-keyboard-controller" import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated" import { useSafeAreaInsets } from "react-native-safe-area-context" import { Logo } from "@/src/components/ui/logo" import { Text } from "@/src/components/ui/typography/Text" -import { useScaleHeight } from "@/src/lib/responsive" +import { useIsTabletLayout, useReadableContainerStyle, useScaleHeight } from "@/src/lib/responsive" import { EmailLogin, EmailSignUp } from "./email" import { SocialLogin } from "./social" @@ -15,6 +16,8 @@ import { SocialLogin } from "./social" export function Login() { const insets = useSafeAreaInsets() const scaledHeight = useScaleHeight() + const isTablet = useIsTabletLayout() + const contentWidthStyle = useReadableContainerStyle(480) const logoSize = scaledHeight(80) const gapSize = scaledHeight(28) const fontSize = scaledHeight(28) @@ -22,81 +25,111 @@ export function Login() { const { t } = useTranslation() const [isRegister, setIsRegister] = useState(true) const [isEmail, setIsEmail] = useState(false) - const contentContainerStyle = { - flexGrow: 1, - paddingTop: insets.top + 56, - paddingBottom: insets.bottom + 24, - } return ( - - - + + { + KeyboardController.dismiss() + }} + accessible={false} > - - + + - - - {`${isRegister ? t("signin.sign_up_to") : t("signin.sign_in_to")} `} - Folo - + {`${isRegister ? t("signin.sign_up_to") : t("signin.sign_in_to")} `} + Folo + + {isEmail ? ( + isRegister ? ( + + ) : ( + + ) + ) : ( + setIsEmail(true)} isRegister={isRegister} /> + )} + + + {!isTablet && ( + <> + + {isEmail ? ( - isRegister ? ( - - ) : ( - - ) + setIsEmail(false)} + > + {t("login.back")} + ) : ( - setIsEmail(true)} isRegister={isRegister} /> - )} - - - - - {isEmail ? ( - setIsEmail(false)} - > - {t("login.back")} + setIsRegister(!isRegister)}> + + , + }} + /> - ) : ( - setIsRegister(!isRegister)}> - - , - }} - /> - - - )} - + + )} - - + + )} + {isTablet && ( + + + + {isEmail ? ( + setIsEmail(false)} + > + {t("login.back")} + + ) : ( + setIsRegister(!isRegister)}> + + , + }} + /> + + + )} + + + )} ) } @@ -110,7 +143,10 @@ const TermsCheckBox = () => { ], })) return ( - + ) @@ -118,16 +154,16 @@ const TermsCheckBox = () => { const TermsText = () => { const { t } = useTranslation() return ( - + {t("login.agree_to")} - + Linking.openURL("https://folo.is/terms-of-service")} className="text-secondary-label" > {t("login.terms")} - / +  &  Linking.openURL("https://folo.is/privacy-policy")} className="text-secondary-label" diff --git a/apps/mobile/src/modules/login/social.tsx b/apps/mobile/src/modules/login/social.tsx index dec5d6e84eb..7b8b891f0af 100644 --- a/apps/mobile/src/modules/login/social.tsx +++ b/apps/mobile/src/modules/login/social.tsx @@ -25,7 +25,7 @@ export function SocialLogin({ onPressEmail }: { isRegister: boolean; onPressEmai const { t } = useTranslation() return ( - + )} @@ -106,7 +106,7 @@ export function SocialLogin({ onPressEmail }: { isRegister: boolean; onPressEmai uri: colorScheme === "dark" ? provider.iconDark64 || provider.icon64 : provider.icon64, }} - className="absolute left-9 size-6" + className="absolute left-6 size-6" contentFit="contain" /> diff --git a/apps/mobile/src/modules/onboarding/shared.tsx b/apps/mobile/src/modules/onboarding/shared.tsx index d705be4b3e3..b46c356b0ce 100644 --- a/apps/mobile/src/modules/onboarding/shared.tsx +++ b/apps/mobile/src/modules/onboarding/shared.tsx @@ -1,16 +1,17 @@ -import { ScrollView } from "react-native" +import { ScrollView, View } from "react-native" -import { useScaleHeight } from "@/src/lib/responsive" +import { useReadableContainerStyle, useScaleHeight } from "@/src/lib/responsive" export const OnboardingSectionScreenContainer = ({ children }: { children: React.ReactNode }) => { const height = useScaleHeight()(50) + const readableContentStyle = useReadableContainerStyle(680) return ( - {children} + {children} ) } diff --git a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx index 36d9cf86ba5..446eda152a9 100644 --- a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx +++ b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx @@ -14,6 +14,7 @@ import { DefaultHeaderBackButton } from "@/src/components/layouts/header/Navigat import { NavigationBlurEffectHeader } from "@/src/components/layouts/views/SafeNavigationScrollView" import { gentleSpringPreset } from "@/src/constants/spring" import { TIMELINE_VIEW_SELECTOR_HEIGHT } from "@/src/constants/ui" +import { useIsTabletLayout } from "@/src/lib/responsive" import { ActionGroup, FeedShareActionButton, @@ -33,12 +34,14 @@ export function TimelineHeader({ feedId }: { feedId?: string }) { const isFeed = screenType === "feed" const isTimeline = screenType === "timeline" const isSubscriptions = screenType === "subscriptions" + const isTablet = useIsTabletLayout() const { isFetching } = useEntries() + const shouldHideDuplicatedTitle = isTablet && (isTimeline || isSubscriptions) return ( } + headerTitle={shouldHideDuplicatedTitle ? undefined : } isLoading={(isFeed || isTimeline) && isFetching} headerLeft={useMemo( () => diff --git a/apps/mobile/src/modules/screen/TimelineViewSelector.tsx b/apps/mobile/src/modules/screen/TimelineViewSelector.tsx index 16a0ec2fd77..89659269c8d 100644 --- a/apps/mobile/src/modules/screen/TimelineViewSelector.tsx +++ b/apps/mobile/src/modules/screen/TimelineViewSelector.tsx @@ -5,13 +5,14 @@ import * as React from "react" import { useEffect } from "react" import { useTranslation } from "react-i18next" import type { StyleProp, ViewStyle } from "react-native" -import { ScrollView, Text, useWindowDimensions, View } from "react-native" +import { ScrollView, StyleSheet, Text, useWindowDimensions, View } from "react-native" import Animated, { interpolate, interpolateColor, useAnimatedStyle } from "react-native-reanimated" import { ReAnimatedPressable } from "@/src/components/common/AnimatedComponents" import { TIMELINE_VIEW_SELECTOR_HEIGHT } from "@/src/constants/ui" import type { ViewDefinition } from "@/src/constants/views" import { views } from "@/src/constants/views" +import { useIsTabletLayout, useReadableContainerStyle } from "@/src/lib/responsive" import { selectTimeline, useSelectedFeed, @@ -25,10 +26,18 @@ import { TimelineViewSelectorContextMenu } from "./TimelineViewSelectorContextMe const ACTIVE_WIDTH = 180 const INACTIVE_WIDTH = 48 const ACTIVE_TEXT_WIDTH = 100 +const MAX_TABLET_ACTIVE_WIDTH = 280 +const styles = StyleSheet.create({ + scrollView: { + width: "100%", + }, +}) export function TimelineViewSelector() { const activeViews = useViewWithSubscription() const scrollViewRef = React.useRef(null) const selectedFeed = useSelectedFeed() + const readableContainerStyle = useReadableContainerStyle(760, 12) + const activeViewCount = activeViews.length return ( - - {activeViews.map((v, index) => { - const view = views.find((view) => view.view === v) - if (!view) return null - return ( - - ) - })} - + + 0 + ? { + minWidth: "100%", + justifyContent: "center", + gap: 12, + } + : undefined + } + showsHorizontalScrollIndicator={false} + > + {activeViews.map((v, index) => { + const view = views.find((view) => view.view === v) + if (!view) return null + return ( + + ) + })} + + ) } @@ -81,10 +102,14 @@ function ItemWrapper({ const { width: windowWidth } = useWindowDimensions() const activeViews = useViewWithSubscription() const dragProgress = useTimelineSelectorDragProgress() + const isTablet = useIsTabletLayout() const activeWidth = Math.max( windowWidth - (INACTIVE_WIDTH + 12) * (activeViews.length - 1) - 8 * 2, ACTIVE_WIDTH, ) + const resolvedActiveWidth = isTablet + ? Math.min(activeWidth, MAX_TABLET_ACTIVE_WIDTH) + : activeWidth const bgColor = useColor("gray5") return ( - - -) export const ItemSeparator = () => { - return el + const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN) + return ( + + + + ) } -const el2 = ( - - - -) export const SecondaryItemSeparator = () => { - return el2 + const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN) + return ( + + + + ) } diff --git a/apps/mobile/src/modules/subscription/SubscriptionLists.tsx b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx index 4a6aeb16144..da6220816a6 100644 --- a/apps/mobile/src/modules/subscription/SubscriptionLists.tsx +++ b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx @@ -31,6 +31,7 @@ import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" import { Text } from "@/src/components/ui/typography/Text" import { StarCuteFiIcon } from "@/src/icons/star_cute_fi" import { useNavigation } from "@/src/lib/navigation/hooks" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { selectFeed } from "@/src/modules/screen/atoms" import { TimelineSelectorList } from "@/src/modules/screen/TimelineSelectorList" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" @@ -227,14 +228,18 @@ const ItemRender = ({ } const SectionTitle = ({ transKey }: { transKey: ParseKeys<"common"> }) => { const { t } = useTranslation("common") + const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN) return ( {t(transKey)} diff --git a/apps/mobile/src/modules/subscription/items/InboxItem.tsx b/apps/mobile/src/modules/subscription/items/InboxItem.tsx index 95a4ccbeda3..9e9cae1daaa 100644 --- a/apps/mobile/src/modules/subscription/items/InboxItem.tsx +++ b/apps/mobile/src/modules/subscription/items/InboxItem.tsx @@ -13,6 +13,7 @@ import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" import { Text } from "@/src/components/ui/typography/Text" import { InboxCuteFiIcon } from "@/src/icons/inbox_cute_fi" import { useNavigation } from "@/src/lib/navigation/hooks" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { selectFeed } from "@/src/modules/screen/atoms" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" @@ -25,13 +26,17 @@ export const InboxItem = memo(({ id, isFirst, isLast }: SubscriptionItemBaseProp const unreadCount = useUnreadById(id) const { colorScheme } = useColorScheme() const navigation = useNavigation() + const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN) if (!subscription) return null return ( { } }, [exit, switchTab, whoami?.id]) const isiPad = useIsiPad() - const Container = isiPad ? ScrollView : Fragment return ( <> - + {isiPad ? ( + + + + ) : ( - + )} ) } LoginScreen.sheetGrabberVisible = false + +const tabletScrollViewContentStyle = { + flexGrow: 1, +} diff --git a/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx b/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx index 434969f92b0..9fcd857537e 100644 --- a/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx +++ b/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx @@ -103,13 +103,14 @@ export const EntryDetailScreen: NavigationControllerView<{ Header={} ScrollViewBottom={} automaticallyAdjustContentInsets={false} + contentContainerMaxWidth={680} contentContainerClassName="flex min-h-full pb-16" {...scrollViewEventHandlers} > entry?.url && openLink(entry.url)} - className="rounded-xl py-4" + className="rounded-xl px-5 py-4" > {viewType === FeedViewType.SocialMedia ? ( @@ -120,14 +121,16 @@ export const EntryDetailScreen: NavigationControllerView<{ )} - + + + {entry && ( - + )} {viewType === FeedViewType.SocialMedia && ( - + )} @@ -207,7 +210,7 @@ const EntryInfo = ({ entryId }: { entryId: string }) => { if (!entry) return null const { publishedAt } = entry return ( - + {feed && ( @@ -238,7 +241,7 @@ const EntryInfoSocial = ({ entryId }: { entryId: string }) => { })) if (!entry) return null return ( - + {entry.publishedAt.toLocaleString(undefined, { dateStyle: "medium", diff --git a/apps/mobile/src/screens/OnboardingScreen.tsx b/apps/mobile/src/screens/OnboardingScreen.tsx index 62b736509ba..9496bc2429e 100644 --- a/apps/mobile/src/screens/OnboardingScreen.tsx +++ b/apps/mobile/src/screens/OnboardingScreen.tsx @@ -7,6 +7,7 @@ import Animated, { FadeInRight, FadeOutLeft } from "react-native-reanimated" import { useSafeAreaInsets } from "react-native-safe-area-context" import { Text } from "@/src/components/ui/typography/Text" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { useNavigation } from "../lib/navigation/hooks" import type { NavigationControllerView } from "../lib/navigation/types" @@ -22,6 +23,7 @@ const ONBOARDING_STEPS = [1, 2, 3, 4] export const OnboardingScreen: NavigationControllerView = () => { const { t } = useTranslation("common") const insets = useSafeAreaInsets() + const readableContentStyle = useReadableContainerStyle(680) const [currentStep, setCurrentStep] = useState(1) const totalSteps = ONBOARDING_STEPS.length const navigation = useNavigation() @@ -73,6 +75,7 @@ export const OnboardingScreen: NavigationControllerView = () => { key={`step-${currentStep}`} exiting={FadeOutLeft} entering={FadeInRight} + style={readableContentStyle} > {/* Content */} {currentStep === 1 && } @@ -82,7 +85,7 @@ export const OnboardingScreen: NavigationControllerView = () => { {/* Navigation buttons */} - + (props: { ...rest, ref: setRefElement, - className: clsx("prose mx-auto px-5 pb-8 [text-autospace:normal]", "dark:prose-invert"), + style: { + width: "100%", + maxWidth: "100%", + ...rest.style, + }, + className: clsx( + "prose max-w-none mx-auto pb-8 [text-autospace:normal]", + "dark:prose-invert", + ), }, markdownElement, )} diff --git a/apps/mobile/web-app/html-renderer/src/common/WrappedElementProvider.tsx b/apps/mobile/web-app/html-renderer/src/common/WrappedElementProvider.tsx index 2c757849f54..ee583aaf5c7 100644 --- a/apps/mobile/web-app/html-renderer/src/common/WrappedElementProvider.tsx +++ b/apps/mobile/web-app/html-renderer/src/common/WrappedElementProvider.tsx @@ -91,7 +91,7 @@ const Content: Component = memo( const As = as as any return ( - + {children} ) diff --git a/apps/mobile/web-app/html-renderer/src/components/image.tsx b/apps/mobile/web-app/html-renderer/src/components/image.tsx index 0db65ad5e11..0605a076ab8 100644 --- a/apps/mobile/web-app/html-renderer/src/components/image.tsx +++ b/apps/mobile/web-app/html-renderer/src/components/image.tsx @@ -37,10 +37,13 @@ export const MarkdownImage = (props: HTMLProps<"img">) => { return ( ``` diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index 4e46d72bdf1..5426fa08bb9 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -22,7 +22,8 @@ export class AuthService extends IpcService { const apiURL = env.VITE_API_URL const url = new URL(apiURL) - const isSecure = url.protocol === "https:" || url.hostname === "localhost" || url.hostname === "127.0.0.1" + const isSecure = + url.protocol === "https:" || url.hostname === "localhost" || url.hostname === "127.0.0.1" const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1" const cookieNames = [ BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN, diff --git a/apps/desktop/layer/renderer/src/lib/api-client.ts b/apps/desktop/layer/renderer/src/lib/api-client.ts index 654002bbf68..21d29d7404e 100644 --- a/apps/desktop/layer/renderer/src/lib/api-client.ts +++ b/apps/desktop/layer/renderer/src/lib/api-client.ts @@ -1,5 +1,5 @@ -import { env } from "@follow/shared/env.desktop" import { IN_ELECTRON } from "@follow/shared/constants" +import { env } from "@follow/shared/env.desktop" import { whoami } from "@follow/store/user/getters" import { userActions } from "@follow/store/user/store" import { createDesktopAPIHeaders } from "@follow/utils/headers" @@ -9,8 +9,7 @@ import PKG from "@pkg" import { NetworkStatus, setApiStatus } from "~/atoms/network" import { setLoginModalShow } from "~/atoms/user" -import { getAuthSessionToken } from "./client-session" -import { getClientId, getSessionId } from "./client-session" +import { getAuthSessionToken, getClientId, getSessionId } from "./client-session" export const followClient = new FollowClient({ credentials: "include", @@ -34,7 +33,7 @@ followClient.addRequestInterceptor(async (ctx) => { if (authSessionToken && !headers.has("Cookie") && !headers.has("cookie")) { headers.set( "Cookie", - `__Secure-better-auth.session_token=${authSessionToken}; better-auth.session_token=${authSessionToken}` + `__Secure-better-auth.session_token=${authSessionToken}; better-auth.session_token=${authSessionToken}`, ) } diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index 1427f06d0f1..96b0d7a9aa7 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -372,17 +372,20 @@ export function RegisterForm({ headers, }), ) - : await signUp.email({ - email: values.email, - password: values.password, - name: values.email.split("@")[0]!, - callbackURL: "/", - }, { - onError(context) { - toast.error(context.error.message) + : await signUp.email( + { + email: values.email, + password: values.password, + name: values.email.split("@")[0]!, + callbackURL: "/", }, - headers, - }) + { + onError(context) { + toast.error(context.error.message) + }, + headers, + }, + ) if (result?.error) { return result diff --git a/apps/ssr/client/pages/(login)/register.tsx b/apps/ssr/client/pages/(login)/register.tsx index e2c2085c3e8..f551befeb4f 100644 --- a/apps/ssr/client/pages/(login)/register.tsx +++ b/apps/ssr/client/pages/(login)/register.tsx @@ -69,27 +69,30 @@ function RegisterForm() { try { const recaptchaToken = await requestRecaptchaToken("ssr_register") - await signUp.email({ - email: values.email, - password: values.password, - name: values.email.split("@")[0]!, - callbackURL: "/", - }, { - onSuccess() { - tracker.register({ - type: "email", - }) - navigate("/login") + await signUp.email( + { + email: values.email, + password: values.password, + name: values.email.split("@")[0]!, + callbackURL: "/", }, - onError(context) { - toast.error(context.error.message) + { + onSuccess() { + tracker.register({ + type: "email", + }) + navigate("/login") + }, + onError(context) { + toast.error(context.error.message) + }, + headers: recaptchaToken + ? { + "x-token": `r3:${recaptchaToken}`, + } + : undefined, }, - headers: recaptchaToken - ? { - "x-token": `r3:${recaptchaToken}`, - } - : undefined, - }) + ) } finally { setIsSubmitting(false) } diff --git a/apps/ssr/tailwind.config.ts b/apps/ssr/tailwind.config.ts index b702de37e3e..0266b83a524 100644 --- a/apps/ssr/tailwind.config.ts +++ b/apps/ssr/tailwind.config.ts @@ -1,7 +1,7 @@ import { extendConfig } from "@follow/configs/tailwindcss/web" import daisyui from "daisyui" -import { withUIKit } from "tailwindcss-uikit-colors/macos" import type { Config } from "tailwindcss" +import { withUIKit } from "tailwindcss-uikit-colors/macos" /** @type {import('tailwindcss').Config} */ export default withUIKit( diff --git a/eslint.config.mjs b/eslint.config.mjs index 1c032ec94f2..b8f59ce9170 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -37,6 +37,14 @@ export default defineConfig( }, rules: { "no-debug/no-debug-stack": "error", + "tailwindcss/classnames-order": "off", + "tailwindcss/enforces-negative-arbitrary-values": "off", + "tailwindcss/enforces-shorthand": "off", + "tailwindcss/migration-from-tailwind-2": "off", + "tailwindcss/no-arbitrary-value": "off", + "tailwindcss/no-contradicting-classname": "off", + "tailwindcss/no-custom-classname": "off", + "tailwindcss/no-unnecessary-arbitrary-value": "off", "@eslint-react/no-clone-element": 0, "@eslint-react/hooks-extra/no-direct-set-state-in-use-effect": 0, "@eslint-react/dom/no-flush-sync": 1, diff --git a/packages/internal/components/src/ui/avatar/index.tsx b/packages/internal/components/src/ui/avatar/index.tsx index 0842052e39b..c881dde16c8 100644 --- a/packages/internal/components/src/ui/avatar/index.tsx +++ b/packages/internal/components/src/ui/avatar/index.tsx @@ -42,7 +42,7 @@ export const AvatarFallback = ({ {tooltipDescription ? ( -
{tooltipDescription}
+
{tooltipDescription}
) : null} diff --git a/packages/internal/components/src/ui/button/index.tsx b/packages/internal/components/src/ui/button/index.tsx index a21b9a4045c..2c3c05324ac 100644 --- a/packages/internal/components/src/ui/button/index.tsx +++ b/packages/internal/components/src/ui/button/index.tsx @@ -129,7 +129,7 @@ export const IconButton = ({ styledButtonVariant({ variant: "ghost", }), - "bg-accent/10 hover:bg-accent/20 active:bg-accent/30 dark:bg-accent/20 dark:hover:bg-accent/30 dark:active:bg-accent/40 group relative gap-2 px-4 transition-all duration-300", + "group relative gap-2 bg-accent/10 px-4 transition-all duration-300 hover:bg-accent/20 active:bg-accent/30 dark:bg-accent/20 dark:hover:bg-accent/30 dark:active:bg-accent/40", rest.className, )} > diff --git a/packages/internal/components/src/ui/card/index.tsx b/packages/internal/components/src/ui/card/index.tsx index fa697bb781b..3b78138af07 100644 --- a/packages/internal/components/src/ui/card/index.tsx +++ b/packages/internal/components/src/ui/card/index.tsx @@ -8,7 +8,7 @@ const Card = ({ }: React.HTMLAttributes & { ref?: React.Ref }) => (
) @@ -44,7 +44,7 @@ const CardDescription = ({ ...props }: React.HTMLAttributes & { ref?: React.Ref -}) =>

+}) =>

CardDescription.displayName = "CardDescription" const CardContent = ({ diff --git a/packages/internal/components/src/ui/checkbox/index.tsx b/packages/internal/components/src/ui/checkbox/index.tsx index 53441d5e4b9..af36162a65f 100644 --- a/packages/internal/components/src/ui/checkbox/index.tsx +++ b/packages/internal/components/src/ui/checkbox/index.tsx @@ -38,7 +38,7 @@ function Checkbox({ = ({ > {title} {!hideArrow && ( -

+
| null> }) => ( ) @@ -194,7 +194,7 @@ const ContextMenuSeparator = ({ ref?: React.Ref | null> }) => ( ({ "relative", "inline-block transition-transform duration-200 ease-out will-change-transform", "hover:before:bg-fill-tertiary", - "before:backdrop-blur-background before:absolute before:-inset-x-2 before:inset-y-0 before:z-[-1] before:scale-0 before:rounded-xl before:opacity-0 before:transition-all before:duration-200 before:[transform-origin:var(--origin-x)_var(--origin-y)] hover:before:scale-100 hover:before:opacity-100", + "before:absolute before:-inset-x-2 before:inset-y-0 before:z-[-1] before:scale-0 before:rounded-xl before:opacity-0 before:backdrop-blur-background before:transition-all before:duration-200 before:[transform-origin:var(--origin-x)_var(--origin-y)] hover:before:scale-100 hover:before:opacity-100", rest.className, )} > diff --git a/packages/internal/components/src/ui/form/index.tsx b/packages/internal/components/src/ui/form/index.tsx index e73f0c34a33..9acb76080c3 100644 --- a/packages/internal/components/src/ui/form/index.tsx +++ b/packages/internal/components/src/ui/form/index.tsx @@ -126,7 +126,7 @@ const FormDescription = ({

) @@ -152,7 +152,7 @@ const FormMessage = ({

{body} diff --git a/packages/internal/components/src/ui/hover-card/index.tsx b/packages/internal/components/src/ui/hover-card/index.tsx index 2412d619c0b..1a08eb919d3 100644 --- a/packages/internal/components/src/ui/hover-card/index.tsx +++ b/packages/internal/components/src/ui/hover-card/index.tsx @@ -23,8 +23,8 @@ const HoverCardContent = ({ align={align} sideOffset={sideOffset} className={cn( - "bg-material-medium backdrop-blur-background border-border text-text z-[60] w-fit overflow-hidden rounded-md border shadow-lg", - "motion-scale-in-95 motion-duration-200 text-body", + "z-[60] w-fit overflow-hidden rounded-md border border-border bg-material-medium text-text shadow-lg backdrop-blur-background", + "text-body motion-scale-in-95 motion-duration-200", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, )} diff --git a/packages/internal/components/src/ui/input/DateTimePicker.tsx b/packages/internal/components/src/ui/input/DateTimePicker.tsx index 3db2bf4c533..fc2d021ea90 100644 --- a/packages/internal/components/src/ui/input/DateTimePicker.tsx +++ b/packages/internal/components/src/ui/input/DateTimePicker.tsx @@ -235,7 +235,7 @@ export const DateTimePicker = memo( {viewMode === "days" && ( <> {/* Weekdays */} -

+
{weekDays.map((day) => (
{day.slice(0, 2)} @@ -362,9 +362,9 @@ export const DateTimePicker = memo( {/* Time Selection (only single mode) */} {!isRangeMode && ( -
+
- Time + Time {icon && ( -
+
{icon}
)} @@ -52,9 +52,9 @@ export const InputV2 = ({ className={cn( "min-w-0 flex-auto appearance-none rounded-lg text-sm", "bg-theme-background py-[calc(theme(spacing.2)-1px)]", - "ring-accent/20 focus:border-accent/80 duration-200 focus:outline-none focus:ring-2", + "ring-accent/20 duration-200 focus:border-accent/80 focus:outline-none focus:ring-2", "focus:!bg-accent/5", - "border-border border", + "border border-border", "placeholder:text-text-tertiary dark:bg-zinc-700/[0.15] dark:text-zinc-200", "hover:border-accent/60", props.type === "password" && "font-mono placeholder:font-sans", @@ -73,7 +73,7 @@ export const InputV2 = ({