diff --git a/api/src/changeRequests/aclValidation.spec.ts b/api/src/changeRequests/aclValidation.spec.ts index 9367f36c71..8ac2f907ec 100644 --- a/api/src/changeRequests/aclValidation.spec.ts +++ b/api/src/changeRequests/aclValidation.spec.ts @@ -137,6 +137,45 @@ describe("validateAcl", () => { expect(result[0].permission).toContain(AclPermission.View); }); + // Share is app-facing and assignable on the content-bearing doc types only. + it.each([DocType.Post, DocType.Tag])("should accept Share on doc type %s", (docType) => { + const acl = [createEntry(docType, "g1", [AclPermission.View, AclPermission.Share])]; + const result = validateAcl(acl); + + expect(result[0].permission).toContain(AclPermission.Share); + expect(result[0].permission).toContain(AclPermission.View); + }); + + it.each([ + DocType.Group, + DocType.Language, + DocType.User, + DocType.Redirect, + DocType.Storage, + DocType.AuthProvider, + DocType.AutoGroupMappings, + ])("should strip Share from doc type %s", (docType) => { + const acl = [createEntry(docType, "g1", [AclPermission.View, AclPermission.Share])]; + const result = validateAcl(acl); + + expect(result[0].permission).not.toContain(AclPermission.Share); + expect(result[0].permission).toContain(AclPermission.View); + }); + + it("should not auto-add CmsView for Share", () => { + const acl = [createEntry(DocType.Post, "g1", [AclPermission.View, AclPermission.Share])]; + const result = validateAcl(acl); + + expect(result[0].permission).not.toContain(AclPermission.CmsView); + }); + + it("should remove a Share-only entry", () => { + const acl = [createEntry(DocType.Post, "g1", [AclPermission.Share])]; + const result = validateAcl(acl); + + expect(result).toHaveLength(0); + }); + it("should strip CmsView from a doc type not in availablePermissionsPerDocType", () => { // Crypto is an internal doc type with no ACL config → all permissions stripped, entry removed. const acl = [ diff --git a/api/src/changeRequests/aclValidation.ts b/api/src/changeRequests/aclValidation.ts index 9bb8274677..e542b9706c 100644 --- a/api/src/changeRequests/aclValidation.ts +++ b/api/src/changeRequests/aclValidation.ts @@ -26,6 +26,7 @@ const availablePermissionsPerDocType = { AclPermission.Delete, AclPermission.Translate, AclPermission.Publish, + AclPermission.Share, AclPermission.CmsView, ], [DocType.Tag]: [ @@ -35,6 +36,7 @@ const availablePermissionsPerDocType = { AclPermission.Assign, AclPermission.Translate, AclPermission.Publish, + AclPermission.Share, AclPermission.CmsView, ], [DocType.User]: [ diff --git a/api/src/db/db.upgrade.spec.ts b/api/src/db/db.upgrade.spec.ts index 966892aa91..82fa66954c 100644 --- a/api/src/db/db.upgrade.spec.ts +++ b/api/src/db/db.upgrade.spec.ts @@ -50,6 +50,10 @@ jest.mock("./schemaUpgrade/v20", () => ({ __esModule: true, default: jest.fn().mockResolvedValue(undefined), })); +jest.mock("./schemaUpgrade/v21", () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue(undefined), +})); import { upgradeDbSchema } from "./db.upgrade"; import initSchemaVersion from "./schemaUpgrade/initSchemaVersion"; @@ -65,6 +69,7 @@ import v17 from "./schemaUpgrade/v17"; import v18 from "./schemaUpgrade/v18"; import v19 from "./schemaUpgrade/v19"; import v20 from "./schemaUpgrade/v20"; +import v21 from "./schemaUpgrade/v21"; describe("upgradeDbSchema", () => { const mockDb = {} as any; @@ -89,6 +94,7 @@ describe("upgradeDbSchema", () => { expect(v18).toHaveBeenCalledWith(mockDb); expect(v19).toHaveBeenCalledWith(mockDb); expect(v20).toHaveBeenCalledWith(mockDb); + expect(v21).toHaveBeenCalledWith(mockDb); }); it("should re-throw error and log it when an upgrade function fails", async () => { diff --git a/api/src/db/db.upgrade.ts b/api/src/db/db.upgrade.ts index 35b2e4e6e6..1f203e06e4 100644 --- a/api/src/db/db.upgrade.ts +++ b/api/src/db/db.upgrade.ts @@ -12,6 +12,7 @@ import v17 from "./schemaUpgrade/v17"; import v18 from "./schemaUpgrade/v18"; import v19 from "./schemaUpgrade/v19"; import v20 from "./schemaUpgrade/v20"; +import v21 from "./schemaUpgrade/v21"; // Re-exported for convenience so callers can read the fresh-DB baseline version from this module. export { FRESH_DB_SCHEMA_VERSION } from "./schemaUpgrade/freshDbSchemaVersion"; @@ -37,6 +38,7 @@ export async function upgradeDbSchema(db: DbService) { await v18(db); await v19(db); await v20(db); + await v21(db); } catch (error) { console.error("Database schema upgrade failed:", error); throw error; // Re-throw to prevent schema version from being updated diff --git a/api/src/db/schemaUpgrade/README.md b/api/src/db/schemaUpgrade/README.md index efa3bbf246..b266750467 100644 --- a/api/src/db/schemaUpgrade/README.md +++ b/api/src/db/schemaUpgrade/README.md @@ -134,7 +134,7 @@ Schema upgrades can be safely removed when: ### Current Baseline -**Current Schema Version**: 20 (as of 2026-08-21) +**Current Schema Version**: 21 (as of 2026-09-04) All production databases are expected to be at version 10 or higher. Historical upgrades v1-v9 have been removed as they are no longer needed. @@ -187,4 +187,8 @@ Backfills the new `CmsView` ACL permission (GitHub #160). CmsView gates CMS-scop Backfills `CmsView` on existing ACL entries that hold a CMS-only permission (`Edit`, `Delete`, `Assign`, `Translate`, `Publish`), matching the new auto-assign rule in `changeRequests/aclValidation.ts` and its CMS mirror `cms/src/components/groups/permissions.ts`. Those permissions previously auto-assigned `View`, which granted app-facing visibility as a side effect of a CMS-only permission change; `CmsView` is what they actually imply, and `View` is now an independent toggle. Entries holding `View` alone are genuine app-consumer grants and are deliberately left untouched, so `CmsView` stays a real, narrowable permission (ADR 0013). `group-public-users` is skipped entirely — it is effectively the anonymous group, and its broad seeded `edit`/`delete`/`publish` grants would otherwise expose drafts and expired content to anyone opening the CMS; its one intended `CmsView` grant (AuthProvider) was made by v19. Idempotent (only pushes `CmsView` where missing), safe to re-run including on fresh DBs and via `npm run seed`. Uses `insertDoc` to preserve `updatedTimeUtc`: the granted access takes effect via the server-recomputed AccessMap delivered on connect. +### v21 — Share ACL backfill (2026-09-04) + +Backfills the new `Share` ACL permission on every Post/Tag ACL entry that already holds `View`, so the audience that can read content in the app keeps being able to share it. `Share` is assignable on Post and Tag only (`changeRequests/aclValidation.ts` and its CMS mirror `cms/src/components/groups/permissions.ts`) and is app-facing — it grants no additional read access, so the broad backfill onto every `View` holder is safe, unlike `CmsView` (v19/v20) which had to stay narrow. Idempotent (only pushes `Share` where missing), safe to re-run including on fresh DBs and via `npm run seed` — the seeded Group fixtures already carry `Share`, making it a no-op there. Uses `insertDoc` to preserve `updatedTimeUtc`: the granted access takes effect via the server-recomputed AccessMap delivered on connect. + The CMS-managed "default affinity" recommendation feature (`DocType.DefaultAffinity`) followed the same ACL-administration path instead of an upgrade script: `group-super-admins`/`group-public-content` get the `DefaultAffinity` ACL entries directly in their seed fixtures (fresh DBs only — existing deployed DBs need it granted via ACL administration), and the singleton doc (`api/src/util/defaultAffinity.ts`) is created lazily by the CMS on first save rather than backfilled (`cms/src/composables/useDefaultAffinity.ts`'s `saveDoc`). diff --git a/api/src/db/schemaUpgrade/v21.spec.ts b/api/src/db/schemaUpgrade/v21.spec.ts new file mode 100644 index 0000000000..d872f419af --- /dev/null +++ b/api/src/db/schemaUpgrade/v21.spec.ts @@ -0,0 +1,129 @@ +import v21 from "./v21"; +import { AclPermission, DocType } from "../../enums"; + +describe("v21 — Share ACL backfill", () => { + function mockDb(version: number, groups: any[]) { + const inserted: any[] = []; + const db = { + getSchemaVersion: jest.fn().mockResolvedValue(version), + setSchemaVersion: jest.fn().mockResolvedValue(undefined), + processAllDocs: jest.fn(async (_types: DocType[], cb: (doc: any) => Promise) => { + for (const g of groups) await cb(g); + }), + insertDoc: jest.fn(async (doc: any) => { + inserted.push(doc); + }), + } as any; + return { db, inserted }; + } + + function entry(type: DocType, permission: AclPermission[], groupId = "g") { + return { type, groupId, permission }; + } + + function group(id: string, acl: any[]) { + return { _id: id, type: DocType.Group, acl }; + } + + it.each([DocType.Post, DocType.Tag])( + "grants Share on a %s entry holding View", + async (type) => { + const g = group("group-public-content", [entry(type, [AclPermission.View])]); + const { db, inserted } = mockDb(20, [g]); + + await v21(db); + + expect(inserted).toHaveLength(1); + expect(inserted[0].acl[0].permission).toContain(AclPermission.Share); + expect(inserted[0].acl[0].permission).toContain(AclPermission.View); + expect(db.setSchemaVersion).toHaveBeenCalledWith(21); + }, + ); + + it("leaves entries without View untouched", async () => { + const g = group("group-public-editors", [ + entry(DocType.Post, [AclPermission.CmsView, AclPermission.Edit]), + ]); + const { db, inserted } = mockDb(20, [g]); + + await v21(db); + + expect(inserted).toHaveLength(0); + expect(g.acl[0].permission).not.toContain(AclPermission.Share); + expect(db.setSchemaVersion).toHaveBeenCalledWith(21); + }); + + it("leaves non-shareable doc types untouched", async () => { + const g = group("group-public-users", [ + entry(DocType.Language, [AclPermission.View]), + entry(DocType.Redirect, [AclPermission.View]), + entry(DocType.Storage, [AclPermission.View]), + ]); + const { db, inserted } = mockDb(20, [g]); + + await v21(db); + + expect(inserted).toHaveLength(0); + for (const e of g.acl) expect(e.permission).not.toContain(AclPermission.Share); + }); + + it("backfills only the qualifying entries of a mixed group", async () => { + const g = group("group-private-content", [ + entry(DocType.Post, [AclPermission.View, AclPermission.Publish]), + entry(DocType.Language, [AclPermission.View]), + ]); + const { db, inserted } = mockDb(20, [g]); + + await v21(db); + + expect(inserted).toHaveLength(1); + const post = inserted[0].acl.find((e: any) => e.type === DocType.Post); + const language = inserted[0].acl.find((e: any) => e.type === DocType.Language); + expect(post.permission).toContain(AclPermission.Share); + expect(language.permission).not.toContain(AclPermission.Share); + }); + + it("is idempotent for entries that already hold Share", async () => { + const g = group("group-public-content", [ + entry(DocType.Tag, [AclPermission.View, AclPermission.Share]), + ]); + const { db, inserted } = mockDb(20, [g]); + + await v21(db); + + expect(inserted).toHaveLength(0); + expect(g.acl[0].permission).toEqual([AclPermission.View, AclPermission.Share]); + }); + + it("skips groups with a malformed acl", async () => { + const { db, inserted } = mockDb(20, [ + { _id: "no-acl", type: DocType.Group }, + group("bad-permission", [{ type: DocType.Post, groupId: "g" }]), + ]); + + await v21(db); + + expect(inserted).toHaveLength(0); + expect(db.setSchemaVersion).toHaveBeenCalledWith(21); + }); + + it.each([19, 21])("does not run when the schema version is %s", async (version) => { + const g = group("group-public-content", [entry(DocType.Post, [AclPermission.View])]); + const { db, inserted } = mockDb(version, [g]); + + await v21(db); + + expect(inserted).toHaveLength(0); + expect(db.processAllDocs).not.toHaveBeenCalled(); + expect(db.setSchemaVersion).not.toHaveBeenCalled(); + }); + + it("re-throws and leaves the version alone when a write fails", async () => { + const g = group("group-public-content", [entry(DocType.Post, [AclPermission.View])]); + const { db } = mockDb(20, [g]); + db.insertDoc = jest.fn().mockRejectedValue(new Error("write failed")); + + await expect(v21(db)).rejects.toThrow("write failed"); + expect(db.setSchemaVersion).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/db/schemaUpgrade/v21.ts b/api/src/db/schemaUpgrade/v21.ts new file mode 100644 index 0000000000..69548a20ef --- /dev/null +++ b/api/src/db/schemaUpgrade/v21.ts @@ -0,0 +1,69 @@ +import { DbService } from "../db.service"; +import { AclPermission, DocType } from "../../enums"; + +/** + * Doc types the Share permission is assignable on, mirroring + * `changeRequests/aclValidation.ts`. + */ +const SHAREABLE_DOC_TYPES = [DocType.Post, DocType.Tag]; + +/** + * Upgrade the database schema from version 20 to 21. + * + * Backfills the new `Share` ACL permission on every Post/Tag entry that already holds `View`, so + * the audience that can read content in the app keeps being able to share it. Share is app-facing + * and grants no additional read access, so a broad backfill is safe — unlike `CmsView` (v19/v20), + * which had to stay narrow. + * + * Idempotent: only pushes `Share` where missing, so re-running (e.g. `npm run seed` runs the + * upgrade chain) is a no-op. Uses `insertDoc` to preserve `updatedTimeUtc`; the granted access + * takes effect via the server-recomputed AccessMap delivered to clients on connect. + */ +export default async function (db: DbService) { + try { + const schemaVersion = await db.getSchemaVersion(); + if (schemaVersion === 20) { + console.info("Upgrading database schema from version 20 to 21"); + + let updatedCount = 0; + let skippedCount = 0; + + await db.processAllDocs([DocType.Group], async (doc: any) => { + if (!doc || !Array.isArray(doc.acl)) return; + + let changed = false; + + doc.acl.forEach((entry: any) => { + if (!Array.isArray(entry.permission)) return; + if (!SHAREABLE_DOC_TYPES.includes(entry.type)) return; + if (entry.permission.includes(AclPermission.Share)) return; + if (!entry.permission.includes(AclPermission.View)) return; + + entry.permission.push(AclPermission.Share); + changed = true; + }); + + if (changed) { + await db.insertDoc(doc); + updatedCount++; + } else { + skippedCount++; + } + }); + + console.info( + `Share backfill complete: ${updatedCount} groups updated, ${skippedCount} unchanged`, + ); + + await db.setSchemaVersion(21); + console.info("Database schema upgrade from version 20 to 21 completed successfully"); + } else { + console.info( + `Skipping schema upgrade v21: current version is ${schemaVersion}, expected 20`, + ); + } + } catch (error) { + console.error("Database schema upgrade from version 20 to 21 failed:", error); + throw error; + } +} diff --git a/api/src/db/seedingDocs/group-private-content.json b/api/src/db/seedingDocs/group-private-content.json index 56bd36fa5f..8071bb81ea 100644 --- a/api/src/db/seedingDocs/group-private-content.json +++ b/api/src/db/seedingDocs/group-private-content.json @@ -7,12 +7,12 @@ { "type": "post", "groupId": "group-private-users", - "permission": ["view"] + "permission": ["view", "share"] }, { "type": "tag", "groupId": "group-private-users", - "permission": ["view"] + "permission": ["view", "share"] }, { "type": "language", @@ -27,12 +27,21 @@ { "type": "post", "groupId": "group-private-editors", - "permission": ["view", "edit", "translate", "publish", "delete", "cmsView"] + "permission": ["view", "share", "edit", "translate", "publish", "delete", "cmsView"] }, { "type": "tag", "groupId": "group-private-editors", - "permission": ["view", "edit", "translate", "publish", "delete", "assign", "cmsView"] + "permission": [ + "view", + "share", + "edit", + "translate", + "publish", + "delete", + "assign", + "cmsView" + ] }, { "type": "group", diff --git a/api/src/db/seedingDocs/group-private-editors.json b/api/src/db/seedingDocs/group-private-editors.json index 61ea4291b0..23c18e2c37 100644 --- a/api/src/db/seedingDocs/group-private-editors.json +++ b/api/src/db/seedingDocs/group-private-editors.json @@ -7,12 +7,21 @@ { "type": "post", "groupId": "group-super-admins", - "permission": ["view", "edit", "delete", "translate", "publish", "cmsView"] + "permission": ["view", "share", "edit", "delete", "translate", "publish", "cmsView"] }, { "type": "tag", "groupId": "group-super-admins", - "permission": ["view", "edit", "delete", "assign", "translate", "publish", "cmsView"] + "permission": [ + "view", + "share", + "edit", + "delete", + "assign", + "translate", + "publish", + "cmsView" + ] }, { "type": "group", diff --git a/api/src/db/seedingDocs/group-private-users.json b/api/src/db/seedingDocs/group-private-users.json index 8a936230a5..345a059353 100644 --- a/api/src/db/seedingDocs/group-private-users.json +++ b/api/src/db/seedingDocs/group-private-users.json @@ -7,12 +7,12 @@ { "type": "post", "groupId": "group-super-admins", - "permission": ["view", "edit", "delete", "translate", "publish"] + "permission": ["view", "share", "edit", "delete", "translate", "publish"] }, { "type": "tag", "groupId": "group-super-admins", - "permission": ["view", "edit", "delete", "assign", "translate", "publish"] + "permission": ["view", "share", "edit", "delete", "assign", "translate", "publish"] }, { "type": "group", diff --git a/api/src/db/seedingDocs/group-public-content.json b/api/src/db/seedingDocs/group-public-content.json index ab8bfe18fe..5e8a93650e 100644 --- a/api/src/db/seedingDocs/group-public-content.json +++ b/api/src/db/seedingDocs/group-public-content.json @@ -7,12 +7,12 @@ { "type": "post", "groupId": "group-public-users", - "permission": ["view"] + "permission": ["view", "share"] }, { "type": "tag", "groupId": "group-public-users", - "permission": ["view"] + "permission": ["view", "share"] }, { "type": "language", @@ -37,12 +37,12 @@ { "type": "post", "groupId": "group-public-editors", - "permission": ["view", "edit", "translate", "publish", "cmsView"] + "permission": ["view", "share", "edit", "translate", "publish", "cmsView"] }, { "type": "tag", "groupId": "group-public-editors", - "permission": ["view", "translate", "assign", "cmsView"] + "permission": ["view", "share", "translate", "assign", "cmsView"] }, { "type": "group", @@ -62,12 +62,12 @@ { "type": "post", "groupId": "group-private-users", - "permission": ["view"] + "permission": ["view", "share"] }, { "type": "tag", "groupId": "group-private-users", - "permission": ["view"] + "permission": ["view", "share"] }, { "type": "language", diff --git a/api/src/db/seedingDocs/group-public-editors.json b/api/src/db/seedingDocs/group-public-editors.json index a563aa0c6a..d7c432df4b 100644 --- a/api/src/db/seedingDocs/group-public-editors.json +++ b/api/src/db/seedingDocs/group-public-editors.json @@ -7,12 +7,21 @@ { "type": "post", "groupId": "group-super-admins", - "permission": ["view", "edit", "delete", "translate", "publish", "cmsView"] + "permission": ["view", "share", "edit", "delete", "translate", "publish", "cmsView"] }, { "type": "tag", "groupId": "group-super-admins", - "permission": ["view", "edit", "delete", "assign", "translate", "publish", "cmsView"] + "permission": [ + "view", + "share", + "edit", + "delete", + "assign", + "translate", + "publish", + "cmsView" + ] }, { "type": "group", diff --git a/api/src/db/seedingDocs/group-public-users.json b/api/src/db/seedingDocs/group-public-users.json index cb53dae002..02fd2a2f2a 100644 --- a/api/src/db/seedingDocs/group-public-users.json +++ b/api/src/db/seedingDocs/group-public-users.json @@ -7,12 +7,12 @@ { "type": "post", "groupId": "group-super-admins", - "permission": ["view", "edit", "delete", "translate", "publish"] + "permission": ["view", "share", "edit", "delete", "translate", "publish"] }, { "type": "tag", "groupId": "group-super-admins", - "permission": ["view", "edit", "delete", "assign", "translate", "publish"] + "permission": ["view", "share", "edit", "delete", "assign", "translate", "publish"] }, { "type": "group", diff --git a/api/src/db/seedingDocs/group-super-admins.json b/api/src/db/seedingDocs/group-super-admins.json index 8af409d844..80b211a6e4 100644 --- a/api/src/db/seedingDocs/group-super-admins.json +++ b/api/src/db/seedingDocs/group-super-admins.json @@ -7,12 +7,21 @@ { "type": "post", "groupId": "group-super-admins", - "permission": ["view", "edit", "delete", "translate", "publish", "cmsView"] + "permission": ["view", "share", "edit", "delete", "translate", "publish", "cmsView"] }, { "type": "tag", "groupId": "group-super-admins", - "permission": ["view", "edit", "delete", "assign", "translate", "publish", "cmsView"] + "permission": [ + "view", + "share", + "edit", + "delete", + "assign", + "translate", + "publish", + "cmsView" + ] }, { "type": "group", 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/api/src/enums.ts b/api/src/enums.ts index 453975900c..f1ec1298a6 100644 --- a/api/src/enums.ts +++ b/api/src/enums.ts @@ -63,6 +63,11 @@ export enum AclPermission { */ Publish = "publish", + /** + * Access to share published content from the app (share sheet / social links) + */ + Share = "share", + /** * Access to view documents in the CMS, including drafts and expired content. * Gates all CMS-scoped (cms:true) reads/sync; the app uses plain View (published only). 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();