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:
parent
6982ec374e
commit
f2ecca64da
14 changed files with 720 additions and 131 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
273
android/app/src/main/java/chat/vojo/app/ShareTargetPlugin.java
Normal file
273
android/app/src/main/java/chat/vojo/app/ShareTargetPlugin.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Отменить"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
34
src/app/features/share-target/ShareTargetStrip.css.ts
Normal file
34
src/app/features/share-target/ShareTargetStrip.css.ts
Normal 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),
|
||||
});
|
||||
62
src/app/features/share-target/ShareTargetStrip.tsx
Normal file
62
src/app/features/share-target/ShareTargetStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/features/share-target/index.ts
Normal file
1
src/app/features/share-target/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './ShareTargetStrip';
|
||||
139
src/app/hooks/useShareTargetReceiver.ts
Normal file
139
src/app/hooks/useShareTargetReceiver.ts
Normal 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]);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
69
src/app/plugins/shareTarget.ts
Normal file
69
src/app/plugins/shareTarget.ts
Normal 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);
|
||||
23
src/app/state/pendingShare.ts
Normal file
23
src/app/state/pendingShare.ts
Normal 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);
|
||||
Loading…
Add table
Reference in a new issue