Fix emoji sending and self push notifications
This commit is contained in:
@@ -33,6 +33,10 @@ class QMaxFirebaseMessagingService : FirebaseMessagingService() {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.data["direction"].equals("Outgoing", ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
|
||||
val title = message.notification?.title
|
||||
?: message.data["title"]
|
||||
?: message.data["chatTitle"]
|
||||
|
||||
@@ -26,6 +26,12 @@ public sealed class FirebasePushNotificationService(
|
||||
public async Task SendNewMessageAsync(Chat chat, Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
var qmax = options.Value;
|
||||
if (message.Direction != MessageDirection.Incoming)
|
||||
{
|
||||
logger.LogDebug("Skipping push for non-incoming message {MessageId} in chat {ChatId}.", message.Id, chat.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!qmax.PushEnabled || chat.IsMuted)
|
||||
{
|
||||
return;
|
||||
@@ -70,7 +76,8 @@ public sealed class FirebasePushNotificationService(
|
||||
["body"] = body,
|
||||
["chatId"] = chat.Id.ToString(),
|
||||
["messageId"] = message.Id.ToString(),
|
||||
["chatTitle"] = chat.Title
|
||||
["chatTitle"] = chat.Title,
|
||||
["direction"] = message.Direction.ToString()
|
||||
};
|
||||
|
||||
foreach (var device in devices)
|
||||
|
||||
@@ -5,10 +5,12 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Contracts;
|
||||
@@ -602,6 +604,60 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FirebasePushSkipsOutgoingMessages()
|
||||
{
|
||||
var databasePath = Path.Combine(Path.GetTempPath(), $"qmax-push-test-{Guid.NewGuid():N}.db");
|
||||
try
|
||||
{
|
||||
var dbOptions = new DbContextOptionsBuilder<QMaxDbContext>()
|
||||
.UseSqlite($"Data Source={databasePath};Pooling=False")
|
||||
.Options;
|
||||
await using (var db = new QMaxDbContext(dbOptions))
|
||||
{
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var user = new User { DisplayName = "Push Test" };
|
||||
db.Users.Add(user);
|
||||
db.PushDevices.Add(new PushDevice { User = user, FirebaseToken = "test-token", Platform = "android" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var service = new FirebasePushNotificationService(
|
||||
db,
|
||||
new ThrowingHttpClientFactory(),
|
||||
Options.Create(new QMaxOptions
|
||||
{
|
||||
PushEnabled = true,
|
||||
FirebaseProjectId = "qmax-test",
|
||||
FirebaseServiceAccountJson = "{ this is not valid json"
|
||||
}),
|
||||
NullLogger<FirebasePushNotificationService>.Instance);
|
||||
var chat = new Chat { Title = "Self Chat" };
|
||||
var message = new Message
|
||||
{
|
||||
Chat = chat,
|
||||
Direction = MessageDirection.Outgoing,
|
||||
Text = "message from me",
|
||||
DeliveryState = MessageDeliveryState.Sent
|
||||
};
|
||||
|
||||
await service.SendNewMessageAsync(chat, message, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, await db.PushDevices.CountAsync());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var suffix in new[] { "", "-shm", "-wal" })
|
||||
{
|
||||
var path = databasePath + suffix;
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminStatusRequiresPairingCode()
|
||||
{
|
||||
@@ -685,6 +741,14 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name)
|
||||
{
|
||||
throw new InvalidOperationException("Push for outgoing messages must not reach Firebase HTTP calls.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingActionMaxBridgeClient : IMaxBridgeClient
|
||||
{
|
||||
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||
|
||||
+172
-7
@@ -468,6 +468,7 @@ async function ensurePage() {
|
||||
viewport: { width: 1280, height: 900 },
|
||||
args: ["--disable-dev-shm-usage", "--no-sandbox"]
|
||||
});
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: maxBaseUrl }).catch(() => {});
|
||||
page = context.pages()[0] || await context.newPage();
|
||||
attachNetworkCapture(page);
|
||||
if (!page.url() || page.url() === "about:blank") {
|
||||
@@ -2837,7 +2838,11 @@ async function sendTextAndConfirm(p, externalChatId, text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialMatches = await countOutgoingTextMatches(p, externalChatId, text);
|
||||
const initialOutgoingMessages = await getOutgoingMessages(p, externalChatId);
|
||||
const initialOutgoingIds = new Set(initialOutgoingMessages.map((message) => message.externalId).filter(Boolean));
|
||||
const initialMatches = initialOutgoingMessages
|
||||
.filter((message) => messageTextMatches(message.text, wanted))
|
||||
.length;
|
||||
const attempts = [
|
||||
{ name: "send button", action: () => clickSendButton(p) },
|
||||
{ name: "Enter", action: async () => { await p.keyboard.press("Enter"); return true; } },
|
||||
@@ -2860,7 +2865,13 @@ async function sendTextAndConfirm(p, externalChatId, text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const externalId = await findSentOutgoingExternalId(p, externalChatId, text, initialMatches, 10000);
|
||||
const externalId = await findSentOutgoingExternalId(
|
||||
p,
|
||||
externalChatId,
|
||||
text,
|
||||
initialMatches,
|
||||
initialOutgoingIds,
|
||||
10000);
|
||||
if (externalId) {
|
||||
return externalId;
|
||||
}
|
||||
@@ -2875,10 +2886,18 @@ async function sendTextAndConfirm(p, externalChatId, text) {
|
||||
}
|
||||
|
||||
async function fillComposerText(p, text, clearFirst) {
|
||||
if (clearFirst && prefersClipboardComposerInput(text) && await fillComposerViaClipboard(p, text, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clearFirst && await fillComposerViaLocator(p, text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clearFirst && await fillComposerViaClipboard(p, text, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
if (!(await focusComposer(p))) {
|
||||
break;
|
||||
@@ -2931,6 +2950,131 @@ async function fillComposerText(p, text, clearFirst) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function prefersClipboardComposerInput(text) {
|
||||
return /[\p{Extended_Pictographic}\u200d\ufe0f]/u.test(String(text || ""));
|
||||
}
|
||||
|
||||
async function fillComposerViaClipboard(p, text, clearFirst) {
|
||||
if (!(await focusComposer(p))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (clearFirst) {
|
||||
await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
|
||||
await p.keyboard.press("Backspace");
|
||||
}
|
||||
|
||||
const wroteClipboard = await p.evaluate(async (value) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, text).catch(() => false);
|
||||
|
||||
if (wroteClipboard) {
|
||||
await p.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
||||
} else if (!(await insertComposerTextViaDom(p, text, false))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await p.waitForTimeout(300);
|
||||
const currentText = await readComposerText(p);
|
||||
return currentText !== null && messageTextMatches(currentText, text);
|
||||
}
|
||||
|
||||
async function insertComposerTextViaDom(p, text, clearFirst = false) {
|
||||
return await p.evaluate(({ value, clear }) => {
|
||||
const cleanText = (textValue) => String(textValue || "")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/[\u200b\u200c\u200d\ufeff]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const isVisible = (element) => {
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.visibility !== "hidden" &&
|
||||
style.display !== "none" &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom >= 0 &&
|
||||
rect.right >= 0 &&
|
||||
rect.top <= window.innerHeight &&
|
||||
rect.left <= window.innerWidth;
|
||||
};
|
||||
const isSearchLike = (element, rect) => {
|
||||
const textValue = cleanText([
|
||||
element.getAttribute("aria-label"),
|
||||
element.getAttribute("placeholder"),
|
||||
element.getAttribute("title"),
|
||||
element.getAttribute("data-testid"),
|
||||
element.className,
|
||||
element.id,
|
||||
element.getAttribute("name")
|
||||
].join(" ")).toLowerCase();
|
||||
return /search|\u043f\u043e\u0438\u0441\u043a/.test(textValue) ||
|
||||
(rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32);
|
||||
};
|
||||
const selectors = [
|
||||
"[data-testid='composer'] [role='textbox']",
|
||||
"[data-testid='composer'] [contenteditable='true']",
|
||||
"[class*='composer' i] [role='textbox']",
|
||||
"[class*='composer' i] [contenteditable='true']",
|
||||
"[class*='composer' i] [class*='contenteditable' i]",
|
||||
"[role='textbox'][class*='contenteditable' i]",
|
||||
"[role='textbox']",
|
||||
"[contenteditable='true']",
|
||||
"textarea"
|
||||
];
|
||||
const candidates = selectors
|
||||
.flatMap((selector) => Array.from(document.querySelectorAll(selector)))
|
||||
.filter((element) => isVisible(element))
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return !isSearchLike(element, rect) &&
|
||||
rect.left > window.innerWidth * 0.32 &&
|
||||
rect.top > window.innerHeight * 0.50;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const ar = a.getBoundingClientRect();
|
||||
const br = b.getBoundingClientRect();
|
||||
return br.bottom - ar.bottom || (br.width * br.height) - (ar.width * ar.height);
|
||||
});
|
||||
const composer = candidates[0];
|
||||
if (!(composer instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
composer.focus({ preventScroll: true });
|
||||
if (clear) {
|
||||
if ("value" in composer) {
|
||||
composer.value = "";
|
||||
} else {
|
||||
composer.textContent = "";
|
||||
}
|
||||
composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null }));
|
||||
}
|
||||
|
||||
const beforeInput = new InputEvent("beforeinput", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
inputType: "insertText",
|
||||
data: value
|
||||
});
|
||||
composer.dispatchEvent(beforeInput);
|
||||
if ("value" in composer) {
|
||||
composer.value = `${composer.value || ""}${value}`;
|
||||
} else if (!document.execCommand("insertText", false, value)) {
|
||||
composer.textContent = `${composer.textContent || ""}${value}`;
|
||||
}
|
||||
composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
|
||||
composer.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}, { value: text, clear: clearFirst }).catch(() => false);
|
||||
}
|
||||
|
||||
async function fillComposerViaLocator(p, text) {
|
||||
const selectors = [
|
||||
"[data-testid='composer'] [role='textbox']",
|
||||
@@ -3145,32 +3289,53 @@ function messageTextMatches(value, wantedText) {
|
||||
));
|
||||
}
|
||||
|
||||
async function getOutgoingMessages(p, externalChatId) {
|
||||
const history = await extractOpenChatHistory(p, externalChatId).catch(() => null);
|
||||
return (history?.messages || []).filter((message) => message.isOutgoing);
|
||||
}
|
||||
|
||||
async function getOutgoingTextMatches(p, externalChatId, text) {
|
||||
const wanted = cleanComparableText(text, 1000);
|
||||
if (!wanted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const history = await extractOpenChatHistory(p, externalChatId).catch(() => null);
|
||||
return (history?.messages || []).filter((message) =>
|
||||
message.isOutgoing && messageTextMatches(message.text, wanted));
|
||||
return (await getOutgoingMessages(p, externalChatId))
|
||||
.filter((message) => messageTextMatches(message.text, wanted));
|
||||
}
|
||||
|
||||
async function countOutgoingTextMatches(p, externalChatId, text) {
|
||||
return (await getOutgoingTextMatches(p, externalChatId, text)).length;
|
||||
}
|
||||
|
||||
async function findSentOutgoingExternalId(p, externalChatId, text, previousMatchCount = 0, timeoutMs = 15000) {
|
||||
async function findSentOutgoingExternalId(
|
||||
p,
|
||||
externalChatId,
|
||||
text,
|
||||
previousMatchCount = 0,
|
||||
initialOutgoingIds = new Set(),
|
||||
timeoutMs = 15000) {
|
||||
const knownIds = initialOutgoingIds instanceof Set
|
||||
? initialOutgoingIds
|
||||
: new Set(Array.from(initialOutgoingIds || []).filter(Boolean));
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await p.waitForTimeout(500);
|
||||
const matches = await getOutgoingTextMatches(p, externalChatId, text);
|
||||
const outgoingMessages = await getOutgoingMessages(p, externalChatId);
|
||||
const matches = outgoingMessages.filter((message) => messageTextMatches(message.text, text));
|
||||
if (matches.length > previousMatchCount) {
|
||||
const latest = matches[matches.length - 1];
|
||||
if (latest?.externalId) {
|
||||
return latest.externalId;
|
||||
}
|
||||
}
|
||||
|
||||
const newOutgoing = outgoingMessages.filter((message) =>
|
||||
message.externalId &&
|
||||
!knownIds.has(message.externalId));
|
||||
if (newOutgoing.length > 0) {
|
||||
return newOutgoing[newOutgoing.length - 1].externalId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user