feat(share): receive Android system share intents and drop the payload into the next chat the user opens via a top banner cue

This commit is contained in:
heaven 2026-05-16 19:33:06 +03:00
parent 6982ec374e
commit f2ecca64da
14 changed files with 720 additions and 131 deletions

View file

@ -46,6 +46,30 @@
android:pathPrefix="/u/" />
</intent-filter>
<!-- System share-sheet target. Three filters because Android's
sheet UI dedupes by activity but resolves by MIME match:
text/* gets its own filter so the Vojo icon shows up
alongside WhatsApp/Telegram for «share link/selection»; */*
covers single-file (image/video/audio/pdf/…) and
SEND_MULTIPLE picks up gallery multi-select.
Payload extraction lives in ShareTargetPlugin — MainActivity
only routes the Intent to the plugin via onNewIntent. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<provider

View file

@ -63,6 +63,7 @@ public class MainActivity extends BridgeActivity {
registerPlugin(FullScreenIntentPlugin.class);
registerPlugin(CallForegroundPlugin.class);
registerPlugin(LaunchSplashPlugin.class);
registerPlugin(ShareTargetPlugin.class);
// AndroidX SplashScreen must be installed before super.onCreate().
// Keep it until the web splash confirms its first visible frame is

View file

@ -0,0 +1,273 @@
package chat.vojo.app;
import android.content.ContentResolver;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.OpenableColumns;
import android.util.Log;
import android.webkit.MimeTypeMap;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Receives ACTION_SEND / ACTION_SEND_MULTIPLE intents from the system share-
* sheet and surfaces them to the WebView as a pending share that JS consumes
* via {@code pickPendingShare()} (or reacts to via the {@code shareReceived}
* event when the app was already in the foreground).
*
* Cold-start flow:
* 1. Share-sheet Vojo MainActivity.onCreate super.onCreate runs
* BridgeActivity.load(), which itself calls bridge.onNewIntent(getIntent())
* and fans the intent out to every plugin's handleOnNewIntent. So
* cold-start and warm-start share the SAME entry point we don't
* double-process via handleOnStart.
* 2. captureFromIntent copies payload bytes into the app cache and stashes
* the result in {@link #pendingShare}.
* 3. JS booting up (Matrix client ready, user logged in) calls
* pickPendingShare(); receives the JSON; opens the room-picker UI. The
* shareReceived event fired here is dropped silently because no JS
* listener is attached yet that's fine, pickPendingShare drains the
* slot regardless.
*
* Warm flow (app already running):
* 1. Share-sheet MainActivity.onNewIntent BridgeActivity forwards to
* plugin.handleOnNewIntent(intent).
* 2. We re-capture the payload AND emit {@code shareReceived} so JS can
* open the picker without polling.
*
* Why we copy to cache instead of handing JS a content:// URI:
* - WebView fetch() rejects content:// schemes outright, and
* `Capacitor.convertFileSrc()` only works on file paths.
* - The originating app holds the read-grant only for the lifetime of the
* launching task; routing the URI through JS+picker+RoomInput would race
* that grant on Android 14+.
* - Copying into our own cache means the share is self-contained: even if
* the user backgrounds Vojo for hours before picking a chat, the bytes
* are still there. We schedule no cleanup of our own Android's cache
* eviction handles long-tail garbage.
*/
@CapacitorPlugin(name = "ShareTarget")
public class ShareTargetPlugin extends Plugin {
private static final String TAG = "ShareTargetPlugin";
private static final String SHARE_CACHE_SUBDIR = "shared";
// Single-slot pending share. Multiple share-sheet invocations before JS
// drains the slot collapse the latest wins. JS contract is "consume
// once, then it's gone" via pickPendingShare(consume=true). This matches
// user intent: tapping share twice on different photos clearly means
// "share THIS one now".
private volatile JSObject pendingShare = null;
@Override
public void handleOnNewIntent(Intent intent) {
super.handleOnNewIntent(intent);
captureFromIntent(intent, /* notifyJs */ true);
}
@PluginMethod
public void pickPendingShare(PluginCall call) {
JSObject ret = new JSObject();
JSObject snapshot = pendingShare;
if (snapshot == null) {
ret.put("empty", true);
} else {
// Default: consume on read. Lets us treat the slot like a one-shot
// mailbox without an extra round-trip. Caller can pass consume=false
// to peek (not used today, but cheap to keep).
Boolean consume = call.getBoolean("consume", Boolean.TRUE);
ret = snapshot;
if (Boolean.TRUE.equals(consume)) {
pendingShare = null;
}
}
call.resolve(ret);
}
private void captureFromIntent(Intent intent, boolean notifyJs) {
if (intent == null) return;
String action = intent.getAction();
if (action == null) return;
// Capacitor's JSObject.put() silently swallows JSONException internally
// (it wraps org.json.JSONObject and returns `this` on failure) so no
// checked exception is thrown here unlike the raw org.json API.
JSObject share = new JSObject();
share.put("empty", false);
String text = intent.getStringExtra(Intent.EXTRA_TEXT);
String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
if (text != null && !text.isEmpty()) share.put("text", text);
if (subject != null && !subject.isEmpty()) share.put("subject", subject);
JSArray items = new JSArray();
List<Uri> uris = new ArrayList<>();
if (Intent.ACTION_SEND.equals(action)) {
Uri uri;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
uri = intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri.class);
} else {
// Deprecated overload required to read EXTRA_STREAM on
// API 32, where the typed variant doesn't exist.
//noinspection deprecation
uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
}
if (uri != null) uris.add(uri);
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
List<Uri> multi;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
multi = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri.class);
} else {
//noinspection deprecation
multi = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
}
if (multi != null) uris.addAll(multi);
}
String intentMime = intent.getType();
for (Uri uri : uris) {
JSObject item = copyUriToCache(uri, intentMime);
if (item != null) items.put(item);
}
share.put("items", items);
// Drop pure-noise intents neither text nor a successfully
// copied file. Possible if a sender app handed us only a content://
// URI we can't read (permission revoked) or an EXTRA_STREAM with a
// null Uri. Keeps JS from showing an empty picker.
if (text == null && subject == null && items.length() == 0) {
Log.w(TAG, "Dropping share intent with no usable payload");
return;
}
pendingShare = share;
if (notifyJs) {
notifyListeners("shareReceived", new JSObject());
}
}
/**
* Stream the content of {@code uri} into a fresh file under
* cacheDir/shared/, then return {name, mimeType, size, path}. The path is
* an absolute filesystem path JS wraps it with
* {@code Capacitor.convertFileSrc} before fetch().
*/
private JSObject copyUriToCache(Uri uri, String fallbackMime) {
if (uri == null) return null;
ContentResolver resolver = getContext().getContentResolver();
String name = queryDisplayName(resolver, uri);
String mimeType = resolver.getType(uri);
if (mimeType == null) mimeType = fallbackMime;
if (mimeType == null) mimeType = "application/octet-stream";
if (name == null || name.isEmpty()) {
String ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
name = "share-" + UUID.randomUUID() + (ext != null ? "." + ext : "");
}
File dir = new File(getContext().getCacheDir(), SHARE_CACHE_SUBDIR);
// mkdirs returns false if the directory already exists not an error.
// The real failure mode is the I/O exception below on FileOutputStream
// construction, which we surface.
if (!dir.exists() && !dir.mkdirs()) {
Log.e(TAG, "Could not create share cache dir: " + dir);
return null;
}
// Prefix with UUID so a repeated share of "IMG_1234.jpg" doesn't
// overwrite the previous payload while the user is still picking a
// chat for the older one (e.g. Gallery Vojo, see room-picker open,
// background Gallery re-share same file foreground Vojo). Both
// payloads stay independently addressable.
File out = new File(dir, UUID.randomUUID() + "_" + safeFileName(name));
// Open the input first; if the sender's provider hands us back
// null (revoked grant, gone-away ContentProvider, ) bail before
// creating any on-disk file otherwise the FileOutputStream
// initializer below would create a zero-byte orphan we'd never
// clean up (catch arm doesn't fire when we early-return).
long size;
try (InputStream in = resolver.openInputStream(uri)) {
if (in == null) {
Log.w(TAG, "openInputStream returned null for " + uri);
return null;
}
try (FileOutputStream fos = new FileOutputStream(out)) {
byte[] buf = new byte[64 * 1024];
int n;
long total = 0;
while ((n = in.read(buf)) > 0) {
fos.write(buf, 0, n);
total += n;
}
size = total;
}
} catch (IOException e) {
Log.e(TAG, "Failed to copy " + uri, e);
// Drop the partial file so we don't surface a truncated
// payload to JS as if it were valid.
//noinspection ResultOfMethodCallIgnored
out.delete();
return null;
}
JSObject item = new JSObject();
item.put("name", name);
item.put("mimeType", mimeType);
item.put("size", size);
item.put("path", out.getAbsolutePath());
return item;
}
private String queryDisplayName(ContentResolver resolver, Uri uri) {
// ContentResolver.query throws if the provider rejects the URI scheme
// (e.g. some senders pass a file:// directly no provider involved).
// Wrap in try/catch and fall back to the URI's last path segment.
try (Cursor c = resolver.query(uri, new String[]{ OpenableColumns.DISPLAY_NAME }, null, null, null)) {
if (c != null && c.moveToFirst()) {
int idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (idx >= 0) {
String name = c.getString(idx);
if (name != null && !name.isEmpty()) return name;
}
}
} catch (Throwable t) {
Log.d(TAG, "queryDisplayName failed for " + uri + ": " + t.getMessage());
}
String last = uri.getLastPathSegment();
if (last != null && !last.isEmpty()) {
// Strip any directory traversal a malicious sender might encode.
int slash = last.lastIndexOf('/');
return slash >= 0 ? last.substring(slash + 1) : last;
}
return null;
}
private static String safeFileName(String name) {
// Strip path separators and trim length the on-disk name is just an
// identifier; the display name we return to JS preserves the user's
// original filename verbatim. Trim from the tail so the recognisable
// head ("IMG_2025_05_16…") survives and the extension is the part
// that gets clipped on absurdly long names; the on-disk extension
// doesn't matter because nothing inside Vojo dispatches on it (the
// display name carries the real extension into JS).
String stripped = name.replaceAll("[/\\\\]", "_");
if (stripped.length() > 120) stripped = stripped.substring(0, 120);
return stripped;
}
}

