From a94fa81f4810b646a5048531175ceb98da9a0c51 Mon Sep 17 00:00:00 2001 From: Anselme Date: Fri, 13 Dec 2024 08:39:58 +0100 Subject: [PATCH] feat: ios preprocessing page (url metadata) --- README.md | 34 +++--- example/basic/App.tsx | 49 +++++++- example/basic/app.json | 4 +- example/basic/package.json | 2 + example/expo-router/app/shareintent.tsx | 47 +++++++- .../app/ShareIntentScreen.tsx | 44 ++++++- ios/ExpoShareIntentModule.swift | 44 ++++++- .../ios/ShareExtensionViewController.swift | 78 +++++++++++-- plugin/src/ios/constants.ts | 2 + .../ios/withIosShareExtensionXcodeTarget.ts | 31 ++--- plugin/src/ios/writeIosShareExtensionFiles.ts | 54 +++++++++ plugin/src/types.ts | 1 + src/ExpoShareIntentModule.types.ts | 5 +- src/index.ts | 4 +- src/useShareIntent.tsx | 93 +-------------- src/utils.ts | 108 +++++++++++++++++- 16 files changed, 446 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index df839be..8448ffd 100644 --- a/README.md +++ b/README.md @@ -148,13 +148,14 @@ export default const App = () => { const { shareIntent } = useShareIntent(); ``` -| attribute | description | example | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `shareIntent.text` | raw text from text/weburl (ios) and text/\* (android) | "`some text`", "`http://example.com`", "`Hey, Click on my link : http://example.com/nickname`" | -| `shareIntent.webUrl` | link extracted from raw text | `null`, "`http://example.com`", "`http://example.com/nickname`" | -| `shareIntent.files` | image / movies / audio / files with name, path, mimetype, size (in octets) and image/video dimensions (width/height/duration) | `[{ path: "file:///local/path/filename", mimeType: "image/jpeg", fileName: "originalFilename.jpg", size: 2567402, width: 800, height: 600 }, { path: "file:///local/path/filename", mimeType: "video/mp4", fileName: "originalFilename.mp4", size: 2567402, width: 800, height: 600, duration: 20000 }]` | -| `shareIntent.meta` | meta object which contains extra information about the share intent | `{ title: "My cool blog article" }` | -| `shareIntent.meta.title` | optional title property sent by other app. Currently only filled on Android | `My cool blog article` | +| attribute | description | example | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `shareIntent.text` | raw text from text/weburl (ios) and text/\* (android) | "`some text`", "`http://example.com`", "`Hey, Click on my link : http://example.com/nickname`" | +| `shareIntent.webUrl` | link extracted from raw text | `null`, "`http://example.com`", "`http://example.com/nickname`" | +| `shareIntent.files` | image / movies / audio / files with name, path, mimetype, size (in octets) and image/video dimensions (width/height/duration) | `[{ path: "file:///local/path/filename", mimeType: "image/jpeg", fileName: "originalFilename.jpg", size: 2567402, width: 800, height: 600 }, { path: "file:///local/path/filename", mimeType: "video/mp4", fileName: "originalFilename.mp4", size: 2567402, width: 800, height: 600, duration: 20000 }]` | +| `shareIntent.meta` | meta object which contains extra information about the share intent | `{ title: "My cool blog article", "og:image": "https://.../image.png" }` | +| `shareIntent.meta.title` | optional title property sent by other app (available on Android and when `NSExtensionActivationSupportsWebPageWithMaxCount` is enabled on iOS) | `My cool blog article` | +| `shareIntent.meta.xxx` | list all webpage metadata available in meta tags `` (iOS only, available with `NSExtensionActivationSupportsWebPageWithMaxCount`) | | #### Customize Content Types in `app.json` @@ -177,16 +178,17 @@ Simply choose content types you need : ], ``` -| Option | Values | -| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Option | Values | +| ----------------------------- | ------------------- | | iosActivationRules | Allow **text** sharing with `"NSExtensionActivationSupportsText": true`
**Url** sharing with `"NSExtensionActivationSupportsWebURLWithMaxCount": 1` and `"NSExtensionActivationSupportsWebPageWithMaxCount": 1`
**Images** sharing with `"NSExtensionActivationSupportsImageWithMaxCount": 1`
**Videos** sharing with `"NSExtensionActivationSupportsMovieWithMaxCount": 1`
**Files and audio** sharing with `"NSExtensionActivationSupportsFileWithMaxCount": 1`
_default value_: `{ "NSExtensionActivationSupportsWebURLWithMaxCount": 1, "NSExtensionActivationSupportsWebPageWithMaxCount": 1 }"`
_More info in apple developper doc [here](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/nsextensionactivationrule)_
you can also provide a custom query (ex: `"iosActivationRules": "SUBQUERY (...)"`) | -| iosShareExtensionName | override `CFBundleDisplayName` the extension `info.plist`, also used as extension name for xcode target (ex: `ExpoShareIntent Example Extension`, folder: `ExpoShareIntentExampleExtension`) | -| iosAppGroupIdentifier | custom application group identifier for `com.apple.security.application-groups` (ex: `group.custom.exposhareintent.example`) cf [#94](https://github.com/achorein/expo-share-intent/issues/94) | -| androidIntentFilters | **one file sharing** array of MIME types :`"text/*"` / `"image/*"` / `"video/*"` / `"*/*"`
_default value_: `["text/*"]` (text and url) | -| androidMultiIntentFilters | **multiple files sharing** array of MIME types : `"image/*"` / `"video/*"` / `"audio/*`/ `"*/*"`
_default value_: `[]` | -| androidMainActivityAttributes | _default value_: `{ "android:launchMode": "singleTask" }` | -| disableAndroid | Disable the android share intent. Useful if you want to use a custom implementation. _default value_: `false` | -| disableIOS | Disable the ios share extension. Useful if you want to use a custom implementation (ex: [iOS Custom View](#ios-custom-view-)). _default value_: `false` | +| iosShareExtensionName | override `CFBundleDisplayName` the extension `info.plist`, also used as extension name for xcode target (ex: `ExpoShareIntent Example Extension`, folder: `ExpoShareIntentExampleExtension`) | +| iosAppGroupIdentifier | custom application group identifier for `com.apple.security.application-groups` (ex: `group.custom.exposhareintent.example`) cf [#94](https://github.com/achorein/expo-share-intent/issues/94) | +| androidIntentFilters | **one file sharing** array of MIME types :`"text/*"` / `"image/*"` / `"video/*"` / `"*/*"`
_default value_: `["text/*"]` (text and url) | +| androidMultiIntentFilters | **multiple files sharing** array of MIME types : `"image/*"` / `"video/*"` / `"audio/*`/ `"*/*"`
_default value_: `[]` | +| androidMainActivityAttributes | _default value_: `{ "android:launchMode": "singleTask" }` | +| preprocessorInjectJS | Add javascript to webpage preprocessor before the share extension is called (cf [Accessing a Webpage](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html#//apple_ref/doc/uid/TP40014214-CH21-SW12)).
Example: preprocessorInjectJS: "metas['og\\:image'] = metas['og\\:image'] || document.querySelector('img#seo-image')?.getAttribute('src')" | +| disableAndroid | Disable the android share intent. Useful if you want to use a custom implementation. _default value_: `false` | +| disableIOS | Disable the ios share extension. Useful if you want to use a custom implementation (ex: [iOS Custom View](#ios-custom-view-)). _default value_: `false` | ### Expo Router diff --git a/example/basic/App.tsx b/example/basic/App.tsx index 22a0acf..100af85 100644 --- a/example/basic/App.tsx +++ b/example/basic/App.tsx @@ -1,8 +1,39 @@ import { Button, Image, StyleSheet, Text, View } from "react-native"; -import { useShareIntent, ShareIntentFile } from "expo-share-intent"; +import { + useShareIntent, + ShareIntentFile, + ShareIntent, +} from "expo-share-intent"; import { Fragment } from "react"; +const WebUrlComponent = ({ shareIntent }: { shareIntent: ShareIntent }) => { + return ( + + + + + {shareIntent.meta?.title || ""} + + {shareIntent.webUrl} + + + ); +}; + export default function App() { const { hasShareIntent, shareIntent, resetShareIntent, error } = useShareIntent({ @@ -22,8 +53,8 @@ export default function App() { {/* TEXT and URL */} {!!shareIntent.text && {shareIntent.text}} - {!!shareIntent.meta?.title && ( - {JSON.stringify(shareIntent.meta)} + {shareIntent?.type === "weburl" && ( + )} {/* FILES */} @@ -69,6 +100,7 @@ const styles = StyleSheet.create({ backgroundColor: "#fff", alignItems: "center", justifyContent: "center", + paddingHorizontal: 10, }, logo: { width: 75, @@ -79,7 +111,16 @@ const styles = StyleSheet.create({ width: 300, height: 200, resizeMode: "contain", - // backgroundColor: "lightgray", + }, + icon: { + width: 100, + height: 100, + resizeMode: "contain", + backgroundColor: "lightgray", + }, + row: { + flexDirection: "row", + gap: 10, }, gap: { marginBottom: 20, diff --git a/example/basic/app.json b/example/basic/app.json index 93d4135..b27db56 100644 --- a/example/basic/app.json +++ b/example/basic/app.json @@ -26,11 +26,9 @@ "NSExtensionActivationSupportsFileWithMaxCount": 1 }, "iosShareExtensionName": "ExpoShareIntent Example Extension", - "iosAppGroupIdentifier": "customgroup.expo.modules.exposhareintent.example", "androidIntentFilters": ["text/*", "image/*", "video/*"], "androidMultiIntentFilters": ["image/*"], - "disableIOS": false, - "disableAndroid": false + "preprocessorInjectJS": "metas['og\\:image'] = metas['og\\:image'] || document.querySelector('img#main-image')?.getAttribute('src') ;" } ], ["expo-updates"] diff --git a/example/basic/package.json b/example/basic/package.json index 3ca58f9..7344d9c 100644 --- a/example/basic/package.json +++ b/example/basic/package.json @@ -13,6 +13,8 @@ "test:fix": "expo install --fix", "doctor": "npx --yes expo-doctor@latest", "lint": "eslint .", + "open:ios": "open -a \"Xcode\" ios", + "open:android": "open -a \"Android Studio\" android", "postinstall": "patch-package" }, "dependencies": { diff --git a/example/expo-router/app/shareintent.tsx b/example/expo-router/app/shareintent.tsx index 547abad..78c6fc9 100644 --- a/example/expo-router/app/shareintent.tsx +++ b/example/expo-router/app/shareintent.tsx @@ -1,7 +1,37 @@ import { Button, Image, StyleSheet, Text, View } from "react-native"; import { useRouter } from "expo-router"; -import { useShareIntentContext } from "expo-share-intent"; +import { + ShareIntent as ShareIntentType, + useShareIntentContext, +} from "expo-share-intent"; + +const WebUrlComponent = ({ shareIntent }: { shareIntent: ShareIntentType }) => { + return ( + + + + + {shareIntent.meta?.title || ""} + + {shareIntent.webUrl} + + + ); +}; export default function ShareIntent() { const router = useRouter(); @@ -21,8 +51,8 @@ export default function ShareIntent() { )} {!!shareIntent.text && {shareIntent.text}} - {!!shareIntent.meta?.title && ( - {JSON.stringify(shareIntent.meta)} + {shareIntent?.type === "weburl" && ( + )} {shareIntent?.files?.map((file) => ( ; } +const WebUrlComponent = ({ shareIntent }: { shareIntent: ShareIntent }) => { + return ( + + + + + {shareIntent.meta?.title || ""} + + {shareIntent.webUrl} + + + ); +}; + export default function ShareIntentScreen({ navigation }: Props) { const { hasShareIntent, shareIntent, resetShareIntent, error } = useShareIntentContext(); @@ -22,8 +49,8 @@ export default function ShareIntentScreen({ navigation }: Props) { {hasShareIntent ? "SHARE INTENT FOUND !" : "NO SHARE INTENT DETECTED"} {!!shareIntent.text && {shareIntent.text}} - {!!shareIntent.meta?.title && ( - {JSON.stringify(shareIntent.meta)} + {shareIntent?.type === "weburl" && ( + )} {shareIntent?.files?.map((file) => ( [SharedMediaFile] { + private func decodeMedia(data: Data) -> [SharedMediaFile] { let encodedData = try? JSONDecoder().decode([SharedMediaFile].self, from: data) return encodedData! } + private func decodeWebUrl(data: Data) -> [WebUrl] { + let encodedData = try? JSONDecoder().decode([WebUrl].self, from: data) + return encodedData! + } private func toJson(data: [SharedMediaFile]?) -> String? { if data == nil { @@ -203,12 +220,31 @@ public class ExpoShareIntentModule: Module { let json = String(data: encodedData!, encoding: .utf8)! return json } + + private func toJson(data: [WebUrl]?) -> String? { + if data == nil { + return nil + } + let encodedData = try? JSONEncoder().encode(data) + let json = String(data: encodedData!, encoding: .utf8)! + return json + } struct ShareIntentText: Codable { let text: String let type: String // text / weburl } + class WebUrl: Codable { + var url: String + var meta: String + + init(url: String, meta: String) { + self.url = url + self.meta = meta + } + } + class SharedMediaFile: Codable { var path: String // can be image, video or url path var thumbnail: String? // video thumbnail diff --git a/plugin/src/ios/ShareExtensionViewController.swift b/plugin/src/ios/ShareExtensionViewController.swift index f422e57..a8b001a 100644 --- a/plugin/src/ios/ShareExtensionViewController.swift +++ b/plugin/src/ios/ShareExtensionViewController.swift @@ -14,13 +14,15 @@ class ShareViewController: UIViewController { let shareProtocol = "" let sharedKey = "ShareKey" var sharedMedia: [SharedMediaFile] = [] + var sharedWebUrl: [WebUrl] = [] var sharedText: [String] = [] - let imageContentType = kUTTypeImage as String - let videoContentType = kUTTypeMovie as String - let textContentType = kUTTypeText as String - let urlContentType = kUTTypeURL as String - let fileURLType = kUTTypeFileURL as String - let pdfContentType = kUTTypePDF as String + let imageContentType: String = UTType.image.identifier + let videoContentType: String = UTType.movie.identifier + let textContentType: String = UTType.text.identifier + let urlContentType: String = UTType.url.identifier + let propertyListType: String = UTType.propertyList.identifier + let fileURLType: String = UTType.fileURL.identifier + let pdfContentType: String = UTType.pdf.identifier override func viewDidLoad() { super.viewDidLoad() @@ -45,6 +47,8 @@ class ShareViewController: UIViewController { await handleFiles(content: content, attachment: attachment, index: index) } else if attachment.hasItemConformingToTypeIdentifier(pdfContentType) { await handlePdf(content: content, attachment: attachment, index: index) + } else if attachment.hasItemConformingToTypeIdentifier(propertyListType) { + await handlePrepocessing(content: content, attachment: attachment, index: index) } else if attachment.hasItemConformingToTypeIdentifier(urlContentType) { await handleUrl(content: content, attachment: attachment, index: index) } else if attachment.hasItemConformingToTypeIdentifier(textContentType) { @@ -87,11 +91,11 @@ class ShareViewController: UIViewController { if let item = try! await attachment.loadItem(forTypeIdentifier: self.urlContentType) as? URL { Task { @MainActor in - self.sharedText.append(item.absoluteString) + self.sharedWebUrl.append(WebUrl(url: item.absoluteString, meta: "")) // If this is the last item, save sharedText in userDefaults and redirect to host app if index == (content.attachments?.count)! - 1 { let userDefaults = UserDefaults(suiteName: self.hostAppGroupIdentifier) - userDefaults?.set(self.sharedText, forKey: self.sharedKey) + userDefaults?.set(self.toData(data: self.sharedWebUrl), forKey: self.sharedKey) userDefaults?.synchronize() self.redirectToHostApp(type: .weburl) } @@ -105,6 +109,46 @@ class ShareViewController: UIViewController { } } + private func handlePrepocessing(content: NSExtensionItem, attachment: NSItemProvider, index: Int) + async + { + Task.detached { + if let item = try! await attachment.loadItem( + forTypeIdentifier: self.propertyListType, options: nil) + as? NSDictionary + { + Task { @MainActor in + + if let results = item[NSExtensionJavaScriptPreprocessingResultsKey] + as? NSDictionary + { + NSLog( + "[DEBUG] NSExtensionJavaScriptPreprocessingResultsKey \(String(describing: results))" + ) + self.sharedWebUrl.append( + WebUrl(url: results["baseURI"] as! String, meta: results["meta"] as! String)) + // If this is the last item, save sharedText in userDefaults and redirect to host app + if index == (content.attachments?.count)! - 1 { + let userDefaults = UserDefaults(suiteName: self.hostAppGroupIdentifier) + userDefaults?.set(self.toData(data: self.sharedWebUrl), forKey: self.sharedKey) + userDefaults?.synchronize() + self.redirectToHostApp(type: .weburl) + } + } else { + NSLog("[ERROR] Cannot load preprocessing results !\(String(describing: content))") + self.dismissWithError( + message: "Cannot load preprocessing results \(String(describing: content))") + } + + } + } else { + NSLog("[ERROR] Cannot load preprocessing content !\(String(describing: content))") + await self.dismissWithError( + message: "Cannot load preprocessing content \(String(describing: content))") + } + } + } + private func handleImages(content: NSExtensionItem, attachment: NSItemProvider, index: Int) async { Task.detached { @@ -443,6 +487,16 @@ class ShareViewController: UIViewController { return path } + class WebUrl: Codable { + var url: String + var meta: String + + init(url: String, meta: String) { + self.url = url + self.meta = meta + } + } + class SharedMediaFile: Codable { var path: String // can be image, video or url path var thumbnail: String? // video thumbnail @@ -476,9 +530,13 @@ class ShareViewController: UIViewController { case file } - func toData(data: [SharedMediaFile]) -> Data { + func toData(data: [WebUrl]) -> Data? { + let encodedData = try? JSONEncoder().encode(data) + return encodedData + } + func toData(data: [SharedMediaFile]) -> Data? { let encodedData = try? JSONEncoder().encode(data) - return encodedData! + return encodedData } } diff --git a/plugin/src/ios/constants.ts b/plugin/src/ios/constants.ts index b9daeeb..f2dfa71 100644 --- a/plugin/src/ios/constants.ts +++ b/plugin/src/ios/constants.ts @@ -6,6 +6,8 @@ export const shareExtensionInfoFileName = `${shareExtensionName}-Info.plist`; export const shareExtensionEntitlementsFileName = `${shareExtensionName}.entitlements`; export const shareExtensionStoryBoardFileName = "MainInterface.storyboard"; export const shareExtensionViewControllerFileName = "ShareViewController.swift"; +export const shareExtensionPreprocessorFileName = + "ShareExtensionPreprocessor.js"; export const getShareExtensionName = (parameters?: Parameters) => { if (!parameters?.iosShareExtensionName) return shareExtensionName; diff --git a/plugin/src/ios/withIosShareExtensionXcodeTarget.ts b/plugin/src/ios/withIosShareExtensionXcodeTarget.ts index b0ad9b8..7062ac1 100644 --- a/plugin/src/ios/withIosShareExtensionXcodeTarget.ts +++ b/plugin/src/ios/withIosShareExtensionXcodeTarget.ts @@ -5,6 +5,7 @@ import { getShareExtensionName, } from "./constants"; import { + getPreprocessorFilePath, getPrivacyInfoFilePath, getShareExtensionEntitlementsFilePath, getShareExtensionInfoFilePath, @@ -40,21 +41,6 @@ export const withShareExtensionXcodeTarget: ConfigPlugin = ( platformProjectRoot, parameters, ); - // ShareViewController.swift - const viewControllerFilePath = getShareExtensionViewControllerPath( - platformProjectRoot, - parameters, - ); - // MainInterface.storyboard - const storyboardFilePath = getShareExtensionStoryboardFilePath( - platformProjectRoot, - parameters, - ); - // PrivacyInfo.xcprivacy - const privacyFilePath = getPrivacyInfoFilePath( - platformProjectRoot, - parameters, - ); await writeShareExtensionFiles( platformProjectRoot, @@ -102,21 +88,28 @@ export const withShareExtensionXcodeTarget: ConfigPlugin = ( // Add source files to our PbxGroup and our newly created PBXSourcesBuildPhase (ShareViewController.swift) pbxProject.addSourceFile( - viewControllerFilePath, + getShareExtensionViewControllerPath(platformProjectRoot, parameters), { target: target.uuid }, pbxGroupKey, ); // Add the resource file and include it into the target PbxResourcesBuildPhase and PbxGroup - // (MainInterface.storyboard / PrivacyInfo.xcprivacy) try { + // ShareExtensionPreprocessor.js + pbxProject.addResourceFile( + getPreprocessorFilePath(platformProjectRoot, parameters), + { target: target.uuid }, + pbxGroupKey, + ); + // MainInterface.storyboard pbxProject.addResourceFile( - storyboardFilePath, + getShareExtensionStoryboardFilePath(platformProjectRoot, parameters), { target: target.uuid }, pbxGroupKey, ); + // PrivacyInfo.xcprivacy pbxProject.addResourceFile( - privacyFilePath, + getPrivacyInfoFilePath(platformProjectRoot, parameters), { target: target.uuid }, pbxGroupKey, ); diff --git a/plugin/src/ios/writeIosShareExtensionFiles.ts b/plugin/src/ios/writeIosShareExtensionFiles.ts index 92b8da0..654a7f3 100644 --- a/plugin/src/ios/writeIosShareExtensionFiles.ts +++ b/plugin/src/ios/writeIosShareExtensionFiles.ts @@ -10,6 +10,7 @@ import { shareExtensionInfoFileName, shareExtensionStoryBoardFileName, shareExtensionViewControllerFileName, + shareExtensionPreprocessorFileName, } from "./constants"; import { Parameters } from "../types"; @@ -70,6 +71,14 @@ export async function writeShareExtensionFiles( getAppGroup(appIdentifier, parameters), ); await fs.promises.writeFile(viewControllerFilePath, viewControllerContent); + + // ShareExtensionPreprocessor.js + const preprocessorFilePath = getPreprocessorFilePath( + platformProjectRoot, + parameters, + ); + const preprocessorContent = getPreprocessorContent(parameters); + await fs.promises.writeFile(preprocessorFilePath, preprocessorContent); } //: [root]/ios/ShareExtension/ShareExtension.entitlements @@ -134,6 +143,7 @@ export function getShareExtensionInfoContent( NSExtensionActivationSupportsWebURLWithMaxCount: 1, NSExtensionActivationSupportsWebPageWithMaxCount: 1, }, + NSExtensionJavaScriptPreprocessingFile: "ShareExtensionPreprocessor", }, NSExtensionMainStoryboard: "MainInterface", NSExtensionPointIdentifier: "com.apple.share-services", @@ -220,6 +230,50 @@ export function getShareExtensionViewControllerPath( ); } +export function getPreprocessorFilePath( + platformProjectRoot: string, + parameters: Parameters, +) { + return path.join( + platformProjectRoot, + getShareExtensionName(parameters), + shareExtensionPreprocessorFileName, + ); +} + +export function getPreprocessorContent(parameters: Parameters) { + const injection = parameters.preprocessorInjectJS || ""; + return `class ShareExtensionPreprocessor { + run({ completionFunction }) { + // Extract meta tags and image sources from the document + const metas = { + title: document.title, + }; + + // Get all meta elements + const metaElements = document.querySelectorAll("meta"); + for (const meta of metaElements) { + const name = meta.getAttribute("name") || meta.getAttribute("property"); + const content = meta.getAttribute("content"); + + if (name && content) { + metas[name] = content; + } + } + + ${injection} + + // Call the completion function with the extracted data + completionFunction({ + baseURI: document.baseURI, + meta: JSON.stringify(metas), + }); + } +} +var ExtensionPreprocessingJS = new ShareExtensionPreprocessor(); +`; +} + export function getShareExtensionViewControllerContent( scheme: string, groupIdentifier: string, diff --git a/plugin/src/types.ts b/plugin/src/types.ts index 445b3f5..9e329c4 100644 --- a/plugin/src/types.ts +++ b/plugin/src/types.ts @@ -9,6 +9,7 @@ export type Parameters = { androidIntentFilters?: ("text/*" | "image/*" | "video/*" | "*/*")[]; androidMultiIntentFilters?: ("image/*" | "video/*" | "*/*")[]; disableExperimental?: boolean; + preprocessorInjectJS?: string; disableAndroid?: boolean; disableIOS?: boolean; }; diff --git a/src/ExpoShareIntentModule.types.ts b/src/ExpoShareIntentModule.types.ts index 7f7d15c..5f7dd07 100644 --- a/src/ExpoShareIntentModule.types.ts +++ b/src/ExpoShareIntentModule.types.ts @@ -40,7 +40,7 @@ export type ShareIntentOptions = { onResetShareIntent?: () => void; }; -export type ShareIntentMeta = { +export type ShareIntentMeta = Record & { title?: string; }; @@ -48,7 +48,7 @@ export type ShareIntentMeta = { * Base type for what shared content is common between both platforms. */ interface BaseShareIntent { - meta?: ShareIntentMeta; + meta?: ShareIntentMeta | null; text?: string | null; } @@ -74,6 +74,7 @@ export interface AndroidShareIntent extends BaseShareIntent { */ export interface IosShareIntent extends BaseShareIntent { files?: IosShareIntentFile[]; + weburls?: { url: string; meta: string }[]; type: "media" | "file" | "text" | "weburl"; } diff --git a/src/index.ts b/src/index.ts index 962b31d..c5b9b52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,9 +5,9 @@ export type { export { default as ShareIntentModule } from "./ExpoShareIntentModule"; -export { default as useShareIntent, parseShareIntent } from "./useShareIntent"; +export { default as useShareIntent } from "./useShareIntent"; -export { getScheme, getShareExtensionKey } from "./utils"; +export { getScheme, getShareExtensionKey, parseShareIntent } from "./utils"; export { ShareIntentProvider, diff --git a/src/useShareIntent.tsx b/src/useShareIntent.tsx index bf21186..e8ab687 100644 --- a/src/useShareIntent.tsx +++ b/src/useShareIntent.tsx @@ -3,14 +3,8 @@ import { useEffect, useRef, useState } from "react"; import { AppState, Platform } from "react-native"; import ExpoShareIntentModule from "./ExpoShareIntentModule"; -import { - AndroidShareIntent, - IosShareIntent, - ShareIntent, - ShareIntentFile, - ShareIntentOptions, -} from "./ExpoShareIntentModule.types"; -import { getScheme, getShareExtensionKey } from "./utils"; +import { ShareIntent, ShareIntentOptions } from "./ExpoShareIntentModule.types"; +import { getScheme, getShareExtensionKey, parseShareIntent } from "./utils"; export const SHAREINTENT_DEFAULTVALUE: ShareIntent = { files: null, @@ -25,83 +19,8 @@ export const SHAREINTENT_OPTIONS_DEFAULT: ShareIntentOptions = { disabled: Platform.OS === "web", }; -// const IOS_SHARE_TYPE_MAPPING = { -// 0: "media", -// 1: "text", -// 2: "weburl", -// 3: "file", -// }; - -export const parseShareIntent = ( - value: string | AndroidShareIntent, - options: ShareIntentOptions, -): ShareIntent => { - let result = SHAREINTENT_DEFAULTVALUE; - if (!value) return result; - let shareIntent; - // ios native module send a raw string of the json, try to parse it - if (typeof value === "string") { - shareIntent = JSON.parse(value) as IosShareIntent; // iOS - } else { - shareIntent = value; // Android - } - - if (shareIntent.text) { - // Try to find the webURL in the SharedIntent text - const webUrl = - shareIntent.text.match( - /[(http(s)?)://(www.)?-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/gi, - )?.[0] || null; - - result = { - ...SHAREINTENT_DEFAULTVALUE, - text: shareIntent.text, - webUrl, - type: webUrl ? "weburl" : "text", - meta: { - title: shareIntent.meta?.title ?? undefined, - }, - }; - } else { - // Ensure we got a valid file. some array value are emply - const files = - shareIntent?.files?.filter((file: any) => file.path || file.contentUri) || - []; - const isMedia = files.every( - (file) => - file.mimeType.startsWith("image/") || - file.mimeType.startsWith("video/"), - ); - result = { - ...SHAREINTENT_DEFAULTVALUE, - files: shareIntent?.files - ? shareIntent.files.reduce((acc: ShareIntentFile[], file: any) => { - if (!file.path && !file.contentUri) return acc; - return [ - ...acc, - { - path: - file.path || - (file.filePath ? `file://${file.filePath}` : null) || - file.contentUri || - null, - mimeType: file.mimeType || null, - fileName: file.fileName || null, - width: file.width ? Number(file.width) : null, - height: file.height ? Number(file.height) : null, - size: file.fileSize ? Number(file.fileSize) : null, - duration: file.duration ? Number(file.duration) : null, - }, - ]; - }, []) - : null, - type: isMedia ? "media" : "file", - }; - } - options.debug && - console.debug("useShareIntent[parsed] ", JSON.stringify(result, null, 2)); - return result; -}; +const isValueAvailable = (shareIntent: ShareIntent) => + !!(shareIntent?.text || shareIntent?.webUrl || shareIntent?.files); export default function useShareIntent( options: ShareIntentOptions = SHAREINTENT_OPTIONS_DEFAULT, @@ -120,7 +39,7 @@ export default function useShareIntent( setError(null); clearNativeModule && ExpoShareIntentModule?.clearShareIntent(getShareExtensionKey(options)); - if (shareIntent?.text || shareIntent?.files) { + if (isValueAvailable(shareIntent)) { setSharedIntent(SHAREINTENT_DEFAULTVALUE); options.onResetShareIntent?.(); } @@ -221,7 +140,7 @@ export default function useShareIntent( return { isReady, - hasShareIntent: !!(shareIntent?.text || shareIntent?.files), + hasShareIntent: isValueAvailable(shareIntent), shareIntent, resetShareIntent, error, diff --git a/src/utils.ts b/src/utils.ts index 2948768..48c1db8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,14 @@ import Constants from "expo-constants"; import { createURL } from "expo-linking"; -import { ShareIntentOptions } from "./ExpoShareIntentModule.types"; +import { + AndroidShareIntent, + IosShareIntent, + ShareIntent, + ShareIntentFile, + ShareIntentOptions, +} from "./ExpoShareIntentModule.types"; +import { SHAREINTENT_DEFAULTVALUE } from "./useShareIntent"; export const getScheme = (options?: ShareIntentOptions) => { if (options?.scheme !== undefined) { @@ -41,3 +48,102 @@ export const getShareExtensionKey = (options?: ShareIntentOptions) => { const scheme = getScheme(options); return `${scheme}ShareKey`; }; + +// const IOS_SHARE_TYPE_MAPPING = { +// 0: "media", +// 1: "text", +// 2: "weburl", +// 3: "file", +// }; + +export function parseJson( + value: string, + defaultValue: T | null = null, +): T | null { + try { + return JSON.parse(value) as T; + } catch (e) { + console.debug(e); + return defaultValue; + } +} + +export const parseShareIntent = ( + value: string | AndroidShareIntent, + options: ShareIntentOptions, +): ShareIntent => { + let result = SHAREINTENT_DEFAULTVALUE; + if (!value) return result; + let shareIntent: IosShareIntent | AndroidShareIntent | null; + // ios native module send a raw string of the json, try to parse it + if (typeof value === "string") { + shareIntent = parseJson(value); // iOS + } else { + shareIntent = value; // Android + } + + if (shareIntent?.text) { + // Try to find the webURL in the SharedIntent text + const webUrl = + shareIntent.text.match( + /[(http(s)?)://(www.)?-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/gi, + )?.[0] || null; + + result = { + ...SHAREINTENT_DEFAULTVALUE, + type: webUrl ? "weburl" : "text", + text: shareIntent.text, + webUrl, + meta: { + title: shareIntent.meta?.title ?? undefined, + }, + }; + } else if ((shareIntent as IosShareIntent)?.weburls?.length) { + const weburl = (shareIntent as IosShareIntent).weburls![0]; + result = { + ...SHAREINTENT_DEFAULTVALUE, + type: "weburl", + text: weburl.url, // retrocompatibility + webUrl: weburl.url, + meta: parseJson>(weburl.meta, {}), + }; + } else { + // Ensure we got a valid file. some array value are emply + const files = + shareIntent?.files?.filter((file: any) => file.path || file.contentUri) || + []; + const isMedia = files.every( + (file) => + file.mimeType.startsWith("image/") || + file.mimeType.startsWith("video/"), + ); + result = { + ...SHAREINTENT_DEFAULTVALUE, + files: shareIntent?.files + ? shareIntent.files.reduce((acc: ShareIntentFile[], file: any) => { + if (!file.path && !file.contentUri) return acc; + return [ + ...acc, + { + path: + file.path || + (file.filePath ? `file://${file.filePath}` : null) || + file.contentUri || + null, + mimeType: file.mimeType || null, + fileName: file.fileName || null, + width: file.width ? Number(file.width) : null, + height: file.height ? Number(file.height) : null, + size: file.fileSize ? Number(file.fileSize) : null, + duration: file.duration ? Number(file.duration) : null, + }, + ]; + }, []) + : null, + type: isMedia ? "media" : "file", + }; + } + options.debug && + console.debug("useShareIntent[parsed] ", JSON.stringify(result, null, 2)); + return result; +};