From f2ecca64dafa9b365b52c8f714de499f2fd4cd0a Mon Sep 17 00:00:00 2001 From: heaven Date: Sat, 16 May 2026 19:33:06 +0300 Subject: [PATCH] feat(share): receive Android system share intents and drop the payload into the next chat the user opens via a top banner cue --- android/app/src/main/AndroidManifest.xml | 24 ++ .../main/java/chat/vojo/app/MainActivity.java | 1 + .../java/chat/vojo/app/ShareTargetPlugin.java | 273 ++++++++++++++++++ public/locales/en.json | 72 +---- public/locales/ru.json | 72 +---- src/app/features/room/RoomInput.tsx | 40 ++- .../share-target/ShareTargetStrip.css.ts | 34 +++ .../share-target/ShareTargetStrip.tsx | 62 ++++ src/app/features/share-target/index.ts | 1 + src/app/hooks/useShareTargetReceiver.ts | 139 +++++++++ src/app/pages/HorseshoeContainer.tsx | 25 +- src/app/pages/Router.tsx | 16 +- src/app/plugins/shareTarget.ts | 69 +++++ src/app/state/pendingShare.ts | 23 ++ 14 files changed, 720 insertions(+), 131 deletions(-) create mode 100644 android/app/src/main/java/chat/vojo/app/ShareTargetPlugin.java create mode 100644 src/app/features/share-target/ShareTargetStrip.css.ts create mode 100644 src/app/features/share-target/ShareTargetStrip.tsx create mode 100644 src/app/features/share-target/index.ts create mode 100644 src/app/hooks/useShareTargetReceiver.ts create mode 100644 src/app/plugins/shareTarget.ts create mode 100644 src/app/state/pendingShare.ts diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5bcf9e68..812752b8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -46,6 +46,30 @@ android:pathPrefix="/u/" /> + + + + + + + + + + + + + + + + + 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 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; + } +} diff --git a/public/locales/en.json b/public/locales/en.json index ece119a5..28ffc6e3 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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 @{{creator}} 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": "{{sender}} accepted {{user}}'s join request", "member_invited": "{{sender}} invited {{user}}", @@ -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 Public, 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" } } diff --git a/public/locales/ru.json b/public/locales/ru.json index b4200f4e..f2a91e1f 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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": "Комната создана @{{creator}} {{date}} {{time}}", "invite_member": "Пригласить", @@ -588,7 +568,6 @@ "leave_room_error": "Не удалось покинуть комнату! {{error}}", "leaving": "Выход...", "leave": "Покинуть", - "member_broken": "Некорректное событие участия", "member_accepted_knock": "{{sender}} одобряет вступление {{user}}", "member_invited": "{{sender}} приглашает {{user}}", @@ -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": "Если доступ публичный, опубликованные адреса будут использоваться для присоединения.", "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": "Отменить" } } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index f8fbe427..4c3aeabf 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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( 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)) { diff --git a/src/app/features/share-target/ShareTargetStrip.css.ts b/src/app/features/share-target/ShareTargetStrip.css.ts new file mode 100644 index 00000000..8c54c421 --- /dev/null +++ b/src/app/features/share-target/ShareTargetStrip.css.ts @@ -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), +}); diff --git a/src/app/features/share-target/ShareTargetStrip.tsx b/src/app/features/share-target/ShareTargetStrip.tsx new file mode 100644 index 00000000..b5af38af --- /dev/null +++ b/src/app/features/share-target/ShareTargetStrip.tsx @@ -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; + +// 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 ( +
+
+ +
+ + {describeShare(share, t)} + + + {t('Share.tap_chat_to_send')} + +
+ setShare(null)} + aria-label={t('Share.cancel')} + > + + +
+
+ ); +} diff --git a/src/app/features/share-target/index.ts b/src/app/features/share-target/index.ts new file mode 100644 index 00000000..7ebffd27 --- /dev/null +++ b/src/app/features/share-target/index.ts @@ -0,0 +1 @@ +export * from './ShareTargetStrip'; diff --git a/src/app/hooks/useShareTargetReceiver.ts b/src/app/hooks/useShareTargetReceiver.ts new file mode 100644 index 00000000..61929f08 --- /dev/null +++ b/src/app/hooks/useShareTargetReceiver.ts @@ -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 => { + 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 => { + 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]); +}; diff --git a/src/app/pages/HorseshoeContainer.tsx b/src/app/pages/HorseshoeContainer.tsx index 951dd871..820a0dfd 100644 --- a/src/app/pages/HorseshoeContainer.tsx +++ b/src/app/pages/HorseshoeContainer.tsx @@ -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 (
+ {/* 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);