View file

@ -91,7 +91,7 @@
"menu_developer_tools": "Developer Tools",
"menu_about": "About",
"drag_to_close": "Drag down to close",
"close": "Close settings",
"close": "Close",
"logout": "Logout",
"logout_confirm": "You're about to log out. Are you sure?",
"logout_failed": "Failed to logout! {{message}}",
@ -99,7 +99,6 @@
"logout_unverified_desc": "Verify your device before logging out to save your encrypted messages.",
"logout_alert_title": "Alert",
"logout_alert_desc": "Enable device verification or export your encrypted data from settings to avoid losing access to your messages.",
"general_title": "General",
"appearance": "Appearance",
"system_theme": "System",
@ -123,7 +122,6 @@
"url_preview": "Url Preview",
"url_preview_encrypted": "Url Preview in Encrypted Room",
"show_hidden_events": "Show Hidden Events",
"account_title": "Account",
"profile": "Profile",
"avatar": "Avatar",
@ -142,7 +140,6 @@
"select_user_desc": "Prevent receiving messages or invites from user by adding their userId.",
"block": "Block",
"users": "Users",
"notifications_title": "Notifications",
"block_messages": "Block Messages",
"block_messages_moved": "This option has been moved to \"Account > Block Users\" section.",
@ -187,7 +184,6 @@
"notif_disable": "Disable",
"notif_silent": "Notify Silent",
"notif_loud": "Notify Loud",
"devices_title": "Devices",
"security": "Security",
"device_verification": "Device Verification",
@ -230,7 +226,6 @@
"verify_other_desc": "Verify device identity and grant access to encrypted messages.",
"verify": "Verify",
"reset": "Reset",
"local_backup": "Local Backup",
"new_password": "New Password",
"confirm_password": "Confirm Password",
@ -244,7 +239,6 @@
"import_desc": "Load password protected copy of encryption data from device to decrypt your messages.",
"import": "Import",
"decrypt": "Decrypt",
"emojis_stickers_title": "Emojis & Stickers",
"default_pack": "Default Pack",
"unknown": "Unknown",
@ -254,7 +248,6 @@
"select_pack_desc": "Pick emoji and sticker packs from rooms to use globally.",
"select": "Select",
"room_packs": "Room Packs",
"close": "Close",
"select_all": "Select All",
"unselect_all": "Unselect All",
"no_packs": "No Packs",
@ -262,7 +255,6 @@
"apply_error": "Failed to apply changes! Please try again.",
"apply_ready": "Changes saved! Apply when ready.",
"apply_changes": "Apply Changes",
"about_title": "About",
"about_tagline": "Yet another matrix client.",
"options": "Options",
@ -274,7 +266,6 @@
"privacy_policy_desc": "How your data is handled.",
"privacy_policy_open": "Open",
"credits": "Credits",
"devtools_title": "Developer Tools",
"enable_devtools": "Enable Developer Tools",
"access_token": "Access Token",
@ -457,7 +448,6 @@
"jump_to_latest": "Jump to Latest",
"today": "Today",
"yesterday": "Yesterday",
"view_reactions": "View Reactions",
"read_receipts": "Read Receipts",
"view_source": "View Source",
@ -469,7 +459,6 @@
"reply": "Reply",
"reply_in_thread": "Reply in Thread",
"edit_message": "Edit Message",
"delete_message": "Delete Message",
"delete_confirm": "This action is irreversible! Are you sure that you want to delete this message?",
"reason": "Reason",
@ -477,7 +466,6 @@
"delete_error": "Failed to delete message! Please try again.",
"deleting": "Deleting...",
"delete": "Delete",
"report_message": "Report Message",
"report_desc": "Report this message to server, which may then notify the appropriate people to take action.",
"report_reason": "Reason",
@ -486,13 +474,11 @@
"reporting": "Reporting...",
"report": "Report",
"no_reason": "No reason provided",
"is_typing": " is typing...",
"and": " and ",
"are_typing": " are typing...",
"others_count": "{{count}} others",
"drop_typing": "Dismiss typing indicator",
"members": "Members",
"members_count_one": "{{formattedCount}} Member",
"members_count_other": "{{formattedCount}} Members",
@ -509,7 +495,6 @@
"room_settings": "Room Settings",
"jump_to_time": "Jump to Time",
"leave_room": "Leave Room",
"send_message": "Send a message...",
"send_message_alt_1": "One line or many...",
"send_message_alt_2": "Write something right now...",
@ -524,19 +509,16 @@
"send_message_alt_11": "After you...",
"drop_files": "Drop Files in \"{{name}}\"",
"drag_drop_desc": "Drag and drop files here or click for selection dialog",
"pinned_messages": "Pinned Messages",
"no_pinned_messages": "No Pinned Messages",
"no_pinned_messages_desc": "Users with sufficient permissions can pin messages from the message context menu.",
"open": "Open",
"failed_to_load": "Failed to load message!",
"time_label": "Time",
"date_label": "Date",
"preset": "Preset",
"beginning": "Beginning",
"open_timeline": "Open Timeline",
"message_deleted": "This message has been deleted",
"message_deleted_reason": "This message has been deleted. {{reason}}",
"unsupported_message": "Unsupported message",
@ -546,7 +528,6 @@
"broken_message": "Broken message",
"empty_message": "Empty message",
"edited": " (edited)",
"thread_caption": "Thread",
"thread_in_channel_subtitle": "in #{{channel}}",
"thread_close": "Close thread",
@ -563,7 +544,6 @@
"thread_summary_highlight_one": "{{count}} mention",
"thread_summary_highlight_other": "{{count}} mentions",
"no_post_permission": "You do not have permission to post in this room",
"conversation_beginning": "This is the beginning of conversation.",
"created_by": "Created by <bold>@{{creator}}</bold> on {{date}} {{time}}",
"invite_member": "Invite Member",
@ -574,7 +554,6 @@
"leave_room_error": "Failed to leave room! {{error}}",
"leaving": "Leaving...",
"leave": "Leave",
"member_broken": "Broken membership event",
"member_accepted_knock": "<bold>{{sender}}</bold> accepted <bold>{{user}}</bold>'s join request",
"member_invited": "<bold>{{sender}}</bold> invited <bold>{{user}}</bold>",
@ -600,19 +579,15 @@
"user_id_placeholder": "@username:server",
"reason_optional": "Reason (Optional)",
"invite_button": "Invite",
"notif_default": "Default",
"notif_all_messages": "All Messages",
"notif_mentions_keywords": "Mention & Keywords",
"notif_mute": "Mute",
"unverified_device": "Unverified Device",
"unverified_devices": "Unverified Devices"
},
"Explore": {
"explore_community": "Explore Community",
"add_server": "Add Server",
"add_server_desc": "Add server name to explore public communities.",
"server_name": "Server Name",
@ -620,13 +595,11 @@
"view": "View",
"featured": "Featured",
"servers": "Servers",
"featured_by_client": "Featured by Client",
"featured_by_client_desc": "Public rooms and spaces hand-picked by this client.",
"featured_spaces": "Featured Spaces",
"featured_rooms": "Featured Rooms",
"no_featured": "No featured rooms or spaces yet.",
"search": "Search",
"search_placeholder": "Search for keyword",
"clear": "Clear",
@ -645,7 +618,6 @@
"previous_page": "Previous Page",
"next_page": "Next Page",
"no_communities": "No communities found!",
"space_badge": "Space",
"members_count_one": "{{formattedCount}} Member",
"members_count_other": "{{formattedCount}} Members",
@ -657,7 +629,6 @@
"view_error": "View Error",
"cancel": "Cancel"
},
"Create": {
"add_space": "Add Space",
"create_space": "Create Space",
@ -665,7 +636,6 @@
"join_with_address": "Join with Address",
"join_with_address_desc": "Join an existing community.",
"new_space": "New Space",
"access": "Access",
"name": "Name",
"topic_optional": "Topic (Optional)",
@ -677,47 +647,38 @@
"allow_federation_desc": "Users from other servers can join.",
"create": "Create",
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
"access_restricted": "Restricted",
"access_restricted_desc": "Only members of the parent space can join.",
"access_private": "Private",
"access_private_desc": "Only people with an invite can join.",
"access_public": "Public",
"access_public_desc": "Anyone with the address can join.",
"address_optional": "Address (Optional)",
"address_hint": "Pick a unique address to make it discoverable.",
"address_taken": "This address is already taken. Please choose a different one.",
"founders": "Founders",
"founders_desc": "Privileged users assigned during creation. They have elevated control and can only be changed during an upgrade.",
"enter": "Enter",
"no_suggestions": "No Suggestions",
"no_suggestions_desc": "Enter a user ID and press Enter.",
"version": "Version",
"versions": "Versions",
"chat_room": "Chat Room",
"chat_room_desc": "Messages, photos, and videos.",
"voice_room": "Voice Room",
"voice_room_desc": "Live audio and video conversations.",
"new_chat_room": "New Chat Room",
"new_voice_room": "New Voice Room",
"existing_space": "Existing Space",
"add_room": "Add Room",
"existing_room": "Existing Room"
},
"RoomSettings": {
"general": "General",
"members": "Members",
"permissions": "Permissions",
"emojis_stickers": "Emojis & Stickers",
"developer_tools": "Developer Tools",
"profile": "Profile",
"edit": "Edit",
"unknown": "Unknown",
@ -729,30 +690,25 @@
"topic": "Topic",
"save": "Save",
"cancel": "Cancel",
"options": "Options",
"addresses": "Addresses",
"advanced_options": "Advanced Options",
"space_access": "Space Access",
"room_access": "Room Access",
"space_access_desc": "Change how people can join the space.",
"room_access_desc": "Change how people can join the room.",
"join_invite_only": "Invite Only",
"join_knock_invite": "Knock & Invite",
"join_space_members_or_knock": "Space Members or Knock",
"join_space_members": "Space Members",
"join_public": "Public",
"join_unsupported": "Unsupported",
"history_visibility": "Message History Visibility",
"history_visibility_desc": "Changes to history visibility will only apply to future messages and will not affect existing history.",
"visibility_after_invite": "After Invite",
"visibility_after_join": "After Join",
"visibility_all_messages": "All Messages",
"visibility_all_messages_guests": "All Messages (Guests)",
"room_encryption": "Room Encryption",
"encryption_enabled_desc": "Messages in this room are protected by end-to-end encryption.",
"encryption_disabled_desc": "Once enabled, encryption cannot be disabled!",
@ -761,11 +717,9 @@
"enable_encryption": "Enable Encryption",
"enable_encryption_confirm": "Are you sure? Once enabled, encryption cannot be disabled!",
"enable_e2e_encryption": "Enable E2E Encryption",
"publish_to_directory": "Publish to Directory",
"publish_space_desc": "List the space in the public directory to make it discoverable by others.",
"publish_room_desc": "List the room in the public directory to make it discoverable by others.",
"published_addresses": "Published Addresses",
"published_addresses_desc": "If access is <b>Public</b>, Published addresses will be used to join by anyone.",
"no_addresses": "No Addresses",
@ -779,13 +733,11 @@
"publish": "Publish",
"delete": "Delete",
"selected_count": "{{count}} Selected",
"local_addresses": "Local Addresses",
"local_addresses_desc": "Set local address so users can join through your homeserver.",
"collapse": "Collapse",
"expand": "Expand",
"loading": "Loading...",
"space_upgrade": "Space Upgrade",
"room_upgrade": "Room Upgrade",
"upgrade": "Upgrade",
@ -799,25 +751,21 @@
"old_room": "Old Room",
"open_new_space": "Open New Space",
"open_new_room": "Open New Room",
"members_count": "{{count}} Members",
"search": "Search",
"no_results": "No Results",
"results_count": "{{count}} Results",
"scroll_to_top": "Scroll to Top",
"no_membership_members": "No \"{{filter}}\" Members",
"filter_joined": "Joined",
"filter_invited": "Invited",
"filter_left": "Left",
"filter_kicked": "Kicked",
"filter_banned": "Banned",
"sort_a_to_z": "A to Z",
"sort_z_to_a": "Z to A",
"sort_newest": "Newest",
"sort_oldest": "Oldest",
"perm_messages": "Messages",
"perm_send_messages": "Send Messages",
"perm_send_stickers": "Send Stickers",
@ -850,12 +798,10 @@
"perm_manage_emojis_stickers": "Manage Emojis & Stickers",
"perm_change_server_acls": "Change Server ACLs",
"perm_modify_widgets": "Modify Widgets",
"founders": "Founders",
"founders_desc": "Founding members have all permissions and can only be changed during a room upgrade.",
"power_levels": "Power Levels",
"power_levels_desc": "Manage and customize incremental power levels for users.",
"new_power_level": "New Power Level",
"new_power_level_desc": "Create a new power level.",
"power_level_placeholder": "Bot",
@ -872,11 +818,9 @@
"failed_to_apply": "Failed to apply changes! Please try again.",
"apply_changes": "Apply Changes",
"and_above": "& Above",
"users": "Users",
"default_power": "Default Power",
"default_power_desc": "Default power level for all users.",
"packs": "Packs",
"new_pack": "New Pack",
"new_pack_desc": "Add your own emoji and sticker pack to use in room.",
@ -885,7 +829,6 @@
"view": "View",
"failed_to_remove_packs": "Failed to remove packs! Please try again.",
"delete_selected_packs": "Delete selected packs. ({{count}} selected)",
"enable_developer_tools": "Enable Developer Tools",
"room_id": "Room ID",
"room_id_desc": "Copy room ID to clipboard.",
@ -908,7 +851,6 @@
"message_event_type": "Message Event Type",
"send": "Send",
"state_key_optional": "State Key (Optional)",
"pack": "Pack",
"images_usage": "Images Usage",
"images_usage_desc": "Select how the images are being used: as emojis, as stickers, or as both.",
@ -923,7 +865,6 @@
"usage_both": "Both",
"usage_sticker": "Sticker",
"usage_emoji": "Emoji",
"power_goku": "Goku",
"power_manager": "Manager",
"power_founder": "Founder",
@ -933,7 +874,6 @@
"power_muted": "Muted",
"power_team": "Team"
},
"Push": {
"new_message": "New message",
"new_messages": "New messages",
@ -1016,5 +956,15 @@
"copy_server": "Copy server",
"explore_community": "Explore community",
"open_in_browser": "Open in browser"
},
"Share": {
"share_text": "Ready to share text",
"share_image": "Ready to share image",
"share_video": "Ready to share video",
"share_audio": "Ready to share audio",
"share_file": "Ready to share: {{name}}",
"share_files": "Ready to share {{count}} files",
"tap_chat_to_send": "Open a chat to drop it in",
"cancel": "Cancel share"
}
}

