+ {/* Share-target banner — rendered before appShell so it occupies its
+ own flex row at the top and pushes ClientLayout (including the
+ nav-tab header) down by the banner's height while a share is
+ pending. Self-renders null when no share, so this slot is free
+ in steady state. */}
+
{children}
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index eab43a6f..962a3dd8 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -81,6 +81,7 @@ import { CreateRoomModalRenderer } from '../features/create-room';
import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search';
+import { useShareTargetReceiver } from '../hooks/useShareTargetReceiver';
import { SettingsScreen } from '../features/settings';
import { getFallbackSession } from '../state/sessions';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
@@ -105,6 +106,15 @@ function IncomingCallsFeature() {
return null;
}
+// Drains the native share-target slot on cold-start and listens for warm
+// shareReceived events. Mounted alongside IncomingCallsFeature so it runs
+// only after login (Matrix client + room list available) and stays alive
+// for the whole authenticated session.
+function ShareTargetFeature() {
+ useShareTargetReceiver();
+ return null;
+}
+
// Deep-link entry for /u/
. `` is either a bare localpart or a
// full MXID; we normalize to an MXID using USER_LINK_HOST (the deep-link's own
// host, NOT the logged-in user's homeserver — a user signed into matrix.org
@@ -191,6 +201,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
+
@@ -328,7 +339,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
{/* Event-anchored URL — search/inbox/mention/push permalinks.
RR6 merges child params into the parent's useParams so
Room.tsx reads `eventId` without re-routing. */}
-
+
diff --git a/src/app/plugins/shareTarget.ts b/src/app/plugins/shareTarget.ts
new file mode 100644
index 00000000..b9f8596f
--- /dev/null
+++ b/src/app/plugins/shareTarget.ts
@@ -0,0 +1,69 @@
+// Bridge to the native ShareTargetPlugin (see
+// android/app/src/main/java/chat/vojo/app/ShareTargetPlugin.java).
+//
+// On Android the native plugin captures ACTION_SEND / ACTION_SEND_MULTIPLE
+// intents into the app cache. JS picks them up on boot via pickPendingShare()
+// and reacts to warm shares via the `shareReceived` event.
+//
+// Web has no system share-sheet handoff, so the fallback is a no-op that
+// always reports an empty slot.
+
+import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
+import { isAndroidPlatform } from '../utils/capacitor';
+
+// Mirror of the JSObject the native plugin assembles.
+export interface SharedFile {
+ name: string;
+ mimeType: string;
+ size: number;
+ // Absolute filesystem path inside the app cache. Wrap with
+ // Capacitor.convertFileSrc() before fetch().
+ path: string;
+}
+
+export type PendingShare =
+ | { empty: true }
+ | {
+ empty: false;
+ text?: string;
+ subject?: string;
+ items: SharedFile[];
+ };
+
+interface ShareTargetPluginIface {
+ pickPendingShare(options?: { consume?: boolean }): Promise;
+ addListener(eventName: 'shareReceived', listener: () => void): Promise;
+}
+
+const plugin = registerPlugin('ShareTarget', {
+ web: {
+ pickPendingShare: async () => ({ empty: true } as PendingShare),
+ addListener: async () => ({
+ remove: async () => undefined,
+ }),
+ },
+});
+
+export const shareTarget = {
+ async pickPendingShare(consume = true): Promise {
+ if (!isAndroidPlatform()) return { empty: true };
+ try {
+ return await plugin.pickPendingShare({ consume });
+ } catch {
+ // Old APK installed before the plugin shipped — be silent and treat
+ // as "no pending share" so the rest of the app boots normally.
+ return { empty: true };
+ }
+ },
+ addShareReceivedListener(cb: () => void): Promise {
+ if (!isAndroidPlatform()) {
+ return Promise.resolve({ remove: async () => undefined });
+ }
+ return plugin.addListener('shareReceived', cb);
+ },
+};
+
+// Convert the cached absolute path into a URL fetchable inside the WebView
+// (Capacitor wraps it as https://localhost/_capacitor_file_/…). Exposed here
+// so the consumer doesn't have to import @capacitor/core directly.
+export const sharedFilePathToUrl = (path: string): string => Capacitor.convertFileSrc(path);
diff --git a/src/app/state/pendingShare.ts b/src/app/state/pendingShare.ts
new file mode 100644
index 00000000..3f08a6c3
--- /dev/null
+++ b/src/app/state/pendingShare.ts
@@ -0,0 +1,23 @@
+import { atom } from 'jotai';
+import type { SharedFile } from '../plugins/shareTarget';
+
+// Hydrated payload from the system share-sheet hand-off. `text` is the
+// merged subject+body — ACTION_SEND sources differ (some put the link in
+// EXTRA_TEXT, some only EXTRA_SUBJECT, some both); the receiver flattens
+// them into one composer-ready string so downstream consumers don't need
+// to know the source. `files` are real `File` instances ready to feed into
+// RoomInput.handleFiles.
+//
+// `null` = no pending share. While non-null the ShareTargetStrip banner is
+// visible and the next RoomInput mount (= chat the user navigates into)
+// consumes the payload by injecting the files into the upload board and
+// any text into the composer.
+export type PendingShareValue = {
+ text?: string;
+ files: File[];
+ // Native cache descriptors kept for follow-up cleanup (see
+ // docs/plans/share_target_followups.md) — JS uses `files` for upload.
+ rawItems: SharedFile[];
+};
+
+export const pendingShareAtom = atom(null);