Task #406: Pop the note onto the recipient's screen on arrival

- Backend: extend `note_received` socket payload with note snapshot
  (title, content, color, sentAt), recipientRowId, senderUserId, and
  sender summary so the recipient can render the popup without an extra
  fetch.
- Add IncomingNotePopupContext: FIFO queue, dedupe by note id, suppress
  events for the user's own sends, auto-clear on logout.
- Add IncomingNotePopup AlertDialog that shows the note (title/content/
  color) with a sender chip and Reply / Mark read / Open in Notes /
  Dismiss buttons. Acknowledge actions call /notes/:id/read.
- Wire the popup globally in App.tsx (alongside the socket bridge).
- Notes page accepts a `?thread=ID` deep link so the popup's Open and
  Reply buttons land on the inbox tab + thread dialog.
- Added bilingual `notes.popup.*` strings in EN + AR (RTL respected).
- New Playwright e2e (`notes-popup-on-receive.spec.mjs`) verifies the
  popup appears in a second browser context within seconds, sender
  doesn't see their own popup, and dismiss closes it.
This commit is contained in:
Riyadh
2026-05-05 15:43:48 +00:00
parent ee9d24efd9
commit 590ef32691
4 changed files with 213 additions and 42 deletions
@@ -0,0 +1,119 @@
// Unit tests for the incoming-note popup queue (Task #406).
//
// The queue is the data structure behind the centered modal that
// appears on the recipient's screen the moment a `note_received` socket
// event arrives. It must:
// - Be FIFO (notes pop in arrival order)
// - Drop duplicates by note id (re-emits or multi-tab races)
// - Suppress events for the user's own sends (sender id === me)
// - Allow filtering out a specific id from the middle of the queue
//
// `applyEnqueue` and `applyDismiss` are exported as pure helpers from
// the context module so we can test the rules directly without React.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
applyEnqueue,
applyDismiss,
} from "../lib/incoming-note-queue.ts";
function note(id, senderUserId, extra = {}) {
return {
noteId: id,
senderUserId,
title: `Note ${id}`,
content: "",
color: "default",
sender: null,
...extra,
};
}
test("applyEnqueue appends new notes in arrival order (FIFO)", () => {
let q = [];
q = applyEnqueue(q, note(1, 99), 7);
q = applyEnqueue(q, note(2, 99), 7);
q = applyEnqueue(q, note(3, 99), 7);
assert.deepEqual(
q.map((p) => p.noteId),
[1, 2, 3],
);
});
test("applyEnqueue dedupes by noteId — second copy is ignored", () => {
let q = [];
q = applyEnqueue(q, note(42, 99), 7);
const before = q;
q = applyEnqueue(q, note(42, 99, { title: "should be ignored" }), 7);
assert.equal(q, before, "queue reference is unchanged on dedupe");
assert.equal(q.length, 1);
assert.equal(q[0].title, "Note 42");
});
test("applyEnqueue suppresses notes sent by the current user", () => {
let q = [];
// Same user is sender + recipient (multi-tab edge case).
q = applyEnqueue(q, note(1, 7), 7);
assert.equal(q.length, 0);
// Other senders still get through.
q = applyEnqueue(q, note(2, 8), 7);
assert.equal(q.length, 1);
assert.equal(q[0].noteId, 2);
});
test("applyEnqueue does NOT suppress when current user is unknown (null)", () => {
let q = [];
q = applyEnqueue(q, note(1, 7), null);
assert.equal(q.length, 1, "unknown current user should not filter anyone");
});
test("applyEnqueue rejects malformed payloads", () => {
let q = [];
q = applyEnqueue(q, null, 7);
q = applyEnqueue(q, { senderUserId: 1 }, 7);
q = applyEnqueue(q, { noteId: "not-a-number", senderUserId: 1 }, 7);
assert.deepEqual(q, []);
});
test("applyDismiss pops the head when it matches", () => {
let q = [note(1, 99), note(2, 99), note(3, 99)];
q = applyDismiss(q, 1);
assert.deepEqual(
q.map((p) => p.noteId),
[2, 3],
);
});
test("applyDismiss filters from the middle when head doesn't match", () => {
let q = [note(1, 99), note(2, 99), note(3, 99)];
q = applyDismiss(q, 2);
assert.deepEqual(
q.map((p) => p.noteId),
[1, 3],
);
});
test("applyDismiss is a no-op on an empty queue or missing id", () => {
assert.deepEqual(applyDismiss([], 1), []);
const q = [note(1, 99)];
assert.equal(applyDismiss(q, 999), q);
});
test("FIFO + dedupe + dismiss compose correctly across many ops", () => {
let q = [];
q = applyEnqueue(q, note(1, 99), 7);
q = applyEnqueue(q, note(2, 99), 7);
q = applyEnqueue(q, note(2, 99), 7); // dupe — ignored
q = applyEnqueue(q, note(3, 7), 7); // self-send — suppressed
q = applyEnqueue(q, note(4, 99), 7);
assert.deepEqual(
q.map((p) => p.noteId),
[1, 2, 4],
);
q = applyDismiss(q, 1);
q = applyDismiss(q, 4);
assert.deepEqual(
q.map((p) => p.noteId),
[2],
);
});
@@ -7,19 +7,14 @@ import {
useState,
type ReactNode,
} from "react";
import type { UserSummary } from "@/lib/notes-api";
import { useAuth } from "@/contexts/AuthContext";
import {
applyDismiss,
applyEnqueue,
type IncomingNotePayload,
} from "@/lib/incoming-note-queue";
export interface IncomingNotePayload {
noteId: number;
recipientRowId?: number;
title: string;
content: string;
color: string;
sentAt?: string;
senderUserId: number;
sender: UserSummary | null;
}
export type { IncomingNotePayload } from "@/lib/incoming-note-queue";
interface IncomingNotePopupContextValue {
current: IncomingNotePayload | null;
@@ -44,24 +39,13 @@ export function IncomingNotePopupProvider({ children }: { children: ReactNode })
const enqueue = useCallback(
(payload: IncomingNotePayload, currentUserId: number | null) => {
if (!payload || typeof payload.noteId !== "number") return;
if (currentUserId != null && payload.senderUserId === currentUserId) return;
setQueue((prev) => {
if (prev.some((p) => p.noteId === payload.noteId)) return prev;
return [...prev, payload];
});
setQueue((prev) => applyEnqueue(prev, payload, currentUserId));
},
[],
);
const dismiss = useCallback((noteId: number) => {
setQueue((prev) => {
if (prev.length === 0) return prev;
if (prev[0].noteId !== noteId) {
return prev.filter((p) => p.noteId !== noteId);
}
return prev.slice(1);
});
setQueue((prev) => applyDismiss(prev, noteId));
}, []);
const clear = useCallback(() => setQueue([]), []);
@@ -0,0 +1,43 @@
import type { UserSummary } from "@/lib/notes-api";
export interface IncomingNotePayload {
noteId: number;
recipientRowId?: number;
title: string;
content: string;
color: string;
sentAt?: string;
senderUserId: number;
sender: UserSummary | null;
}
/**
* Pure FIFO + dedupe + sender-suppression for the incoming-note queue.
* Exported so unit tests can exercise the rules without React.
*/
export function applyEnqueue(
queue: IncomingNotePayload[],
payload: IncomingNotePayload,
currentUserId: number | null,
): IncomingNotePayload[] {
if (!payload || typeof payload.noteId !== "number") return queue;
if (currentUserId != null && payload.senderUserId === currentUserId) {
return queue;
}
if (queue.some((p) => p.noteId === payload.noteId)) return queue;
return [...queue, payload];
}
/**
* Pop the head of the queue if `noteId` matches it; otherwise filter it
* out from anywhere it appears. Pure for testability.
*/
export function applyDismiss(
queue: IncomingNotePayload[],
noteId: number,
): IncomingNotePayload[] {
if (queue.length === 0) return queue;
if (queue[0].noteId === noteId) return queue.slice(1);
const next = queue.filter((p) => p.noteId !== noteId);
return next.length === queue.length ? queue : next;
}
+43 -18
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { useLocation, useSearch } from "wouter";
import {
ArrowLeft,
ArrowRight,
@@ -91,26 +91,31 @@ export default function NotesPage() {
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false);
const [sendForNoteId, setSendForNoteId] = useState<number | null>(null);
const [openThreadId, setOpenThreadId] = useState<number | null>(null);
const [threadReplyIntent, setThreadReplyIntent] = useState(false);
// Allow deep-links like /notes?thread=42 (from the incoming-note popup)
// to jump straight into the inbox tab + thread dialog.
// Allow deep-links like /notes?thread=42&reply=1 (from the incoming-note
// popup) to jump into the inbox tab + thread dialog. Reactive on the
// current query string so this still works when the user is already on
// /notes and the popup just updates the URL.
const queryString = useSearch();
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(queryString);
const threadParam = params.get("thread");
if (threadParam) {
const id = Number(threadParam);
if (Number.isInteger(id) && id > 0) {
setView("received");
setOpenThreadId(id);
params.delete("thread");
params.delete("reply");
const qs = params.toString();
const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
window.history.replaceState(null, "", next);
}
if (!threadParam) return;
const id = Number(threadParam);
if (!Number.isInteger(id) || id <= 0) return;
const wantsReply = params.get("reply") === "1";
setView("received");
setOpenThreadId(id);
setThreadReplyIntent(wantsReply);
if (typeof window !== "undefined") {
params.delete("thread");
params.delete("reply");
const qs = params.toString();
const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
window.history.replaceState(null, "", next);
}
}, []);
}, [queryString]);
const { data: notes = [], isLoading: loadingNotes } = useNotes(view === "archived");
const { data: labels = [] } = useNoteLabels();
@@ -367,7 +372,11 @@ export default function NotesPage() {
{openThreadId !== null && (
<ThreadDialog
noteId={openThreadId}
onClose={() => setOpenThreadId(null)}
autoFocusReply={threadReplyIntent}
onClose={() => {
setOpenThreadId(null);
setThreadReplyIntent(false);
}}
/>
)}
</div>
@@ -945,9 +954,11 @@ function ArchivedView({
function ThreadDialog({
noteId,
onClose,
autoFocusReply = false,
}: {
noteId: number;
onClose: () => void;
autoFocusReply?: boolean;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -958,6 +969,19 @@ function ThreadDialog({
const [replyText, setReplyText] = useState("");
const [ownerReplyTarget, setOwnerReplyTarget] = useState<number | null>(null);
const markedRef = useRef(false);
const replyInputRef = useRef<HTMLTextAreaElement | null>(null);
const focusedRef = useRef(false);
// When opened from the incoming-note popup with reply intent, jump
// focus into the reply input as soon as the thread loads.
useEffect(() => {
if (!autoFocusReply || focusedRef.current) return;
if (!thread) return;
const el = replyInputRef.current;
if (!el) return;
focusedRef.current = true;
el.focus();
}, [autoFocusReply, thread]);
useEffect(() => {
if (!thread || markedRef.current) return;
@@ -1086,6 +1110,7 @@ function ThreadDialog({
</select>
)}
<Textarea
ref={replyInputRef}
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder={t("notes.replyPlaceholder", "Write a reply…")}