View file

@ -91,7 +91,7 @@
"menu_developer_tools": "Инструменты разработчика",
"menu_about": "О приложении",
"drag_to_close": "Потянуть вниз чтобы закрыть",
"close": "Закрыть настройки",
"close": "Закрыть",
"logout": "Выйти",
"logout_confirm": "Вы собираетесь выйти из аккаунта. Вы уверены?",
"logout_failed": "Не удалось выйти! {{message}}",
@ -99,7 +99,6 @@
"logout_unverified_desc": "Верифицируйте устройство перед выходом, чтобы сохранить зашифрованные сообщения.",
"logout_alert_title": "Внимание",
"logout_alert_desc": "Включите верификацию устройства или экспортируйте зашифрованные данные в настройках, чтобы не потерять доступ к сообщениям.",
"general_title": "Общие",
"appearance": "Внешний вид",
"system_theme": "Системная",
@ -123,7 +122,6 @@
"url_preview": "Предпросмотр ссылок",
"url_preview_encrypted": "Предпросмотр ссылок в зашифрованных комнатах",
"show_hidden_events": "Показывать скрытые события",
"account_title": "Аккаунт",
"profile": "Профиль",
"avatar": "Аватар",
@ -142,7 +140,6 @@
"select_user_desc": "Заблокируйте получение сообщений и приглашений от пользователя, добавив его идентификатор.",
"block": "Заблокировать",
"users": "Пользователи",
"notifications_title": "Уведомления",
"block_messages": "Блокировка сообщений",
"block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».",
@ -187,7 +184,6 @@
"notif_disable": "Отключить",
"notif_silent": "Тихое уведомление",
"notif_loud": "Громкое уведомление",
"devices_title": "Устройства",
"security": "Безопасность",
"device_verification": "Верификация устройства",
@ -230,7 +226,6 @@
"verify_other_desc": "Подтвердите идентичность устройства и получите доступ к зашифрованным сообщениям.",
"verify": "Верифицировать",
"reset": "Сбросить",
"local_backup": "Локальная копия",
"new_password": "Новый пароль",
"confirm_password": "Подтвердите пароль",
@ -244,7 +239,6 @@
"import_desc": "Загрузите защищённую паролем копию ключей шифрования с устройства для расшифровки сообщений.",
"import": "Импорт",
"decrypt": "Расшифровать",
"emojis_stickers_title": "Эмодзи и стикеры",
"default_pack": "Пакет по умолчанию",
"unknown": "Неизвестно",
@ -254,7 +248,6 @@
"select_pack_desc": "Выберите пакеты эмодзи и стикеров из комнат для использования во всех комнатах.",
"select": "Выбрать",
"room_packs": "Пакеты комнат",
"close": "Закрыть",
"select_all": "Выбрать все",
"unselect_all": "Снять выделение",
"no_packs": "Нет пакетов",
@ -262,7 +255,6 @@
"apply_error": "Не удалось применить изменения! Попробуйте снова.",
"apply_ready": "Изменения сохранены! Примените, когда будете готовы.",
"apply_changes": "Применить изменения",
"about_title": "О приложении",
"about_tagline": "Ещё один клиент для Matrix.",
"options": "Параметры",
@ -274,7 +266,6 @@
"privacy_policy_desc": "Как обрабатываются ваши данные.",
"privacy_policy_open": "Открыть",
"credits": "Благодарности",
"devtools_title": "Инструменты разработчика",
"enable_devtools": "Включить инструменты разработчика",
"access_token": "Токен доступа",
@ -463,7 +454,6 @@
"jump_to_latest": "К последним",
"today": "Сегодня",
"yesterday": "Вчера",
"view_reactions": "Реакции",
"read_receipts": "Подтверждения прочтения",
"view_source": "Исходный код",
@ -475,7 +465,6 @@
"reply": "Ответить",
"reply_in_thread": "Ответить в треде",
"edit_message": "Редактировать",
"delete_message": "Удалить сообщение",
"delete_confirm": "Это действие необратимо! Вы уверены, что хотите удалить это сообщение?",
"reason": "Причина",
@ -483,7 +472,6 @@
"delete_error": "Не удалось удалить сообщение! Попробуйте снова.",
"deleting": "Удаление...",
"delete": "Удалить",
"report_message": "Пожаловаться",
"report_desc": "Сообщить о нарушении на сервер, который может уведомить ответственных лиц для принятия мер.",
"report_reason": "Причина",
@ -492,13 +480,11 @@
"reporting": "Отправка...",
"report": "Пожаловаться",
"no_reason": "Причина не указана",
"is_typing": " печатает...",
"and": " и ",
"are_typing": " печатают...",
"others_count": "ещё {{count}}",
"drop_typing": "Скрыть индикатор набора",
"members": "Участники",
"members_count_one": "{{formattedCount}} участник",
"members_count_few": "{{formattedCount}} участника",
@ -517,7 +503,6 @@
"room_settings": "Настройки комнаты",
"jump_to_time": "Перейти к дате",
"leave_room": "Покинуть комнату",
"send_message": "Написать сообщение...",
"send_message_alt_1": "В одну строку или несколько...",
"send_message_alt_2": "Написать в эту минуту...",
@ -532,19 +517,16 @@
"send_message_alt_11": "Только после вас...",
"drop_files": "Перетащите файлы в \"{{name}}\"",
"drag_drop_desc": "Перетащите файлы сюда или нажмите для выбора",
"pinned_messages": "Закреплённые сообщения",
"no_pinned_messages": "Нет закреплённых сообщений",
"no_pinned_messages_desc": "Пользователи с достаточным уровнем прав могут закреплять сообщения через контекстное меню.",
"open": "Открыть",
"failed_to_load": "Не удалось загрузить сообщение!",
"time_label": "Время",
"date_label": "Дата",
"preset": "Пресет",
"beginning": "Начало",
"open_timeline": "Открыть ленту",
"message_deleted": "Сообщение было удалено",
"message_deleted_reason": "Сообщение было удалено. {{reason}}",
"unsupported_message": "Неподдерживаемое сообщение",
@ -554,7 +536,6 @@
"broken_message": "Повреждённое сообщение",
"empty_message": "Пустое сообщение",
"edited": " (изменено)",
"thread_caption": "Тред",
"thread_in_channel_subtitle": "в #{{channel}}",
"thread_close": "Закрыть тред",
@ -577,7 +558,6 @@
"thread_summary_highlight_many": "{{count}} упоминаний",
"thread_summary_highlight_other": "{{count}} упоминания",
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
"conversation_beginning": "Начало переписки.",
"created_by": "Комната создана <bold>@{{creator}}</bold> {{date}} {{time}}",
"invite_member": "Пригласить",
@ -588,7 +568,6 @@
"leave_room_error": "Не удалось покинуть комнату! {{error}}",
"leaving": "Выход...",
"leave": "Покинуть",
"member_broken": "Некорректное событие участия",
"member_accepted_knock": "<bold>{{sender}}</bold> одобряет вступление <bold>{{user}}</bold>",
"member_invited": "<bold>{{sender}}</bold> приглашает <bold>{{user}}</bold>",
@ -614,19 +593,15 @@
"user_id_placeholder": "@username:server",
"reason_optional": "Причина (необязательно)",
"invite_button": "Пригласить",
"notif_default": "По умолчанию",
"notif_all_messages": "Все сообщения",
"notif_mentions_keywords": "Упоминания и ключевые слова",
"notif_mute": "Без уведомлений",
"unverified_device": "Неподтверждённое устройство",
"unverified_devices": "Неподтверждённые устройства"
},
"Explore": {
"explore_community": "Обзор сообществ",
"add_server": "Добавить сервер",
"add_server_desc": "Укажите имя сервера для обзора публичных сообществ.",
"server_name": "Имя сервера",
@ -634,13 +609,11 @@
"view": "Открыть",
"featured": "Рекомендуемые",
"servers": "Серверы",
"featured_by_client": "Рекомендации клиента",
"featured_by_client_desc": "Подборка публичных комнат и пространств от этого клиента.",
"featured_spaces": "Рекомендуемые пространства",
"featured_rooms": "Рекомендуемые комнаты",
"no_featured": "Рекомендуемых комнат и пространств пока нет.",
"search": "Поиск",
"search_placeholder": "Поиск по ключевому слову",
"clear": "Очистить",
@ -659,7 +632,6 @@
"previous_page": "Предыдущая",
"next_page": "Следующая",
"no_communities": "Сообщества не найдены!",
"space_badge": "Пространство",
"members_count_one": "{{formattedCount}} участник",
"members_count_few": "{{formattedCount}} участника",
@ -673,7 +645,6 @@
"view_error": "Подробности",
"cancel": "Отмена"
},
"Create": {
"add_space": "Добавить пространство",
"create_space": "Создать пространство",
@ -681,7 +652,6 @@
"join_with_address": "Присоединиться по адресу",
"join_with_address_desc": "Присоединиться к существующему сообществу.",
"new_space": "Новое пространство",
"access": "Доступ",
"name": "Название",
"topic_optional": "Тема (необязательно)",
@ -693,47 +663,38 @@
"allow_federation_desc": "Пользователи с других серверов смогут присоединиться.",
"create": "Создать",
"rate_limited": "Сервер ограничил ваш запрос на {{minutes}} мин.!",
"access_restricted": "Ограниченный",
"access_restricted_desc": "Могут присоединиться только участники родительского пространства.",
"access_private": "Приватный",
"access_private_desc": "Могут присоединиться только приглашённые.",
"access_public": "Публичный",
"access_public_desc": "Любой, у кого есть адрес, может присоединиться.",
"address_optional": "Адрес (необязательно)",
"address_hint": "Выберите уникальный адрес, чтобы пространство можно было найти.",
"address_taken": "Этот адрес уже занят. Выберите другой.",
"founders": "Основатели",
"founders_desc": "Привилегированные пользователи, назначенные при создании. Они имеют расширенные полномочия; изменить их можно только при обновлении пространства.",
"enter": "Добавить",
"no_suggestions": "Нет предложений",
"no_suggestions_desc": "Введите ID пользователя и нажмите Добавить.",
"version": "Версия",
"versions": "Версии",
"chat_room": "Чат-комната",
"chat_room_desc": "Сообщения, фото и видео.",
"voice_room": "Голосовая комната",
"voice_room_desc": "Голосовые и видеозвонки в реальном времени.",
"new_chat_room": "Новая чат-комната",
"new_voice_room": "Новая голосовая комната",
"existing_space": "Существующее пространство",
"add_room": "Добавить комнату",
"existing_room": "Существующая комната"
},
"RoomSettings": {
"general": "Основные",
"members": "Участники",
"permissions": "Права доступа",
"emojis_stickers": "Эмодзи и стикеры",
"developer_tools": "Инструменты разработчика",
"profile": "Профиль",
"edit": "Редактировать",
"unknown": "Неизвестно",
@ -745,30 +706,25 @@
"topic": "Тема",
"save": "Сохранить",
"cancel": "Отмена",
"options": "Настройки",
"addresses": "Адреса",
"advanced_options": "Дополнительные настройки",
"space_access": "Доступ к пространству",
"room_access": "Доступ к комнате",
"space_access_desc": "Изменить способ вступления в пространство.",
"room_access_desc": "Изменить способ вступления в комнату.",
"join_invite_only": "Только по приглашению",
"join_knock_invite": "Запрос и приглашение",
"join_space_members_or_knock": "Участники пространства или запрос",
"join_space_members": "Участники пространства",
"join_public": "Публичный",
"join_unsupported": "Не поддерживается",
"history_visibility": "Видимость истории сообщений",
"history_visibility_desc": "Изменения видимости истории применяются только к новым сообщениям и не затрагивают существующую историю.",
"visibility_after_invite": "После приглашения",
"visibility_after_join": "После вступления",
"visibility_all_messages": "Все сообщения",
"visibility_all_messages_guests": "Все сообщения (гости)",
"room_encryption": "Шифрование комнаты",
"encryption_enabled_desc": "Сообщения в этой комнате защищены сквозным шифрованием.",
"encryption_disabled_desc": "После включения шифрование невозможно отключить!",
@ -777,11 +733,9 @@
"enable_encryption": "Включить шифрование",
"enable_encryption_confirm": "Вы уверены? После включения шифрование невозможно отключить!",
"enable_e2e_encryption": "Включить E2E-шифрование",
"publish_to_directory": "Показывать в поиске",
"publish_space_desc": "Сделать пространство видимым в общем списке, чтобы другие пользователи могли его найти.",
"publish_room_desc": "Сделать комнату видимой в общем списке, чтобы другие пользователи могли её найти.",
"published_addresses": "Опубликованные адреса",
"published_addresses_desc": "Если доступ <b>публичный</b>, опубликованные адреса будут использоваться для присоединения.",
"no_addresses": "Нет адресов",
@ -795,13 +749,11 @@
"publish": "Опубликовать",
"delete": "Удалить",
"selected_count": "Выбрано: {{count}}",
"local_addresses": "Локальные адреса",
"local_addresses_desc": "Задайте локальный адрес, чтобы пользователи могли присоединиться через ваш сервер.",
"collapse": "Свернуть",
"expand": "Развернуть",
"loading": "Загрузка...",
"space_upgrade": "Обновление пространства",
"room_upgrade": "Обновление комнаты",
"upgrade": "Обновить",
@ -815,25 +767,21 @@
"old_room": "Старая комната",
"open_new_space": "Открыть новое пространство",
"open_new_room": "Открыть новую комнату",
"members_count": "{{count}} участников",
"search": "Поиск",
"no_results": "Ничего не найдено",
"results_count": "{{count}} результатов",
"scroll_to_top": "Наверх",
"no_membership_members": "Нет участников «{{filter}}»",
"filter_joined": "Вступившие",
"filter_invited": "Приглашённые",
"filter_left": "Вышедшие",
"filter_kicked": "Исключённые",
"filter_banned": "Забаненные",
"sort_a_to_z": "А — Я",
"sort_z_to_a": "Я — А",
"sort_newest": "Новые",
"sort_oldest": "Старые",
"perm_messages": "Сообщения",
"perm_send_messages": "Отправка сообщений",
"perm_send_stickers": "Отправка стикеров",
@ -866,12 +814,10 @@
"perm_manage_emojis_stickers": "Управление эмодзи и стикерами",
"perm_change_server_acls": "Изменение ACL серверов",
"perm_modify_widgets": "Изменение виджетов",
"founders": "Основатели",
"founders_desc": "Основатели имеют все права. Изменить их состав можно только при обновлении комнаты.",
"power_levels": "Уровни власти",
"power_levels_desc": "Управление и настройка уровней власти для пользователей.",
"new_power_level": "Новый уровень власти",
"power_level_placeholder": "Бот",
"new_power_level_desc": "Создать новый уровень власти.",
@ -888,11 +834,9 @@
"failed_to_apply": "Не удалось применить изменения! Попробуйте ещё раз.",
"apply_changes": "Применить изменения",
"and_above": "и выше",
"users": "Пользователи",
"default_power": "Уровень по умолчанию",
"default_power_desc": "Уровень власти по умолчанию для всех пользователей.",
"packs": "Паки",
"new_pack": "Новый пак",
"new_pack_desc": "Добавьте свой пак эмодзи и стикеров для использования в комнате.",
@ -901,7 +845,6 @@
"view": "Открыть",
"failed_to_remove_packs": "Не удалось удалить паки! Попробуйте ещё раз.",
"delete_selected_packs": "Удалить выбранные паки. (Выбрано: {{count}})",
"enable_developer_tools": "Включить инструменты разработчика",
"room_id": "ID комнаты",
"room_id_desc": "Скопировать ID комнаты в буфер обмена.",
@ -924,7 +867,6 @@
"message_event_type": "Тип события сообщения",
"send": "Отправить",
"state_key_optional": "State Key (необязательно)",
"pack": "Пак",
"images_usage": "Использование изображений",
"images_usage_desc": "Выберите, как используются изображения: как эмодзи, как стикеры или как и то, и другое.",
@ -939,7 +881,6 @@
"usage_both": "Оба",
"usage_sticker": "Стикер",
"usage_emoji": "Эмодзи",
"power_goku": "Гоку",
"power_manager": "Менеджер",
"power_founder": "Основатель",
@ -949,7 +890,6 @@
"power_muted": "Без голоса",
"power_team": "Команда"
},
"Push": {
"new_message": "Новое сообщение",
"new_messages": "Новые сообщения",
@ -1035,5 +975,15 @@
"copy_server": "Скопировать сервер",
"explore_community": "Открыть сервер",
"open_in_browser": "Открыть в браузере"
},
"Share": {
"share_text": "Готово к пересылке: текст",
"share_image": "Готово к пересылке: изображение",
"share_video": "Готово к пересылке: видео",
"share_audio": "Готово к пересылке: аудио",
"share_file": "Готово к пересылке: {{name}}",
"share_files": "Готово к пересылке: {{count}} файлов",
"tap_chat_to_send": "Откройте чат, чтобы отправить",
"cancel": "Отменить"
}
}

