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(() => { - +
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© 2026 Luminary. All rights reserved.
More.
" }]; + + const { copyrightHtml, copyrightText } = useGlobalCopyright(); + + expect(copyrightHtml.value).toContain("All rights reserved."); + expect(copyrightText.value).toBe("© 2026 Luminary. All rights reserved."); + }); +}); diff --git a/app/src/composables/useGlobalCopyright.ts b/app/src/composables/useGlobalCopyright.ts new file mode 100644 index 0000000000..2388a2383c --- /dev/null +++ b/app/src/composables/useGlobalCopyright.ts @@ -0,0 +1,39 @@ +import { computed } from "vue"; +import { useContentQuery } from "@/composables/useContentQuery"; +import { firstParagraphExcerpt } from "@/composables/useSocialShare"; + +/** + * The instance-wide copyright notice (`VITE_COPYRIGHT_ID`) — as HTML for the page banner and + * as plain text for share messages, where it stands in for posts that carry no copyright of + * their own. + */ +export function 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 copyrightHtml = computed(() => copyright.value[0]?.text ?? ""); + + // A share message wants one attribution line, so take the notice's opening paragraph + // and let the same excerpt cap keep a long policy page out of the message. + const copyrightText = computed(() => firstParagraphExcerpt(copyrightHtml.value)); + + return { copyrightHtml, copyrightText }; +} diff --git a/app/src/composables/useSocialShare.spec.ts b/app/src/composables/useSocialShare.spec.ts new file mode 100644 index 0000000000..77a93dd197 --- /dev/null +++ b/app/src/composables/useSocialShare.spec.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import type { ContentDto, ImageFileCollectionDto } from "luminary-shared"; + +import { fetchShareImageFile, formatShareMessage, shareImageUrl } from "./useSocialShare"; + +const contentWith = (fileCollections: ImageFileCollectionDto[]) => + ({ parentImageData: { fileCollections } }) as ContentDto; + +describe("formatShareMessage", () => { + it("marks the copyright line with ©", () => { + expect(formatShareMessage({ title: "Post 1", copyright: "2026 Luminary" })).toBe( + "— from “Post 1”\n© 2026 Luminary", + ); + }); + + it("leaves a notice that already carries the symbol alone", () => { + expect(formatShareMessage({ title: "Post 1", copyright: "© 2026 Luminary" })).toBe( + "— from “Post 1”\n© 2026 Luminary", + ); + }); +}); + +describe("shareImageUrl", () => { + it("picks the best-fitting variant of the collection the article displays", () => { + const content = contentWith([ + { + aspectRatio: 1, + imageFiles: [{ width: 1200, height: 1200, filename: "square.webp" }], + }, + { + aspectRatio: 1.78, + imageFiles: [ + { width: 480, height: 270, filename: "small.webp" }, + { width: 1280, height: 720, filename: "large.webp" }, + ], + }, + ]); + + expect(shareImageUrl(content, "https://cdn.test/bucket")).toBe( + "https://cdn.test/bucket/large.webp", + ); + }); + + // A phone on mobile data shouldn't pull a print-sized original for a target that + // re-encodes it anyway. + it("keeps the fetch small when every variant is oversized", () => { + const content = contentWith([ + { + aspectRatio: 1.78, + imageFiles: [ + { width: 4000, height: 2250, filename: "huge.webp" }, + { width: 2400, height: 1350, filename: "big.webp" }, + ], + }, + ]); + + expect(shareImageUrl(content, "https://cdn.test/bucket/")).toBe( + "https://cdn.test/bucket/big.webp", + ); + }); + + it("has nothing to share without an image or a bucket", () => { + expect(shareImageUrl(contentWith([]), "https://cdn.test/bucket")).toBeUndefined(); + expect( + shareImageUrl( + contentWith([ + { + aspectRatio: 1.78, + imageFiles: [{ width: 800, height: 450, filename: "a.webp" }], + }, + ]), + undefined, + ), + ).toBeUndefined(); + }); +}); + +describe("fetchShareImageFile", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("names the file after the image it fetched", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + blob: async () => new Blob(["image-bytes"], { type: "image/webp" }), + }), + ); + + const file = await fetchShareImageFile("https://cdn.test/bucket/large.webp"); + + expect(file?.name).toBe("large.webp"); + expect(file?.type).toBe("image/webp"); + }); + + it("gives up quietly when the image can't be fetched", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("blocked by CORS"))); + expect(await fetchShareImageFile("https://cdn.test/bucket/large.webp")).toBeUndefined(); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + blob: async () => new Blob([""], { type: "text/html" }), + }), + ); + expect(await fetchShareImageFile("https://cdn.test/bucket/large.webp")).toBeUndefined(); + }); +}); diff --git a/app/src/composables/useSocialShare.ts b/app/src/composables/useSocialShare.ts new file mode 100644 index 0000000000..e9dfb3641c --- /dev/null +++ b/app/src/composables/useSocialShare.ts @@ -0,0 +1,132 @@ +import type { ContentDto } from "luminary-shared"; + +// `url` must carry the link: t.me/share/url treats it as the thing being shared and +// bounces to Telegram's home page when it's empty, whatever `text` holds. Telegram +// composes the draft as `url` then `text`, so the link sits above the quote. +export function buildTelegramShareUrl(text: string, url: string): string { + const shareUrl = new URL("https://t.me/share/url"); + shareUrl.searchParams.set("url", url); + shareUrl.searchParams.set("text", text); + return shareUrl.toString(); +} + +// web.whatsapp.com, not wa.me / api.whatsapp.com — both of those are registered as +// OS-level Universal Links, so the click is handed straight to the native desktop app +// before the page (or the `text` param) is involved, and the app's own handler drops +// everything but the trailing URL. web.whatsapp.com isn't a Universal Link target, so +// it opens as a normal page and keeps the full pre-filled text intact. +export function buildWhatsAppShareUrl(text: string): string { + const shareUrl = new URL("https://web.whatsapp.com/send"); + shareUrl.searchParams.set("text", text); + return shareUrl.toString(); +} + +export function buildXShareUrl(text: string, url: string): string { + const shareUrl = new URL("https://twitter.com/intent/tweet"); + shareUrl.searchParams.set("text", text); + shareUrl.searchParams.set("url", url); + return shareUrl.toString(); +} + +// Reddit's link-submit intent only takes a title, not a body — passing one turns the +// submission into a text post instead of a link share. +export function buildRedditShareUrl(title: string, url: string): string { + const shareUrl = new URL("https://www.reddit.com/submit"); + shareUrl.searchParams.set("url", url); + shareUrl.searchParams.set("title", title); + return shareUrl.toString(); +} + +const EXCERPT_MAX_LENGTH = 220; + +// Plain-text excerpt of an article's first paragraph, trimmed to a whole word and +// marked with an ellipsis when cut short — gives a taste of the actual content rather +// than just the title/summary. Parsing the CMS-authored HTML into a detached element is +// safe: it's never inserted into the document, so nothing in it can execute. Returns +// empty during a prerender, where there is no DOM (and nothing shares). +export function firstParagraphExcerpt(html: string | undefined): string { + if (!html || typeof document === "undefined") return ""; + const container = document.createElement("div"); + container.innerHTML = html; + const text = (container.querySelector("p")?.textContent ?? container.textContent ?? "") + .trim() + .replace(/\s+/g, " "); + if (!text) return ""; + if (text.length <= EXCERPT_MAX_LENGTH) return text; + const truncated = text.slice(0, EXCERPT_MAX_LENGTH); + const lastSpace = truncated.lastIndexOf(" "); + return `${truncated.slice(0, lastSpace > 0 ? lastSpace : EXCERPT_MAX_LENGTH)}…`; +} + +type ShareMessageInput = { + /** The quoted passage: a reader's text selection, or an excerpt of the article. */ + quote?: string; + title: string; + copyright?: string; + /** + * Appended as the closing line. Only for targets that don't take the link as their + * own parameter (Telegram, WhatsApp, Instagram, clipboard); passing it to the others + * would show the link twice. + */ + url?: string; +}; + +/** The one share/copy layout: quote, attribution, then the bare link. */ +export function formatShareMessage({ quote, title, copyright, url }: ShareMessageInput): string { + // The copyright line is marked with © rather than the attribution dash, unless the + // notice already carries the symbol itself. + const notice = copyright?.trim(); + const copyrightLine = notice && (notice.includes("©") ? notice : `© ${notice}`); + + const attribution = [`— from “${title}”`, copyrightLine].filter(Boolean).join("\n"); + + return [quote ? `“${quote}”` : undefined, attribution, url].filter(Boolean).join("\n\n"); +} + +// The hero's aspect ratio, so the shared image is the one the reader saw on the page. +const HERO_ASPECT_RATIO = 1.78; + +// Instagram re-encodes what it receives to about this width, so a bigger variant costs +// the reader bytes without reaching them any sharper. +const SHARE_IMAGE_TARGET_WIDTH = 1080; + +/** URL of the image variant to attach to a share, or undefined when the post has no image. */ +export function shareImageUrl(content: ContentDto, bucketBaseUrl?: string): string | undefined { + const collections = content.parentImageData?.fileCollections ?? []; + if (!bucketBaseUrl || !collections.length) return undefined; + + const collection = collections.reduce((closest, candidate) => + Math.abs(candidate.aspectRatio - HERO_ASPECT_RATIO) < + Math.abs(closest.aspectRatio - HERO_ASPECT_RATIO) + ? candidate + : closest, + ); + + const files = collection.imageFiles ?? []; + if (!files.length) return undefined; + + const file = files.reduce((closest, candidate) => + Math.abs(candidate.width - SHARE_IMAGE_TARGET_WIDTH) < + Math.abs(closest.width - SHARE_IMAGE_TARGET_WIDTH) + ? candidate + : closest, + ); + + return `${bucketBaseUrl.replace(/\/$/, "")}/${file.filename}`; +} + +/** + * Fetch a share image as a `File`. Resolves undefined on any failure (offline, CORS, a + * non-image response) so a share still goes out as text. + */ +export async function fetchShareImageFile(url: string): Promise
-
{