diff --git a/package.json b/package.json index ba652b3c..265a81f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.17.31", + "version": "0.17.32", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 59ec65fd..2d25b1e8 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -3805,6 +3805,7 @@ image-carousel { border-radius: 8px; font-size: 13px; opacity: 0.9; + cursor: pointer; } .message-sent .message-reply-quote { @@ -3843,6 +3844,7 @@ image-carousel { font-size: 12px; color: var(--text-color-muted); margin-bottom: 2px; + cursor: pointer; } .message-reply-caption-arrow { @@ -3860,6 +3862,28 @@ image-carousel { padding-left: 44px; } +@keyframes message-highlight-flash { + 0% { + background-color: transparent; + } + 10%, + 37% { + background-color: color-mix( + in srgb, + var(--highlight-color) 22%, + transparent + ); + } + 67% { + background-color: transparent; + } +} + +.message-wrapper.message-highlighted { + border-radius: 12px; + animation: message-highlight-flash 1000ms ease-out; +} + .message-sent a { color: var(--off-white); } diff --git a/src/js/views/chatDetail.view.js b/src/js/views/chatDetail.view.js index 0b6433b3..0ece626e 100644 --- a/src/js/views/chatDetail.view.js +++ b/src/js/views/chatDetail.view.js @@ -50,6 +50,29 @@ class ChatDetailView extends View { state.$paletteMessageId = new Signal.State(null); state.$reactionsDialogMessageId = new Signal.State(null); + function triggerHighlightAnimation(messageEl) { + messageEl.classList.remove("message-highlighted"); + // Force a reflow + void messageEl.offsetWidth; + messageEl.classList.add("message-highlighted"); + messageEl.addEventListener( + "animationend", + () => messageEl.classList.remove("message-highlighted"), + { once: true }, + ); + } + + function scrollToAndHighlightMessage(messageId) { + const element = root.querySelector( + `.message-wrapper[data-message-id="${CSS.escape(messageId)}"]`, + ); + if (!element) { + return; + } + element.scrollIntoView({ block: "center" }); + triggerHighlightAnimation(element); + } + function focusChatInput() { const chatInput = root.querySelector("chat-input"); if (chatInput) { @@ -613,6 +636,10 @@ class ChatDetailView extends View { ? "message-reply-quote-sent" : "message-reply-quote-received"}" data-testid="message-reply-quote" + @click=${(event) => { + event.stopPropagation(); + scrollToAndHighlightMessage(replyTo.id); + }} >
{ + event.stopPropagation(); + scrollToAndHighlightMessage(replyTo.id); + }} > ${captionText} @@ -680,6 +712,7 @@ class ChatDetailView extends View { return html`
handleMessageClick(message.id)} @@ -799,6 +832,7 @@ class ChatDetailView extends View { > ${leadingReplyTo && (isGroup || group.isCurrentUser) ? messageReplyCaptionTemplate({ + replyTo: leadingReplyTo, replierProfile, originalSenderProfile, isCurrentUserReplier: group.isCurrentUser, diff --git a/tests/e2e/specs/views/chatDetail.view.test.js b/tests/e2e/specs/views/chatDetail.view.test.js index 0494875d..0b6e4734 100644 --- a/tests/e2e/specs/views/chatDetail.view.test.js +++ b/tests/e2e/specs/views/chatDetail.view.test.js @@ -248,7 +248,21 @@ test.describe("Chat detail view", () => { text: "", senderDid: alice.did, sentAt: "2025-01-15T12:00:00.000Z", - embed: { $type: "app.bsky.embed.record#view" }, + embed: { + $type: "app.bsky.embed.record#view", + record: { + $type: "app.bsky.embed.record#viewRecord", + uri: "at://did:plc:alice1/app.bsky.feed.post/quoted", + cid: "bafyquoted", + author: alice, + value: { + $type: "app.bsky.feed.post", + text: "Original post", + createdAt: "2025-01-15T11:59:00.000Z", + }, + indexedAt: "2025-01-15T11:59:00.000Z", + }, + }, }); const reply = createMessage({ id: "msg-2", @@ -272,6 +286,57 @@ test.describe("Chat detail view", () => { ); }); + test("tapping a reply quote highlights the original message then fades", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const convo = createConvo({ + id: "convo-1", + otherMember: alice, + }); + const original = createMessage({ + id: "msg-original", + text: "What time are we meeting?", + senderDid: alice.did, + sentAt: "2025-01-15T12:00:00.000Z", + }); + const reply = createMessage({ + id: "msg-reply", + text: "Around 7pm", + senderDid: userProfile.did, + sentAt: "2025-01-15T12:01:00.000Z", + replyTo: original, + }); + mockServer.addConvos([convo]); + mockServer.addConvoMessages("convo-1", [reply, original]); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1"); + + const chatDetailView = page.locator("#chat-detail-view"); + await expect(chatDetailView.locator(".message-bubble")).toHaveCount(2, { + timeout: 10000, + }); + + const originalWrapper = chatDetailView.locator( + '.message-wrapper[data-message-id="msg-original"]', + ); + await expect(originalWrapper).not.toHaveClass(/message-highlighted/); + + await chatDetailView.locator('[data-testid="message-reply-quote"]').click(); + + await expect(originalWrapper).toHaveClass(/message-highlighted/); + await expect(originalWrapper).not.toHaveClass(/message-highlighted/, { + timeout: 2500, + }); + }); + test("should send a message and display it", async ({ page }) => { const mockServer = new MockServer(); const alice = createProfile({