View file

@ -9,7 +9,7 @@ import React, {
useRef,
useState,
} from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
import { useTranslation } from 'react-i18next';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
@ -119,6 +119,7 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
import { pendingShareAtom } from '../../state/pendingShare';
// Placeholder rotation set — 12 i18n keys (1 default + 11 alternates) under
// the `Room` namespace; the hour-of-day slot picks one. With 12 variants
@ -295,10 +296,47 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const isComposing = useComposingCheck();
// Subscribe write-side only: the value is read inside the effect below
// via the snapshot the effect captures from its closure deps.
const pendingShare = useAtomValue(pendingShareAtom);
const setPendingShare = useSetAtom(pendingShareAtom);
useEffect(() => {
Transforms.insertFragment(editor, msgDraft);
}, [editor, msgDraft]);
// Drain the global share-target hand-off into THIS chat. The system
// share-sheet flow doesn't open a picker — it just lights up the
// ShareTargetStrip banner and lets the user pick a chat by navigating
// normally. The first RoomInput that mounts (or, if the user was
// already inside a chat when the share arrived, the current RoomInput
// re-running this effect with a non-null `pendingShare`) consumes the
// payload: files into the upload board, text into the composer. The
// user can still bail by tapping the [×] on each upload card before
// pressing Send.
//
// Declared AFTER the msgDraft restore so the share text appends to
// any saved draft instead of getting overwritten by it.
//
// Thread composers (threadId set) deliberately skip — sharing into a
// thread isn't a flow users ask for, and would silently consume the
// share leaving the main composer empty.
useEffect(() => {
if (threadId) return;
if (!pendingShare) return;
// Clear first so a re-render mid-handleFiles can't queue another
// run for the same payload.
const consumed = pendingShare;
setPendingShare(null);
if (consumed.files.length > 0) {
handleFiles(consumed.files);
}
const { text } = consumed;
if (text) {
Transforms.insertText(editor, text);
}
}, [threadId, pendingShare, setPendingShare, handleFiles, editor]);
useEffect(
() => () => {
if (!isEmptyEditor(editor)) {

View file

@ -0,0 +1,34 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Thin banner shown at the very top of the app shell while a share is
// pending. Mounted as the first flex-child of HorseshoeContainer's surface
// (a flex column) so it pushes the entire app — including the nav-tab
// header inside ClientLayout — down by its own height. Not `position:
// fixed` because that would overlay the navigation chrome and trap the
// user on whichever route was active when the share arrived.
//
// `flexShrink: 0` keeps the strip at its natural height even when nested
// scroll containers below try to claim space.
export const Strip = style({
flexShrink: 0,
paddingTop: 'env(safe-area-inset-top)',
background: color.Primary.Container,
color: color.Primary.OnContainer,
borderBottom: `${config.borderWidth.B300} solid ${color.Primary.ContainerLine}`,
});
export const Content = style({
display: 'flex',
alignItems: 'center',
gap: toRem(12),
padding: `${toRem(10)} ${toRem(16)}`,
});
export const TextBlock = style({
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: toRem(2),
});

View file

@ -0,0 +1,62 @@
import React from 'react';
import { useAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { Icon, IconButton, Icons, Text } from 'folds';
import { pendingShareAtom, type PendingShareValue } from '../../state/pendingShare';
import * as css from './ShareTargetStrip.css';
type Translator = (key: string, options?: Record<string, unknown>) => string;
// One-line description of what's queued — used as the strip's primary
// label. Mirrors the file-vs-text branching the upload board uses for
// its own cards, so the wording the user sees on the strip matches the
// cards once they land in the chat.
function describeShare(share: PendingShareValue, t: Translator): string {
const fileCount = share.files.length;
if (fileCount === 0 && share.text) return t('Share.share_text');
if (fileCount === 1) {
const f = share.files[0];
if (f.type.startsWith('image/')) return t('Share.share_image');
if (f.type.startsWith('video/')) return t('Share.share_video');
if (f.type.startsWith('audio/')) return t('Share.share_audio');
return t('Share.share_file', { name: f.name });
}
return t('Share.share_files', { count: fileCount });
}
// Persistent banner shown at the top of the viewport while
// `pendingShareAtom` holds a payload. Renders nothing when the atom is
// null. The user navigates to whichever chat they want — once a
// RoomInput mounts (= they entered a chat) it consumes the atom via its
// own effect, the atom flips to null, and this strip unmounts itself.
export function ShareTargetStrip() {
const { t } = useTranslation();
const [share, setShare] = useAtom(pendingShareAtom);
if (!share) return null;
return (
<div className={css.Strip} role="status" aria-live="polite">
<div className={css.Content}>
<Icon size="200" src={Icons.Send} />
<div className={css.TextBlock}>
<Text size="T300" truncate>
<b>{describeShare(share, t)}</b>
</Text>
<Text size="T200" priority="300" truncate>
{t('Share.tap_chat_to_send')}
</Text>
</div>
<IconButton
size="300"
radii="Pill"
variant="Primary"
fill="None"
onClick={() => setShare(null)}
aria-label={t('Share.cancel')}
>
<Icon src={Icons.Cross} />
</IconButton>
</div>
</div>
);
}

View file

@ -0,0 +1 @@
export * from './ShareTargetStrip';

View file

@ -0,0 +1,139 @@
import { useEffect } from 'react';
import { useSetAtom } from 'jotai';
import type { PluginListenerHandle } from '@capacitor/core';
import {
shareTarget,
sharedFilePathToUrl,
type PendingShare,
type SharedFile,
} from '../plugins/shareTarget';
import { pendingShareAtom, type PendingShareValue } from '../state/pendingShare';
import { isAndroidPlatform } from '../utils/capacitor';
// Convert a SharedFile (cached path + metadata) into a real File so it slots
// straight into RoomInput.handleFiles. The fetch() goes through the
// capacitor:// scheme handler which translates back to a direct cacheDir
// read — no network round-trip.
const hydrateSharedFile = async (item: SharedFile): Promise<File | null> => {
try {
const url = sharedFilePathToUrl(item.path);
const res = await fetch(url);
if (!res.ok) return null;
const blob = await res.blob();
// ContentResolver-reported mimeType is more accurate than the blob's
// sniffed type for ambiguous cases (no extension, octet-stream).
return new File([blob], item.name, {
type: item.mimeType || blob.type || 'application/octet-stream',
});
} catch {
return null;
}
};
// Merge subject and text into a single composer string. ACTION_SEND sources
// like Gmail "Share thread" put the email subject in EXTRA_SUBJECT and the
// body in EXTRA_TEXT; sources like Chrome "Share page" sometimes send only
// EXTRA_SUBJECT (the page title) with no body. We treat them as one stream
// of text the user will edit before sending — no need to model them as two
// independent fields downstream.
const mergeSubjectAndText = (
text: string | undefined,
subject: string | undefined
): string | undefined => {
if (text && subject && text !== subject) return `${subject}\n\n${text}`;
return text || subject;
};
const hydrateShare = async (share: PendingShare): Promise<PendingShareValue | null> => {
if (share.empty) return null;
const fileResults = await Promise.all((share.items ?? []).map(hydrateSharedFile));
const files = fileResults.filter((f): f is File => f !== null);
const text = mergeSubjectAndText(share.text, share.subject);
// Nothing useful to act on — drop. Possible when text+subject were both
// empty AND every file failed to hydrate (cache eviction between native
// capture and JS pickup, or a permission-denied content URI we never
// managed to read).
if (!text && files.length === 0) return null;
return {
text,
files,
rawItems: share.items ?? [],
};
};
/**
* Drains the native share-target slot on mount and on every warm
* `shareReceived` event, writing the hydrated payload into
* {@link pendingShareAtom}. Two consumers subscribe to that atom:
* - `ShareTargetStrip` shows the banner above the app shell while it's
* non-null,
* - `RoomInput.useEffect` drains the atom into the first chat the user
* opens, dropping files into the upload board and any text into the
* composer (see RoomInput.tsx).
*
* Mounted once inside the authenticated tree (see Router.tsx) before that
* point Matrix client / room list aren't available, so even if a cold-start
* intent landed we couldn't route it anywhere yet. The native plugin keeps
* the payload in its slot until JS finally drains via pickPendingShare(true),
* so deferring is safe: cold-start path comes via BridgeActivity.load()
* handleOnNewIntent (Capacitor lifecycle), warm path via Activity.onNewIntent
* handleOnNewIntent + a `shareReceived` event.
*/
export const useShareTargetReceiver = (): void => {
const setPendingShare = useSetAtom(pendingShareAtom);
useEffect(() => {
if (!isAndroidPlatform()) return undefined;
let cancelled = false;
let handle: PluginListenerHandle | undefined;
// Monotonic drain id: every drain() bumps a counter, captures its own id,
// and only commits its hydrated result if it's still the latest. Without
// this, an in-flight hydration of a large (slow) payload can land after
// a subsequent quick payload's hydration and overwrite the picker with
// stale state.
let drainSeq = 0;
let latestDrain = 0;
const drain = async () => {
drainSeq += 1;
const mySeq = drainSeq;
latestDrain = mySeq;
const share = await shareTarget.pickPendingShare(true);
if (cancelled || mySeq !== latestDrain) return;
const hydrated = await hydrateShare(share);
if (cancelled || mySeq !== latestDrain || !hydrated) return;
setPendingShare(hydrated);
};
// Register the warm listener FIRST so a `shareReceived` that lands while
// initial drain is awaiting the slot read doesn't slip through. The
// native plugin emits the event after stashing into the slot, so even
// if our drain races a warm capture, the listener will fire a second
// drain that re-reads the slot. Without this ordering, a warm share
// arriving in the gap between `drain()` and `addListener` would leave
// its payload stuck in the native slot with no JS-side trigger.
shareTarget
.addShareReceivedListener(() => {
drain();
})
.then((h) => {
if (cancelled) {
h.remove();
} else {
handle = h;
}
})
.catch(() => undefined);
// Initial drain: covers cold-start (user launched Vojo straight from the
// share-sheet) and warm app-mount after re-login.
drain();
return () => {
cancelled = true;
handle?.remove();
};
}, [setPendingShare]);
};

View file

@ -1,16 +1,20 @@
// Bottom-horseshoe call surface.
// App-shell wrapper hosting the optional top share-target banner, the
// `ClientLayout` body, and the bottom call-rail (incoming-ring strips +
// active-call status pill) inside a single flex column.
//
// Wraps `ClientLayout` and the bottom call-rail (incoming-ring strips
// + active-call status pill) into a single flex column. While at least
// one ring is queued or an active call's pill is visible, the
// container paints `#090909` in the gap and pulls the app shell up
// While at least one ring is queued or an active call's pill is visible,
// the container paints `#090909` in the gap and pulls the app shell up
// with rounded bottom corners.
//
// While `pendingShareAtom` is non-null, `ShareTargetStrip` renders above
// `appShell` and pushes ClientLayout (including its nav-tab header)
// down by the banner's height. Returns null when no share is pending,
// so the slot is free in steady state.
//
// The TOP horseshoe (user profile sheet) used to live here too, but
// was hoisted into `features/room/RoomViewProfilePanel.tsx` so it
// only affects the room/chat column — on desktop it no longer
// blankets the sidebar / nav rails. This wrapper now owns the call
// surface only.
// blankets the sidebar / nav rails.
import React, { ReactNode } from 'react';
import classNames from 'classnames';
@ -18,6 +22,7 @@ import { useAtomValue } from 'jotai';
import { isRingingAtom } from '../state/incomingCalls';
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
import { CallStatusRenderer, useCallStatusVisible } from './CallStatusRenderer';
import { ShareTargetStrip } from '../features/share-target';
import * as css from './HorseshoeContainer.css';
type HorseshoeContainerProps = {
@ -37,6 +42,12 @@ export function HorseshoeContainer({ children }: HorseshoeContainerProps) {
return (
<div className={classNames(css.surface, callPresent && css.surfaceActive)}>
{/* 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. */}
<ShareTargetStrip />
<div className={classNames(css.appShell, callPresent && css.appShellBottomRound)}>
{children}
</div>

View file

@ -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/<user>. `<user>` 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)
</ClientLayout>
</HorseshoeContainer>
<IncomingCallsFeature />
<ShareTargetFeature />
</CallEmbedProvider>
<SearchModalRenderer />
<CreateRoomModalRenderer />
@ -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. */}
<Route path={CHANNELS_ROOM_EVENT_PATH.slice(CHANNELS_ROOM_PATH.length)} element={null} />
<Route
path={CHANNELS_ROOM_EVENT_PATH.slice(CHANNELS_ROOM_PATH.length)}
element={null}
/>
</Route>
</Route>
</Route>

View file

@ -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<PendingShare>;
addListener(eventName: 'shareReceived', listener: () => void): Promise<PluginListenerHandle>;
}
const plugin = registerPlugin<ShareTargetPluginIface>('ShareTarget', {
web: {
pickPendingShare: async () => ({ empty: true } as PendingShare),
addListener: async () => ({
remove: async () => undefined,
}),
},
});
export const shareTarget = {
async pickPendingShare(consume = true): Promise<PendingShare> {
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<PluginListenerHandle> {
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);

View file

@ -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<PendingShareValue | null>(null);