Skip to content
Merged
Show file tree
Hide file tree
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 Aug 8, 2026
ec8350a
fix(mobile): harden conversation image sharing
dashhuang Aug 8, 2026
3c9247d
fix(mobile): improve conversation image sharing reliability
dashhuang Aug 8, 2026
6a74645
feat(mobile): bundle rich content assets for sharing
dashhuang Aug 8, 2026
37e9059
fix(mobile): preserve visible content in shared images
dashhuang Aug 8, 2026
a3ee6aa
fix(mobile): harden shared conversation rendering
dashhuang Aug 8, 2026
f91f92b
fix(mobile): exclude collapsed messages from share
dashhuang Aug 9, 2026
93bbb6e
fix(mobile): streamline conversation share rendering
dashhuang Aug 9, 2026
a914aef
fix(mobile): harden conversation share fallback
dashhuang Aug 9, 2026
47306ef
fix(mobile): guard screenshot sharing overlays
dashhuang Aug 9, 2026
2b391e4
fix(mobile): keep share exports current
dashhuang Aug 9, 2026
d7aedd2
fix(mobile): keep collapsed share exports visible
dashhuang Aug 9, 2026
cab831c
fix(mobile): close share review gaps
dashhuang Aug 9, 2026
e4959d0
fix(mobile): preserve collapsed share previews
dashhuang Aug 9, 2026
3846b63
fix(mobile): bound collapsed share projections
dashhuang Aug 9, 2026
9e0749f
fix(mobile): keep share state current
dashhuang Aug 9, 2026
87a073d
refactor(mobile): simplify share projections
dashhuang Aug 9, 2026
6306cce
fix(mobile): keep share export active during branding load
dashhuang Aug 9, 2026
19aa2fb
fix(mobile): avoid decoding share attachment images
dashhuang Aug 9, 2026
cb15790
fix(mobile): harden share resource lifecycles
dashhuang Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
367 changes: 360 additions & 7 deletions apps/mobile/app/sessions/[sessionId].tsx

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"platforms": ["apple"],
"apple": {
"podspecPath": "./ios/XdtScreenshotMonitor.podspec",
"modules": ["XdtScreenshotMonitorModule"]
}
}
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
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)
Comment thread
dashhuang marked this conversation as resolved.
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)
)
Comment thread
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
}
7 changes: 7 additions & 0 deletions apps/mobile/modules/xdt-screenshot-monitor/package.json
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"
}
46 changes: 46 additions & 0 deletions apps/mobile/modules/xdt-screenshot-monitor/src/index.ts
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,
});
}
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"react-native-worklets": "0.8.3",
"xdt-ios-app-distribution": "file:./modules/xdt-ios-app-distribution",
"xdt-mobile-realtime-audio": "file:./modules/xdt-mobile-realtime-audio",
"xdt-screenshot-monitor": "file:./modules/xdt-screenshot-monitor",
Comment thread
MagicLizi marked this conversation as resolved.
"xdt-tapdb": "file:./modules/xdt-tapdb",
"xdt-wechat-login": "file:./modules/xdt-wechat-login"
},
Expand Down
Loading