Repository navigation
feat(mobile): 支持将对话分享为图片 #2188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat(mobile): 支持将对话分享为图片 #2188
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
43f0ca6
feat(mobile): support conversation image sharing
dashhuang ec8350a
fix(mobile): harden conversation image sharing
dashhuang 3c9247d
fix(mobile): improve conversation image sharing reliability
dashhuang 6a74645
feat(mobile): bundle rich content assets for sharing
dashhuang 37e9059
fix(mobile): preserve visible content in shared images
dashhuang a3ee6aa
fix(mobile): harden shared conversation rendering
dashhuang f91f92b
fix(mobile): exclude collapsed messages from share
dashhuang 93bbb6e
fix(mobile): streamline conversation share rendering
dashhuang a914aef
fix(mobile): harden conversation share fallback
dashhuang 47306ef
fix(mobile): guard screenshot sharing overlays
dashhuang 2b391e4
fix(mobile): keep share exports current
dashhuang d7aedd2
fix(mobile): keep collapsed share exports visible
dashhuang cab831c
fix(mobile): close share review gaps
dashhuang e4959d0
fix(mobile): preserve collapsed share previews
dashhuang 3846b63
fix(mobile): bound collapsed share projections
dashhuang 9e0749f
fix(mobile): keep share state current
dashhuang 87a073d
refactor(mobile): simplify share projections
dashhuang 6306cce
fix(mobile): keep share export active during branding load
dashhuang 19aa2fb
fix(mobile): avoid decoding share attachment images
dashhuang cb15790
fix(mobile): harden share resource lifecycles
dashhuang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions
7
apps/mobile/modules/xdt-screenshot-monitor/expo-module.config.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "platforms": ["apple"], | ||
| "apple": { | ||
| "podspecPath": "./ios/XdtScreenshotMonitor.podspec", | ||
| "modules": ["XdtScreenshotMonitorModule"] | ||
| } | ||
| } |
28 changes: 28 additions & 0 deletions
28
apps/mobile/modules/xdt-screenshot-monitor/ios/XdtScreenshotMonitor.podspec
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| require 'json' | ||
|
|
||
| package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) | ||
|
|
||
| Pod::Spec.new do |s| | ||
| s.name = 'XdtScreenshotMonitor' | ||
| s.version = package['version'] | ||
| s.summary = 'Foreground screenshot events for Cindy mobile.' | ||
| s.description = 'Expo module that forwards the iOS screenshot notification to Cindy mobile.' | ||
| s.license = { :type => 'Apache-2.0', :file => '../../../../../LICENSE' } | ||
| s.author = 'Cindy' | ||
| s.homepage = 'https://github.com/makecindy/cindy' | ||
| s.platforms = { | ||
| :ios => '16.4' | ||
| } | ||
| s.swift_version = '5.9' | ||
| s.source = { git: 'https://github.com/makecindy/cindy.git' } | ||
| s.static_framework = true | ||
|
|
||
| s.dependency 'ExpoModulesCore' | ||
|
|
||
| s.pod_target_xcconfig = { | ||
| 'DEFINES_MODULE' => 'YES', | ||
| 'SWIFT_COMPILATION_MODE' => 'wholemodule' | ||
| } | ||
|
|
||
| s.source_files = '**/*.{h,m,swift}' | ||
| end |
268 changes: 268 additions & 0 deletions
268
apps/mobile/modules/xdt-screenshot-monitor/ios/XdtScreenshotMonitorModule.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,268 @@ | ||
| import ExpoModulesCore | ||
| import UIKit | ||
| import WebKit | ||
|
|
||
| private let onScreenshot = "onScreenshot" | ||
| private let conversationShareRenderTimeout: TimeInterval = 20 | ||
| private let conversationShareMaxOutputPixels: CGFloat = 12_000_000 | ||
| private let conversationShareMaxSourcePixels: CGFloat = 12_000_000 | ||
|
|
||
| public class XdtScreenshotMonitorModule: Module { | ||
| private var screenshotObserver: NSObjectProtocol? | ||
| private var conversationShareRenderers: [UUID: ConversationShareHtmlRenderer] = [:] | ||
|
|
||
| public func definition() -> ModuleDefinition { | ||
| Name("XdtScreenshotMonitor") | ||
|
|
||
| Events(onScreenshot) | ||
|
|
||
| OnStartObserving(onScreenshot) { | ||
| self.startObservingScreenshots() | ||
| } | ||
|
|
||
| OnStopObserving(onScreenshot) { | ||
| self.stopObservingScreenshots() | ||
| } | ||
|
|
||
| AsyncFunction("renderConversationShareHtmlToPng") { (options: [String: Any], promise: Promise) in | ||
| guard let html = options["html"] as? String, !html.isEmpty else { | ||
| promise.reject("ERR_CONVERSATION_SHARE_HTML", "Conversation share HTML is missing.") | ||
| return | ||
| } | ||
| let width = max(280, numericOption(options["width"]) ?? 390) | ||
| let scale = max(0.25, numericOption(options["scale"]) ?? 2) | ||
| DispatchQueue.main.async { | ||
| let identifier = UUID() | ||
| let renderer = ConversationShareHtmlRenderer(html: html, width: width, scale: scale) { [weak self] result in | ||
| self?.conversationShareRenderers.removeValue(forKey: identifier) | ||
| switch result { | ||
| case .success(let base64): | ||
| promise.resolve(base64) | ||
| case .failure(let error): | ||
| promise.reject("ERR_CONVERSATION_SHARE_RENDER", error.localizedDescription) | ||
| } | ||
| } | ||
| self.conversationShareRenderers[identifier] = renderer | ||
| renderer.start() | ||
| } | ||
| } | ||
|
|
||
| OnDestroy { | ||
| self.stopObservingScreenshots() | ||
| let renderers = Array(self.conversationShareRenderers.values) | ||
| self.conversationShareRenderers.removeAll() | ||
| renderers.forEach { $0.cancel() } | ||
| } | ||
| } | ||
|
|
||
| private func startObservingScreenshots() { | ||
| guard screenshotObserver == nil else { | ||
| return | ||
| } | ||
| screenshotObserver = NotificationCenter.default.addObserver( | ||
| forName: UIApplication.userDidTakeScreenshotNotification, | ||
| object: nil, | ||
| queue: .main | ||
| ) { [weak self] _ in | ||
| self?.sendEvent(onScreenshot, [ | ||
| "capturedAt": Date().timeIntervalSince1970 * 1_000 | ||
| ]) | ||
| } | ||
| } | ||
|
|
||
| private func stopObservingScreenshots() { | ||
| guard let screenshotObserver else { | ||
| return | ||
| } | ||
| NotificationCenter.default.removeObserver(screenshotObserver) | ||
| self.screenshotObserver = nil | ||
| } | ||
| } | ||
|
|
||
| private final class ConversationShareHtmlRenderer: NSObject, WKNavigationDelegate { | ||
| private let html: String | ||
| private let width: CGFloat | ||
| private let scale: CGFloat | ||
| private let completion: (Result<String, Error>) -> Void | ||
| private var completed = false | ||
| private var timeoutWorkItem: DispatchWorkItem? | ||
| private var webView: WKWebView? | ||
|
|
||
| init( | ||
| html: String, | ||
| width: CGFloat, | ||
| scale: CGFloat, | ||
| completion: @escaping (Result<String, Error>) -> Void | ||
| ) { | ||
| self.html = html | ||
| self.width = width | ||
| self.scale = scale | ||
| self.completion = completion | ||
| } | ||
|
|
||
| func start() { | ||
| let configuration = WKWebViewConfiguration() | ||
| configuration.websiteDataStore = .nonPersistent() | ||
| let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: width, height: 1), configuration: configuration) | ||
| webView.navigationDelegate = self | ||
| webView.isOpaque = false | ||
| webView.scrollView.isScrollEnabled = false | ||
| self.webView = webView | ||
|
|
||
| let timeout = DispatchWorkItem { [weak self] in | ||
| self?.finish(.failure(ConversationShareRenderError("Conversation share rendering timed out."))) | ||
| } | ||
| timeoutWorkItem = timeout | ||
| DispatchQueue.main.asyncAfter(deadline: .now() + conversationShareRenderTimeout, execute: timeout) | ||
| webView.loadHTMLString(html, baseURL: URL(string: "https://cindy-mobile.local")) | ||
| } | ||
|
|
||
| func cancel() { | ||
| finish(.failure(ConversationShareRenderError("Conversation share rendering was cancelled."))) | ||
| } | ||
|
|
||
| func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { | ||
| let script = """ | ||
| return (async function () { | ||
| const stage = document.getElementById('xdt-content'); | ||
| if (!stage) throw new Error('stage-not-found'); | ||
| Array.from(stage.querySelectorAll('img')).forEach((image) => { | ||
| const source = image.getAttribute('src') || ''; | ||
| if (!source.startsWith('data:')) image.replaceWith(document.createTextNode(image.getAttribute('alt') || '')); | ||
| }); | ||
| await new Promise((resolve) => { | ||
| const deadline = Date.now() + 14_000; | ||
| const check = () => { | ||
| if (window.__cindyConversationShareRichContentReady === true || Date.now() >= deadline) { | ||
| resolve(); | ||
| return; | ||
| } | ||
| setTimeout(check, 25); | ||
| }; | ||
| check(); | ||
| }); | ||
| await Promise.all(Array.from(document.images).map(async (image) => { | ||
| if (!image.complete) { | ||
| await new Promise((resolve) => { | ||
| image.addEventListener('load', resolve, { once: true }); | ||
| image.addEventListener('error', resolve, { once: true }); | ||
| }); | ||
| } | ||
| if (image.decode) { | ||
| try { await image.decode(); } catch (_) {} | ||
| } | ||
| })); | ||
| if (document.fonts && document.fonts.ready) { | ||
| try { await document.fonts.ready; } catch (_) {} | ||
| } | ||
| await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); | ||
| const rect = stage.getBoundingClientRect(); | ||
| return { | ||
| width: Math.max(stage.scrollWidth, Math.ceil(rect.width)), | ||
| height: Math.max(stage.scrollHeight, Math.ceil(rect.height)) | ||
| }; | ||
| })(); | ||
| """ | ||
| webView.callAsyncJavaScript( | ||
| script, | ||
| arguments: [:], | ||
| in: nil, | ||
| in: .page | ||
| ) { [weak self] result in | ||
| guard let self else { return } | ||
| guard case .success(let value) = result else { | ||
| if case .failure(let error) = result { | ||
| self.finish(.failure(error)) | ||
| } | ||
| return | ||
| } | ||
| guard | ||
| let dimensions = value as? [String: Any], | ||
| let captureWidth = numericOption(dimensions["width"]), | ||
| let captureHeight = numericOption(dimensions["height"]), | ||
| captureWidth > 0, | ||
| captureHeight > 0, | ||
| captureWidth * captureHeight <= conversationShareMaxSourcePixels | ||
| else { | ||
| self.finish(.failure(ConversationShareRenderError("Conversation share content is too large."))) | ||
| return | ||
| } | ||
| webView.frame = CGRect(x: 0, y: 0, width: captureWidth, height: captureHeight) | ||
| webView.setNeedsLayout() | ||
| webView.layoutIfNeeded() | ||
| let requestedScale = max(0.25, self.scale) | ||
| let maxScale = sqrt( | ||
| conversationShareMaxOutputPixels / max(1, captureWidth * captureHeight) | ||
| ) | ||
|
dashhuang marked this conversation as resolved.
|
||
| let effectiveScale = min(requestedScale, maxScale) | ||
| let snapshot = WKSnapshotConfiguration() | ||
| snapshot.rect = CGRect(x: 0, y: 0, width: captureWidth, height: captureHeight) | ||
| snapshot.snapshotWidth = NSNumber(value: Double(captureWidth * effectiveScale)) | ||
| snapshot.afterScreenUpdates = true | ||
| webView.takeSnapshot(with: snapshot) { image, error in | ||
| if let error { | ||
| self.finish(.failure(error)) | ||
| return | ||
| } | ||
| guard let data = image?.pngData(), !data.isEmpty else { | ||
| self.finish(.failure(ConversationShareRenderError("Conversation share PNG is empty."))) | ||
| return | ||
| } | ||
| self.finish(.success(data.base64EncodedString())) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func webView( | ||
| _ webView: WKWebView, | ||
| didFail navigation: WKNavigation!, | ||
| withError error: Error | ||
| ) { | ||
| finish(.failure(error)) | ||
| } | ||
|
|
||
| func webView( | ||
| _ webView: WKWebView, | ||
| didFailProvisionalNavigation navigation: WKNavigation!, | ||
| withError error: Error | ||
| ) { | ||
| finish(.failure(error)) | ||
| } | ||
|
|
||
| private func finish(_ result: Result<String, Error>) { | ||
| guard !completed else { return } | ||
| completed = true | ||
| timeoutWorkItem?.cancel() | ||
| timeoutWorkItem = nil | ||
| webView?.stopLoading() | ||
| webView?.navigationDelegate = nil | ||
| webView = nil | ||
| completion(result) | ||
| } | ||
| } | ||
|
|
||
| private struct ConversationShareRenderError: LocalizedError { | ||
| let message: String | ||
|
|
||
| init(_ message: String) { | ||
| self.message = message | ||
| } | ||
|
|
||
| var errorDescription: String? { | ||
| message | ||
| } | ||
| } | ||
|
|
||
| private func numericOption(_ value: Any?) -> CGFloat? { | ||
| let numericValue: CGFloat | ||
| if let number = value as? NSNumber { | ||
| numericValue = CGFloat(number.doubleValue) | ||
| } else if let value = value as? Double { | ||
| numericValue = CGFloat(value) | ||
| } else if let value = value as? Int { | ||
| numericValue = CGFloat(value) | ||
| } else { | ||
| return nil | ||
| } | ||
| return numericValue.isFinite ? numericValue : nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "name": "xdt-screenshot-monitor", | ||
| "version": "0.1.0", | ||
| "main": "src/index.ts", | ||
| "private": true, | ||
| "license": "Apache-2.0" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { | ||
| requireOptionalNativeModule, | ||
| type EventSubscription, | ||
| } from 'expo-modules-core'; | ||
|
|
||
| export type ScreenshotEvent = { | ||
| capturedAt: number; | ||
| }; | ||
|
|
||
| type ScreenshotMonitorNativeModule = { | ||
| addListener( | ||
| eventName: 'onScreenshot', | ||
| listener: (event: ScreenshotEvent) => void, | ||
| ): EventSubscription; | ||
| renderConversationShareHtmlToPng?(options: { | ||
| html: string; | ||
| width: number; | ||
| scale: number; | ||
| }): Promise<string>; | ||
| }; | ||
|
|
||
| const nativeModule = requireOptionalNativeModule<ScreenshotMonitorNativeModule>( | ||
| 'XdtScreenshotMonitor', | ||
| ); | ||
|
|
||
| export function addScreenshotListener( | ||
| listener: (event: ScreenshotEvent) => void, | ||
| ): EventSubscription | null { | ||
| return nativeModule?.addListener('onScreenshot', listener) ?? null; | ||
| } | ||
|
|
||
| export const conversationShareNativeRendererAvailable = | ||
| typeof nativeModule?.renderConversationShareHtmlToPng === 'function'; | ||
|
|
||
| export async function renderConversationShareHtmlToPng(options: { | ||
| html: string; | ||
| width: number; | ||
| scale?: number; | ||
| }): Promise<string | null> { | ||
| if (!nativeModule?.renderConversationShareHtmlToPng) return null; | ||
| return nativeModule.renderConversationShareHtmlToPng({ | ||
| html: options.html, | ||
| width: options.width, | ||
| scale: options.scale ?? 2, | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.