diff --git a/api/src/db/seedingDocs/lang-eng.json b/api/src/db/seedingDocs/lang-eng.json index 836e304283..13fa2ef220 100644 --- a/api/src/db/seedingDocs/lang-eng.json +++ b/api/src/db/seedingDocs/lang-eng.json @@ -160,6 +160,14 @@ "auth.sign_in": "Sign in", "auth.no_methods_available": "No authentication methods available. Please try again in a moment.", "singlecontent.listen": "Listen", - "singlecontent.loading": "Loading..." + "singlecontent.loading": "Loading...", + "singlecontent.shareTelegram": "Share on Telegram", + "singlecontent.shareWhatsApp": "Share on WhatsApp", + "singlecontent.share": "Share", + "singlecontent.shareX": "Share on X", + "singlecontent.shareReddit": "Share on Reddit", + "singlecontent.shareInstagram": "Share on Instagram", + "singlecontent.shareInstagramCopiedTitle": "Link copied", + "singlecontent.shareInstagramCopiedDescription": "Instagram doesn't support sharing links directly — paste it into a DM, Story or bio." } } diff --git a/api/src/db/seedingDocs/lang-fra.json b/api/src/db/seedingDocs/lang-fra.json index 02d545a94c..b012aad5ac 100644 --- a/api/src/db/seedingDocs/lang-fra.json +++ b/api/src/db/seedingDocs/lang-fra.json @@ -160,6 +160,14 @@ "auth.sign_in": "Se connecter", "auth.no_methods_available": "Aucun méthode d'authentification disponible. Veuillez réessayer dans un moment.", "singlecontent.listen": "Écoutez", - "singlecontent.loading": "Chargement..." + "singlecontent.loading": "Chargement...", + "singlecontent.shareTelegram": "Partager sur Telegram", + "singlecontent.shareWhatsApp": "Partager sur WhatsApp", + "singlecontent.share": "Partager", + "singlecontent.shareX": "Partager sur X", + "singlecontent.shareReddit": "Partager sur Reddit", + "singlecontent.shareInstagram": "Partager sur Instagram", + "singlecontent.shareInstagramCopiedTitle": "Lien copié", + "singlecontent.shareInstagramCopiedDescription": "Instagram ne permet pas de partager un lien directement — collez-le dans un DM, une story ou votre bio." } } diff --git a/app/src/components/BasePage.vue b/app/src/components/BasePage.vue index 7ef5964725..843118903a 100644 --- a/app/src/components/BasePage.vue +++ b/app/src/components/BasePage.vue @@ -104,17 +104,13 @@ onUnmounted(() => { - + diff --git a/app/src/components/common/LHighlightable.spec.ts b/app/src/components/common/LHighlightable.spec.ts index 9367a49a99..599c4f1c4b 100644 --- a/app/src/components/common/LHighlightable.spec.ts +++ b/app/src/components/common/LHighlightable.spec.ts @@ -2,6 +2,8 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { mount } from "@vue/test-utils"; import waitForExpect from "wait-for-expect"; +import { setActivePinia } from "pinia"; +import { createTestingPinia } from "@pinia/testing"; import LHighlightable from "./LHighlightable.vue"; import { db } from "luminary-shared"; @@ -13,9 +15,13 @@ vi.mock("vue", async (importOriginal) => { }; }); -const mountHighlightable = (contentId = "test-content-1") => +const mountHighlightable = ( + contentId = "test-content-1", + title = "Test Article", + copyright?: string, +) => mount(LHighlightable, { - props: { contentId }, + props: { contentId, title, copyright }, slots: { default: "

Some highlighted text content

" }, attachTo: document.body, }); @@ -23,6 +29,7 @@ const mountHighlightable = (contentId = "test-content-1") => describe("LHighlightable", () => { beforeEach(() => { vi.useFakeTimers(); + setActivePinia(createTestingPinia()); }); afterEach(() => { @@ -492,4 +499,186 @@ describe("LHighlightable", () => { wrapper.unmount(); }); + + it("shows share targets and opens the correct URL for the selected text", async () => { + const wrapper = mountHighlightable("share-test", "Test Article"); + // Let the async onMounted (restoreHighlights) finish before dispatching + // selectionchange — the listener isn't registered until it resolves. + await vi.advanceTimersByTimeAsync(50); + const prose = wrapper.find(".prose"); + + const textNode = prose.element.querySelector("p")!.firstChild!; + const range = document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, 4); // "Some" + + range.getBoundingClientRect = vi.fn(() => ({ + left: 100, + top: 100, + right: 200, + bottom: 120, + width: 100, + height: 20, + x: 100, + y: 100, + toJSON: () => {}, + })); + + const mockSelection = { + isCollapsed: false, + rangeCount: 1, + getRangeAt: vi.fn(() => range), + toString: () => "Some", + removeAllRanges: vi.fn(), + anchorNode: textNode, + }; + vi.spyOn(window, "getSelection").mockReturnValue(mockSelection as any); + + document.dispatchEvent(new Event("selectionchange")); + vi.advanceTimersByTime(300); + await wrapper.vm.$nextTick(); + + const shareTrigger = document.body.querySelector( + '[data-test="highlightShareTrigger"]', + ) as HTMLElement; + expect(shareTrigger).toBeTruthy(); + shareTrigger.click(); + await wrapper.vm.$nextTick(); + + const telegramBtn = document.body.querySelector( + '[data-test="highlightShareTelegram"]', + ) as HTMLElement; + const whatsappBtn = document.body.querySelector('[data-test="highlightShareWhatsApp"]'); + const xBtn = document.body.querySelector('[data-test="highlightShareX"]'); + const redditBtn = document.body.querySelector('[data-test="highlightShareReddit"]'); + const instagramBtn = document.body.querySelector('[data-test="highlightShareInstagram"]'); + expect(telegramBtn).toBeTruthy(); + expect(whatsappBtn).toBeTruthy(); + expect(xBtn).toBeTruthy(); + expect(redditBtn).toBeTruthy(); + expect(instagramBtn).toBeTruthy(); + + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + telegramBtn.click(); + await wrapper.vm.$nextTick(); + + expect(openSpy).toHaveBeenCalledTimes(1); + const openedUrl = new URL(openSpy.mock.calls[0][0] as string); + expect(openedUrl.origin + openedUrl.pathname).toBe("https://t.me/share/url"); + const openedText = openedUrl.searchParams.get("text"); + expect(openedText).toContain("Some"); + expect(openedText).toContain("Test Article"); + + // The popup fully closes after a share action, same as Highlight/Copy. + expect(document.body.querySelector(".fixed.z-50")).toBeFalsy(); + + wrapper.unmount(); + }); + + it("copies the selection with its attribution, copyright and link", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + const wrapper = mountHighlightable("copy-format-test", "Test Article", "© Test Publisher"); + await vi.advanceTimersByTimeAsync(50); + const prose = wrapper.find(".prose"); + + const textNode = prose.element.querySelector("p")!.firstChild!; + const range = document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, 4); + range.getBoundingClientRect = vi.fn(() => ({ + left: 100, + top: 100, + right: 200, + bottom: 120, + width: 100, + height: 20, + x: 100, + y: 100, + toJSON: () => {}, + })); + + vi.spyOn(window, "getSelection").mockReturnValue({ + isCollapsed: false, + rangeCount: 1, + getRangeAt: vi.fn(() => range), + toString: () => "Some", + removeAllRanges: vi.fn(), + anchorNode: textNode, + } as any); + + document.dispatchEvent(new Event("selectionchange")); + vi.advanceTimersByTime(300); + await wrapper.vm.$nextTick(); + + (document.body.querySelector('[data-test="highlightCopy"]') as HTMLElement).click(); + await wrapper.vm.$nextTick(); + + expect(writeText).toHaveBeenCalledWith( + [ + "“Some”", + "", + "— from “Test Article”\n© Test Publisher", + "", + window.location.href, + ].join("\n"), + ); + + wrapper.unmount(); + }); + + it("copies the selected text and article link when sharing to Instagram", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + const wrapper = mountHighlightable("share-instagram-test", "Test Article"); + await vi.advanceTimersByTimeAsync(50); + const prose = wrapper.find(".prose"); + + const textNode = prose.element.querySelector("p")!.firstChild!; + const range = document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, 4); + range.getBoundingClientRect = vi.fn(() => ({ + left: 100, + top: 100, + right: 200, + bottom: 120, + width: 100, + height: 20, + x: 100, + y: 100, + toJSON: () => {}, + })); + + const mockSelection = { + isCollapsed: false, + rangeCount: 1, + getRangeAt: vi.fn(() => range), + toString: () => "Some", + removeAllRanges: vi.fn(), + anchorNode: textNode, + }; + vi.spyOn(window, "getSelection").mockReturnValue(mockSelection as any); + + document.dispatchEvent(new Event("selectionchange")); + vi.advanceTimersByTime(300); + await wrapper.vm.$nextTick(); + + (document.body.querySelector('[data-test="highlightShareTrigger"]') as HTMLElement).click(); + await wrapper.vm.$nextTick(); + + ( + document.body.querySelector('[data-test="highlightShareInstagram"]') as HTMLElement + ).click(); + await wrapper.vm.$nextTick(); + + expect(writeText).toHaveBeenCalledTimes(1); + const copiedText = writeText.mock.calls[0][0] as string; + expect(copiedText).toContain("Some"); + expect(copiedText).toContain("Test Article"); + + wrapper.unmount(); + }); }); diff --git a/app/src/components/common/LHighlightable.vue b/app/src/components/common/LHighlightable.vue index f2809d939d..a3f52bbfd6 100644 --- a/app/src/components/common/LHighlightable.vue +++ b/app/src/components/common/LHighlightable.vue @@ -5,11 +5,25 @@ import { PencilSquareIcon, TrashIcon, ChevronLeftIcon, + ShareIcon, } from "@heroicons/vue/24/outline"; import { db } from "luminary-shared"; import { getHighlightHtml, type SavedHighlight } from "@/recommendation/highlightStore"; - -const props = defineProps<{ contentId: string }>(); +import TelegramIcon from "@/components/icons/TelegramIcon.vue"; +import WhatsAppIcon from "@/components/icons/WhatsAppIcon.vue"; +import XIcon from "@/components/icons/XIcon.vue"; +import RedditIcon from "@/components/icons/RedditIcon.vue"; +import InstagramIcon from "@/components/icons/InstagramIcon.vue"; +import { + buildTelegramShareUrl, + buildWhatsAppShareUrl, + buildXShareUrl, + buildRedditShareUrl, + formatShareMessage, +} from "@/composables/useSocialShare"; +import { useNotificationStore } from "@/stores/notification"; + +const props = defineProps<{ contentId: string; title: string; copyright?: string }>(); // Fired when a highlight is created or genuinely removed. The parent (which knows // the content's tags) decides what to do with these events. `highlightsChanged` is // emitted only after IndexedDB reflects the active markup, so other local consumers @@ -22,6 +36,8 @@ const showActions = ref(false); const menuPos = ref({ x: 0, y: 0 }); const isHighlighted = ref(false); const showColorPicker = ref(false); +const showShareMenu = ref(false); +const selectedTextForShare = ref(""); let debounceTimeout: ReturnType | undefined; @@ -51,6 +67,7 @@ function onSelectionChange() { // Hide menu immediately when selection starts changing to prevent jitter showActions.value = false; showColorPicker.value = false; + showShareMenu.value = false; clearTimeout(debounceTimeout); debounceTimeout = setTimeout(() => { @@ -307,15 +324,85 @@ function removeHighlight() { finalizeHighlight(); } +// Copied text carries its attribution and a link back, so a pasted quote can always be +// traced to the article it came from. function copyText() { const sel = window.getSelection(); if (sel) { - navigator.clipboard.writeText(sel.toString()); + navigator.clipboard.writeText(shareMessage(sel.toString(), { withUrl: true })); showActions.value = false; sel.removeAllRanges(); } } +// Sharing + +// Captured on open, not read lazily by each platform button — the selection can +// collapse once the user starts interacting with the popup. +function openShareMenu() { + const sel = window.getSelection(); + selectedTextForShare.value = sel ? sel.toString() : ""; + showShareMenu.value = true; +} + +function closeShareMenu() { + showShareMenu.value = false; +} + +function finalizeShare() { + showActions.value = false; + showShareMenu.value = false; +} + +function shareMessage(quote: string, options: { withUrl?: boolean } = {}): string { + return formatShareMessage({ + quote, + title: props.title, + copyright: props.copyright, + url: options.withUrl ? window.location.href : undefined, + }); +} + +function shareHighlightText(options: { withUrl?: boolean } = {}): string { + return shareMessage(selectedTextForShare.value, options); +} + +function shareHighlightToTelegram() { + window.open(buildTelegramShareUrl(shareHighlightText(), window.location.href), "_blank"); + finalizeShare(); +} + +function shareHighlightToWhatsApp() { + window.open(buildWhatsAppShareUrl(shareHighlightText({ withUrl: true })), "_blank"); + finalizeShare(); +} + +function shareHighlightToX() { + window.open(buildXShareUrl(shareHighlightText(), window.location.href), "_blank"); + finalizeShare(); +} + +function shareHighlightToReddit() { + window.open(buildRedditShareUrl(props.title, window.location.href), "_blank"); + finalizeShare(); +} + +// Instagram has no web share-URL API for posts/links, so the closest one-click +// equivalent is copying the text + link for the user to paste into a DM, Story or bio. +async function shareHighlightToInstagram() { + await navigator.clipboard.writeText(shareHighlightText({ withUrl: true })); + useNotificationStore().addNotification({ + id: "share-link-copied", + title: "Link copied", + description: + "Instagram doesn't support sharing links directly — paste it into a DM, Story or bio.", + state: "success", + type: "toast", + timeout: 5000, + }); + finalizeShare(); +} + // Persistence /** @@ -333,7 +420,9 @@ async function saveHighlights(): Promise { // Get existing highlights data from IndexedDB const existingData = (await db.getLuminaryInternals("highlights")) || {}; const data: Record = - typeof existingData === "object" && existingData !== null && !Array.isArray(existingData) + typeof existingData === "object" && + existingData !== null && + !Array.isArray(existingData) ? { ...existingData } : {}; @@ -483,7 +572,10 @@ onUnmounted(() => { @mousedown.stop.prevent > -
+
+ +
+ + +
-
+
+ +
+ + +
+ + + + + +
+
+
-import { computed } from "vue"; -import { useContentQuery } from "@/composables/useContentQuery"; +import { useGlobalCopyright } from "@/composables/useGlobalCopyright"; -// When VITE_COPYRIGHT_ID is unset there is no copyright page to seek. A -// `{ parentId: undefined }` clause serializes to `{}` over the wire, leaving the -// parentId index pinned with a publishDate sort but no parentId equality — which -// CouchDB rejects ("No index exists for this sort"). Match nothing via a -// provably-empty `$in` so HybridQuery short-circuits before any Dexie read or POST. -const copyrightId = import.meta.env.VITE_COPYRIGHT_ID; -const copyright = useContentQuery( - () => (copyrightId ? [{ parentId: copyrightId }] : [{ parentId: { $in: [] } }]), - { - includeScheduled: false, - limit: 1, - // Seek by parentId; the publishDate sort is required to engage the index. - useIndex: "content-parentId-publishDate-index", - sort: [{ publishDate: "desc" }], - // Keep `text` — the copyright body is rendered below; the default strips it. - stripFields: ["fts", "ftsTokenCount", "memberOf", "_rev"], - // Same selector on every page for the whole build — fetch it once, not per route. - buildOnce: true, - }, -); - -const copyrightContent = computed(() => copyright.value[0]?.text ?? ""); +const { copyrightHtml } = useGlobalCopyright();