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:
@@ -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;
|
||||
}
|
||||
@@ -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…")}
|
||||
|
||||
Reference in New Issue
Block a user