diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d6bb5178..3deb0cf9 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -117,6 +117,10 @@
+
+
diff --git a/android/app/src/main/java/chat/vojo/app/PushStrings.java b/android/app/src/main/java/chat/vojo/app/PushStrings.java
index 17baedb6..08ed2955 100644
--- a/android/app/src/main/java/chat/vojo/app/PushStrings.java
+++ b/android/app/src/main/java/chat/vojo/app/PushStrings.java
@@ -82,6 +82,18 @@ final class PushStrings {
return forAppLocale(ctx).getString(R.string.push_action_mark_as_read);
}
+ static String replyAction(Context ctx) {
+ return forAppLocale(ctx).getString(R.string.push_action_reply);
+ }
+
+ static String replyHint(Context ctx) {
+ return forAppLocale(ctx).getString(R.string.push_reply_hint);
+ }
+
+ static String replyFailed(Context ctx) {
+ return forAppLocale(ctx).getString(R.string.push_reply_failed);
+ }
+
/**
* Build the invite-notification body from inviter + room name, falling
* back through four variants when one or both are absent. The res IDs
diff --git a/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java b/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java
new file mode 100644
index 00000000..083a2b93
--- /dev/null
+++ b/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java
@@ -0,0 +1,200 @@
+package chat.vojo.app;
+
+import android.app.NotificationManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.RemoteInput;
+
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Handles the inline-reply RemoteInput action on a per-room MessagingStyle
+ * notification.
+ *
+ * Flow:
+ * 1. User taps reply, types text, presses send → broadcast fires here.
+ * 2. We immediately append the outgoing message to RoomMessageCache and
+ * re-post the notification (instant UX feedback — the message appears
+ * as a self-Person bubble in the conversation while the HTTP is in
+ * flight).
+ * 3. PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}
+ * with {msgtype: "m.text", body}. Uses the vojo_poll_state token (same
+ * storage as Worker / MarkAsReadReceiver — single credential lifecycle).
+ * 4. On 2xx: nothing further; the JS sync echo will eventually replace
+ * the local-echo bubble in-app.
+ * 5. On non-2xx or thrown: post a small error notification "Could not
+ * send your reply" so the user knows to retry from in-app — better
+ * than silently swallowing the message.
+ *
+ * E2EE rooms are guarded UP-STREAM in VojoFirebaseMessagingService.
+ * renderMessageNotification: we don't even attach the reply action when
+ * RoomMetadata.isEncrypted is true. So this receiver never has to encrypt.
+ * Defense in depth: if a stale notification with the action ever survives
+ * an encryption flip we still detect the failure as a non-2xx HTTP and
+ * surface the error notification rather than sending cleartext (which
+ * Synapse would in any case reject for an encrypted room).
+ *
+ * Null-credential edge case: post the error notification so the user
+ * notices and retries in-app. Same logic as a network failure.
+ */
+public class ReplyReceiver extends BroadcastReceiver {
+
+ public static final String ACTION_REPLY = "chat.vojo.app.REPLY";
+ public static final String EXTRA_ROOM_ID = "room_id";
+ public static final String KEY_TEXT_REPLY = "vojo.text_reply";
+
+ private static final int CONNECT_TIMEOUT_MS = 8_000;
+ private static final int READ_TIMEOUT_MS = 8_000;
+ private static final String TAG = "ReplyRcvr";
+
+ private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null) return;
+ final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
+ if (roomId == null || roomId.isEmpty()) {
+ Log.w(TAG, "onReceive: missing room_id, abort");
+ return;
+ }
+
+ Bundle remote = RemoteInput.getResultsFromIntent(intent);
+ if (remote == null) {
+ Log.w(TAG, "onReceive: no RemoteInput results");
+ return;
+ }
+ CharSequence reply = remote.getCharSequence(KEY_TEXT_REPLY);
+ if (reply == null) {
+ Log.w(TAG, "onReceive: RemoteInput missing text");
+ return;
+ }
+ final String text = reply.toString().trim();
+ if (text.isEmpty()) return;
+
+ final Context appContext = context.getApplicationContext();
+
+ // Optimistic local echo — appends a self-Person message to the
+ // conversation and re-posts, so the user sees their reply in the
+ // shade before the HTTP completes.
+ long now = System.currentTimeMillis();
+ VojoFirebaseMessagingService.appendOutgoingMessage(appContext, roomId, text, now);
+
+ final SharedPreferences prefs = appContext.getSharedPreferences(
+ VojoPollWorker.PREFS, Context.MODE_PRIVATE);
+ final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
+ final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
+ if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
+ Log.w(TAG, "onReceive: no credentials in prefs, surfacing error notif");
+ postReplyError(appContext, roomId);
+ return;
+ }
+
+ final PendingResult pendingResult = goAsync();
+ final String txnId = "vojo-reply-" + UUID.randomUUID();
+ EXECUTOR.execute(() -> {
+ try {
+ int status = sendReply(homeserver, token, roomId, txnId, text);
+ if (status >= 200 && status < 300) {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "reply ok status=" + status + " room=" + roomId);
+ }
+ } else {
+ Log.w(TAG, "reply non-2xx status=" + status + " room=" + roomId);
+ postReplyError(appContext, roomId);
+ }
+ } catch (Throwable t) {
+ Log.w(TAG, "reply threw room=" + roomId, t);
+ postReplyError(appContext, roomId);
+ } finally {
+ pendingResult.finish();
+ }
+ });
+ }
+
+ private int sendReply(
+ String baseUrl,
+ String accessToken,
+ String roomId,
+ String txnId,
+ String text
+ ) throws IOException {
+ String url = trimTrailingSlash(baseUrl)
+ + "/_matrix/client/v3/rooms/"
+ + URLEncoder.encode(roomId, "UTF-8")
+ + "/send/m.room.message/"
+ + URLEncoder.encode(txnId, "UTF-8");
+
+ JSONObject body;
+ try {
+ body = new JSONObject();
+ body.put("msgtype", "m.text");
+ body.put("body", text);
+ } catch (org.json.JSONException je) {
+ // JSONObject.put only throws on NaN/Inf doubles, neither of
+ // which we use — but keep the type contract honest.
+ throw new IOException("payload encode failed", je);
+ }
+ byte[] payload = body.toString().getBytes("UTF-8");
+
+ HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+ try {
+ conn.setRequestMethod("PUT");
+ conn.setRequestProperty("Authorization", "Bearer " + accessToken);
+ conn.setRequestProperty("Content-Type", "application/json");
+ conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
+ conn.setReadTimeout(READ_TIMEOUT_MS);
+ conn.setDoOutput(true);
+ conn.setFixedLengthStreamingMode(payload.length);
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(payload);
+ }
+ return conn.getResponseCode();
+ } finally {
+ conn.disconnect();
+ }
+ }
+
+ /**
+ * Surface a short error notification when the reply HTTP fails so the
+ * user knows the message did NOT land server-side and can retry from
+ * within the app. Posted on the DM channel as a one-shot. Unique notif
+ * id per room so it can't clobber the room's conversation slot.
+ */
+ private static void postReplyError(Context ctx, String roomId) {
+ NotificationManager nm = (NotificationManager)
+ ctx.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (nm == null) return;
+ try {
+ String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM_PUBLIC;
+ NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, channel)
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentTitle(PushStrings.replyFailed(ctx))
+ .setContentText(PushStrings.replyFailed(ctx))
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT);
+ int errId = ("replyErr_" + roomId).hashCode();
+ nm.notify(errId, b.build());
+ } catch (Throwable t) {
+ Log.w(TAG, "reply error notif failed", t);
+ }
+ }
+
+ private static String trimTrailingSlash(String s) {
+ return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
+ }
+}
diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java
index f6f7fffc..a7208e65 100644
--- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java
+++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java
@@ -19,6 +19,7 @@ import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.core.app.Person;
+import androidx.core.app.RemoteInput;
import com.capacitorjs.plugins.pushnotifications.MessagingService;
import com.google.firebase.messaging.RemoteMessage;
@@ -67,6 +68,10 @@ public class VojoFirebaseMessagingService extends MessagingService {
private static final String MESSAGE_CHANNEL_GROUP_ID = "vojo_messages_v1";
private static final String CHANNEL_ID_DM = "vojo_messages_dm_v1";
private static final String CHANNEL_ID_GROUP = "vojo_messages_group_v1";
+ // Package-visible mirror of CHANNEL_ID_DM so ReplyReceiver can post its
+ // one-shot "reply failed" error notification on the same channel without
+ // duplicating the literal.
+ static final String CHANNEL_ID_DM_PUBLIC = CHANNEL_ID_DM;
private static final String GROUP_KEY = "vojo_messages";
// NotificationChannel settings (vibration pattern, sound, importance) are
@@ -491,6 +496,15 @@ public class VojoFirebaseMessagingService extends MessagingService {
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.addAction(buildMarkAsReadAction(ctx, roomId, eventId));
+ // Inline reply is only safe in cleartext rooms — the receiver
+ // builds a vanilla `m.room.message`, and we have no key material
+ // on the Java side to encrypt. RoomMetadata.isEncrypted defaults
+ // to true on missing/legacy snapshots (privacy-first), so reply
+ // is opt-in via a confirmed cleartext flag rather than opt-out.
+ if (!meta.isEncrypted) {
+ builder.addAction(buildReplyAction(ctx, roomId));
+ }
+
dlog("msg: posting notif id=" + notifId + " channel=" + channelId
+ " historySize=" + history.size()
+ " notifsEnabled=" + nm.areNotificationsEnabled());
@@ -655,6 +669,137 @@ public class VojoFirebaseMessagingService extends MessagingService {
.build();
}
+ /**
+ * Inline reply action with RemoteInput. Tapping the action opens an
+ * in-shade text field; submit fires the receiver and PUTs the message
+ * as `m.room.message` from the cleartext path. Only attached for
+ * non-encrypted rooms (see caller gate); E2EE replies need keys the
+ * Java side does not have.
+ *
+ * MutabilityCompat: RemoteInput requires the PendingIntent to be
+ * MUTABLE so the input bundle can be attached at submit time. We OR
+ * FLAG_MUTABLE on API 31+ where the immutable default would otherwise
+ * cause the receiver to fire without any RemoteInput payload.
+ */
+ private static NotificationCompat.Action buildReplyAction(Context ctx, String roomId) {
+ Intent intent = new Intent(ctx, ReplyReceiver.class)
+ .setAction(ReplyReceiver.ACTION_REPLY)
+ .putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId);
+ int requestCode = ("reply_" + roomId).hashCode();
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ flags |= PendingIntent.FLAG_MUTABLE;
+ }
+ PendingIntent pi = PendingIntent.getBroadcast(ctx, requestCode, intent, flags);
+ RemoteInput remoteInput = new RemoteInput.Builder(ReplyReceiver.KEY_TEXT_REPLY)
+ .setLabel(PushStrings.replyHint(ctx))
+ .build();
+ return new NotificationCompat.Action.Builder(
+ R.mipmap.ic_launcher,
+ PushStrings.replyAction(ctx),
+ pi
+ )
+ .addRemoteInput(remoteInput)
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
+ .setShowsUserInterface(false)
+ // Allow Wear / Android Auto auto-replies for accessibility.
+ .setAllowGeneratedReplies(true)
+ .build();
+ }
+
+ /**
+ * Append a self-Person message to the room's MessagingStyle cache and
+ * re-post the notification with the new entry visible. Used by
+ * ReplyReceiver for instant optimistic feedback before the HTTP PUT
+ * completes — the user sees their text in the conversation bubble
+ * immediately, the live sync echo eventually replaces it with the
+ * server-authoritative version when they next open the app.
+ *
+ * Returns true iff the notification re-render succeeded.
+ */
+ static boolean appendOutgoingMessage(
+ Context ctx, String roomId, String body, long timestamp
+ ) {
+ if (roomId == null || roomId.isEmpty() || body == null || body.isEmpty()) {
+ return false;
+ }
+ NotificationManager nm = (NotificationManager)
+ ctx.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (nm == null) return false;
+ RoomMetadata meta = loadRoomMetadata(ctx, roomId);
+ String channelId = meta.isDirect ? CHANNEL_ID_DM : CHANNEL_ID_GROUP;
+ ensureMessageChannels(ctx, nm);
+
+ // Re-seed from active notification before append so the new self
+ // message lands at the tail of any in-flight conversation history
+ // (covers the cold-render-after-kill case for outgoing replies).
+ seedCacheFromActiveNotification(ctx, nm, roomId);
+
+ RoomMessageCache.Entry self = new RoomMessageCache.Entry(
+ body, timestamp, /*senderKey*/ null, /*senderName*/ "", /*fromSelf*/ true
+ );
+ java.util.List history = RoomMessageCache.append(roomId, self);
+
+ Person selfPerson = buildSelfPerson(ctx);
+ NotificationCompat.MessagingStyle style = new NotificationCompat.MessagingStyle(selfPerson);
+ boolean isGroup = !meta.isDirect;
+ if (isGroup && !meta.name.isEmpty()) {
+ style.setConversationTitle(meta.name);
+ }
+ style.setGroupConversation(isGroup);
+ for (RoomMessageCache.Entry e : history) {
+ Person sender = e.fromSelf ? null : new Person.Builder()
+ .setName(e.senderName != null ? e.senderName : "")
+ .setKey(e.senderKey != null ? e.senderKey : "")
+ .build();
+ style.addMessage(new NotificationCompat.MessagingStyle.Message(
+ e.body != null ? e.body : "", e.timestamp, sender
+ ));
+ }
+
+ Intent launchIntent = new Intent(ctx, MainActivity.class);
+ launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ launchIntent.putExtra("google.message_id", "");
+ launchIntent.putExtra("room_id", roomId);
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT
+ | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
+ PendingIntent contentIntent = PendingIntent.getActivity(
+ ctx, ("open_" + roomId).hashCode(), launchIntent, flags
+ );
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, channelId)
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setStyle(style)
+ .setAutoCancel(true)
+ .setContentIntent(contentIntent)
+ .setDeleteIntent(buildDismissPendingIntent(ctx, roomId))
+ .setGroup(GROUP_KEY)
+ // Always silent — sending a reply must not re-alert the user.
+ .setOnlyAlertOnce(true)
+ .setShortcutId(roomId)
+ .setWhen(timestamp)
+ .setShowWhen(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
+ // Re-attach mark-as-read so the action set stays consistent on
+ // re-render; eventId is unknown for an outgoing-only update so
+ // it falls through to the local-dismiss-only branch in the
+ // receiver — acceptable for an optimistic echo.
+ .addAction(buildMarkAsReadAction(ctx, roomId, null));
+ if (!meta.isEncrypted) {
+ builder.addAction(buildReplyAction(ctx, roomId));
+ }
+
+ try {
+ nm.notify(roomNotifId(roomId), builder.build());
+ return true;
+ } catch (SecurityException e) {
+ Log.e(TAG, "outgoing: nm.notify threw SecurityException", e);
+ return false;
+ }
+ }
+
private static Person buildSelfPerson(Context ctx) {
SharedPreferences prefs = ctx.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
@@ -712,36 +857,54 @@ public class VojoFirebaseMessagingService extends MessagingService {
/**
* Snapshot of per-room metadata bridged from JS via
* PollingPlugin.saveRoomNames (which now accepts both a legacy
- * room_id → name string map AND a structured shape including isDirect).
+ * room_id → name string map AND a structured shape including isDirect
+ * and isEncrypted). isEncrypted controls whether the inline reply
+ * action is offered — we can't sign + encrypt without keys on the Java
+ * side, so encrypted rooms get a read-only notification.
*/
private static final class RoomMetadata {
final String name;
final boolean isDirect;
+ final boolean isEncrypted;
- RoomMetadata(String name, boolean isDirect) {
+ RoomMetadata(String name, boolean isDirect, boolean isEncrypted) {
this.name = name == null ? "" : name;
this.isDirect = isDirect;
+ this.isEncrypted = isEncrypted;
}
}
private static RoomMetadata loadRoomMetadata(Context ctx, String roomId) {
- if (roomId == null || roomId.isEmpty()) return new RoomMetadata("", true);
+ if (roomId == null || roomId.isEmpty()) {
+ return new RoomMetadata("", true, false);
+ }
SharedPreferences prefs = ctx.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null);
- if (raw == null || raw.isEmpty()) return new RoomMetadata("", true);
+ if (raw == null || raw.isEmpty()) {
+ return new RoomMetadata("", true, false);
+ }
try {
JSONObject map = new JSONObject(raw);
- if (!map.has(roomId) || map.isNull(roomId)) return new RoomMetadata("", true);
+ if (!map.has(roomId) || map.isNull(roomId)) {
+ return new RoomMetadata("", true, false);
+ }
// Legacy shape: { roomId: "Display name" }. New shape:
- // { roomId: { name: "Display name", isDirect: bool } }. Parse
- // tolerantly so an APK that still has the old map written to
- // prefs from a previous boot doesn't lose channel routing.
+ // { roomId: { name: "Display name", isDirect: bool,
+ // isEncrypted: bool } }. Parse tolerantly so an APK
+ // that still has the old map written to prefs from a previous
+ // boot doesn't lose channel routing.
JSONObject obj = map.optJSONObject(roomId);
if (obj != null) {
String name = obj.optString("name", "");
boolean isDirect = obj.optBoolean("isDirect", true);
- return new RoomMetadata(name, isDirect);
+ // Default encrypted=true on missing flag: refusing to offer
+ // reply on a falsely-flagged-encrypted room is harmless;
+ // offering reply on a truly-encrypted room sends cleartext
+ // into the timeline, which is a privacy leak. The conservative
+ // direction is to assume encryption.
+ boolean isEncrypted = obj.optBoolean("isEncrypted", true);
+ return new RoomMetadata(name, isDirect, isEncrypted);
}
String legacyName = map.optString(roomId, "");
// Default to DM when we have no isDirect signal. DM is the
@@ -750,9 +913,12 @@ public class VojoFirebaseMessagingService extends MessagingService {
// bad failure than under-alerting on a misclassified DM, which
// would silently swallow a personal message during the brief
// post-fresh-install window before the JS bridge dumps metadata.
- return new RoomMetadata(legacyName, true);
+ // Legacy shape predates the encryption flag, so assume
+ // encrypted=true (no reply action) to err on the side of
+ // privacy.
+ return new RoomMetadata(legacyName, true, true);
} catch (Throwable t) {
- return new RoomMetadata("", true);
+ return new RoomMetadata("", true, true);
}
}
diff --git a/public/locales/en.json b/public/locales/en.json
index 8477d8e4..9912b342 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -893,7 +893,10 @@
"channel_group_room": "Group chats",
"channel_group_room_description": "New messages from group chats and channels",
"self_name": "You",
- "action_mark_as_read": "Mark as read"
+ "action_mark_as_read": "Mark as read",
+ "action_reply": "Reply",
+ "reply_hint": "Reply…",
+ "reply_failed": "Could not send your reply"
},
"Bots": {
"not_connected_title": "{{name}} is not connected",
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 3bd72cff..adb18d75 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -909,7 +909,10 @@
"channel_group_room": "Групповые чаты",
"channel_group_room_description": "Новые сообщения из групповых чатов и каналов",
"self_name": "Я",
- "action_mark_as_read": "Прочитано"
+ "action_mark_as_read": "Прочитано",
+ "action_reply": "Ответить",
+ "reply_hint": "Ответ…",
+ "reply_failed": "Не удалось отправить ответ"
},
"Bots": {
"not_connected_title": "{{name}} не подключён",
diff --git a/scripts/gen-push-strings.mjs b/scripts/gen-push-strings.mjs
index 38c162d7..03d97340 100644
--- a/scripts/gen-push-strings.mjs
+++ b/scripts/gen-push-strings.mjs
@@ -60,6 +60,9 @@ const ANDROID_KEYS = [
'channel_group_room_description',
'self_name',
'action_mark_as_read',
+ 'action_reply',
+ 'reply_hint',
+ 'reply_failed',
];
// i18next uses named placeholders ({{inviter}}); Android string resources
diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts
index 99f34ba1..61138823 100644
--- a/src/app/hooks/usePushNotifications.ts
+++ b/src/app/hooks/usePushNotifications.ts
@@ -51,9 +51,15 @@ const buildRoomMetadataSnapshot = (mx: MatrixClient): RoomMetadataMap => {
return mx.getRooms().reduce((acc, room) => {
const { name } = room;
if (typeof name !== 'string' || name.length === 0) return acc;
+ // hasEncryptionStateEvent reads the m.room.encryption state event from
+ // the live timeline — synchronous and cheap. Used by the Java side to
+ // gate the inline reply action: encrypted rooms get a read-only
+ // notification because the Java path has no key material to encrypt
+ // outgoing replies with.
acc[room.roomId] = {
name,
isDirect: dmRooms.has(room.roomId),
+ isEncrypted: room.hasEncryptionStateEvent(),
};
return acc;
}, {});
@@ -418,7 +424,15 @@ export function usePushNotificationsLifecycle(): void {
// Re-dump the room metadata snapshot whenever m.direct changes — without
// this, freshly-created DMs would land on the louder group-message
// channel until the next visibilitychange re-bridge. The dump is small
- // (room id + name + isDirect bool) and account-data updates are rare.
+ // (room id + name + isDirect + isEncrypted) and account-data updates are
+ // rare.
+ //
+ // The same effect also re-dumps on m.room.encryption state events so the
+ // inline-reply action is dropped within seconds of a room being switched
+ // to E2EE. Without this re-dump, a cleartext-by-prefs notification could
+ // post the reply action onto a freshly-encrypted room and the receiver
+ // would send a cleartext reply (Synapse does not enforce the
+ // "encrypted-only" rule, so the leak is real).
useEffect(() => {
if (!isAndroidPlatform()) return undefined;
if (!pushEnabled) return undefined;
@@ -427,10 +441,17 @@ export function usePushNotificationsLifecycle(): void {
if (event.getType() !== AccountDataEvent.Direct) return;
dumpRoomNamesToNative(mx).catch(noop);
};
+ const handleTimeline = (event: MatrixEvent) => {
+ if (event.getType() === 'm.room.encryption') {
+ dumpRoomNamesToNative(mx).catch(noop);
+ }
+ };
mx.on(ClientEvent.AccountData, handleAccountData);
+ mx.on(RoomEvent.Timeline, handleTimeline);
return () => {
mx.removeListener(ClientEvent.AccountData, handleAccountData);
+ mx.removeListener(RoomEvent.Timeline, handleTimeline);
};
}, [mx, pushEnabled]);
diff --git a/src/app/plugins/polling.ts b/src/app/plugins/polling.ts
index d110cdcd..adfd81b0 100644
--- a/src/app/plugins/polling.ts
+++ b/src/app/plugins/polling.ts
@@ -13,7 +13,10 @@
import { registerPlugin } from '@capacitor/core';
import { isAndroidPlatform } from '../utils/capacitor';
-export type RoomMetadataMap = Record;
+export type RoomMetadataMap = Record<
+ string,
+ string | { name: string; isDirect: boolean; isEncrypted: boolean }
+>;
interface PollingPluginIface {
saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise;