From 2b0b412dbe071ea77e76d2acb6bdacbb2f1bf44f Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 26 May 2026 14:11:11 +0800 Subject: [PATCH] fix(mobile): avoid duplicate shared links --- apps/mobile/src/lib/share.test.ts | 55 +++++++++++++++++++ apps/mobile/src/lib/share.ts | 29 ++++++++++ .../mobile/src/modules/context-menu/entry.tsx | 15 +++-- .../mobile/src/modules/context-menu/video.tsx | 16 ++++-- .../EntryContentHeaderRightActions.tsx | 11 ++-- apps/mobile/src/modules/screen/action.tsx | 16 ++++-- .../src/screens/(modal)/ProfileScreen.tsx | 15 +++-- 7 files changed, 127 insertions(+), 30 deletions(-) create mode 100644 apps/mobile/src/lib/share.test.ts create mode 100644 apps/mobile/src/lib/share.ts diff --git a/apps/mobile/src/lib/share.test.ts b/apps/mobile/src/lib/share.test.ts new file mode 100644 index 000000000..d3a380b77 --- /dev/null +++ b/apps/mobile/src/lib/share.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest" + +import { createLinkShareContent } from "./share" + +describe("createLinkShareContent", () => { + it("uses a single URL item on iOS", () => { + const url = "https://example.com/post/1" + + const content = createLinkShareContent({ + platform: "ios", + title: "Post title", + url, + message: url, + }) + + expect(content).toEqual({ + title: "Post title", + url, + }) + expect(content).not.toHaveProperty("message") + }) + + it("uses a single text item on Android", () => { + const url = "https://example.com/post/1" + const message = `Check out this post: ${url}` + + const content = createLinkShareContent({ + platform: "android", + title: "Post title", + url, + message, + }) + + expect(content).toEqual({ + title: "Post title", + message, + }) + expect(content).not.toHaveProperty("url") + }) + + it("falls back to the URL as Android share text", () => { + const url = "https://example.com/post/1" + + expect( + createLinkShareContent({ + platform: "android", + title: "Post title", + url, + }), + ).toEqual({ + title: "Post title", + message: url, + }) + }) +}) diff --git a/apps/mobile/src/lib/share.ts b/apps/mobile/src/lib/share.ts new file mode 100644 index 000000000..c3c6451e0 --- /dev/null +++ b/apps/mobile/src/lib/share.ts @@ -0,0 +1,29 @@ +import type { ShareContent } from "react-native" + +type LinkSharePlatform = "android" | "ios" | "macos" | "native" | "web" | "windows" + +interface CreateLinkShareContentOptions { + platform: LinkSharePlatform + title?: string + url: string + message?: string +} + +export const createLinkShareContent = ({ + platform, + title, + url, + message, +}: CreateLinkShareContentOptions): ShareContent => { + if (platform === "ios") { + return { + title, + url, + } + } + + return { + title, + message: message || url, + } +} diff --git a/apps/mobile/src/modules/context-menu/entry.tsx b/apps/mobile/src/modules/context-menu/entry.tsx index a6c3c0a68..c1bad50e3 100644 --- a/apps/mobile/src/modules/context-menu/entry.tsx +++ b/apps/mobile/src/modules/context-menu/entry.tsx @@ -9,7 +9,7 @@ import { PortalProvider } from "@gorhom/portal" import type { PropsWithChildren } from "react" import { useCallback } from "react" import { useTranslation } from "react-i18next" -import { Share, View } from "react-native" +import { Platform, Share, View } from "react-native" import { getHideAllReadSubscriptions } from "@/src/atoms/settings/general" import { EntryContentWebView } from "@/src/components/native/webview/EntryContentWebView" @@ -17,6 +17,7 @@ import { WebViewManager } from "@/src/components/native/webview/webview-manager" import { ContextMenu } from "@/src/components/ui/context-menu" import { Text } from "@/src/components/ui/typography/Text" import { useNavigation } from "@/src/lib/navigation/hooks" +import { createLinkShareContent } from "@/src/lib/share" import { toast } from "@/src/lib/toast" import { playEntryTts } from "@/src/modules/player/entry-tts" import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen" @@ -204,11 +205,13 @@ export const EntryItemContextMenu = ({ key="Share" onSelect={async () => { if (!entry.url) return - await Share.share({ - message: entry.url, - url: entry.url, - title: entry.title || "Shared Link", - }) + await Share.share( + createLinkShareContent({ + platform: Platform.OS, + title: entry.title || "Shared Link", + url: entry.url, + }), + ) }} > { if (!entry.url) return - await Share.share({ - message: [entry.title, entry.url].filter(Boolean).join("\n"), - url: entry.url, - title: entry.title || "Shared Video", - }) + await Share.share( + createLinkShareContent({ + platform: Platform.OS, + title: entry.title || "Shared Video", + url: entry.url, + message: [entry.title, entry.url].filter(Boolean).join("\n"), + }), + ) return }} > diff --git a/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx b/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx index 095677523..854dc7a57 100644 --- a/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx +++ b/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx @@ -10,7 +10,7 @@ import { setStringAsync } from "expo-clipboard" import { useAtom } from "jotai" import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" -import { Pressable, Share, View } from "react-native" +import { Platform, Pressable, Share, View } from "react-native" import type { SharedValue } from "react-native-reanimated" import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated" import { useColor } from "react-native-uikit-colors" @@ -27,6 +27,7 @@ import { StarCuteReIcon } from "@/src/icons/star_cute_re" import { Translate2CuteReIcon } from "@/src/icons/translate_2_cute_re" import { VoiceCuteReIcon } from "@/src/icons/voice_cute_re" import { hideIntelligenceGlowEffect, openLink } from "@/src/lib/native" +import { createLinkShareContent } from "@/src/lib/share" import { toast } from "@/src/lib/toast" import { playEntryTts } from "@/src/modules/player/entry-tts" @@ -100,11 +101,9 @@ const HeaderRightActionsImpl = ({ const handleShare = () => { if (!entry?.title || !entry?.url) return - Share.share({ - message: entry.url, - title: entry.title, - url: entry.url, - }) + Share.share( + createLinkShareContent({ platform: Platform.OS, title: entry.title, url: entry.url }), + ) } const toggleAITranslation = () => { diff --git a/apps/mobile/src/modules/screen/action.tsx b/apps/mobile/src/modules/screen/action.tsx index 60d13e56d..a2a97889b 100644 --- a/apps/mobile/src/modules/screen/action.tsx +++ b/apps/mobile/src/modules/screen/action.tsx @@ -5,7 +5,7 @@ import * as Haptics from "expo-haptics" import type { PropsWithChildren } from "react" import { useCallback } from "react" import { useTranslation } from "react-i18next" -import { Pressable, Share, View } from "react-native" +import { Platform, Pressable, Share, View } from "react-native" import { setGeneralSetting, useGeneralSettingKey } from "@/src/atoms/settings/general" import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar" @@ -17,6 +17,7 @@ import { ShareForwardCuteReIcon } from "@/src/icons/share_forward_cute_re" import { Dialog } from "@/src/lib/dialog" import { useNavigation } from "@/src/lib/navigation/hooks" import { proxyEnv } from "@/src/lib/proxy-env" +import { createLinkShareContent } from "@/src/lib/share" import { toast } from "@/src/lib/toast" import { LoginScreen } from "@/src/screens/(modal)/LoginScreen" import { ProfileScreen } from "@/src/screens/(modal)/ProfileScreen" @@ -124,11 +125,14 @@ export const FeedShareActionButton = ({ const feed = getFeedById(feedId) if (!feed) return const url = `${proxyEnv.WEB_URL}/share/feeds/${feedId}` - Share.share({ - message: `Check out ${feed.title} on Folo: ${url}`, - title: feed.title!, - url, - }) + Share.share( + createLinkShareContent({ + platform: Platform.OS, + title: feed.title!, + url, + message: `Check out ${feed.title} on Folo: ${url}`, + }), + ) }} /> ) diff --git a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx index 99dba97db..9827711e1 100644 --- a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx +++ b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx @@ -7,7 +7,7 @@ import { usePrefetchUser, useUserById, useWhoami } from "@follow/store/user/hook import { Image as ExpoImage } from "expo-image" import { createContext, Fragment, use, useCallback, useEffect, useMemo } from "react" import { useTranslation } from "react-i18next" -import { Alert, FlatList, Pressable, Share, View } from "react-native" +import { Alert, FlatList, Platform, Pressable, Share, View } from "react-native" import Animated, { interpolate, useAnimatedScrollHandler, @@ -42,6 +42,7 @@ import { ShareForwardCuteReIcon } from "@/src/icons/share_forward_cute_re" import type { followClient } from "@/src/lib/api-client" import { Navigation } from "@/src/lib/navigation/Navigation" import type { NavigationControllerView } from "@/src/lib/navigation/types" +import { createLinkShareContent } from "@/src/lib/share" import { toast } from "@/src/lib/toast" import { useShareSubscription } from "@/src/modules/settings/hooks/useShareSubscription" import { UserHeaderBanner } from "@/src/modules/settings/UserHeaderBanner" @@ -95,11 +96,13 @@ function ProfileScreenImpl(props: { userId: string }) { const openShareUrl = useCallback(() => { if (!user?.id) return const shareUrl = `https://app.folo.is/share/users/${user.id}` - Share.share({ - message: shareUrl, - url: shareUrl, - title: `Folo | ${user.name}'s Profile`, - }) + Share.share( + createLinkShareContent({ + platform: Platform.OS, + title: `Folo | ${user.name}'s Profile`, + url: shareUrl, + }), + ) }, [user?.id, user?.name]) const whoami = useWhoami()