From 1e19437494ebf5a6a2a472d90ceaf4fb1d57d01f Mon Sep 17 00:00:00 2001
From: DIGI Byte <6645396+digimbyte@users.noreply.github.com>
Date: Wed, 2 Sep 2026 18:11:01 +1000
Subject: [PATCH 01/14] Fix annotation composer and identity label layout
---
src/components/PublicGuestDialog.vue | 2 +-
src/components/PublicLightboxAnnotations.vue | 4 ++--
tests/e2e/gallery.spec.ts | 16 +++++++++++++++-
3 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/src/components/PublicGuestDialog.vue b/src/components/PublicGuestDialog.vue
index 513c8d1..9c4038c 100644
--- a/src/components/PublicGuestDialog.vue
+++ b/src/components/PublicGuestDialog.vue
@@ -70,5 +70,5 @@ defineEmits<{ dismiss: []; submit: [] }>()
.guest-dialog__form ion-item { --background: var(--gallery-surface); --border-color: var(--gallery-border); --color: var(--gallery-text); --min-height: 62px; }
-.guest-dialog__form > ion-button { min-height: 46px; margin: 0; --border-radius: 9px; text-transform: none; }
+.guest-dialog__form > ion-button { min-height: 46px; margin: 0; --background: var(--gallery-accent); --border-radius: 9px; --color: var(--ion-color-primary-contrast, #fff); text-transform: none; }
diff --git a/src/components/PublicLightboxAnnotations.vue b/src/components/PublicLightboxAnnotations.vue
index 2bb671e..c3cb65a 100644
--- a/src/components/PublicLightboxAnnotations.vue
+++ b/src/components/PublicLightboxAnnotations.vue
@@ -120,9 +120,9 @@ function onComposerKeydown(event: KeyboardEvent) {
.annotation-positioning { position: fixed; z-index: 100120; inset: auto 50% 24px auto; margin: 0; padding: 10px 16px; border-radius: 999px; background: rgb(24 24 27 / 96%); box-shadow: 0 8px 28px rgb(0 0 0 / 35%); color: #fff; font-size: 13px; pointer-events: none; transform: translateX(50%); }
-.annotation-composer { position: fixed; z-index: 100120; display: grid; width: min(360px, calc(100vw - 32px)); gap: 10px; padding: 16px; border: 1px solid rgb(255 255 255 / 16%); border-radius: 14px; background: rgb(24 24 27 / 98%); box-shadow: 0 18px 50px rgb(0 0 0 / 48%); color: #fff; pointer-events: auto; transform: translate(var(--annotation-composer-offset, 18px), -18px); }
+.annotation-composer { position: fixed; z-index: 100120; display: grid; box-sizing: border-box; width: min(360px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow-y: auto; gap: 10px; padding: 16px; border: 1px solid rgb(255 255 255 / 16%); border-radius: 14px; background: rgb(24 24 27 / 98%); box-shadow: 0 18px 50px rgb(0 0 0 / 48%); color: #fff; overscroll-behavior: contain; pointer-events: auto; transform: translate(var(--annotation-composer-offset, 18px), -18px); }
-.annotation-composer textarea { min-height: 96px; padding: 10px 12px; border: 1px solid rgb(255 255 255 / 24%); border-radius: 8px; background: #fff; color: #111; font: inherit; resize: vertical; }
+.annotation-composer textarea { box-sizing: border-box; width: 100%; min-width: 0; max-width: 100%; min-height: 96px; padding: 10px 12px; border: 1px solid rgb(255 255 255 / 24%); border-radius: 8px; background: #fff; color: #111; font: inherit; resize: vertical; }
.annotation-composer > div { display: flex; gap: 8px; }
diff --git a/tests/e2e/gallery.spec.ts b/tests/e2e/gallery.spec.ts
index 98a6798..97e522e 100644
--- a/tests/e2e/gallery.spec.ts
+++ b/tests/e2e/gallery.spec.ts
@@ -416,10 +416,24 @@ test('guest completes an accessible proofing flow', async ({ page, baseURL }) =>
const imageBounds = await image.boundingBox()
expect(imageBounds).not.toBeNull()
await image.click({ position: { x: imageBounds!.width * 0.67, y: imageBounds!.height * 0.42 } })
+ const composer = page.locator('.annotation-composer')
+ const composerBounds = await composer.boundingBox()
+ const textareaBounds = await page.getByRole('textbox', { name: 'Point comment' }).boundingBox()
+ const viewport = page.viewportSize()
+ expect(composerBounds).not.toBeNull()
+ expect(textareaBounds).not.toBeNull()
+ expect(viewport).not.toBeNull()
+ expect(composerBounds!.x).toBeGreaterThanOrEqual(0)
+ expect(composerBounds!.x + composerBounds!.width).toBeLessThanOrEqual(viewport!.width)
+ expect(textareaBounds!.x).toBeGreaterThanOrEqual(composerBounds!.x)
+ expect(textareaBounds!.x + textareaBounds!.width).toBeLessThanOrEqual(composerBounds!.x + composerBounds!.width)
await page.getByRole('textbox', { name: 'Point comment' }).fill('Retouch this point')
await page.getByRole('button', { name: 'Comment', exact: true }).click()
await page.getByRole('textbox', { name: 'Your name' }).fill('Playwright Reviewer')
- await page.getByRole('button', { name: 'Continue' }).click()
+ const identityButton = page.getByRole('button', { name: 'Continue' })
+ await expect(identityButton).toContainText('Continue')
+ await expect(identityButton).toHaveCSS('--color', '#ffffff')
+ await identityButton.click()
await expect(page.getByRole('textbox', { name: 'Your name' })).toHaveCount(0)
const pointMarker = page.getByRole('button', { name: 'Open point comment 1' })
await expect(pointMarker).toBeVisible()
From a23dbc2f71b113641bad8681502c9be613740c50 Mon Sep 17 00:00:00 2001
From: DIGI Byte <6645396+digimbyte@users.noreply.github.com>
Date: Wed, 2 Sep 2026 19:16:25 +1000
Subject: [PATCH 02/14] Fix annotation feedback filtering and pin layout
---
l10n/de.js | 3 +
l10n/de.json | 3 +
l10n/en.js | 3 +
l10n/en.json | 3 +
lib/Dto/Settings/ReviewSettings.php | 2 +-
src/components/PublicLightbox.vue | 72 ++++++--------
src/components/PublicLightboxAnnotations.vue | 2 +-
src/components/PublicLightboxComments.spec.ts | 40 ++++++++
src/components/PublicLightboxComments.vue | 97 +++++++++++++++++++
src/components/styles/PublicLightbox.css | 36 +++----
src/domain/gallerySettings.spec.ts | 2 +-
src/domain/gallerySettings.ts | 8 +-
src/domain/lightboxReview.spec.ts | 13 ++-
src/domain/lightboxReview.ts | 8 ++
tests/Unit/Dto/GallerySettingsTest.php | 2 +-
tests/e2e/gallery.spec.ts | 14 +++
16 files changed, 237 insertions(+), 71 deletions(-)
create mode 100644 src/components/PublicLightboxComments.spec.ts
create mode 100644 src/components/PublicLightboxComments.vue
diff --git a/l10n/de.js b/l10n/de.js
index daff44f..ad1a643 100644
--- a/l10n/de.js
+++ b/l10n/de.js
@@ -130,6 +130,7 @@ OC.L10N.register("proofing_gallery", {
"All activity": "Alle Aktivitäten",
"All changes are saved": "Alle Änderungen sind gespeichert",
"All collection files are available": "Alle Dateien der Sammlung sind verfügbar",
+ "All feedback": "Gesamtes Feedback",
"All galleries": "Alle Galerien",
"All photos": "Alle Fotos",
"All source files available": "Alle Quelldateien verfügbar",
@@ -525,6 +526,7 @@ OC.L10N.register("proofing_gallery", {
"Gallery updates for “%s”": "Aktualisierungen für „%s“",
"Gallery view": "Galerieansicht",
"General": "Allgemein",
+ "General comment": "Allgemeiner Kommentar",
"Generated locally in your browser. The gallery link is not sent to a third party.": "Wird lokal in Ihrem Browser erstellt. Der Galerielink wird nicht an Dritte gesendet.",
"Give a Nextcloud user or group view-only or editing access.": "Geben Sie einer Nextcloud-Person oder -Gruppe Lese- oder Bearbeitungszugriff.",
"Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.": "Gib jeder Zielgruppe eine eigene URL, eigene Berechtigungen und einen eigenen Ordnerbereich. Passwörter und Token werden nie im Auditprotokoll gespeichert.",
@@ -1059,6 +1061,7 @@ OC.L10N.register("proofing_gallery", {
"Select all visible files": "Alle sichtbaren Dateien auswählen",
"Select photos across pages, then download them together.": "Fotos seitenübergreifend auswählen und anschließend gemeinsam herunterladen.",
"Select public image information to make this panel available to guests.": "Wählen Sie öffentliche Bildinformationen aus, damit dieses Panel Gästen zur Verfügung steht.",
+ "Selected annotation": "Ausgewählte Anmerkung",
"Selected file actions": "Aktionen für ausgewählte Dateien",
"Selected files": "Ausgewählte Dateien",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Ausgewählte Informationen erscheinen in der Fotoansicht. Änderungen an dieser Auswahl öffnen die entsprechende Vorschauszene.",
diff --git a/l10n/de.json b/l10n/de.json
index 51bf5ad..b013783 100644
--- a/l10n/de.json
+++ b/l10n/de.json
@@ -131,6 +131,7 @@
"All activity": "Alle Aktivitäten",
"All changes are saved": "Alle Änderungen sind gespeichert",
"All collection files are available": "Alle Dateien der Sammlung sind verfügbar",
+ "All feedback": "Gesamtes Feedback",
"All galleries": "Alle Galerien",
"All photos": "Alle Fotos",
"All source files available": "Alle Quelldateien verfügbar",
@@ -526,6 +527,7 @@
"Gallery updates for “%s”": "Aktualisierungen für „%s“",
"Gallery view": "Galerieansicht",
"General": "Allgemein",
+ "General comment": "Allgemeiner Kommentar",
"Generated locally in your browser. The gallery link is not sent to a third party.": "Wird lokal in Ihrem Browser erstellt. Der Galerielink wird nicht an Dritte gesendet.",
"Give a Nextcloud user or group view-only or editing access.": "Geben Sie einer Nextcloud-Person oder -Gruppe Lese- oder Bearbeitungszugriff.",
"Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.": "Gib jeder Zielgruppe eine eigene URL, eigene Berechtigungen und einen eigenen Ordnerbereich. Passwörter und Token werden nie im Auditprotokoll gespeichert.",
@@ -1060,6 +1062,7 @@
"Select all visible files": "Alle sichtbaren Dateien auswählen",
"Select photos across pages, then download them together.": "Fotos seitenübergreifend auswählen und anschließend gemeinsam herunterladen.",
"Select public image information to make this panel available to guests.": "Wählen Sie öffentliche Bildinformationen aus, damit dieses Panel Gästen zur Verfügung steht.",
+ "Selected annotation": "Ausgewählte Anmerkung",
"Selected file actions": "Aktionen für ausgewählte Dateien",
"Selected files": "Ausgewählte Dateien",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Ausgewählte Informationen erscheinen in der Fotoansicht. Änderungen an dieser Auswahl öffnen die entsprechende Vorschauszene.",
diff --git a/l10n/en.js b/l10n/en.js
index 33b9f6e..4fc7395 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -130,6 +130,7 @@ OC.L10N.register("proofing_gallery", {
"All activity": "All activity",
"All changes are saved": "All changes are saved",
"All collection files are available": "All collection files are available",
+ "All feedback": "All feedback",
"All galleries": "All galleries",
"All photos": "All photos",
"All source files available": "All source files available",
@@ -525,6 +526,7 @@ OC.L10N.register("proofing_gallery", {
"Gallery updates for “%s”": "Gallery updates for “%s”",
"Gallery view": "Gallery view",
"General": "General",
+ "General comment": "General comment",
"Generated locally in your browser. The gallery link is not sent to a third party.": "Generated locally in your browser. The gallery link is not sent to a third party.",
"Give a Nextcloud user or group view-only or editing access.": "Give a Nextcloud user or group view-only or editing access.",
"Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.": "Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.",
@@ -1059,6 +1061,7 @@ OC.L10N.register("proofing_gallery", {
"Select all visible files": "Select all visible files",
"Select photos across pages, then download them together.": "Select photos across pages, then download them together.",
"Select public image information to make this panel available to guests.": "Select public image information to make this panel available to guests.",
+ "Selected annotation": "Selected annotation",
"Selected file actions": "Selected file actions",
"Selected files": "Selected files",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Selected information appears in the photo viewer. Changing this selection opens that preview scene.",
diff --git a/l10n/en.json b/l10n/en.json
index 694f6e2..8b487e1 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -131,6 +131,7 @@
"All activity": "All activity",
"All changes are saved": "All changes are saved",
"All collection files are available": "All collection files are available",
+ "All feedback": "All feedback",
"All galleries": "All galleries",
"All photos": "All photos",
"All source files available": "All source files available",
@@ -526,6 +527,7 @@
"Gallery updates for “%s”": "Gallery updates for “%s”",
"Gallery view": "Gallery view",
"General": "General",
+ "General comment": "General comment",
"Generated locally in your browser. The gallery link is not sent to a third party.": "Generated locally in your browser. The gallery link is not sent to a third party.",
"Give a Nextcloud user or group view-only or editing access.": "Give a Nextcloud user or group view-only or editing access.",
"Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.": "Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.",
@@ -1060,6 +1062,7 @@
"Select all visible files": "Select all visible files",
"Select photos across pages, then download them together.": "Select photos across pages, then download them together.",
"Select public image information to make this panel available to guests.": "Select public image information to make this panel available to guests.",
+ "Selected annotation": "Selected annotation",
"Selected file actions": "Selected file actions",
"Selected files": "Selected files",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Selected information appears in the photo viewer. Changing this selection opens that preview scene.",
diff --git a/lib/Dto/Settings/ReviewSettings.php b/lib/Dto/Settings/ReviewSettings.php
index f6c0b9a..08f2077 100644
--- a/lib/Dto/Settings/ReviewSettings.php
+++ b/lib/Dto/Settings/ReviewSettings.php
@@ -33,7 +33,7 @@ public static function defaults(): array {
return [
'visibility' => 'collaborative', 'likes' => true, 'colors' => true, 'comments' => true,
'annotations' => true, 'selections' => true, 'ratings' => false, 'pick' => false,
- 'colorLabels' => ['Favorit', 'Auswahl', 'Überarbeiten', 'Ablehnen'],
+ 'colorLabels' => ['Favorite', 'Selected', 'Needs changes', 'Rejected'],
'colorEnabled' => [true, true, true, true], 'selectionWarningThreshold' => 0,
];
}
diff --git a/src/components/PublicLightbox.vue b/src/components/PublicLightbox.vue
index a7b357f..18d6a73 100644
--- a/src/components/PublicLightbox.vue
+++ b/src/components/PublicLightbox.vue
@@ -35,9 +35,10 @@ import StarOutlineIcon from 'vue-material-design-icons/StarOutline.vue'
import { usePublicLightboxAnnotations } from '../composables/usePublicLightboxAnnotations.ts'
import type { GallerySettings } from '../domain/gallerySettings.ts'
-import { annotationNumbersByComment, shouldAutoHideLightboxChrome } from '../domain/lightboxReview.ts'
+import { annotationNumbersByComment, findSelectedAnnotationComment, shouldAutoHideLightboxChrome } from '../domain/lightboxReview.ts'
import type { CollaborationState, MediaItem } from '../publicTypes.ts'
import PublicLightboxAnnotations from './PublicLightboxAnnotations.vue'
+import PublicLightboxComments from './PublicLightboxComments.vue'
import PublicLightboxFilmstrip from './PublicLightboxFilmstrip.vue'
import PublicLightboxHeader from './PublicLightboxHeader.vue'
import PublicLightboxMetadata from './PublicLightboxMetadata.vue'
@@ -171,6 +172,11 @@ const {
} = annotations
const annotationNumbers = computed(() => annotationNumbersByComment(activeComments.value))
+const selectedAnnotationComment = computed(() => findSelectedAnnotationComment(activeComments.value, selectedCommentId.value))
+const visibleComments = computed(() => selectedAnnotationComment.value ? [selectedAnnotationComment.value] : activeComments.value)
+
+function showAllFeedback() { selectedCommentId.value = null }
+function openFeedback() { showAllFeedback(); feedbackOpen.value = true; metadataOpen.value = false }
function bindPhotoSwipeEvents() {
if (!pswp) return
@@ -510,7 +516,7 @@ async function toggleLike() {
await props.mutate(`media/${item.id}/like`, 'POST')
}
-async function openFeedbackAndLike() { feedbackOpen.value = true; await toggleLike() }
+async function openFeedbackAndLike() { openFeedback(); await toggleLike() }
async function setColor(value: string) {
const item = activeItem.value
@@ -574,7 +580,7 @@ async function saveEditedComment(commentId: number) {
@close="close"
@zoom="zoom"
@like="openFeedbackAndLike"
- @feedback="feedbackOpen = true; metadataOpen = false"
+ @feedback="openFeedback"
@info="metadataOpen = true; feedbackOpen = false"
@more="actionMenuOpen = true" />
{{ activeItem.name }}
-
+
+ {{ t('proofing_gallery', 'Selected annotation') }} {{ t('proofing_gallery', 'Point comment {number}', { number: annotationNumbers.get(selectedAnnotationComment.id)?.[0] ?? 0 }) }}
+
+ {{ t('proofing_gallery', 'All feedback') }}
+
+
+
{{ collaboration?.likes[activeItem.id]?.mine ? '♥' : '♡' }} {{ t('proofing_gallery', 'Like') }} {{ collaboration?.likes[activeItem.id]?.count || '' }}
@@ -698,7 +710,7 @@ async function saveEditedComment(commentId: number) {
-
+
{{ t('proofing_gallery', 'Your private rating') }}
{{ t('proofing_gallery', 'Only you and the gallery owner can see this rating.') }}
-
-
+
{{ t('proofing_gallery', 'Saved selections') }}
diff --git a/src/components/PublicLightboxAnnotations.vue b/src/components/PublicLightboxAnnotations.vue
index c3cb65a..12b2284 100644
--- a/src/components/PublicLightboxAnnotations.vue
+++ b/src/components/PublicLightboxAnnotations.vue
@@ -112,7 +112,7 @@ function onComposerKeydown(event: KeyboardEvent) {
diff --git a/src/components/styles/PublicLightbox.css b/src/components/styles/PublicLightbox.css
index bf21982..ecefd33 100644
--- a/src/components/styles/PublicLightbox.css
+++ b/src/components/styles/PublicLightbox.css
@@ -169,14 +169,22 @@
.lightbox-feedback { --color: var(--ion-text-color); }
-.lightbox-feedback__body { min-height: 100%; }
+.lightbox-feedback__body { box-sizing: border-box; width: 100%; min-height: 100%; }
+
+.annotation-feedback-filter { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; padding: 14px; border: 1px solid color-mix(in srgb, var(--gallery-accent) 42%, var(--ion-border-color)); border-radius: 14px; background: color-mix(in srgb, var(--gallery-accent) 12%, var(--ion-background-color)); }
+
+.annotation-feedback-filter div { display: grid; min-width: 0; gap: 3px; }
+
+.annotation-feedback-filter span { color: var(--ion-color-medium); font-size: 11px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
+
+.annotation-feedback-filter strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.annotation-feedback-filter button { min-height: 36px; flex: 0 0 auto; padding: 0 12px; border: 1px solid var(--ion-border-color); border-radius: 9px; background: var(--ion-color-light); color: var(--ion-text-color); font: inherit; font-size: 12px; font-weight: 650; }
.feedback-actions { display: grid; gap: 12px; }
.feedback-actions button,
.comment-form button,
-.comment-actions button,
-.comment-edit button,
.guest-rating button {
min-height: 42px;
padding: 0 14px;
@@ -209,11 +217,11 @@
.guest-star--filled { color: #f1be3d; }
-.guest-rating small, .comment-list small, .saved-selections small { color: var(--ion-color-medium); }
+.guest-rating small, .saved-selections small { color: var(--ion-color-medium); }
.feedback-actions label { display: grid; gap: 6px; color: var(--ion-color-medium); font-size: 12px; }
-.feedback-actions select, .comment-form textarea, .comment-edit textarea { width: 100%; padding: 12px; border: 1px solid var(--ion-border-color); border-radius: 10px; background: var(--ion-background-color); color: var(--ion-text-color); font: inherit; }
+.feedback-actions select, .comment-form textarea { box-sizing: border-box; width: 100%; min-width: 0; max-width: 100%; padding: 12px; border: 1px solid var(--ion-border-color); border-radius: 10px; background: var(--ion-background-color); color: var(--ion-text-color); font: inherit; }
.comment-form { display: grid; gap: 10px; margin-top: 20px; }
@@ -221,21 +229,7 @@
.comment-form textarea { min-height: 104px; resize: vertical; }
-.comment-list { padding: 0; list-style: none; }
-
-.comment-list li { padding: 16px 0; border-bottom: 1px solid var(--ion-border-color); }
-
-.comment-list__item--selected { margin-inline: -10px; padding-inline: 10px !important; border-radius: 9px; background: color-mix(in srgb, var(--gallery-accent) 16%, transparent); }
-
-.comment-list p { margin: 0 0 7px; white-space: pre-wrap; }
-
-.comment-actions, .saved-selections article div { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 9px; }
-
-.comment-actions button { min-height: 34px; color: var(--ion-color-medium); font-size: 12px; }
-
-.comment-edit { display: grid; grid-template-columns: 1fr auto auto; gap: 7px; }
-
-.comment-edit textarea { grid-column: 1 / -1; min-height: 82px; resize: vertical; }
+.saved-selections article div { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 9px; }
.saved-selections { margin-top: 28px; border-top: 1px solid var(--ion-border-color); }
@@ -261,6 +255,8 @@
.lightbox-nav--next, .lightbox-shell--filmstrip-side .lightbox-nav--next { inset-inline-end: 8px; }
.lightbox-touch-hint { bottom: calc(96px + env(safe-area-inset-bottom)); max-width: calc(100% - 32px); text-align: center; white-space: nowrap; }
:global(ion-modal.lightbox-sheet) { --width: 100%; --height: min(78dvh, 680px); --border-radius: 18px 18px 0 0; align-items: flex-end; }
+ .annotation-feedback-filter { align-items: stretch; flex-direction: column; }
+ .annotation-feedback-filter button { width: 100%; }
.guest-rating__stars { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); }
.guest-rating__stars span { grid-column: 1 / -1; margin-inline-end: 0; }
.guest-rating__stars button { width: 100%; min-width: 0; padding-inline: 0; }
diff --git a/src/domain/gallerySettings.spec.ts b/src/domain/gallerySettings.spec.ts
index fc85469..eba7b7f 100644
--- a/src/domain/gallerySettings.spec.ts
+++ b/src/domain/gallerySettings.spec.ts
@@ -35,6 +35,6 @@ describe('createDefaultGallerySettings', () => {
first.review.colorLabels[0] = 'Changed'
- expect(second.review.colorLabels[0]).toBe('Favorit')
+ expect(second.review.colorLabels[0]).toBe('Favorite')
})
})
diff --git a/src/domain/gallerySettings.ts b/src/domain/gallerySettings.ts
index 67b1051..769942e 100644
--- a/src/domain/gallerySettings.ts
+++ b/src/domain/gallerySettings.ts
@@ -119,10 +119,10 @@ export function canonicalGallerySettings(settings: GallerySettings): CanonicalGa
}
const DEFAULT_COLOR_LABELS: GallerySettings['review']['colorLabels'] = [
- 'Favorit',
- 'Auswahl',
- 'Überarbeiten',
- 'Ablehnen',
+ 'Favorite',
+ 'Selected',
+ 'Needs changes',
+ 'Rejected',
]
export function createDefaultGallerySettings(): GallerySettings {
diff --git a/src/domain/lightboxReview.spec.ts b/src/domain/lightboxReview.spec.ts
index 59c3a61..e3d4ce9 100644
--- a/src/domain/lightboxReview.spec.ts
+++ b/src/domain/lightboxReview.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { annotationAtImagePoint, annotationScreenPoint, moveAnnotationPoint, shouldAutoHideLightboxChrome } from './lightboxReview.ts'
+import { annotationAtImagePoint, annotationScreenPoint, findSelectedAnnotationComment, moveAnnotationPoint, shouldAutoHideLightboxChrome } from './lightboxReview.ts'
describe('lightbox review interaction', () => {
it('keeps review chrome visible while respecting presentation behavior', () => {
@@ -23,4 +23,15 @@ describe('lightbox review interaction', () => {
expect(annotationScreenPoint(point, { left: 200, top: 100, width: 1200, height: 600 })).toEqual({ x: 500, y: 550 })
expect(moveAnnotationPoint({ ...point, x: 9900, y: 100 }, 500, -500)).toMatchObject({ x: 10000, y: 0 })
})
+
+ it('selects only a comment that belongs to an annotation pin', () => {
+ const comments = [
+ { id: 1, annotations: [{ x: 1000, y: 1000, width: 800, height: 800 }] },
+ { id: 2, annotations: [] },
+ ]
+ expect(findSelectedAnnotationComment(comments, 1)).toBe(comments[0])
+ expect(findSelectedAnnotationComment(comments, 2)).toBeNull()
+ expect(findSelectedAnnotationComment(comments, 99)).toBeNull()
+ expect(findSelectedAnnotationComment(comments, null)).toBeNull()
+ })
})
diff --git a/src/domain/lightboxReview.ts b/src/domain/lightboxReview.ts
index da267dc..948ad36 100644
--- a/src/domain/lightboxReview.ts
+++ b/src/domain/lightboxReview.ts
@@ -53,3 +53,11 @@ export function annotationNumbersByComment(comments: Array<{
}
return result
}
+
+export function findSelectedAnnotationComment(
+ comments: T[],
+ selectedCommentId: number | null,
+): T | null {
+ if (selectedCommentId === null) return null
+ return comments.find(comment => comment.id === selectedCommentId && comment.annotations.length > 0) ?? null
+}
diff --git a/tests/Unit/Dto/GallerySettingsTest.php b/tests/Unit/Dto/GallerySettingsTest.php
index 31eef75..6dc9d60 100644
--- a/tests/Unit/Dto/GallerySettingsTest.php
+++ b/tests/Unit/Dto/GallerySettingsTest.php
@@ -29,7 +29,7 @@ public function testDefaultsAreSafe(): void {
self::assertSame('presentation', $settings['mode']);
self::assertSame('none', $settings['delivery']['downloadScope']);
self::assertFalse($settings['delivery']['guestUploads']);
- self::assertCount(4, $settings['review']['colorLabels']);
+ self::assertSame(['Favorite', 'Selected', 'Needs changes', 'Rejected'], $settings['review']['colorLabels']);
self::assertSame(50, $settings['presentation']['heroFocusX']);
self::assertSame('modern', $settings['presentation']['fontPreset']);
self::assertSame('minimal', $settings['presentation']['openerStyle']);
diff --git a/tests/e2e/gallery.spec.ts b/tests/e2e/gallery.spec.ts
index 97e522e..e82ffcb 100644
--- a/tests/e2e/gallery.spec.ts
+++ b/tests/e2e/gallery.spec.ts
@@ -416,6 +416,9 @@ test('guest completes an accessible proofing flow', async ({ page, baseURL }) =>
const imageBounds = await image.boundingBox()
expect(imageBounds).not.toBeNull()
await image.click({ position: { x: imageBounds!.width * 0.67, y: imageBounds!.height * 0.42 } })
+ const draftMarkerBounds = await page.locator('.annotation-marker--draft').boundingBox()
+ expect(draftMarkerBounds).not.toBeNull()
+ expect(draftMarkerBounds!.width).toBeCloseTo(draftMarkerBounds!.height, 1)
const composer = page.locator('.annotation-composer')
const composerBounds = await composer.boundingBox()
const textareaBounds = await page.getByRole('textbox', { name: 'Point comment' }).boundingBox()
@@ -437,6 +440,9 @@ test('guest completes an accessible proofing flow', async ({ page, baseURL }) =>
await expect(page.getByRole('textbox', { name: 'Your name' })).toHaveCount(0)
const pointMarker = page.getByRole('button', { name: 'Open point comment 1' })
await expect(pointMarker).toBeVisible()
+ const pointMarkerBounds = await pointMarker.boundingBox()
+ expect(pointMarkerBounds).not.toBeNull()
+ expect(pointMarkerBounds!.width).toBeCloseTo(pointMarkerBounds!.height, 1)
await pointMarker.click()
await expect(page.getByText('Retouch this point')).toBeVisible()
await page.getByRole('button', { name: 'Close feedback' }).click()
@@ -468,6 +474,14 @@ test('guest completes an accessible proofing flow', async ({ page, baseURL }) =>
await lightbox.getByRole('button', { name: 'Close', exact: true }).click()
await page.getByRole('button', { name: 'Open proof.png' }).click()
await expect(pointMarker).toBeVisible()
+ await pointMarker.click()
+ await expect(page.getByText('Selected annotation')).toBeVisible()
+ await expect(page.locator('.comment-list > li')).toHaveCount(1)
+ await expect(page.getByText('Retouch this point')).toBeVisible()
+ await expect(page.getByText('Approved in automated review')).toHaveCount(0)
+ await page.getByRole('button', { name: 'All feedback' }).click()
+ await expect(page.getByText('Approved in automated review')).toBeVisible()
+ await page.getByRole('button', { name: 'Close feedback' }).click()
const reopenedImageBounds = await image.boundingBox()
const markerBounds = await pointMarker.boundingBox()
expect(reopenedImageBounds).not.toBeNull()
From 1933e6886aa2c7110b6520f1e7f2af7173443870 Mon Sep 17 00:00:00 2001
From: DIGI Byte <6645396+digimbyte@users.noreply.github.com>
Date: Thu, 3 Sep 2026 10:13:13 +1000
Subject: [PATCH 03/14] Refine public annotation collaboration
---
docs/DEVELOPMENT.md | 48 +++++
l10n/de.js | 1 -
l10n/de.json | 1 -
l10n/en.js | 1 -
l10n/en.json | 1 -
lib/Controller/CollaborationController.php | 86 +++++----
lib/Controller/GuestController.php | 22 +++
lib/Db/CollaborationRepository.php | 174 ++++++++++++------
lib/Db/Guest.php | 3 +-
lib/Domain/CollaborationActor.php | 70 +++++++
lib/Domain/CollaborationReadScope.php | 12 ++
.../AuthenticatedCollaborationSession.php | 53 ++++++
lib/Service/CollaborationService.php | 156 ++++++++++------
lib/Share/PublicShareTemplateProvider.php | 23 ++-
src/PublicApp.vue | 33 +---
src/components/PublicCollaborationSheet.vue | 14 +-
src/components/PublicGuestDialog.spec.ts | 67 +++++++
src/components/PublicGuestDialog.vue | 96 ++++++----
src/components/PublicGuestIdentity.vue | 2 +-
src/components/PublicLightbox.vue | 130 +++++++------
.../PublicLightboxAnnotations.spec.ts | 39 +++-
src/components/PublicLightboxAnnotations.vue | 121 +++++++++---
src/components/PublicLightboxComments.spec.ts | 43 ++++-
src/components/PublicLightboxComments.vue | 98 +++++++---
.../styles/PublicGalleryControls.css | 2 +-
src/components/styles/PublicLightbox.css | 55 ++++--
.../usePublicLightboxAnnotations.spec.ts | 1 +
.../usePublicLightboxAnnotations.ts | 28 ++-
src/domain/lightboxReview.spec.ts | 43 ++++-
src/domain/lightboxReview.ts | 105 ++++++++++-
src/domain/publicGalleryThemeStyle.spec.ts | 19 ++
src/domain/publicGalleryThemeStyle.ts | 18 ++
src/publicTypes.ts | 2 +
src/services/currentViewerApi.ts | 12 ++
tests/Unit/Domain/CollaborationActorTest.php | 38 ++++
.../Domain/CollaborationReadScopeTest.php | 7 +
36 files changed, 1256 insertions(+), 368 deletions(-)
create mode 100644 lib/Domain/CollaborationActor.php
create mode 100644 lib/Service/AuthenticatedCollaborationSession.php
create mode 100644 src/components/PublicGuestDialog.spec.ts
create mode 100644 src/domain/publicGalleryThemeStyle.spec.ts
create mode 100644 src/domain/publicGalleryThemeStyle.ts
create mode 100644 src/services/currentViewerApi.ts
create mode 100644 tests/Unit/Domain/CollaborationActorTest.php
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index ce67c99..eb2ba81 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -22,6 +22,47 @@ PHP routes, templates, or controller constructors, run
`docker compose restart nextcloud` to clear PHP OPcache before validating the
change.
+## Persistent remote test tenant
+
+A Docker-capable Linux VM can be used as a persistent manual-QA tenant when
+Docker is not installed on the editing workstation. Keep source and build
+dependencies in a staging directory outside the live Nextcloud Compose tree.
+Build and test in disposable containers, then copy only a successful runtime
+tree into the tenant's `custom_apps/proofing_gallery` directory.
+
+Before deployment, record `occ status`, the enabled-app list, migration state,
+and container health. Keep the previous app directory only during the immediate
+HTTP, `occ`, log, and browser checks. Restore it only if that deployment fails;
+delete it as soon as the replacement passes so the development tenant retains
+only the latest verified app build. Never reset the tenant's Compose volumes as
+part of an app deployment.
+
+Keep rollback copies outside `custom_apps`; Nextcloud scans every directory in
+that path and a backup containing the same app ID can cause duplicate-app
+discovery. App Store packages intentionally omit development-only `scripts/`,
+so run migration diagnostics from the source staging tree rather than assuming
+they are installed with the runtime package.
+
+The persistent tenant is for manual integration and browser QA only. Do not
+run this repository's Playwright global setup against it: the current E2E
+fixtures assume the disposable `admin` / `admin` tenant, rewrite that user's
+preferences, and remove prior `E2E` fixtures. Use `npm run test:e2e` only with
+the repository's isolated loopback Compose stack, or refactor the harness for
+a dedicated test identity before targeting another tenant.
+
+For frontend-only UI work, a persistent pinned Node container may run
+`npm run watch` against the staging checkout. Run the affected Vitest files
+during each small iteration and copy only generated `build`, `css`, and `js`
+assets into the installed development app. Stable entry assets must be served
+without long-lived caching on that development host so a refresh discovers
+new content-hashed chunks. Run the complete lint, unit, build, package, PHP,
+and compatibility gates at milestones and before publication. Never use the
+frontend-only path for PHP, routes, migrations, or dependency changes.
+
+Machine-specific addresses, SSH key locations, credentials, and deployment
+commands belong in an ignored local runbook under `.local/`, never in tracked
+documentation.
+
## Persistent demo studio
The regular E2E tenant is disposable test infrastructure. For visual QA,
@@ -92,6 +133,13 @@ verify desktop and 390 px mobile layouts, scroll reachability, horizontal
overflow, media hit testing, rows below the hero, and side and bottom filmstrip
placement inside the viewport.
+For feedback panels, test the overflow model rather than requiring every item
+to fit at once. A history of 20 or more comments must scroll inside the panel;
+headers, comment text, identity labels, edit controls, and the composer must not
+be clipped, overlapped, or made unreachable at short viewport heights. Selecting
+an image annotation may keep its marker visible for context, but the comment
+workflow must remain usable without relying on the marker staying on screen.
+
Playwright global setup creates and later supersedes its own E2E gallery.
Snapshots are intentionally versioned. Update them only after reviewing the
rendered images, preferably through the isolation-preserving wrapper:
diff --git a/l10n/de.js b/l10n/de.js
index ad1a643..ff7c5b6 100644
--- a/l10n/de.js
+++ b/l10n/de.js
@@ -1061,7 +1061,6 @@ OC.L10N.register("proofing_gallery", {
"Select all visible files": "Alle sichtbaren Dateien auswählen",
"Select photos across pages, then download them together.": "Fotos seitenübergreifend auswählen und anschließend gemeinsam herunterladen.",
"Select public image information to make this panel available to guests.": "Wählen Sie öffentliche Bildinformationen aus, damit dieses Panel Gästen zur Verfügung steht.",
- "Selected annotation": "Ausgewählte Anmerkung",
"Selected file actions": "Aktionen für ausgewählte Dateien",
"Selected files": "Ausgewählte Dateien",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Ausgewählte Informationen erscheinen in der Fotoansicht. Änderungen an dieser Auswahl öffnen die entsprechende Vorschauszene.",
diff --git a/l10n/de.json b/l10n/de.json
index b013783..373db81 100644
--- a/l10n/de.json
+++ b/l10n/de.json
@@ -1062,7 +1062,6 @@
"Select all visible files": "Alle sichtbaren Dateien auswählen",
"Select photos across pages, then download them together.": "Fotos seitenübergreifend auswählen und anschließend gemeinsam herunterladen.",
"Select public image information to make this panel available to guests.": "Wählen Sie öffentliche Bildinformationen aus, damit dieses Panel Gästen zur Verfügung steht.",
- "Selected annotation": "Ausgewählte Anmerkung",
"Selected file actions": "Aktionen für ausgewählte Dateien",
"Selected files": "Ausgewählte Dateien",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Ausgewählte Informationen erscheinen in der Fotoansicht. Änderungen an dieser Auswahl öffnen die entsprechende Vorschauszene.",
diff --git a/l10n/en.js b/l10n/en.js
index 4fc7395..d8851a1 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1061,7 +1061,6 @@ OC.L10N.register("proofing_gallery", {
"Select all visible files": "Select all visible files",
"Select photos across pages, then download them together.": "Select photos across pages, then download them together.",
"Select public image information to make this panel available to guests.": "Select public image information to make this panel available to guests.",
- "Selected annotation": "Selected annotation",
"Selected file actions": "Selected file actions",
"Selected files": "Selected files",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Selected information appears in the photo viewer. Changing this selection opens that preview scene.",
diff --git a/l10n/en.json b/l10n/en.json
index 8b487e1..6e93c83 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1062,7 +1062,6 @@
"Select all visible files": "Select all visible files",
"Select photos across pages, then download them together.": "Select photos across pages, then download them together.",
"Select public image information to make this panel available to guests.": "Select public image information to make this panel available to guests.",
- "Selected annotation": "Selected annotation",
"Selected file actions": "Selected file actions",
"Selected files": "Selected files",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Selected information appears in the photo viewer. Changing this selection opens that preview scene.",
diff --git a/lib/Controller/CollaborationController.php b/lib/Controller/CollaborationController.php
index 33e5d8e..55cb3cc 100644
--- a/lib/Controller/CollaborationController.php
+++ b/lib/Controller/CollaborationController.php
@@ -8,6 +8,8 @@
use OCA\ProofingGallery\Db\Gallery;
use OCA\ProofingGallery\Db\PublicLink;
use OCA\ProofingGallery\Db\Guest;
+use OCA\ProofingGallery\Domain\CollaborationActor;
+use OCA\ProofingGallery\Service\AuthenticatedCollaborationSession;
use OCA\ProofingGallery\Service\CollaborationService;
use OCA\ProofingGallery\Service\GuestService;
use OCA\ProofingGallery\Service\PublicShareContextResolver;
@@ -38,6 +40,7 @@ public function __construct(
private \OCA\ProofingGallery\Service\GuestRatingService $guestRatings,
private \OCA\ProofingGallery\Service\CapabilityPolicyService $capabilities,
private \OCA\ProofingGallery\Service\ShareAuditService $shareAudit,
+ private AuthenticatedCollaborationSession $authenticated,
) {
parent::__construct($request, $session, $contextResolver);
}
@@ -48,7 +51,7 @@ public function __construct(
public function state(int $cursor = 0, string $fileIds = ''): JSONResponse {
try {
$visibleFileIds = $this->parseVisibleFileIds($cursor, $fileIds);
- $state = $this->collaboration->publicState($this->resolvedGallery(), $this->optionalGuest(), $cursor, $visibleFileIds);
+ $state = $this->collaboration->publicState($this->resolvedGallery(), $this->optionalActor(), $cursor, $visibleFileIds);
} catch (InvalidArgumentException $exception) {
return new JSONResponse(['message' => $exception->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY);
}
@@ -93,7 +96,8 @@ public function state(int $cursor = 0, string $fileIds = ''): JSONResponse {
}
return true;
}));
- $guest = $this->optionalGuest();
+ $actor = $this->optionalActor();
+ $guest = $actor?->isGuest() ? $this->optionalGuest() : null;
$state['ratings'] = $guest === null || !$ratingEnabled
? []
: array_values(array_map(
@@ -111,6 +115,9 @@ public function setRating(int $fileId, int $rating = 0, string $pick = 'none'):
if (!$this->ratingEnabled()) return new JSONResponse(['code' => 'policy_denied', 'message' => 'Guest ratings are disabled for this link'], Http::STATUS_FORBIDDEN);
if (!$this->allowsFile($fileId)) return new JSONResponse(['message' => 'Media not found'], Http::STATUS_NOT_FOUND);
try {
+ if ($this->authenticated->actor() !== null) {
+ return new JSONResponse(['code' => 'policy_denied', 'message' => 'Ratings are currently available to guest reviewers only'], Http::STATUS_FORBIDDEN);
+ }
$guest = $this->guests->authenticate($this->resolvedGallery(), $this->guestSecret($this->resolvedGallery()), $this->request->getHeader('X-Proofing-Nonce'));
$permissions = $this->ratingPermissions();
$current = null;
@@ -136,8 +143,8 @@ public function setRating(int $fileId, int $rating = 0, string $pick = 'none'):
#[FrontpageRoute(verb: 'POST', url: '/public/{token}/collaboration/media/{fileId}/like')]
public function toggleLike(int $fileId): JSONResponse {
if (!$this->allowsFile($fileId)) return new JSONResponse(['message' => 'Media not found'], Http::STATUS_NOT_FOUND);
- return $this->mutation('likes', fn (Guest $guest): array => [
- 'liked' => $this->collaboration->toggleLike($this->resolvedGallery(), $guest, $fileId),
+ return $this->mutation('likes', fn (CollaborationActor $actor): array => [
+ 'liked' => $this->collaboration->toggleLike($this->resolvedGallery(), $actor, $fileId),
]);
}
@@ -147,8 +154,8 @@ public function toggleLike(int $fileId): JSONResponse {
#[FrontpageRoute(verb: 'PUT', url: '/public/{token}/collaboration/media/{fileId}/color')]
public function setColor(int $fileId, ?string $value = null): JSONResponse {
if (!$this->allowsFile($fileId)) return new JSONResponse(['message' => 'Media not found'], Http::STATUS_NOT_FOUND);
- return $this->mutation('colors', function (Guest $guest) use ($fileId, $value): array {
- $this->collaboration->setColor($this->resolvedGallery(), $guest, $fileId, $value);
+ return $this->mutation('colors', function (CollaborationActor $actor) use ($fileId, $value): array {
+ $this->collaboration->setColor($this->resolvedGallery(), $actor, $fileId, $value);
return [];
});
}
@@ -163,8 +170,8 @@ public function addComment(int $fileId, string $body, ?array $annotation = null)
if ($annotation !== null && !$this->policy()['annotations']) {
return new JSONResponse(['code' => 'policy_denied', 'message' => 'Image annotations are disabled for this link'], Http::STATUS_FORBIDDEN);
}
- return $this->mutation('comments', fn (Guest $guest): array => [
- 'id' => $this->collaboration->addComment($this->resolvedGallery(), $guest, $fileId, $body, $annotation),
+ return $this->mutation('comments', fn (CollaborationActor $actor): array => [
+ 'id' => $this->collaboration->addComment($this->resolvedGallery(), $actor, $fileId, $body, $annotation),
], Http::STATUS_CREATED);
}
@@ -173,11 +180,11 @@ public function addComment(int $fileId, string $body, ?array $annotation = null)
#[AnonRateLimit(limit: 240, period: 3600)]
#[FrontpageRoute(verb: 'DELETE', url: '/public/{token}/collaboration/comments/{commentId}')]
public function deleteComment(int $commentId): JSONResponse {
- return $this->mutation('comments', function (Guest $guest) use ($commentId): array {
- if (!$this->allowsFile($this->collaboration->ownedCommentFileId($this->resolvedGallery(), $guest, $commentId))) {
+ return $this->mutation('comments', function (CollaborationActor $actor) use ($commentId): array {
+ if (!$this->allowsFile($this->collaboration->ownedCommentFileId($this->resolvedGallery(), $actor, $commentId))) {
throw new InvalidArgumentException('Comment not found');
}
- $this->collaboration->deleteComment($this->resolvedGallery(), $guest, $commentId);
+ $this->collaboration->deleteComment($this->resolvedGallery(), $actor, $commentId);
return [];
});
}
@@ -187,11 +194,11 @@ public function deleteComment(int $commentId): JSONResponse {
#[AnonRateLimit(limit: 240, period: 3600)]
#[FrontpageRoute(verb: 'PUT', url: '/public/{token}/collaboration/comments/{commentId}')]
public function updateComment(int $commentId, string $body): JSONResponse {
- return $this->mutation('comments', function (Guest $guest) use ($commentId, $body): array {
- if (!$this->allowsFile($this->collaboration->ownedCommentFileId($this->resolvedGallery(), $guest, $commentId))) {
+ return $this->mutation('comments', function (CollaborationActor $actor) use ($commentId, $body): array {
+ if (!$this->allowsFile($this->collaboration->ownedCommentFileId($this->resolvedGallery(), $actor, $commentId))) {
throw new InvalidArgumentException('Comment not found');
}
- $this->collaboration->updateComment($this->resolvedGallery(), $guest, $commentId, $body);
+ $this->collaboration->updateComment($this->resolvedGallery(), $actor, $commentId, $body);
return [];
});
}
@@ -203,10 +210,10 @@ public function updateComment(int $commentId, string $body): JSONResponse {
#[FrontpageRoute(verb: 'POST', url: '/public/{token}/collaboration/selections')]
public function saveSelection(string $name, string $message = '', array $fileIds = []): JSONResponse {
foreach ($fileIds as $fileId) if (!$this->allowsFile((int)$fileId)) return new JSONResponse(['message' => 'Media not found'], Http::STATUS_NOT_FOUND);
- return $this->mutation('selections', fn (Guest $guest): array => [
+ return $this->mutation('selections', fn (CollaborationActor $actor): array => [
'id' => $this->collaboration->saveSelection(
$this->resolvedGallery(),
- $guest,
+ $actor,
$name,
$message,
$fileIds,
@@ -219,21 +226,16 @@ public function saveSelection(string $name, string $message = '', array $fileIds
#[FrontpageRoute(verb: 'GET', url: '/public/{token}/collaboration/selections/{selectionId}/export')]
public function exportSelection(string $selectionId, string $format = 'csv', string $fields = ''): Response {
if (!$this->policy()['export']) return new JSONResponse(['code' => 'policy_denied', 'message' => 'Export is disabled for this link'], Http::STATUS_FORBIDDEN);
+ $actor = $this->optionalActor();
+ if ($actor === null) return new JSONResponse(['message' => 'Collaboration identity required'], Http::STATUS_UNAUTHORIZED);
try {
- $guest = $this->guests->authenticate(
- $this->resolvedGallery(),
- $this->guestSecret($this->resolvedGallery()),
- );
- foreach ($this->collaboration->guestSelectionFileIds($this->resolvedGallery(), $guest, $selectionId) as $fileId) {
+ foreach ($this->collaboration->actorSelectionFileIds($this->resolvedGallery(), $actor, $selectionId) as $fileId) {
if (!$this->allowsFile($fileId)) throw new InvalidArgumentException('Selection not found');
}
- $export = $this->collaboration->exportSelection(
- $this->resolvedGallery(),
- $guest,
- $selectionId,
- $format,
- array_filter(explode(',', $fields)),
- );
+ $guest = $actor->isGuest() ? $this->optionalGuest() : null;
+ $export = $guest !== null
+ ? $this->collaboration->exportSelection($this->resolvedGallery(), $guest, $selectionId, $format, array_filter(explode(',', $fields)))
+ : $this->collaboration->exportActorSelection($this->resolvedGallery(), $actor, $selectionId, $format, array_filter(explode(',', $fields)));
return new DataDownloadResponse(
$export['content'],
$export['filename'],
@@ -264,22 +266,38 @@ private function optionalGuest(): ?Guest {
}
}
+ private function optionalActor(): ?CollaborationActor {
+ $authenticated = $this->authenticated->actor();
+ if ($authenticated !== null) return $authenticated;
+ $guest = $this->optionalGuest();
+ return $guest === null ? null : CollaborationActor::guest($guest);
+ }
+
+ private function authenticateActor(): CollaborationActor {
+ $authenticated = $this->authenticated->authenticate(
+ $this->resolvedGallery(),
+ $this->request->getHeader('X-Proofing-Nonce'),
+ );
+ if ($authenticated !== null) return $authenticated;
+ return CollaborationActor::guest($this->guests->authenticate(
+ $this->resolvedGallery(),
+ $this->guestSecret($this->resolvedGallery()),
+ $this->request->getHeader('X-Proofing-Nonce'),
+ ));
+ }
+
/** @param Http::STATUS_OK|Http::STATUS_CREATED $status */
private function mutation(string $feature, callable $callback, int $status = Http::STATUS_OK): JSONResponse {
if (!$this->policy()[$feature]) return new JSONResponse(['code' => 'policy_denied', 'message' => 'This action is disabled for this link'], Http::STATUS_FORBIDDEN);
try {
- $guest = $this->guests->authenticate(
- $this->resolvedGallery(),
- $this->guestSecret($this->resolvedGallery()),
- $this->request->getHeader('X-Proofing-Nonce'),
- );
+ $actor = $this->authenticateActor();
} catch (DoesNotExistException) {
return new JSONResponse(['code' => 'guest_session_required', 'message' => 'Guest session required'], Http::STATUS_UNAUTHORIZED);
} catch (InvalidArgumentException) {
return new JSONResponse(['code' => 'invalid_nonce', 'message' => 'Invalid request nonce'], Http::STATUS_FORBIDDEN);
}
try {
- return new JSONResponse($callback($guest), $status);
+ return new JSONResponse($callback($actor), $status);
} catch (InvalidArgumentException $exception) {
return new JSONResponse(['message' => $exception->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY);
}
diff --git a/lib/Controller/GuestController.php b/lib/Controller/GuestController.php
index 58a7788..e4dc0f2 100644
--- a/lib/Controller/GuestController.php
+++ b/lib/Controller/GuestController.php
@@ -7,6 +7,7 @@
use InvalidArgumentException;
use OCA\ProofingGallery\Db\Gallery;
use OCA\ProofingGallery\Service\GuestService;
+use OCA\ProofingGallery\Service\AuthenticatedCollaborationSession;
use OCA\ProofingGallery\Service\PublicShareContextResolver;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@@ -24,6 +25,7 @@ public function __construct(
ISession $session,
PublicShareContextResolver $contextResolver,
private GuestService $guests,
+ private AuthenticatedCollaborationSession $authenticated,
) {
parent::__construct($request, $session, $contextResolver);
}
@@ -34,6 +36,16 @@ public function __construct(
#[FrontpageRoute(verb: 'POST', url: '/public/{token}/session')]
public function create(string $displayName = '', ?string $email = null): JSONResponse {
try {
+ $authenticated = $this->authenticated->current($this->gallery());
+ if ($authenticated !== null) {
+ $response = new JSONResponse([
+ 'guest' => $authenticated['actor'],
+ 'nonce' => $authenticated['nonce'],
+ 'expiresIn' => null,
+ ]);
+ $response->addHeader('Cache-Control', 'private, no-store');
+ return $response;
+ }
$session = $this->guests->create($this->gallery(), $displayName, $email);
$response = new JSONResponse([
'guest' => $session['guest'],
@@ -53,6 +65,16 @@ public function create(string $displayName = '', ?string $email = null): JSONRes
#[FrontpageRoute(verb: 'GET', url: '/public/{token}/session')]
public function current(): JSONResponse {
try {
+ $authenticated = $this->authenticated->current($this->gallery());
+ if ($authenticated !== null) {
+ $response = new JSONResponse([
+ 'guest' => $authenticated['actor'],
+ 'nonce' => $authenticated['nonce'],
+ 'expiresIn' => null,
+ ]);
+ $response->addHeader('Cache-Control', 'private, no-store');
+ return $response;
+ }
$secret = $this->guestSecret($this->gallery());
$session = $this->guests->resume($this->gallery(), $secret);
$response = new JSONResponse(['guest' => $session['guest'], 'nonce' => $session['nonce'], 'expiresIn' => 2592000]);
diff --git a/lib/Db/CollaborationRepository.php b/lib/Db/CollaborationRepository.php
index 5d46307..e3c3b99 100644
--- a/lib/Db/CollaborationRepository.php
+++ b/lib/Db/CollaborationRepository.php
@@ -8,6 +8,7 @@
use OCA\ProofingGallery\Domain\CollaborationReadScope;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
+use OCP\IUserManager;
final class CollaborationRepository {
private const COLLABORATION_EVENT_TYPES = [
@@ -19,7 +20,7 @@ final class CollaborationRepository {
'proofing_comments' => 10000,
'proofing_selections' => 2000,
];
- public function __construct(private IDBConnection $db) {
+ public function __construct(private IDBConnection $db, private IUserManager $users) {
}
/**
@@ -39,7 +40,8 @@ public function state(int $galleryId, CollaborationReadScope $scope, int $cursor
];
}
$guestId = $scope->guestId();
- $events = $this->events($galleryId, $guestId, $cursor);
+ $actorUid = $scope->actorUid();
+ $events = $this->events($galleryId, $guestId, $actorUid, $cursor);
if ($cursor > 0 && $events === []) {
return ['feedback' => [], 'comments' => [], 'selections' => [], 'events' => [], 'unchanged' => true];
}
@@ -58,23 +60,21 @@ public function state(int $galleryId, CollaborationReadScope $scope, int $cursor
}
$fileIds = array_values(array_unique($delta ? $eventFileIds : array_map('intval', $visibleFileIds)));
$feedback = $allFiles && !$delta
- ? $this->rows('proofing_feedback', $galleryId, $guestId, 'updated_at')
- : $this->rowsForFiles('proofing_feedback', $galleryId, $guestId, $fileIds, 'updated_at');
+ ? $this->rows('proofing_feedback', $galleryId, $guestId, $actorUid, 'updated_at')
+ : $this->rowsForFiles('proofing_feedback', $galleryId, $guestId, $actorUid, $fileIds, 'updated_at');
$comments = $allFiles && !$delta
- ? $this->rows('proofing_comments', $galleryId, $guestId, 'created_at')
- : $this->commentsForDelta($galleryId, $guestId, $fileIds, $commentIds);
+ ? $this->rows('proofing_comments', $galleryId, $guestId, $actorUid, 'created_at')
+ : $this->commentsForDelta($galleryId, $guestId, $actorUid, $fileIds, $commentIds);
$selections = $delta
- ? $this->selectionsByPublicIds($galleryId, $guestId, $selectionIds)
- : $this->selectionPage($galleryId, $guestId, null, 50);
+ ? $this->selectionsByPublicIds($galleryId, $guestId, $actorUid, $selectionIds)
+ : $this->selectionPage($galleryId, $guestId, $actorUid, null, 50);
$this->decorateSelections($selections);
$annotations = $this->annotations(array_map(static fn (array $row): int => (int)$row['id'], $comments));
- $names = $this->guestNames(array_values(array_unique(array_map(
- static fn (array $row): int => (int)$row['guest_id'],
- $comments,
- ))));
+ $guestNames = $this->guestNames($this->guestIds($comments));
+ $userNames = $this->userNames($this->actorUids($comments));
foreach ($comments as &$comment) {
$commentId = (int)$comment['id'];
- $comment['author'] = $names[(int)$comment['guest_id']] ?? 'Deleted guest';
+ $comment['author'] = $this->authorName($comment, $guestNames, $userNames);
$comment['annotations'] = $annotations[$commentId] ?? [];
}
unset($comment);
@@ -90,18 +90,18 @@ public function state(int $galleryId, CollaborationReadScope $scope, int $cursor
/** @return list> */
public function selections(int $galleryId, ?int $guestId): array {
- $selections = $this->rows('proofing_selections', $galleryId, $guestId, 'updated_at');
+ $selections = $this->rows('proofing_selections', $galleryId, $guestId, null, 'updated_at');
$this->decorateSelections($selections);
return $selections;
}
/** @return list> */
- public function selectionPage(int $galleryId, ?int $guestId, ?int $beforeId, int $limit): array {
+ public function selectionPage(int $galleryId, ?int $guestId, ?string $actorUid, ?int $beforeId, int $limit): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from('proofing_selections')
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
->orderBy('id', 'DESC')->setMaxResults(max(1, min(101, $limit)));
- if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)));
+ $this->actorCondition($qb, $guestId, $actorUid);
if ($beforeId !== null) $qb->andWhere($qb->expr()->lt('id', $qb->createNamedParameter($beforeId, IQueryBuilder::PARAM_INT)));
return QueryResult::rows($qb->executeQuery());
}
@@ -116,12 +116,10 @@ public function selectionCount(int $galleryId): int {
/** @param list> $selections */
public function decorateSelections(array &$selections): void {
$items = $this->selectionItems(array_map(static fn (array $row): int => (int)$row['id'], $selections));
- $names = $this->guestNames(array_values(array_unique(array_map(
- static fn (array $row): int => (int)$row['guest_id'],
- $selections,
- ))));
+ $guestNames = $this->guestNames($this->guestIds($selections));
+ $userNames = $this->userNames($this->actorUids($selections));
foreach ($selections as &$selection) {
- $selection['author'] = $names[(int)$selection['guest_id']] ?? 'Deleted guest';
+ $selection['author'] = $this->authorName($selection, $guestNames, $userNames);
$selection['fileIds'] = $items[(int)$selection['id']] ?? [];
}
unset($selection);
@@ -130,7 +128,7 @@ public function decorateSelections(array &$selections): void {
/** @param list $fileIds
* @param list $commentIds
* @return list> */
- private function commentsForDelta(int $galleryId, ?int $guestId, array $fileIds, array $commentIds): array {
+ private function commentsForDelta(int $galleryId, ?int $guestId, ?string $actorUid, array $fileIds, array $commentIds): array {
if ($fileIds === [] && $commentIds === []) return [];
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from('proofing_comments')
@@ -139,31 +137,31 @@ private function commentsForDelta(int $galleryId, ?int $guestId, array $fileIds,
$fileIds === [] ? $qb->expr()->eq('id', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT)) : $qb->expr()->in('file_id', $qb->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY)),
$commentIds === [] ? $qb->expr()->eq('id', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT)) : $qb->expr()->in('id', $qb->createNamedParameter($commentIds, IQueryBuilder::PARAM_INT_ARRAY)),
))->orderBy('id', 'ASC')->setMaxResults(1000);
- if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)));
+ $this->actorCondition($qb, $guestId, $actorUid);
return QueryResult::rows($qb->executeQuery());
}
/** @param list $publicIds
* @return list> */
- private function selectionsByPublicIds(int $galleryId, ?int $guestId, array $publicIds): array {
+ private function selectionsByPublicIds(int $galleryId, ?int $guestId, ?string $actorUid, array $publicIds): array {
if ($publicIds === []) return [];
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from('proofing_selections')
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->in('public_id', $qb->createNamedParameter($publicIds, IQueryBuilder::PARAM_STR_ARRAY)))
->orderBy('id', 'ASC')->setMaxResults(200);
- if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)));
+ $this->actorCondition($qb, $guestId, $actorUid);
return QueryResult::rows($qb->executeQuery());
}
- public function feedbackId(int $galleryId, int $guestId, int $fileId, string $kind): ?int {
+ public function feedbackId(int $galleryId, ?int $guestId, ?string $actorUid, int $fileId, string $kind): ?int {
$qb = $this->db->getQueryBuilder();
- $value = $qb->select('id')->from('proofing_feedback')
+ $qb->select('id')->from('proofing_feedback')
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
- ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
- ->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)))
- ->executeQuery()->fetchOne();
+ ->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)));
+ $this->actorCondition($qb, $guestId, $actorUid);
+ $value = $qb->executeQuery()->fetchOne();
return $value === false ? null : (int)$value;
}
@@ -174,12 +172,12 @@ public function deleteFeedback(int $id): void {
->executeStatement();
}
- public function insertFeedback(int $galleryId, int $guestId, int $fileId, string $kind, string $value, int $now): void {
+ public function insertFeedback(int $galleryId, ?int $guestId, ?string $actorUid, int $fileId, string $kind, string $value, int $now): void {
$qb = $this->db->getQueryBuilder();
$qb->insert('proofing_feedback')->values([
'gallery_id' => $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT),
'guest_id' => $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT),
- 'actor_uid' => $qb->createNamedParameter(null),
+ 'actor_uid' => $qb->createNamedParameter($actorUid),
'file_id' => $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT),
'kind' => $qb->createNamedParameter($kind),
'value' => $qb->createNamedParameter($value),
@@ -188,13 +186,13 @@ public function insertFeedback(int $galleryId, int $guestId, int $fileId, string
])->executeStatement();
}
- public function hasAtLeastRows(string $table, int $galleryId, int $threshold, ?int $guestId = null): bool {
+ public function hasAtLeastRows(string $table, int $galleryId, int $threshold, ?int $guestId = null, ?string $actorUid = null): bool {
if (!array_key_exists($table, self::ROW_LIMITS)) throw new InvalidArgumentException('Unsupported collaboration table');
if ($threshold < 1) return true;
$qb = $this->db->getQueryBuilder();
$qb->select('id')->from($table)
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)));
- if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)));
+ $this->actorCondition($qb, $guestId, $actorUid);
$qb->orderBy('id', 'ASC')->setFirstResult($threshold - 1)->setMaxResults(1);
return $qb->executeQuery()->fetchOne() !== false;
}
@@ -209,7 +207,7 @@ public function updateFeedback(int $id, string $value, int $now): void {
}
/** @param array|null $annotation */
- public function insertComment(int $galleryId, int $guestId, int $fileId, string $body, ?array $annotation, int $now): int {
+ public function insertComment(int $galleryId, ?int $guestId, ?string $actorUid, int $fileId, string $body, ?array $annotation, int $now): int {
$ownsTransaction = !$this->db->inTransaction();
if ($ownsTransaction) $this->db->beginTransaction();
try {
@@ -217,7 +215,7 @@ public function insertComment(int $galleryId, int $guestId, int $fileId, string
$qb->insert('proofing_comments')->values([
'gallery_id' => $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT),
'guest_id' => $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT),
- 'actor_uid' => $qb->createNamedParameter(null),
+ 'actor_uid' => $qb->createNamedParameter($actorUid),
'file_id' => $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT),
'parent_id' => $qb->createNamedParameter(null),
'body' => $qb->createNamedParameter($body),
@@ -235,40 +233,42 @@ public function insertComment(int $galleryId, int $guestId, int $fileId, string
}
}
- public function deleteComment(int $galleryId, int $guestId, int $commentId, int $now): bool {
+ public function deleteComment(int $galleryId, ?int $guestId, ?string $actorUid, int $commentId, int $now): bool {
$qb = $this->db->getQueryBuilder();
- return $qb->update('proofing_comments')
+ $qb->update('proofing_comments')
->set('deleted_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))
->set('body', $qb->createNamedParameter(''))
->where($qb->expr()->eq('id', $qb->createNamedParameter($commentId, IQueryBuilder::PARAM_INT)))
- ->andWhere($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
- ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)))
- ->executeStatement() === 1;
+ ->andWhere($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)));
+ $this->actorCondition($qb, $guestId, $actorUid);
+ return $qb->executeStatement() === 1;
}
- public function ownedCommentFileId(int $galleryId, int $guestId, int $commentId): ?int {
+ public function ownedCommentFileId(int $galleryId, ?int $guestId, ?string $actorUid, int $commentId): ?int {
$qb = $this->db->getQueryBuilder();
$value = $qb->select('file_id')->from('proofing_comments')
->where($qb->expr()->eq('id', $qb->createNamedParameter($commentId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
- ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)))
- ->andWhere($qb->expr()->isNull('deleted_at'))->executeQuery()->fetchOne();
+ ->andWhere($qb->expr()->isNull('deleted_at'));
+ $this->actorCondition($qb, $guestId, $actorUid);
+ $value = $qb->executeQuery()->fetchOne();
return $value === false ? null : (int)$value;
}
- public function updateComment(int $galleryId, int $guestId, int $commentId, string $body, int $now): bool {
+ public function updateComment(int $galleryId, ?int $guestId, ?string $actorUid, int $commentId, string $body, int $now): bool {
$qb = $this->db->getQueryBuilder();
- return $qb->update('proofing_comments')
+ $qb->update('proofing_comments')
->set('body', $qb->createNamedParameter($body))
->set('edited_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('id', $qb->createNamedParameter($commentId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
- ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)))
- ->andWhere($qb->expr()->isNull('deleted_at'))->executeStatement() === 1;
+ ->andWhere($qb->expr()->isNull('deleted_at'));
+ $this->actorCondition($qb, $guestId, $actorUid);
+ return $qb->executeStatement() === 1;
}
/** @param list $fileIds */
- public function insertSelection(int $galleryId, int $guestId, string $publicId, string $name, string $message, array $fileIds, int $now): void {
+ public function insertSelection(int $galleryId, ?int $guestId, ?string $actorUid, string $publicId, string $name, string $message, array $fileIds, int $now): void {
$ownsTransaction = !$this->db->inTransaction();
if ($ownsTransaction) $this->db->beginTransaction();
try {
@@ -276,7 +276,7 @@ public function insertSelection(int $galleryId, int $guestId, string $publicId,
$qb->insert('proofing_selections')->values([
'gallery_id' => $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT),
'guest_id' => $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT),
- 'actor_uid' => $qb->createNamedParameter(null),
+ 'actor_uid' => $qb->createNamedParameter($actorUid),
'public_id' => $qb->createNamedParameter($publicId),
'name' => $qb->createNamedParameter($name),
'message' => $qb->createNamedParameter($message),
@@ -374,17 +374,17 @@ public function deleteSelection(int $galleryId, string $publicId): bool {
}
/** @param array $payload */
- public function insertEvent(int $galleryId, int $guestId, string $type, array $payload, int $now): int {
- return $this->insertActorEvent($galleryId, $guestId, null, $type, $payload, $now);
+ public function insertEvent(int $galleryId, ?int $guestId, ?string $actorUid, string $type, array $payload, int $now): int {
+ return $this->insertActorEvent($galleryId, $guestId, $actorUid, $type, $payload, $now);
}
/** @param array $payload */
- public function insertOwnerEvent(int $galleryId, int $guestId, string $actorUid, string $type, array $payload, int $now): int {
+ public function insertOwnerEvent(int $galleryId, ?int $guestId, string $actorUid, string $type, array $payload, int $now): int {
return $this->insertActorEvent($galleryId, $guestId, $actorUid, $type, $payload, $now);
}
/** @param array $payload */
- private function insertActorEvent(int $galleryId, int $guestId, ?string $actorUid, string $type, array $payload, int $now): int {
+ private function insertActorEvent(int $galleryId, ?int $guestId, ?string $actorUid, string $type, array $payload, int $now): int {
$qb = $this->db->getQueryBuilder();
$qb->insert('proofing_events')->values([
'gallery_id' => $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT),
@@ -398,26 +398,26 @@ private function insertActorEvent(int $galleryId, int $guestId, ?string $actorUi
}
/** @return list> */
- private function rows(string $table, int $galleryId, ?int $guestId, string $order): array {
+ private function rows(string $table, int $galleryId, ?int $guestId, ?string $actorUid, string $order): array {
$limit = self::ROW_LIMITS[$table] ?? throw new InvalidArgumentException('Unsupported collaboration table');
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($table)
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
->orderBy($order, 'DESC')->setMaxResults(min(5000, $limit));
- if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)));
+ $this->actorCondition($qb, $guestId, $actorUid);
return array_reverse(QueryResult::rows($qb->executeQuery()));
}
/** @param list $fileIds
* @return list> */
- private function rowsForFiles(string $table, int $galleryId, ?int $guestId, array $fileIds, string $order): array {
+ private function rowsForFiles(string $table, int $galleryId, ?int $guestId, ?string $actorUid, array $fileIds, string $order): array {
if ($fileIds === []) return [];
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($table)
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->in('file_id', $qb->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY)))
->orderBy($order, 'ASC')->setMaxResults(5000);
- if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)));
+ $this->actorCondition($qb, $guestId, $actorUid);
return QueryResult::rows($qb->executeQuery());
}
@@ -472,17 +472,71 @@ private function guestNames(array $guestIds): array {
}
/** @return list> */
- private function events(int $galleryId, ?int $guestId, int $cursor): array {
+ private function events(int $galleryId, ?int $guestId, ?string $actorUid, int $cursor): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from('proofing_events')
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->gt('id', $qb->createNamedParameter(max(0, $cursor), IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->in('event_type', $qb->createNamedParameter(self::COLLABORATION_EVENT_TYPES, IQueryBuilder::PARAM_STR_ARRAY)))
->orderBy('id', 'ASC')->setMaxResults(200);
- if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)));
+ $this->actorCondition($qb, $guestId, $actorUid);
return QueryResult::rows($qb->executeQuery());
}
+ /**
+ * @param list> $rows
+ * @return list
+ */
+ private function guestIds(array $rows): array {
+ return array_values(array_unique(array_map('intval', array_filter(
+ array_column($rows, 'guest_id'),
+ static fn (mixed $id): bool => $id !== null,
+ ))));
+ }
+
+ /**
+ * @param list> $rows
+ * @return list
+ */
+ private function actorUids(array $rows): array {
+ return array_values(array_unique(array_filter(
+ array_map('strval', array_column($rows, 'actor_uid')),
+ static fn (string $uid): bool => $uid !== '',
+ )));
+ }
+
+ /**
+ * @param list $uids
+ * @return array
+ */
+ private function userNames(array $uids): array {
+ $result = [];
+ foreach ($uids as $uid) {
+ $result[$uid] = $this->users->get($uid)?->getDisplayName() ?? $uid;
+ }
+ return $result;
+ }
+
+ /**
+ * @param array $row
+ * @param array $guestNames
+ * @param array $userNames
+ */
+ private function authorName(array $row, array $guestNames, array $userNames): string {
+ if ($row['actor_uid'] !== null && (string)$row['actor_uid'] !== '') {
+ return $userNames[(string)$row['actor_uid']] ?? (string)$row['actor_uid'];
+ }
+ return $row['guest_id'] === null ? 'Deleted user' : ($guestNames[(int)$row['guest_id']] ?? 'Deleted guest');
+ }
+
+ private function actorCondition(IQueryBuilder $qb, ?int $guestId, ?string $actorUid): void {
+ if ($guestId !== null) {
+ $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)));
+ } elseif ($actorUid !== null) {
+ $qb->andWhere($qb->expr()->eq('actor_uid', $qb->createNamedParameter($actorUid)));
+ }
+ }
+
/** @param array $annotation */
private function insertAnnotation(int $galleryId, int $fileId, int $commentId, array $annotation): void {
foreach (['x', 'y', 'width', 'height'] as $key) {
diff --git a/lib/Db/Guest.php b/lib/Db/Guest.php
index 8dfe727..db63701 100644
--- a/lib/Db/Guest.php
+++ b/lib/Db/Guest.php
@@ -44,10 +44,11 @@ public function __construct() {
}
}
- /** @return array{id: string, displayName: string, createdAt: int} */
+ /** @return array{id: string, kind: 'guest', displayName: string, createdAt: int} */
public function jsonSerialize(): array {
return [
'id' => $this->getPublicId(),
+ 'kind' => 'guest',
'displayName' => $this->getDisplayName(),
'createdAt' => $this->getCreatedAt(),
];
diff --git a/lib/Domain/CollaborationActor.php b/lib/Domain/CollaborationActor.php
new file mode 100644
index 0000000..9263cf9
--- /dev/null
+++ b/lib/Domain/CollaborationActor.php
@@ -0,0 +1,70 @@
+getId(),
+ null,
+ $guest->getPublicId(),
+ $guest->getDisplayName(),
+ $guest->getCreatedAt(),
+ );
+ }
+
+ public static function user(IUser $user): self {
+ return new self(null, $user->getUID(), 'account', $user->getDisplayName(), 0);
+ }
+
+ public function guestId(): ?int {
+ return $this->guestId;
+ }
+
+ public function userUid(): ?string {
+ return $this->userUid;
+ }
+
+ public function isGuest(): bool {
+ return $this->guestId !== null;
+ }
+
+ /** @param array $row */
+ public function owns(array $row): bool {
+ return $this->guestId !== null
+ ? $row['guest_id'] !== null && (int)$row['guest_id'] === $this->guestId
+ : $row['actor_uid'] !== null && hash_equals($this->userUid ?? '', (string)$row['actor_uid']);
+ }
+
+ /** @return array{id: string, kind: 'guest'|'user', displayName: string, createdAt: int} */
+ public function jsonSerialize(): array {
+ return [
+ 'id' => $this->publicId,
+ 'kind' => $this->isGuest() ? 'guest' : 'user',
+ 'displayName' => $this->displayName,
+ 'createdAt' => $this->createdAt,
+ ];
+ }
+}
diff --git a/lib/Domain/CollaborationReadScope.php b/lib/Domain/CollaborationReadScope.php
index 7c2efd4..c538719 100644
--- a/lib/Domain/CollaborationReadScope.php
+++ b/lib/Domain/CollaborationReadScope.php
@@ -10,10 +10,14 @@ final class CollaborationReadScope {
private function __construct(
private string $mode,
private ?int $guestId = null,
+ private ?string $actorUid = null,
) {
if ($mode === 'guest' && ($guestId ?? 0) < 1) {
throw new InvalidArgumentException('A guest collaboration scope requires a guest ID');
}
+ if ($mode === 'user' && ($actorUid === null || $actorUid === '')) {
+ throw new InvalidArgumentException('A user collaboration scope requires a user UID');
+ }
}
public static function all(): self {
@@ -28,6 +32,10 @@ public static function guest(int $guestId): self {
return new self('guest', $guestId);
}
+ public static function user(string $actorUid): self {
+ return new self('user', null, $actorUid);
+ }
+
public function isEmpty(): bool {
return $this->mode === 'none';
}
@@ -35,4 +43,8 @@ public function isEmpty(): bool {
public function guestId(): ?int {
return $this->mode === 'guest' ? $this->guestId : null;
}
+
+ public function actorUid(): ?string {
+ return $this->mode === 'user' ? $this->actorUid : null;
+ }
}
diff --git a/lib/Service/AuthenticatedCollaborationSession.php b/lib/Service/AuthenticatedCollaborationSession.php
new file mode 100644
index 0000000..babaa09
--- /dev/null
+++ b/lib/Service/AuthenticatedCollaborationSession.php
@@ -0,0 +1,53 @@
+users->getUser();
+ return $user === null ? null : CollaborationActor::user($user);
+ }
+
+ /** @return array{actor: CollaborationActor, nonce: string}|null */
+ public function current(Gallery $gallery): ?array {
+ $actor = $this->actor();
+ if ($actor === null) return null;
+ $key = $this->key($gallery, $actor->userUid() ?? '');
+ $nonce = $this->session->get($key);
+ if (!is_string($nonce) || strlen($nonce) < 32) {
+ $nonce = $this->random->generate(64, ISecureRandom::CHAR_ALPHANUMERIC);
+ $this->session->set($key, $nonce);
+ }
+ return ['actor' => $actor, 'nonce' => $nonce];
+ }
+
+ public function authenticate(Gallery $gallery, ?string $nonce): ?CollaborationActor {
+ $current = $this->current($gallery);
+ if ($current === null) return null;
+ if ($nonce === null || $nonce === '' || !hash_equals($current['nonce'], $nonce)) {
+ throw new InvalidArgumentException('Invalid request nonce');
+ }
+ return $current['actor'];
+ }
+
+ private function key(Gallery $gallery, string $uid): string {
+ return 'proofing_gallery_actor_nonce:' . $gallery->getId() . ':' . hash('sha256', $uid);
+ }
+}
diff --git a/lib/Service/CollaborationService.php b/lib/Service/CollaborationService.php
index 80e7de8..5a3843c 100644
--- a/lib/Service/CollaborationService.php
+++ b/lib/Service/CollaborationService.php
@@ -11,6 +11,7 @@
use OCA\ProofingGallery\Db\GuestRating;
use OCA\ProofingGallery\Db\PublicLink;
use OCA\ProofingGallery\Domain\CollaborationReadScope;
+use OCA\ProofingGallery\Domain\CollaborationActor;
use OCA\ProofingGallery\Domain\FeedbackVisibility;
use OCA\ProofingGallery\Domain\GalleryMode;
use OCA\ProofingGallery\Dto\GallerySettings;
@@ -41,12 +42,12 @@ public function __construct(
/** @param list $visibleFileIds
* @return array */
- public function publicState(Gallery $gallery, ?Guest $guest, int $cursor, array $visibleFileIds = []): array {
+ public function publicState(Gallery $gallery, ?CollaborationActor $actor, int $cursor, array $visibleFileIds = []): array {
$settings = $this->settings($gallery);
$scope = $settings->review->visibility === FeedbackVisibility::Collaborative
? CollaborationReadScope::all()
- : ($guest === null ? CollaborationReadScope::none() : CollaborationReadScope::guest($guest->getId()));
- return $this->state($gallery, $guest, $scope, $cursor, $visibleFileIds, false);
+ : ($actor === null ? CollaborationReadScope::none() : $this->actorScope($actor));
+ return $this->state($gallery, $actor, $scope, $cursor, $visibleFileIds, false);
}
/** @param list $visibleFileIds
@@ -57,7 +58,7 @@ public function ownerState(Gallery $gallery, array $visibleFileIds = []): array
/** @param list $visibleFileIds
* @return array */
- private function state(Gallery $gallery, ?Guest $guest, CollaborationReadScope $scope, int $cursor, array $visibleFileIds, bool $allFiles): array {
+ private function state(Gallery $gallery, ?CollaborationActor $actor, CollaborationReadScope $scope, int $cursor, array $visibleFileIds, bool $allFiles): array {
$settings = $this->settings($gallery);
$visibleFileIds = array_values(array_unique(array_filter(array_map('intval', $visibleFileIds), static fn (int $id): bool => $id > 0)));
if (count($visibleFileIds) > 200) throw new InvalidArgumentException('Too many visible media IDs');
@@ -97,11 +98,11 @@ private function state(Gallery $gallery, ?Guest $guest, CollaborationReadScope $
if ($row['kind'] === 'like') {
$likes[$fileId] ??= ['count' => 0, 'mine' => false];
$likes[$fileId]['count']++;
- if ($guest !== null && (int)$row['guest_id'] === $guest->getId()) {
+ if ($actor !== null && $actor->owns($row)) {
$likes[$fileId]['mine'] = true;
}
}
- if ($row['kind'] === 'color' && $guest !== null && (int)$row['guest_id'] === $guest->getId()) {
+ if ($row['kind'] === 'color' && $actor !== null && $actor->owns($row)) {
$colors[$fileId] = $row['value'];
}
if ($row['kind'] === 'color') {
@@ -115,7 +116,7 @@ private function state(Gallery $gallery, ?Guest $guest, CollaborationReadScope $
'enabled' => $settings->mode === GalleryMode::Collaboration,
'visibility' => $settings->review->visibility->value,
'colorLabels' => $settings->review->colorLabels,
- 'requiresSession' => $guest === null,
+ 'requiresSession' => $actor === null,
'features' => [
'likes' => $settings->review->likes && $this->capabilities->feature('likes'),
'colors' => $settings->review->colors && $this->capabilities->feature('colors'),
@@ -124,12 +125,12 @@ private function state(Gallery $gallery, ?Guest $guest, CollaborationReadScope $
'selections' => $settings->review->selections && $this->capabilities->feature('selections'),
],
],
- 'guest' => $guest,
+ 'guest' => $actor,
'likes' => $likes,
'colors' => $colors,
'colorStates' => $colorStates,
- 'comments' => $this->presentComments($comments, $guest),
- 'selections' => $this->presentSelections($selections, $guest),
+ 'comments' => $this->presentComments($comments, $actor),
+ 'selections' => $this->presentSelections($selections, $actor),
'events' => $events,
'cursor' => $nextCursor,
'delta' => (bool)($state['delta'] ?? false),
@@ -140,7 +141,7 @@ private function state(Gallery $gallery, ?Guest $guest, CollaborationReadScope $
public function ownerSelectionPage(Gallery $gallery, int $limit, ?string $cursor, ScopedCursorCodec $cursors): array {
$limit = max(1, min(100, $limit));
$scope = 'owner-selections:' . $gallery->getId();
- $rows = $this->repository->selectionPage($gallery->getId(), null, $cursors->decode($cursor, $scope), $limit + 1);
+ $rows = $this->repository->selectionPage($gallery->getId(), null, null, $cursors->decode($cursor, $scope), $limit + 1);
$hasMore = count($rows) > $limit;
if ($hasMore) array_pop($rows);
$this->repository->decorateSelections($rows);
@@ -149,23 +150,23 @@ public function ownerSelectionPage(Gallery $gallery, int $limit, ?string $cursor
return ['items' => $items, 'total' => $this->repository->selectionCount($gallery->getId()), 'nextCursor' => $hasMore && $last !== null ? $cursors->encode($scope, (int)$last['id']) : null];
}
- public function toggleLike(Gallery $gallery, Guest $guest, int $fileId): bool {
- return $this->atomic(function () use ($gallery, $guest, $fileId): bool {
+ public function toggleLike(Gallery $gallery, CollaborationActor $actor, int $fileId): bool {
+ return $this->atomic(function () use ($gallery, $actor, $fileId): bool {
$this->capabilities->assertFeature('likes');
$settings = $this->assertCollaboration($gallery, $fileId);
if (!$settings->review->likes) {
throw new InvalidArgumentException('Likes are disabled');
}
- $existing = $this->repository->feedbackId($gallery->getId(), $guest->getId(), $fileId, 'like');
+ $existing = $this->repository->feedbackId($gallery->getId(), $actor->guestId(), $actor->userUid(), $fileId, 'like');
if ($existing !== null) {
$this->repository->deleteFeedback($existing);
$liked = false;
} else {
- $this->assertQuota('proofing_feedback', $gallery->getId(), $guest->getId(), self::MAX_FEEDBACK_PER_GALLERY, self::MAX_FEEDBACK_PER_GALLERY);
- $this->repository->insertFeedback($gallery->getId(), $guest->getId(), $fileId, 'like', '1', $this->clock->getTime());
+ $this->assertQuota('proofing_feedback', $gallery->getId(), $actor, self::MAX_FEEDBACK_PER_GALLERY, self::MAX_FEEDBACK_PER_GALLERY);
+ $this->repository->insertFeedback($gallery->getId(), $actor->guestId(), $actor->userUid(), $fileId, 'like', '1', $this->clock->getTime());
$liked = true;
}
- $this->event($gallery, $guest, 'like.changed', ['fileId' => $fileId, 'liked' => $liked]);
+ $this->event($gallery, $actor, 'like.changed', ['fileId' => $fileId, 'liked' => $liked]);
return $liked;
});
}
@@ -173,13 +174,13 @@ public function toggleLike(Gallery $gallery, Guest $guest, int $fileId): bool {
public function saveRating(PublicLink $link, Gallery $gallery, Guest $guest, int $fileId, int $rating, string $pick): GuestRating {
return $this->atomic(function () use ($link, $gallery, $guest, $fileId, $rating, $pick): GuestRating {
$value = $this->guestRatings->save($link, $guest, $fileId, $rating, $pick);
- $this->event($gallery, $guest, 'rating.changed', ['fileId' => $fileId]);
+ $this->event($gallery, CollaborationActor::guest($guest), 'rating.changed', ['fileId' => $fileId]);
return $value;
});
}
- public function setColor(Gallery $gallery, Guest $guest, int $fileId, ?string $value): void {
- $this->atomic(function () use ($gallery, $guest, $fileId, $value): void {
+ public function setColor(Gallery $gallery, CollaborationActor $actor, int $fileId, ?string $value): void {
+ $this->atomic(function () use ($gallery, $actor, $fileId, $value): void {
$this->capabilities->assertFeature('colors');
$settings = $this->assertCollaboration($gallery, $fileId);
if (!$settings->review->colors) {
@@ -193,22 +194,22 @@ public function setColor(Gallery $gallery, Guest $guest, int $fileId, ?string $v
if ($value !== null && !in_array($value, $enabledLabels, true)) {
throw new InvalidArgumentException('Unknown color workflow state');
}
- $id = $this->repository->feedbackId($gallery->getId(), $guest->getId(), $fileId, 'color');
+ $id = $this->repository->feedbackId($gallery->getId(), $actor->guestId(), $actor->userUid(), $fileId, 'color');
if ($value === null && $id !== null) {
$this->repository->deleteFeedback($id);
} elseif ($value !== null && $id === null) {
- $this->assertQuota('proofing_feedback', $gallery->getId(), $guest->getId(), self::MAX_FEEDBACK_PER_GALLERY, self::MAX_FEEDBACK_PER_GALLERY);
- $this->repository->insertFeedback($gallery->getId(), $guest->getId(), $fileId, 'color', $value, $this->clock->getTime());
+ $this->assertQuota('proofing_feedback', $gallery->getId(), $actor, self::MAX_FEEDBACK_PER_GALLERY, self::MAX_FEEDBACK_PER_GALLERY);
+ $this->repository->insertFeedback($gallery->getId(), $actor->guestId(), $actor->userUid(), $fileId, 'color', $value, $this->clock->getTime());
} elseif ($value !== null) {
$this->repository->updateFeedback($id, $value, $this->clock->getTime());
}
- $this->event($gallery, $guest, 'color.changed', ['fileId' => $fileId, 'value' => $value]);
+ $this->event($gallery, $actor, 'color.changed', ['fileId' => $fileId, 'value' => $value]);
});
}
/** @param array|null $annotation */
- public function addComment(Gallery $gallery, Guest $guest, int $fileId, string $body, ?array $annotation): int {
- return $this->atomic(function () use ($gallery, $guest, $fileId, $body, $annotation): int {
+ public function addComment(Gallery $gallery, CollaborationActor $actor, int $fileId, string $body, ?array $annotation): int {
+ return $this->atomic(function () use ($gallery, $actor, $fileId, $body, $annotation): int {
$this->capabilities->assertFeature('comments');
if ($annotation !== null) $this->capabilities->assertFeature('annotations');
$settings = $this->assertCollaboration($gallery, $fileId);
@@ -225,50 +226,50 @@ public function addComment(Gallery $gallery, Guest $guest, int $fileId, string $
if ($body === '' || mb_strlen($body) > 5000) {
throw new InvalidArgumentException('Comment must contain between 1 and 5000 characters');
}
- $this->assertQuota('proofing_comments', $gallery->getId(), $guest->getId(), self::MAX_COMMENTS_PER_GALLERY, self::MAX_COMMENTS_PER_GUEST);
+ $this->assertQuota('proofing_comments', $gallery->getId(), $actor, self::MAX_COMMENTS_PER_GALLERY, self::MAX_COMMENTS_PER_GUEST);
$commentId = $this->repository->insertComment(
- $gallery->getId(), $guest->getId(), $fileId, $body, $annotation, $this->clock->getTime(),
+ $gallery->getId(), $actor->guestId(), $actor->userUid(), $fileId, $body, $annotation, $this->clock->getTime(),
);
- $this->event($gallery, $guest, 'comment.created', ['fileId' => $fileId, 'commentId' => $commentId]);
+ $this->event($gallery, $actor, 'comment.created', ['fileId' => $fileId, 'commentId' => $commentId]);
return $commentId;
});
}
- public function deleteComment(Gallery $gallery, Guest $guest, int $commentId): void {
- $this->atomic(function () use ($gallery, $guest, $commentId): void {
+ public function deleteComment(Gallery $gallery, CollaborationActor $actor, int $commentId): void {
+ $this->atomic(function () use ($gallery, $actor, $commentId): void {
$this->capabilities->assertFeature('comments');
- $fileId = $this->ownedCommentFileId($gallery, $guest, $commentId);
- if (!$this->repository->deleteComment($gallery->getId(), $guest->getId(), $commentId, $this->clock->getTime())) {
+ $fileId = $this->ownedCommentFileId($gallery, $actor, $commentId);
+ if (!$this->repository->deleteComment($gallery->getId(), $actor->guestId(), $actor->userUid(), $commentId, $this->clock->getTime())) {
throw new InvalidArgumentException('Comment cannot be deleted');
}
- $this->event($gallery, $guest, 'comment.deleted', ['fileId' => $fileId, 'commentId' => $commentId]);
+ $this->event($gallery, $actor, 'comment.deleted', ['fileId' => $fileId, 'commentId' => $commentId]);
});
}
- public function ownedCommentFileId(Gallery $gallery, Guest $guest, int $commentId): int {
- $fileId = $this->repository->ownedCommentFileId($gallery->getId(), $guest->getId(), $commentId);
+ public function ownedCommentFileId(Gallery $gallery, CollaborationActor $actor, int $commentId): int {
+ $fileId = $this->repository->ownedCommentFileId($gallery->getId(), $actor->guestId(), $actor->userUid(), $commentId);
if ($fileId === null) throw new InvalidArgumentException('Comment not found');
return $fileId;
}
- public function updateComment(Gallery $gallery, Guest $guest, int $commentId, string $body): void {
- $this->atomic(function () use ($gallery, $guest, $commentId, $body): void {
+ public function updateComment(Gallery $gallery, CollaborationActor $actor, int $commentId, string $body): void {
+ $this->atomic(function () use ($gallery, $actor, $commentId, $body): void {
$this->capabilities->assertFeature('comments');
- $fileId = $this->ownedCommentFileId($gallery, $guest, $commentId);
+ $fileId = $this->ownedCommentFileId($gallery, $actor, $commentId);
$body = trim($body);
if ($body === '' || mb_strlen($body) > 5000) {
throw new InvalidArgumentException('Comment must contain between 1 and 5000 characters');
}
- if (!$this->repository->updateComment($gallery->getId(), $guest->getId(), $commentId, $body, $this->clock->getTime())) {
+ if (!$this->repository->updateComment($gallery->getId(), $actor->guestId(), $actor->userUid(), $commentId, $body, $this->clock->getTime())) {
throw new InvalidArgumentException('Comment cannot be edited');
}
- $this->event($gallery, $guest, 'comment.updated', ['fileId' => $fileId, 'commentId' => $commentId]);
+ $this->event($gallery, $actor, 'comment.updated', ['fileId' => $fileId, 'commentId' => $commentId]);
});
}
/** @param list $fileIds */
- public function saveSelection(Gallery $gallery, Guest $guest, string $name, string $message, array $fileIds): string {
- return $this->atomic(function () use ($gallery, $guest, $name, $message, $fileIds): string {
+ public function saveSelection(Gallery $gallery, CollaborationActor $actor, string $name, string $message, array $fileIds): string {
+ return $this->atomic(function () use ($gallery, $actor, $name, $message, $fileIds): string {
$this->capabilities->assertFeature('selections');
$this->assertCollaborationMode($gallery);
if (!$this->settings($gallery)->review->selections) {
@@ -286,13 +287,13 @@ public function saveSelection(Gallery $gallery, Guest $guest, string $name, stri
foreach ($fileIds as $fileId) {
$this->resolveMedia($gallery, $fileId);
}
- $this->assertQuota('proofing_selections', $gallery->getId(), $guest->getId(), self::MAX_SELECTIONS_PER_GALLERY, self::MAX_SELECTIONS_PER_GUEST);
+ $this->assertQuota('proofing_selections', $gallery->getId(), $actor, self::MAX_SELECTIONS_PER_GALLERY, self::MAX_SELECTIONS_PER_GUEST);
$publicId = $this->uuid();
$now = $this->clock->getTime();
$this->repository->insertSelection(
- $gallery->getId(), $guest->getId(), $publicId, $name, $message, $fileIds, $now,
+ $gallery->getId(), $actor->guestId(), $actor->userUid(), $publicId, $name, $message, $fileIds, $now,
);
- $this->event($gallery, $guest, 'selection.created', ['selectionId' => $publicId, 'count' => count($fileIds)]);
+ $this->event($gallery, $actor, 'selection.created', ['selectionId' => $publicId, 'count' => count($fileIds)]);
$this->markResponseReceived($gallery, $now);
return $publicId;
});
@@ -365,6 +366,43 @@ public function guestSelectionFileIds(Gallery $gallery, Guest $guest, string $pu
return $this->repository->selectionFileIds((int)$row['id']);
}
+ /** @return list */
+ public function actorSelectionFileIds(Gallery $gallery, CollaborationActor $actor, string $publicId): array {
+ $row = $this->repository->selection($gallery->getId(), $publicId);
+ if ($row === null || ($this->settings($gallery)->review->visibility === FeedbackVisibility::Private && !$actor->owns($row))) {
+ throw new InvalidArgumentException('Selection not found');
+ }
+ return $this->repository->selectionFileIds((int)$row['id']);
+ }
+
+ /**
+ * Authenticated reviewers export their selection without being treated as
+ * either a guest-rating principal or a gallery owner.
+ * @param list $requestedFields
+ * @return array{content: string, filename: string, mimeType: string}
+ */
+ public function exportActorSelection(Gallery $gallery, CollaborationActor $actor, string $publicId, string $format, array $requestedFields = []): array {
+ $this->capabilities->assertFeature('selections');
+ $row = $this->repository->selection($gallery->getId(), $publicId);
+ if ($row === null || ($this->settings($gallery)->review->visibility === FeedbackVisibility::Private && !$actor->owns($row))) {
+ throw new InvalidArgumentException('Selection not found');
+ }
+ $names = [];
+ foreach ($this->repository->selectionFileIds((int)$row['id']) as $fileId) {
+ try { $names[] = $this->resolveMedia($gallery, $fileId)->getName(); } catch (\Throwable) {}
+ }
+ $base = preg_replace('/[^a-z0-9._-]+/i', '-', (string)$row['name']) ?: 'selection';
+ if ($format === 'csv' || $format === 'preview') {
+ $content = "\xEF\xBB\xBF" . $this->csv->encode([['filename'], ...array_map(static fn (string $name): array => [$name], $names)]);
+ return ['content' => $content, 'filename' => $base . ($format === 'preview' ? '-preview.csv' : '.csv'), 'mimeType' => 'text/csv; charset=utf-8'];
+ }
+ return match ($format) {
+ 'search' => ['content' => implode(' OR ', array_map(static fn (string $name): string => 'name:"' . str_replace('"', '\\"', $name) . '"', $names)), 'filename' => $base . '-search.txt', 'mimeType' => 'text/plain'],
+ 'plain' => ['content' => implode("\n", $names) . "\n", 'filename' => $base . '.txt', 'mimeType' => 'text/plain'],
+ default => throw new InvalidArgumentException('Unknown export format'),
+ };
+ }
+
/**
* @param list $fileIds
* @param list $fields
@@ -432,7 +470,7 @@ public function updateOwnerSelection(Gallery $gallery, string $publicId, string
throw new InvalidArgumentException('Selection not found');
}
$this->repository->insertOwnerEvent(
- $gallery->getId(), (int)$selection['guest_id'], $gallery->getOwnerUid(),
+ $gallery->getId(), $selection['guest_id'] === null ? null : (int)$selection['guest_id'], $gallery->getOwnerUid(),
'selection.updated', ['selectionId' => $publicId], $now,
);
});
@@ -444,7 +482,7 @@ public function deleteOwnerSelection(Gallery $gallery, string $publicId): void {
if ($selection === null) throw new InvalidArgumentException('Selection not found');
if (!$this->repository->deleteSelection($gallery->getId(), $publicId)) throw new InvalidArgumentException('Selection not found');
$this->repository->insertOwnerEvent(
- $gallery->getId(), (int)$selection['guest_id'], $gallery->getOwnerUid(),
+ $gallery->getId(), $selection['guest_id'] === null ? null : (int)$selection['guest_id'], $gallery->getOwnerUid(),
'selection.deleted', ['selectionId' => $publicId, 'deleted' => true], $this->clock->getTime(),
);
});
@@ -454,9 +492,9 @@ private function settings(Gallery $gallery): GallerySettings {
return GallerySettings::fromArray(json_decode($gallery->getSettings(), true, flags: JSON_THROW_ON_ERROR));
}
- private function assertQuota(string $table, int $galleryId, int $guestId, int $galleryLimit, int $guestLimit): void {
+ private function assertQuota(string $table, int $galleryId, CollaborationActor $actor, int $galleryLimit, int $guestLimit): void {
if ($this->repository->hasAtLeastRows($table, $galleryId, $galleryLimit)
- || $this->repository->hasAtLeastRows($table, $galleryId, $guestLimit, $guestId)) {
+ || $this->repository->hasAtLeastRows($table, $galleryId, $guestLimit, $actor->guestId(), $actor->userUid())) {
throw new InvalidArgumentException('Collaboration data limit reached');
}
}
@@ -492,7 +530,7 @@ private function assertCollaborationMode(Gallery $gallery): GallerySettings {
/** @param list> $rows
* @return list>
*/
- private function presentComments(array $rows, ?Guest $viewer): array {
+ private function presentComments(array $rows, ?CollaborationActor $viewer): array {
return array_map(
static fn (array $row): array => [
'id' => (int)$row['id'],
@@ -501,7 +539,7 @@ private function presentComments(array $rows, ?Guest $viewer): array {
'createdAt' => (int)$row['created_at'],
'editedAt' => $row['edited_at'] === null ? null : (int)$row['edited_at'],
'deletedAt' => $row['deleted_at'] === null ? null : (int)$row['deleted_at'],
- 'mine' => $viewer !== null && (int)$row['guest_id'] === $viewer->getId(),
+ 'mine' => $viewer !== null && $viewer->owns($row),
'author' => (string)$row['author'],
'annotations' => $row['annotations'],
],
@@ -512,7 +550,7 @@ private function presentComments(array $rows, ?Guest $viewer): array {
/** @param list> $rows
* @return list>
*/
- private function presentSelections(array $rows, ?Guest $viewer): array {
+ private function presentSelections(array $rows, ?CollaborationActor $viewer): array {
return array_map(static fn (array $row): array => [
'id' => $row['public_id'],
'name' => $row['name'],
@@ -520,15 +558,15 @@ private function presentSelections(array $rows, ?Guest $viewer): array {
'status' => $row['status'],
'fileIds' => $row['fileIds'],
'updatedAt' => (int)$row['updated_at'],
- 'mine' => $viewer !== null && (int)$row['guest_id'] === $viewer->getId(),
+ 'mine' => $viewer !== null && $viewer->owns($row),
'author' => (string)$row['author'],
], $rows);
}
/** @param array $payload */
- private function event(Gallery $gallery, Guest $guest, string $type, array $payload): void {
+ private function event(Gallery $gallery, CollaborationActor $actor, string $type, array $payload): void {
$now = $this->clock->getTime();
- $eventId = $this->repository->insertEvent($gallery->getId(), $guest->getId(), $type, $payload, $now);
+ $eventId = $this->repository->insertEvent($gallery->getId(), $actor->guestId(), $actor->userUid(), $type, $payload, $now);
$staged = $this->notifications->stage($gallery, $eventId, $type, $now);
$this->stagedActivities[] = [
'gallery' => $gallery,
@@ -539,6 +577,12 @@ private function event(Gallery $gallery, Guest $guest, string $type, array $payl
];
}
+ private function actorScope(CollaborationActor $actor): CollaborationReadScope {
+ return $actor->guestId() !== null
+ ? CollaborationReadScope::guest($actor->guestId())
+ : CollaborationReadScope::user($actor->userUid() ?? '');
+ }
+
private function atomic(callable $callback): mixed {
$ownsTransaction = !$this->db->inTransaction();
$activityOffset = count($this->stagedActivities);
diff --git a/lib/Share/PublicShareTemplateProvider.php b/lib/Share/PublicShareTemplateProvider.php
index 4fb5efe..ac52a12 100644
--- a/lib/Share/PublicShareTemplateProvider.php
+++ b/lib/Share/PublicShareTemplateProvider.php
@@ -11,6 +11,7 @@
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IURLGenerator;
+use OCP\IUserSession;
use OCP\Share\IPublicShareTemplateProvider;
use OCP\Share\IShare;
use OCP\Util;
@@ -22,6 +23,7 @@ public function __construct(
private IURLGenerator $urlGenerator,
private \OCA\ProofingGallery\Service\PublicShareContextResolver $contexts,
private \OCA\ProofingGallery\Service\ReviewWorkflowService $reviews,
+ private IUserSession $userSession,
) {
}
@@ -42,6 +44,7 @@ public function renderPage(IShare $share, string $token, string $path): Template
return $response;
}
$gallery = $context->gallery;
+ $user = $this->userSession->getUser();
$initialPage = $this->galleryData->page($context, new PublicGalleryQuery(path: $path));
$firstImage = null;
foreach ($initialPage['items'] as $item) {
@@ -71,11 +74,16 @@ public function renderPage(IShare $share, string $token, string $path): Template
'path' => $path,
'initialPage' => $initialPage,
'review' => $this->reviews->publicState($context->link),
+ 'viewer' => $user === null ? null : [
+ 'displayName' => $user->getDisplayName(),
+ 'email' => $user->getEMailAddress(),
+ ],
]);
- Util::addScript(Application::APP_ID, 'proofing_gallery-public');
+ Util::addScript(Application::APP_ID, $this->publicScriptName());
Util::addStyle(Application::APP_ID, 'proofing_gallery-public');
$response = new PublicTemplateResponse(Application::APP_ID, 'public');
+ $response->addHeader('Cache-Control', 'private, no-store');
if ($firstPaintImage !== null) {
$response->addHeader('Link', sprintf('<%s>; rel=preload; as=image', $firstPaintImage));
}
@@ -86,4 +94,17 @@ public function renderPage(IShare $share, string $token, string $path): Template
]);
return $response;
}
+
+ private function publicScriptName(): string {
+ $directory = dirname(__DIR__, 2) . '/js';
+ $pointer = $directory . '/proofing_gallery-public.current';
+ if (!is_readable($pointer)) {
+ return 'proofing_gallery-public';
+ }
+ $candidate = trim((string)file_get_contents($pointer));
+ if (!preg_match('/^proofing_gallery-public-[a-f0-9]{12}$/', $candidate)) {
+ return 'proofing_gallery-public';
+ }
+ return is_file($directory . '/' . $candidate . '.mjs') ? $candidate : 'proofing_gallery-public';
+ }
}
diff --git a/src/PublicApp.vue b/src/PublicApp.vue
index 7eace07..3ace1b9 100644
--- a/src/PublicApp.vue
+++ b/src/PublicApp.vue
@@ -6,7 +6,7 @@ import { IonAlert, IonApp, IonContent, IonLoading, IonPage } from '@ionic/vue'
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { calculateMediaLayout } from './domain/mediaGridLayout.ts'
-import { contrastRgb, hexRgb, mixHex, readableText } from './domain/galleryTheme.ts'
+import { publicGalleryThemeStyle } from './domain/publicGalleryThemeStyle.ts'
import { PUBLIC_GALLERY_PAGE_SIZE, readPublicGalleryLocation, writePublicGalleryLocation } from './domain/publicGalleryNavigation.ts'
import { continuationStorageKey, layoutSessionStorageKey, loadPublicGalleryCompareIds, loadPublicGalleryContinuation, loadPublicGallerySavedView, loadPublicGallerySessionLayout, viewStorageKey } from './domain/publicGalleryPreferences.ts'
import { serialTask } from './domain/serialTask.ts'
@@ -50,21 +50,7 @@ const indexState = ref(props.gallery.initialPage?.indexState ?? null)
const scope = ref(props.gallery.initialPage?.scope ?? null)
const settings = ref(props.gallery.initialPage?.gallery.settings ?? props.gallery.settings)
const title = ref(props.gallery.initialPage?.gallery.title ?? props.gallery.title)
-const pageStyle = computed(() => {
- const accent = settings.value.presentation.accentColor || '#E85D4A'
- const rgb = hexRgb(accent)
- const contrast = readableText(rgb)
- return {
- '--gallery-accent': accent,
- '--ion-color-primary': accent,
- '--ion-color-primary-rgb': rgb.join(', '),
- '--ion-color-primary-contrast': contrast,
- '--ion-color-primary-contrast-rgb': contrastRgb(contrast),
- '--ion-color-primary-shade': mixHex(rgb, [0, 0, 0], 0.12),
- '--ion-color-primary-tint': mixHex(rgb, [255, 255, 255], 0.14),
- '--hero-focus': `${settings.value.presentation.heroFocusX}% ${settings.value.presentation.heroFocusY}%`,
- }
-})
+const pageStyle = computed(() => publicGalleryThemeStyle(settings.value))
const mediaItems = computed(() => items.value.filter(item => !item.folder))
const headerHeroUrl = computed(() => settings.value.presentation.heroFileId ? assetUrl('hero') : null)
@@ -84,7 +70,6 @@ const compareOpen = ref(false)
const compareItems = computed(() => compareIds.value.map(id => mediaItems.value.find(item => item.id === id)).filter((item): item is MediaItem => !!item))
let collaborationTimer: number | undefined
const { guest, collaboration, hydratedIds: collaborationHydratedIds, nonce, restoreIdentity, clearIdentity } = usePublicCollaborationIdentity(props.gallery.token)
-const [guestName, guestEmail] = [ref(''), ref('')]
const joining = ref(false)
const collaborationError = ref('')
const galleryDownloadBusy = ref(false)
@@ -120,7 +105,9 @@ const activePanel = ref<'menu' | 'search' | 'view' | 'pages' | 'download' | 'sel
const searchOpen = ref(false)
const collaborationSheetOpen = ref(false)
const mediaDimensions = ref>({})
-const mobileViewportQuery = window.matchMedia('(max-width: 640px)')
+// Panel mode changes only on phone-sized viewports. Narrow desktop windows keep
+// centered dialogs and floating annotation controls instead of mobile sheets.
+const mobileViewportQuery = window.matchMedia('(max-width: 520px)')
const mobileViewport = ref(mobileViewportQuery.matches)
const viewportWidth = ref(window.innerWidth)
let searchTimer: number | undefined
@@ -433,7 +420,7 @@ function onVisibilityChange() {
startCollaborationPolling()
}
-async function joinCollaboration() {
+async function joinCollaboration(identity: { displayName: string; email: string }) {
joining.value = true
collaborationError.value = ''
try {
@@ -441,7 +428,7 @@ async function joinCollaboration() {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
- body: JSON.stringify({ displayName: guestName.value, email: guestEmail.value || null }),
+ body: JSON.stringify({ displayName: identity.displayName, email: identity.email || null }),
})
const payload = await response.json() as { guest?: GuestIdentity, nonce?: string, message?: string }
if (!response.ok || !payload.guest || !payload.nonce) {
@@ -968,6 +955,7 @@ function upOneLevel() {
-
diff --git a/src/components/PublicCollaborationSheet.vue b/src/components/PublicCollaborationSheet.vue
index 00b8454..ebd97b3 100644
--- a/src/components/PublicCollaborationSheet.vue
+++ b/src/components/PublicCollaborationSheet.vue
@@ -10,6 +10,7 @@ import PublicReviewBar from './PublicReviewBar.vue'
defineProps<{
open: boolean
+ mobile: boolean
guest: GuestIdentity | null
review: PublicReviewState
nonce: string
@@ -32,8 +33,9 @@ const emit = defineEmits<{
@@ -51,7 +53,7 @@ const emit = defineEmits<{
:token="token"
:nonce="nonce"
:private-feedback="privateFeedback"
- :allow-uploads="allowUploads"
+ :allow-uploads="allowUploads && guest.kind !== 'user'"
:request="request"
@deleted="emit('deleted')"
@error="emit('error', $event)" />
@@ -72,8 +74,12 @@ const emit = defineEmits<{
diff --git a/src/components/PublicGuestDialog.spec.ts b/src/components/PublicGuestDialog.spec.ts
new file mode 100644
index 0000000..4b3047c
--- /dev/null
+++ b/src/components/PublicGuestDialog.spec.ts
@@ -0,0 +1,67 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+
+import PublicGuestDialog from './PublicGuestDialog.vue'
+
+vi.mock('@nextcloud/l10n', () => ({ t: (_app: string, message: string) => message }))
+
+const ionicStubs = {
+ IonModal: { template: '
' },
+ IonHeader: { template: '' },
+ IonToolbar: { template: '
' },
+ IonTitle: { template: '
' },
+ IonButtons: { template: '
' },
+ IonButton: { template: ' ' },
+ IonIcon: { template: ' ' },
+ IonContent: { template: ' ' },
+}
+
+describe('PublicGuestDialog', () => {
+ it('renders labelled identity fields and a visible submit label', async () => {
+ const wrapper = mount(PublicGuestDialog, {
+ props: { open: true, joining: false, viewer: { displayName: 'Nextcloud User', email: 'user@example.test' } },
+ global: { stubs: ionicStubs },
+ })
+ expect(wrapper.get('label[for="proofing-gallery-guest-name"]').text()).toBe('Your name')
+ expect(wrapper.get('label[for="proofing-gallery-guest-email"]').text()).toBe('Email (optional)')
+ expect(wrapper.get('#proofing-gallery-guest-name').element.value).toBe('Nextcloud User')
+ expect(wrapper.get('#proofing-gallery-guest-email').element.value).toBe('user@example.test')
+ expect(wrapper.get('.guest-dialog__submit').text()).toBe('Continue')
+ await wrapper.get('form').trigger('submit')
+ expect(wrapper.emitted('submit')).toEqual([[{ displayName: 'Nextcloud User', email: 'user@example.test' }]])
+ })
+
+ it('shows progress without dropping the button label', () => {
+ const wrapper = mount(PublicGuestDialog, {
+ props: { open: true, joining: true, viewer: null },
+ global: { stubs: ionicStubs },
+ })
+ expect(wrapper.get('.guest-dialog__submit').text()).toBe('Saving…')
+ expect(wrapper.get('.guest-dialog__submit').attributes('disabled')).toBeDefined()
+ })
+
+ it('loads the signed-in Nextcloud profile when public-share state has no viewer', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ ocs: { data: { 'display-name': 'ncadmin', email: 'nc@example.test' } } }),
+ }))
+ const wrapper = mount(PublicGuestDialog, {
+ props: { open: true, joining: false, viewer: null },
+ global: { stubs: ionicStubs },
+ })
+ await flushPromises()
+ expect(wrapper.get('#proofing-gallery-guest-name').element.value).toBe('ncadmin')
+ expect(wrapper.get('#proofing-gallery-guest-email').element.value).toBe('nc@example.test')
+ vi.unstubAllGlobals()
+ })
+
+ it('uses Nextcloud’s signed-in display name synchronously', () => {
+ document.head.setAttribute('data-user-displayname', 'ncadmin')
+ const wrapper = mount(PublicGuestDialog, {
+ props: { open: true, joining: false, viewer: null },
+ global: { stubs: ionicStubs },
+ })
+ expect(wrapper.get('#proofing-gallery-guest-name').element.value).toBe('ncadmin')
+ document.head.removeAttribute('data-user-displayname')
+ })
+})
diff --git a/src/components/PublicGuestDialog.vue b/src/components/PublicGuestDialog.vue
index 9c4038c..2518947 100644
--- a/src/components/PublicGuestDialog.vue
+++ b/src/components/PublicGuestDialog.vue
@@ -3,12 +3,24 @@
import { t } from '@nextcloud/l10n'
import { closeOutline } from 'ionicons/icons'
-import { IonButton, IonButtons, IonContent, IonHeader, IonIcon, IonInput, IonItem, IonList, IonModal, IonTitle, IonToolbar } from '@ionic/vue'
+import { IonButton, IonButtons, IonContent, IonHeader, IonIcon, IonModal, IonTitle, IonToolbar } from '@ionic/vue'
+import { onMounted, ref } from 'vue'
-defineProps<{ open: boolean; joining: boolean }>()
-const name = defineModel('name', { required: true })
-const email = defineModel('email', { required: true })
-defineEmits<{ dismiss: []; submit: [] }>()
+import { fetchCurrentViewer } from '../services/currentViewerApi.ts'
+
+const props = defineProps<{ open: boolean; joining: boolean; viewer?: { displayName: string; email: string | null } | null }>()
+const name = ref(props.viewer?.displayName ?? document.head.getAttribute('data-user-displayname') ?? '')
+const email = ref(props.viewer?.email ?? '')
+defineEmits<{ dismiss: []; submit: [identity: { displayName: string; email: string }] }>()
+
+onMounted(async () => {
+ try {
+ const viewer = await fetchCurrentViewer()
+ if (viewer) [name.value, email.value] = [name.value || viewer.displayName, email.value || viewer.email]
+ } catch {
+ // Anonymous public shares and unavailable profile APIs keep editable blank fields.
+ }
+})
@@ -24,51 +36,63 @@ defineEmits<{ dismiss: []; submit: [] }>()
-
diff --git a/src/components/PublicGuestIdentity.vue b/src/components/PublicGuestIdentity.vue
index febe89f..d71a7b5 100644
--- a/src/components/PublicGuestIdentity.vue
+++ b/src/components/PublicGuestIdentity.vue
@@ -45,7 +45,7 @@ async function deleteData() {
{{ t('proofing_gallery', 'Reviewing as {name}', { name: guest.displayName }) }}
{{ privateFeedback ? t('proofing_gallery', 'Your feedback is private') : t('proofing_gallery', 'Feedback is shared with reviewers') }}
-
+
{{ t('proofing_gallery', 'Export my data') }}
{{ t('proofing_gallery', 'Delete my data') }}
diff --git a/src/components/PublicLightbox.vue b/src/components/PublicLightbox.vue
index 18d6a73..e3d7ea5 100644
--- a/src/components/PublicLightbox.vue
+++ b/src/components/PublicLightbox.vue
@@ -1,29 +1,8 @@
-
diff --git a/src/components/styles/PublicGalleryControls.css b/src/components/styles/PublicGalleryControls.css
index d45689d..0705839 100644
--- a/src/components/styles/PublicGalleryControls.css
+++ b/src/components/styles/PublicGalleryControls.css
@@ -36,7 +36,7 @@
.gallery-sheet__downloads { padding-top: 16px; }
-@media (max-width: 640px) {
+@media (max-width: 520px) {
.gallery-sheet { --width: 100%; }
.gallery-sheet__pages > div { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
diff --git a/src/components/styles/PublicLightbox.css b/src/components/styles/PublicLightbox.css
index ecefd33..b05552c 100644
--- a/src/components/styles/PublicLightbox.css
+++ b/src/components/styles/PublicLightbox.css
@@ -96,7 +96,7 @@
.touch-hint-enter-from, .touch-hint-leave-to { opacity: 0; transform: translate(50%, 6px); }
-:global(.proofing-annotation-layer) { position: absolute; z-index: 2; inset-block-start: 0; inset-inline-start: 0; pointer-events: none; }
+:global(.proofing-annotation-layer) { position: absolute; z-index: 2; inset: 0; pointer-events: none; }
:global(ion-action-sheet.lightbox-action-sheet),
:global(ion-modal.lightbox-sheet),
@@ -132,7 +132,23 @@
to { transform: scaleX(1); }
}
-:global(ion-modal.lightbox-sheet) { --width: min(440px, calc(100% - 32px)); --height: min(720px, calc(100% - 48px)); --border-radius: 16px; }
+:global(ion-modal.lightbox-sheet) { --width: min(440px, calc(100% - 32px)); --height: auto; --border-radius: 16px; }
+
+:global(ion-modal.lightbox-sheet::part(content)) { max-height: min(720px, calc(100dvh - 48px)); }
+
+:global(ion-modal.lightbox-sheet .ion-page) { position: relative; contain: content; max-height: min(720px, calc(100dvh - 48px)); }
+
+:global(ion-modal.lightbox-sheet.lightbox-feedback-sheet) { --width: min(400px, calc(100% - 32px)); --height: min(680px, calc(100dvh - 48px)); --border-radius: 18px; align-items: center; justify-content: center; }
+
+:global(ion-modal.lightbox-sheet.lightbox-feedback-sheet--left) { box-sizing: border-box; justify-content: flex-start; padding-inline-start: var(--feedback-panel-edge-inset, 16px); }
+
+:global(ion-modal.lightbox-sheet.lightbox-feedback-sheet--right) { box-sizing: border-box; justify-content: flex-end; padding-inline-end: var(--feedback-panel-edge-inset, 16px); }
+
+:global(ion-modal.lightbox-sheet.lightbox-feedback-sheet--center) { justify-content: center; }
+
+:global(ion-modal.lightbox-sheet.lightbox-feedback-sheet::part(content)) { max-height: min(680px, calc(100dvh - 48px)); box-shadow: 0 24px 64px rgb(0 0 0 / 48%); }
+
+:global(ion-modal.lightbox-sheet.lightbox-feedback-sheet .ion-page) { height: 100%; max-height: none; contain: size layout style; }
:global(ion-modal.lightbox-dialog) { --width: min(430px, calc(100% - 32px)); --height: auto; --border-radius: 16px; }
@@ -169,17 +185,7 @@
.lightbox-feedback { --color: var(--ion-text-color); }
-.lightbox-feedback__body { box-sizing: border-box; width: 100%; min-height: 100%; }
-
-.annotation-feedback-filter { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; padding: 14px; border: 1px solid color-mix(in srgb, var(--gallery-accent) 42%, var(--ion-border-color)); border-radius: 14px; background: color-mix(in srgb, var(--gallery-accent) 12%, var(--ion-background-color)); }
-
-.annotation-feedback-filter div { display: grid; min-width: 0; gap: 3px; }
-
-.annotation-feedback-filter span { color: var(--ion-color-medium); font-size: 11px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
-
-.annotation-feedback-filter strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-
-.annotation-feedback-filter button { min-height: 36px; flex: 0 0 auto; padding: 0 12px; border: 1px solid var(--ion-border-color); border-radius: 9px; background: var(--ion-color-light); color: var(--ion-text-color); font: inherit; font-size: 12px; font-weight: 650; }
+.lightbox-feedback__body { box-sizing: border-box; width: 100%; min-height: 100%; padding: 16px; }
.feedback-actions { display: grid; gap: 12px; }
@@ -229,6 +235,16 @@
.comment-form textarea { min-height: 104px; resize: vertical; }
+.annotation-reply-form { position: relative; z-index: 2; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 10px; padding: 12px 16px max(12px, env(safe-area-inset-bottom)); border-top: 1px solid var(--ion-border-color); background: color-mix(in srgb, var(--ion-background-color) 94%, transparent); box-shadow: 0 -12px 30px rgb(0 0 0 / 12%); }
+
+.annotation-reply-form textarea { box-sizing: border-box; width: 100%; min-width: 0; min-height: 72px; max-height: 160px; padding: 11px 13px; border: 1px solid var(--ion-border-color); border-radius: 12px; outline: none; background: var(--ion-color-light); color: var(--ion-text-color); font: inherit; line-height: 1.4; resize: vertical; }
+
+.annotation-reply-form textarea:focus { border-color: var(--gallery-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--gallery-accent) 22%, transparent); }
+
+.annotation-reply-form button { min-height: 42px; padding: 0 16px; border: 0; border-radius: 10px; background: var(--gallery-accent); color: #fff; font: inherit; font-size: 13px; font-weight: 750; }
+
+.annotation-reply-form button:disabled { cursor: not-allowed; opacity: .48; }
+
.saved-selections article div { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 9px; }
.saved-selections { margin-top: 28px; border-top: 1px solid var(--ion-border-color); }
@@ -254,14 +270,21 @@
.lightbox-nav--previous { inset-inline-start: 8px; }
.lightbox-nav--next, .lightbox-shell--filmstrip-side .lightbox-nav--next { inset-inline-end: 8px; }
.lightbox-touch-hint { bottom: calc(96px + env(safe-area-inset-bottom)); max-width: calc(100% - 32px); text-align: center; white-space: nowrap; }
- :global(ion-modal.lightbox-sheet) { --width: 100%; --height: min(78dvh, 680px); --border-radius: 18px 18px 0 0; align-items: flex-end; }
- .annotation-feedback-filter { align-items: stretch; flex-direction: column; }
- .annotation-feedback-filter button { width: 100%; }
.guest-rating__stars { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); }
.guest-rating__stars span { grid-column: 1 / -1; margin-inline-end: 0; }
.guest-rating__stars button { width: 100%; min-width: 0; padding-inline: 0; }
}
+@media (max-width: 520px) {
+ :global(ion-modal.lightbox-sheet) { --width: 100%; --height: auto; --border-radius: 18px 18px 0 0; align-items: flex-end; }
+ :global(ion-modal.lightbox-sheet::part(content)),
+ :global(ion-modal.lightbox-sheet .ion-page) { max-height: min(78dvh, 680px); }
+ :global(ion-modal.lightbox-sheet.lightbox-feedback-sheet) { --width: 100%; --height: min(78dvh, 680px); --border-radius: 18px 18px 0 0; align-items: flex-end; justify-content: center; }
+ :global(ion-modal.lightbox-sheet.lightbox-feedback-sheet::part(content)) { max-height: min(78dvh, 680px); box-shadow: 0 -18px 54px rgb(0 0 0 / 42%); }
+ .annotation-reply-form { grid-template-columns: 1fr; }
+ .annotation-reply-form button { width: 100%; }
+}
+
@media (prefers-reduced-motion: reduce) {
.lightbox-bar, .lightbox-nav, :deep(.public-filmstrip) { transition: none; }
:global(.lightbox-shell .pswp__container), :global(.lightbox-shell .pswp__zoom-wrap), .touch-hint-enter-active, .touch-hint-leave-active { transition: none !important; }
diff --git a/src/composables/usePublicLightboxAnnotations.spec.ts b/src/composables/usePublicLightboxAnnotations.spec.ts
index f66741c..7e9426c 100644
--- a/src/composables/usePublicLightboxAnnotations.spec.ts
+++ b/src/composables/usePublicLightboxAnnotations.spec.ts
@@ -15,6 +15,7 @@ function setup(mutate = vi.fn().mockResolvedValue(true), hasIdentity = true) {
Object.defineProperties(image, { offsetWidth: { value: 1600 }, offsetHeight: { value: 800 } })
const container = document.createElement('div')
const photoSwipe = {
+ element: container,
currSlide: { content: { element: image }, container, currZoomLevel: 2, currentResolution: 1, zoomLevels: { initial: 1 } },
} as unknown as PhotoSwipe
const item: MediaItem = { id: 7, name: 'sheet.png', mimeType: 'image/png', size: 1, modifiedAt: 1, etag: 'a', folder: false }
diff --git a/src/composables/usePublicLightboxAnnotations.ts b/src/composables/usePublicLightboxAnnotations.ts
index 74f5cbc..4b3ae5b 100644
--- a/src/composables/usePublicLightboxAnnotations.ts
+++ b/src/composables/usePublicLightboxAnnotations.ts
@@ -6,7 +6,7 @@ import { computed, nextTick, ref } from 'vue'
import type { NormalizedAnnotation } from '../domain/collaboration.ts'
import type { GallerySettings } from '../domain/gallerySettings.ts'
import { annotationAtImagePoint, annotationScreenPoint, moveAnnotationPoint } from '../domain/lightboxReview.ts'
-import type { ScreenPoint } from '../domain/lightboxReview.ts'
+import type { ScreenBounds, ScreenPoint } from '../domain/lightboxReview.ts'
import type { CollaborationState, MediaItem } from '../publicTypes.ts'
interface Options {
@@ -23,6 +23,7 @@ interface Options {
interface AnnotationState {
host: Ref
+ imageBounds: Ref
draft: Ref
anchor: Ref
body: Ref
@@ -40,7 +41,7 @@ function createOverlay(options: Options, state: AnnotationState) {
return image instanceof HTMLImageElement ? image : null
}
- function updateAnchor(bounds = activeImage()?.getBoundingClientRect() ?? null) {
+ function updateAnchor(bounds: ScreenBounds | null = activeImage()?.getBoundingClientRect() ?? null) {
state.anchor.value = state.draft.value && bounds ? annotationScreenPoint(state.draft.value, bounds) : null
}
@@ -48,11 +49,19 @@ function createOverlay(options: Options, state: AnnotationState) {
const slide = options.photoSwipe()?.currSlide
const image = activeImage()
if (!image || !state.host.value || !slide) return
- state.host.value.style.width = `${image.offsetWidth}px`
- state.host.value.style.height = `${image.offsetHeight}px`
- const transformScale = slide.currZoomLevel / (slide.currentResolution || slide.zoomLevels.initial || 1)
- state.host.value.style.setProperty('--annotation-marker-scale', `${1 / Math.max(0.01, transformScale)}`)
- updateAnchor(image.getBoundingClientRect())
+ if (!slide.pan) {
+ const bounds = image.getBoundingClientRect()
+ state.imageBounds.value = { left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height }
+ updateAnchor(state.imageBounds.value)
+ return
+ }
+ const left = slide.pan.x
+ const top = slide.pan.y
+ const width = slide.width * slide.currZoomLevel
+ const height = slide.height * slide.currZoomLevel
+ if (width <= 0 || height <= 0) return
+ state.imageBounds.value = { left, top, width, height }
+ updateAnchor(state.imageBounds.value)
}
function syncHost() {
@@ -62,7 +71,7 @@ function createOverlay(options: Options, state: AnnotationState) {
if (!pswp?.currSlide || !options.activeItem.value?.mimeType.startsWith('image/')) return
const element = document.createElement('div')
element.className = 'proofing-annotation-layer'
- pswp.currSlide.container.append(element)
+ pswp.element?.append(element)
state.host.value = element
syncGeometry()
}
@@ -159,7 +168,7 @@ function createDraftActions(options: Options, state: AnnotationState, overlay: R
export function usePublicLightboxAnnotations(options: Options) {
const state: AnnotationState = {
- host: ref(null), draft: ref(null), anchor: ref(null), body: ref(''), error: ref(''),
+ host: ref(null), imageBounds: ref(null), draft: ref(null), anchor: ref(null), body: ref(''), error: ref(''),
composerOpen: ref(false), keyboardPositioning: ref(false), submitting: ref(false), selectedCommentId: ref(null),
returnFocus: ref(null),
}
@@ -198,6 +207,7 @@ export function usePublicLightboxAnnotations(options: Options) {
function destroy() {
state.host.value?.remove()
state.host.value = null
+ state.imageBounds.value = null
actions.cancel(false)
}
diff --git a/src/domain/lightboxReview.spec.ts b/src/domain/lightboxReview.spec.ts
index e3d4ce9..1231fa9 100644
--- a/src/domain/lightboxReview.spec.ts
+++ b/src/domain/lightboxReview.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { annotationAtImagePoint, annotationScreenPoint, findSelectedAnnotationComment, moveAnnotationPoint, shouldAutoHideLightboxChrome } from './lightboxReview.ts'
+import { annotationAtImagePoint, annotationNumbersByComment, annotationScreenPoint, annotationThreadPanelLayout, commentsForAnnotationThread, findSelectedAnnotationComment, moveAnnotationPoint, shouldAutoHideLightboxChrome } from './lightboxReview.ts'
describe('lightbox review interaction', () => {
it('keeps review chrome visible while respecting presentation behavior', () => {
@@ -34,4 +34,45 @@ describe('lightbox review interaction', () => {
expect(findSelectedAnnotationComment(comments, 99)).toBeNull()
expect(findSelectedAnnotationComment(comments, null)).toBeNull()
})
+
+ it('groups replies that reuse one annotation into a single numbered thread', () => {
+ const point = { x: 1000, y: 1000, width: 800, height: 800 }
+ const comments = [
+ { id: 1, createdAt: 1, annotations: [point] },
+ { id: 2, createdAt: 2, annotations: [point] },
+ { id: 3, createdAt: 3, annotations: [{ ...point, x: 3000 }] },
+ { id: 4, createdAt: 4, annotations: [] },
+ ]
+ const numbers = annotationNumbersByComment(comments)
+ expect(numbers.get(1)).toEqual([1])
+ expect(numbers.get(2)).toEqual([1])
+ expect(numbers.get(3)).toEqual([2])
+ expect(commentsForAnnotationThread(comments, 1).map(comment => comment.id)).toEqual([1, 2])
+ expect(commentsForAnnotationThread(comments, 4)).toEqual([])
+ })
+
+ it('places selected annotation threads on a clear desktop edge', () => {
+ const leftPin = annotationThreadPanelLayout({ viewportWidth: 1280, viewportHeight: 800, annotationPoint: { x: 320, y: 300 }, filmstripSide: true })
+ expect(leftPin).toEqual({ placement: 'right', modalEdgeInset: 108 })
+ const rightPin = annotationThreadPanelLayout({ viewportWidth: 1280, viewportHeight: 800, annotationPoint: { x: 960, y: 300 }, filmstripSide: true })
+ expect(rightPin).toEqual({ placement: 'left', modalEdgeInset: 16 })
+ })
+
+ it('uses the available side before falling back to center', () => {
+ expect(annotationThreadPanelLayout({ viewportWidth: 920, viewportHeight: 700, annotationPoint: { x: 230, y: 300 }, filmstripSide: true }).placement).toBe('left')
+ expect(annotationThreadPanelLayout({ viewportWidth: 800, viewportHeight: 700, annotationPoint: { x: 200, y: 300 }, filmstripSide: false }).placement).toBe('center')
+ expect(annotationThreadPanelLayout({ viewportWidth: 390, viewportHeight: 700, annotationPoint: { x: 100, y: 300 }, filmstripSide: false })).toEqual({
+ placement: 'center', modalEdgeInset: 0,
+ })
+ })
+
+ it('breaks centered-pin placement ties to the right', () => {
+ expect(annotationThreadPanelLayout({ viewportWidth: 1280, viewportHeight: 800, annotationPoint: { x: 640, y: 300 }, filmstripSide: true }).placement).toBe('right')
+ expect(annotationThreadPanelLayout({ viewportWidth: 1000, viewportHeight: 800, annotationPoint: { x: 500, y: 300 }, filmstripSide: false }).placement).toBe('right')
+ })
+
+ it('centers the thread when its annotation is outside the viewport', () => {
+ expect(annotationThreadPanelLayout({ viewportWidth: 1280, viewportHeight: 800, annotationPoint: { x: -1, y: 300 }, filmstripSide: true }).placement).toBe('center')
+ expect(annotationThreadPanelLayout({ viewportWidth: 1280, viewportHeight: 800, annotationPoint: { x: 400, y: 801 }, filmstripSide: true }).placement).toBe('center')
+ })
})
diff --git a/src/domain/lightboxReview.ts b/src/domain/lightboxReview.ts
index 948ad36..33956b7 100644
--- a/src/domain/lightboxReview.ts
+++ b/src/domain/lightboxReview.ts
@@ -2,13 +2,74 @@ import { normalizeAnnotationPoint } from './collaboration.ts'
import type { NormalizedAnnotation } from './collaboration.ts'
import type { GalleryMode, GalleryPresentation } from './gallerySettings.ts'
-type Bounds = Pick
+export type ScreenBounds = Pick
export interface ScreenPoint {
x: number
y: number
}
+export type AnnotationThreadPanelPlacement = 'left' | 'right' | 'center'
+
+export interface AnnotationThreadPanelLayout {
+ placement: AnnotationThreadPanelPlacement
+ modalEdgeInset: number
+}
+
+function annotationPointIsOnScreen(point: ScreenPoint | null, viewportWidth: number, viewportHeight: number): point is ScreenPoint {
+ return point !== null
+ && point.x >= 0 && point.x <= viewportWidth
+ && point.y >= 0 && point.y <= viewportHeight
+}
+
+function preferredPanel(
+ preferred: AnnotationThreadPanelLayout,
+ preferredFits: boolean,
+ fallback: AnnotationThreadPanelLayout,
+ fallbackFits: boolean,
+ centered: AnnotationThreadPanelLayout,
+): AnnotationThreadPanelLayout {
+ if (preferredFits) return preferred
+ if (fallbackFits) return fallback
+ return centered
+}
+
+export function annotationThreadPanelLayout({
+ viewportWidth,
+ viewportHeight,
+ annotationPoint,
+ filmstripSide,
+}: {
+ viewportWidth: number
+ viewportHeight: number
+ annotationPoint: ScreenPoint | null
+ filmstripSide: boolean
+}): AnnotationThreadPanelLayout {
+ const baseLeft = viewportWidth <= 640 ? 8 : 72
+ const baseRight = filmstripSide ? 104 : viewportWidth <= 640 ? 8 : 72
+ const centered = { placement: 'center' as const, modalEdgeInset: 0 }
+ if (viewportWidth <= 520 || !annotationPointIsOnScreen(annotationPoint, viewportWidth, viewportHeight)) return centered
+
+ const panelWidth = Math.min(400, Math.max(0, viewportWidth - 32))
+ const panelMargin = 16
+ const panelGap = 24
+ const minimumMediaWidth = 320
+ const rightEdgeInset = filmstripSide ? 108 : panelMargin
+ const leftPadding = Math.max(baseLeft, panelMargin + panelWidth + panelGap)
+ const rightPadding = Math.max(baseRight, rightEdgeInset + panelWidth + panelGap)
+ const leftMediaWidth = viewportWidth - leftPadding - baseRight
+ const rightMediaWidth = viewportWidth - baseLeft - rightPadding
+ const leftFits = leftMediaWidth >= minimumMediaWidth
+ const rightFits = rightMediaWidth >= minimumMediaWidth
+
+ const left = { placement: 'left' as const, modalEdgeInset: panelMargin }
+ const right = { placement: 'right' as const, modalEdgeInset: rightEdgeInset }
+ if (annotationPoint.x < viewportWidth / 2) return preferredPanel(right, rightFits, left, leftFits, centered)
+ if (annotationPoint.x > viewportWidth / 2) return preferredPanel(left, leftFits, right, rightFits, centered)
+ if (leftFits && rightFits) return right
+ return preferredPanel(left, leftFits, right, rightFits, centered)
+}
+
export function shouldAutoHideLightboxChrome(
mode: GalleryMode,
behavior: GalleryPresentation['lightboxChromeBehavior'],
@@ -16,14 +77,25 @@ export function shouldAutoHideLightboxChrome(
return mode === 'presentation' && behavior === 'autoHide'
}
-export function annotationAtImagePoint(clientX: number, clientY: number, bounds: Bounds): NormalizedAnnotation | null {
+export function resolvedFilmstripPlacement(configured: 'auto' | 'side' | 'bottom' | 'hidden', viewportWidth: number): 'side' | 'bottom' | 'hidden' {
+ if (configured === 'hidden') return 'hidden'
+ if (configured === 'side') return viewportWidth > 900 ? 'side' : 'bottom'
+ if (configured === 'bottom') return 'bottom'
+ return viewportWidth >= 1180 ? 'side' : 'bottom'
+}
+
+export function hasReadyPublicMetadata(metadata: { state?: unknown } | undefined): boolean {
+ return metadata?.state === 'ready' && Object.keys(metadata).some(key => key !== 'state')
+}
+
+export function annotationAtImagePoint(clientX: number, clientY: number, bounds: ScreenBounds): NormalizedAnnotation | null {
if (bounds.width <= 0 || bounds.height <= 0
|| clientX < bounds.left || clientX > bounds.left + bounds.width
|| clientY < bounds.top || clientY > bounds.top + bounds.height) return null
return normalizeAnnotationPoint(clientX, clientY, bounds)
}
-export function annotationScreenPoint(annotation: NormalizedAnnotation, bounds: Bounds): ScreenPoint {
+export function annotationScreenPoint(annotation: NormalizedAnnotation, bounds: ScreenBounds): ScreenPoint {
return {
x: bounds.left + bounds.width * annotation.x / 10000,
y: bounds.top + bounds.height * annotation.y / 10000,
@@ -38,6 +110,10 @@ export function moveAnnotationPoint(annotation: NormalizedAnnotation, deltaX: nu
}
}
+export function annotationThreadKey(annotation: NormalizedAnnotation): string {
+ return `${annotation.x}:${annotation.y}:${annotation.width}:${annotation.height}`
+}
+
export function annotationNumbersByComment(comments: Array<{
id: number
createdAt: number
@@ -45,10 +121,16 @@ export function annotationNumbersByComment(comments: Array<{
}>): Map {
const result = new Map()
let number = 0
+ const numbersByThread = new Map()
for (const comment of [...comments].sort((left, right) => left.createdAt - right.createdAt || left.id - right.id)) {
- for (let annotationIndex = 0; annotationIndex < comment.annotations.length; annotationIndex++) {
- number++
- result.set(comment.id, [...result.get(comment.id) ?? [], number])
+ for (const annotation of comment.annotations) {
+ const key = annotationThreadKey(annotation)
+ let threadNumber = numbersByThread.get(key)
+ if (threadNumber === undefined) {
+ threadNumber = ++number
+ numbersByThread.set(key, threadNumber)
+ }
+ result.set(comment.id, [...result.get(comment.id) ?? [], threadNumber])
}
}
return result
@@ -61,3 +143,14 @@ export function findSelectedAnnotationComment comment.id === selectedCommentId && comment.annotations.length > 0) ?? null
}
+
+export function commentsForAnnotationThread(
+ comments: T[],
+ selectedCommentId: number | null,
+): T[] {
+ const selected = findSelectedAnnotationComment(comments, selectedCommentId)
+ const annotation = selected?.annotations[0]
+ if (!annotation) return []
+ const key = annotationThreadKey(annotation)
+ return comments.filter(comment => comment.annotations.some(candidate => annotationThreadKey(candidate) === key))
+}
diff --git a/src/domain/publicGalleryThemeStyle.spec.ts b/src/domain/publicGalleryThemeStyle.spec.ts
new file mode 100644
index 0000000..da09b72
--- /dev/null
+++ b/src/domain/publicGalleryThemeStyle.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+
+import { createDefaultGallerySettings } from './gallerySettings.ts'
+import { publicGalleryThemeStyle } from './publicGalleryThemeStyle.ts'
+
+describe('publicGalleryThemeStyle', () => {
+ it('maps presentation settings to public-page theme variables', () => {
+ const settings = createDefaultGallerySettings()
+ settings.presentation.accentColor = '#336699'
+ settings.presentation.heroFocusX = 25
+ settings.presentation.heroFocusY = 75
+
+ expect(publicGalleryThemeStyle(settings)).toMatchObject({
+ '--gallery-accent': '#336699',
+ '--ion-color-primary-rgb': '51, 102, 153',
+ '--hero-focus': '25% 75%',
+ })
+ })
+})
diff --git a/src/domain/publicGalleryThemeStyle.ts b/src/domain/publicGalleryThemeStyle.ts
new file mode 100644
index 0000000..4398cec
--- /dev/null
+++ b/src/domain/publicGalleryThemeStyle.ts
@@ -0,0 +1,18 @@
+import type { GallerySettings } from './gallerySettings.ts'
+import { contrastRgb, hexRgb, mixHex, readableText } from './galleryTheme.ts'
+
+export function publicGalleryThemeStyle(settings: GallerySettings): Record {
+ const accent = settings.presentation.accentColor || '#E85D4A'
+ const rgb = hexRgb(accent)
+ const contrast = readableText(rgb)
+ return {
+ '--gallery-accent': accent,
+ '--ion-color-primary': accent,
+ '--ion-color-primary-rgb': rgb.join(', '),
+ '--ion-color-primary-contrast': contrast,
+ '--ion-color-primary-contrast-rgb': contrastRgb(contrast),
+ '--ion-color-primary-shade': mixHex(rgb, [0, 0, 0], 0.12),
+ '--ion-color-primary-tint': mixHex(rgb, [255, 255, 255], 0.14),
+ '--hero-focus': `${settings.presentation.heroFocusX}% ${settings.presentation.heroFocusY}%`,
+ }
+}
diff --git a/src/publicTypes.ts b/src/publicTypes.ts
index c1a6194..5e042e4 100644
--- a/src/publicTypes.ts
+++ b/src/publicTypes.ts
@@ -28,6 +28,7 @@ export interface PublicGallery {
settings: GallerySettings
initialPage?: PublicGalleryPage
review?: PublicReviewState
+ viewer?: { displayName: string; email: string | null } | null
}
export type ReviewStatus = 'awaiting_feedback' | 'submitted' | 'changes_requested' | 'approved'
@@ -50,6 +51,7 @@ export interface PublicReviewState {
export interface GuestIdentity {
id: string
+ kind?: 'guest' | 'user'
displayName: string
createdAt: number
}
diff --git a/src/services/currentViewerApi.ts b/src/services/currentViewerApi.ts
new file mode 100644
index 0000000..7e2c183
--- /dev/null
+++ b/src/services/currentViewerApi.ts
@@ -0,0 +1,12 @@
+export interface CurrentViewer { displayName: string; email: string }
+
+export async function fetchCurrentViewer(): Promise {
+ const response = await fetch('/ocs/v2.php/cloud/user?format=json', {
+ credentials: 'same-origin',
+ headers: { Accept: 'application/json', 'OCS-APIRequest': 'true' },
+ })
+ if (!response.ok) return null
+ const payload = await response.json() as { ocs?: { data?: { 'display-name'?: string; email?: string | null } } }
+ const data = payload.ocs?.data
+ return data?.['display-name'] ? { displayName: data['display-name'], email: data.email ?? '' } : null
+}
diff --git a/tests/Unit/Domain/CollaborationActorTest.php b/tests/Unit/Domain/CollaborationActorTest.php
new file mode 100644
index 0000000..33d4cfd
--- /dev/null
+++ b/tests/Unit/Domain/CollaborationActorTest.php
@@ -0,0 +1,38 @@
+createMock(IUser::class);
+ $user->method('getUID')->willReturn('ncadmin');
+ $user->method('getDisplayName')->willReturn('Nextcloud Admin');
+ $actor = CollaborationActor::user($user);
+
+ self::assertTrue($actor->owns(['guest_id' => null, 'actor_uid' => 'ncadmin']));
+ self::assertFalse($actor->owns(['guest_id' => 7, 'actor_uid' => null]));
+ self::assertFalse($actor->owns(['guest_id' => null, 'actor_uid' => 'other-user']));
+ self::assertSame('user', $actor->jsonSerialize()['kind']);
+ }
+
+ public function testGuestOwnsOnlyRowsWithTheSameGuestId(): void {
+ $guest = new Guest();
+ $guest->setId(7);
+ $guest->setPublicId('guest-public-id');
+ $guest->setDisplayName('Reviewer');
+ $guest->setCreatedAt(100);
+ $actor = CollaborationActor::guest($guest);
+
+ self::assertTrue($actor->owns(['guest_id' => 7, 'actor_uid' => null]));
+ self::assertFalse($actor->owns(['guest_id' => 8, 'actor_uid' => null]));
+ self::assertFalse($actor->owns(['guest_id' => null, 'actor_uid' => 'ncadmin']));
+ self::assertSame('guest', $actor->jsonSerialize()['kind']);
+ }
+}
diff --git a/tests/Unit/Domain/CollaborationReadScopeTest.php b/tests/Unit/Domain/CollaborationReadScopeTest.php
index b060f49..6a6a342 100644
--- a/tests/Unit/Domain/CollaborationReadScopeTest.php
+++ b/tests/Unit/Domain/CollaborationReadScopeTest.php
@@ -15,10 +15,17 @@ public function testScopesCannotConfuseAnonymousWithUnfilteredReads(): void {
self::assertFalse(CollaborationReadScope::all()->isEmpty());
self::assertNull(CollaborationReadScope::all()->guestId());
self::assertSame(42, CollaborationReadScope::guest(42)->guestId());
+ self::assertSame('ncadmin', CollaborationReadScope::user('ncadmin')->actorUid());
+ self::assertNull(CollaborationReadScope::user('ncadmin')->guestId());
}
public function testGuestScopeRejectsInvalidIds(): void {
$this->expectException(InvalidArgumentException::class);
CollaborationReadScope::guest(0);
}
+
+ public function testUserScopeRejectsEmptyUids(): void {
+ $this->expectException(InvalidArgumentException::class);
+ CollaborationReadScope::user('');
+ }
}
From 5e75e44e3f2149e347e37d95af533c050b3f2b15 Mon Sep 17 00:00:00 2001
From: DIGI Byte <6645396+digimbyte@users.noreply.github.com>
Date: Thu, 3 Sep 2026 10:34:28 +1000
Subject: [PATCH 04/14] Simplify public viewer localization
---
l10n/de.js | 2 ++
l10n/de.json | 2 ++
l10n/en.js | 2 ++
l10n/en.json | 2 ++
src/PublicApp.vue | 2 +-
src/components/PublicGuestDialog.spec.ts | 24 ++++---------------
src/components/PublicGuestDialog.vue | 15 ++----------
src/components/PublicLightbox.vue | 2 +-
src/components/PublicLightboxComments.spec.ts | 4 ++--
src/components/PublicLightboxComments.vue | 9 ++++---
src/public.ts | 1 +
src/services/currentViewerApi.ts | 12 ----------
12 files changed, 23 insertions(+), 54 deletions(-)
delete mode 100644 src/services/currentViewerApi.ts
diff --git a/l10n/de.js b/l10n/de.js
index ff7c5b6..bc1a7eb 100644
--- a/l10n/de.js
+++ b/l10n/de.js
@@ -640,6 +640,7 @@ OC.L10N.register("proofing_gallery", {
"Loading domains…": "Domains werden geladen …",
"Loading files…": "Dateien werden geladen …",
"Loading galleries…": "Galerien werden geladen …",
+ "Loading gallery": "Galerie wird geladen",
"Loading more photographs…": "Weitere Fotos werden geladen …",
"Loading private ratings…": "Private Bewertungen werden geladen…",
"Loading review rounds…": "Prüfrunden werden geladen…",
@@ -859,6 +860,7 @@ OC.L10N.register("proofing_gallery", {
"Private guest feedback": "Privates Kundenfeedback",
"Private guest pick or reject": "Private Auswahl oder Ablehnung durch Kunden",
"Private guest star ratings": "Private Sternebewertungen durch Kunden",
+ "Private rating": "Private Bewertung",
"private ratings on this page": "private Bewertungen auf dieser Seite",
"Processing, video and search": "Verarbeitung, Video und Suche",
"Project automation": "Projektautomatisierung",
diff --git a/l10n/de.json b/l10n/de.json
index 373db81..bdb4f13 100644
--- a/l10n/de.json
+++ b/l10n/de.json
@@ -641,6 +641,7 @@
"Loading domains…": "Domains werden geladen …",
"Loading files…": "Dateien werden geladen …",
"Loading galleries…": "Galerien werden geladen …",
+ "Loading gallery": "Galerie wird geladen",
"Loading more photographs…": "Weitere Fotos werden geladen …",
"Loading private ratings…": "Private Bewertungen werden geladen…",
"Loading review rounds…": "Prüfrunden werden geladen…",
@@ -860,6 +861,7 @@
"Private guest feedback": "Privates Kundenfeedback",
"Private guest pick or reject": "Private Auswahl oder Ablehnung durch Kunden",
"Private guest star ratings": "Private Sternebewertungen durch Kunden",
+ "Private rating": "Private Bewertung",
"private ratings on this page": "private Bewertungen auf dieser Seite",
"Processing, video and search": "Verarbeitung, Video und Suche",
"Project automation": "Projektautomatisierung",
diff --git a/l10n/en.js b/l10n/en.js
index d8851a1..9e90797 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -640,6 +640,7 @@ OC.L10N.register("proofing_gallery", {
"Loading domains…": "Loading domains…",
"Loading files…": "Loading files…",
"Loading galleries…": "Loading galleries…",
+ "Loading gallery": "Loading gallery",
"Loading more photographs…": "Loading more photographs…",
"Loading private ratings…": "Loading private ratings…",
"Loading review rounds…": "Loading review rounds…",
@@ -859,6 +860,7 @@ OC.L10N.register("proofing_gallery", {
"Private guest feedback": "Private guest feedback",
"Private guest pick or reject": "Private guest pick or reject",
"Private guest star ratings": "Private guest star ratings",
+ "Private rating": "Private rating",
"private ratings on this page": "private ratings on this page",
"Processing, video and search": "Processing, video and search",
"Project automation": "Project automation",
diff --git a/l10n/en.json b/l10n/en.json
index 6e93c83..1b1963d 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -641,6 +641,7 @@
"Loading domains…": "Loading domains…",
"Loading files…": "Loading files…",
"Loading galleries…": "Loading galleries…",
+ "Loading gallery": "Loading gallery",
"Loading more photographs…": "Loading more photographs…",
"Loading private ratings…": "Loading private ratings…",
"Loading review rounds…": "Loading review rounds…",
@@ -860,6 +861,7 @@
"Private guest feedback": "Private guest feedback",
"Private guest pick or reject": "Private guest pick or reject",
"Private guest star ratings": "Private guest star ratings",
+ "Private rating": "Private rating",
"private ratings on this page": "private ratings on this page",
"Processing, video and search": "Processing, video and search",
"Project automation": "Project automation",
diff --git a/src/PublicApp.vue b/src/PublicApp.vue
index 3ace1b9..014077f 100644
--- a/src/PublicApp.vue
+++ b/src/PublicApp.vue
@@ -861,7 +861,7 @@ function upOneLevel() {
{{ t('proofing_gallery', 'This recursive gallery is still being indexed. Reload shortly or ask the gallery owner to rebuild the media index.') }}
-
+
diff --git a/src/components/PublicGuestDialog.spec.ts b/src/components/PublicGuestDialog.spec.ts
index 4b3047c..c185ec6 100644
--- a/src/components/PublicGuestDialog.spec.ts
+++ b/src/components/PublicGuestDialog.spec.ts
@@ -1,4 +1,4 @@
-import { flushPromises, mount } from '@vue/test-utils'
+import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import PublicGuestDialog from './PublicGuestDialog.vue'
@@ -40,28 +40,12 @@ describe('PublicGuestDialog', () => {
expect(wrapper.get('.guest-dialog__submit').attributes('disabled')).toBeDefined()
})
- it('loads the signed-in Nextcloud profile when public-share state has no viewer', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
- ok: true,
- json: async () => ({ ocs: { data: { 'display-name': 'ncadmin', email: 'nc@example.test' } } }),
- }))
+ it('keeps guest fields blank when server state has no signed-in viewer', () => {
const wrapper = mount(PublicGuestDialog, {
props: { open: true, joining: false, viewer: null },
global: { stubs: ionicStubs },
})
- await flushPromises()
- expect(wrapper.get
('#proofing-gallery-guest-name').element.value).toBe('ncadmin')
- expect(wrapper.get('#proofing-gallery-guest-email').element.value).toBe('nc@example.test')
- vi.unstubAllGlobals()
- })
-
- it('uses Nextcloud’s signed-in display name synchronously', () => {
- document.head.setAttribute('data-user-displayname', 'ncadmin')
- const wrapper = mount(PublicGuestDialog, {
- props: { open: true, joining: false, viewer: null },
- global: { stubs: ionicStubs },
- })
- expect(wrapper.get('#proofing-gallery-guest-name').element.value).toBe('ncadmin')
- document.head.removeAttribute('data-user-displayname')
+ expect(wrapper.get('#proofing-gallery-guest-name').element.value).toBe('')
+ expect(wrapper.get('#proofing-gallery-guest-email').element.value).toBe('')
})
})
diff --git a/src/components/PublicGuestDialog.vue b/src/components/PublicGuestDialog.vue
index 2518947..e3b750a 100644
--- a/src/components/PublicGuestDialog.vue
+++ b/src/components/PublicGuestDialog.vue
@@ -4,23 +4,12 @@ import { t } from '@nextcloud/l10n'
import { closeOutline } from 'ionicons/icons'
import { IonButton, IonButtons, IonContent, IonHeader, IonIcon, IonModal, IonTitle, IonToolbar } from '@ionic/vue'
-import { onMounted, ref } from 'vue'
-
-import { fetchCurrentViewer } from '../services/currentViewerApi.ts'
+import { ref } from 'vue'
const props = defineProps<{ open: boolean; joining: boolean; viewer?: { displayName: string; email: string | null } | null }>()
-const name = ref(props.viewer?.displayName ?? document.head.getAttribute('data-user-displayname') ?? '')
+const name = ref(props.viewer?.displayName ?? '')
const email = ref(props.viewer?.email ?? '')
defineEmits<{ dismiss: []; submit: [identity: { displayName: string; email: string }] }>()
-
-onMounted(async () => {
- try {
- const viewer = await fetchCurrentViewer()
- if (viewer) [name.value, email.value] = [name.value || viewer.displayName, email.value || viewer.email]
- } catch {
- // Anonymous public shares and unavailable profile APIs keep editable blank fields.
- }
-})
diff --git a/src/components/PublicLightbox.vue b/src/components/PublicLightbox.vue
index e3d7ea5..741d946 100644
--- a/src/components/PublicLightbox.vue
+++ b/src/components/PublicLightbox.vue
@@ -711,7 +711,7 @@ async function saveEditedComment(commentId: number) {
-
+
{{ t('proofing_gallery', 'Your private rating') }}
({ t: (_app: string, message: string, values?: Record) => values ? message.replace('{number}', String(values.number)) : message }))
+vi.mock('@nextcloud/l10n', () => ({ getLocale: () => 'en_US', t: (_app: string, message: string, values?: Record) => values ? message.replace('{number}', String(values.number)) : message }))
const pointComment = {
id: 12, fileId: 7, body: 'Tighten this detail', author: 'Reviewer', mine: true,
@@ -54,7 +54,7 @@ describe('PublicLightboxComments', () => {
})
const timestamps = wrapper.findAll('small.comment-list__timestamp')
expect(timestamps).toHaveLength(1)
- expect(timestamps[0].text()).toMatch(/\d.+, \d{1,2}:\d{2} (?:am|pm)$/)
+ expect(timestamps[0].text()).toMatch(/\d.+, \d{1,2}:\d{2}\s(?:AM|PM)$/)
})
it('offers point navigation only outside the focused thread', async () => {
diff --git a/src/components/PublicLightboxComments.vue b/src/components/PublicLightboxComments.vue
index c0c79e7..9ef9fde 100644
--- a/src/components/PublicLightboxComments.vue
+++ b/src/components/PublicLightboxComments.vue
@@ -1,5 +1,5 @@
diff --git a/src/public.ts b/src/public.ts
index 301c88c..dbfc3c0 100644
--- a/src/public.ts
+++ b/src/public.ts
@@ -25,6 +25,7 @@ interface PublicGalleryState {
settings: GallerySettings
initialPage: PublicGalleryPage
review?: PublicReviewState
+ viewer?: { displayName: string; email: string | null } | null
}
const state = loadState('proofing_gallery', 'public-gallery')
diff --git a/src/services/currentViewerApi.ts b/src/services/currentViewerApi.ts
deleted file mode 100644
index 7e2c183..0000000
--- a/src/services/currentViewerApi.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export interface CurrentViewer { displayName: string; email: string }
-
-export async function fetchCurrentViewer(): Promise {
- const response = await fetch('/ocs/v2.php/cloud/user?format=json', {
- credentials: 'same-origin',
- headers: { Accept: 'application/json', 'OCS-APIRequest': 'true' },
- })
- if (!response.ok) return null
- const payload = await response.json() as { ocs?: { data?: { 'display-name'?: string; email?: string | null } } }
- const data = payload.ocs?.data
- return data?.['display-name'] ? { displayName: data['display-name'], email: data.email ?? '' } : null
-}
From f42720e694b7f84400fc1b0f44af1ec51442dc6a Mon Sep 17 00:00:00 2001
From: DIGI Byte <6645396+digimbyte@users.noreply.github.com>
Date: Thu, 3 Sep 2026 11:14:39 +1000
Subject: [PATCH 05/14] Complete account and guest collaboration identities
---
CHANGELOG.md | 10 ++
appinfo/info.xml | 2 +-
l10n/de.js | 5 +
l10n/de.json | 5 +
l10n/en.js | 5 +
l10n/en.json | 5 +
lib/Controller/CollaborationController.php | 29 +++---
lib/Controller/PublicReviewController.php | 23 +++--
lib/Db/ActivityRepository.php | 4 +-
lib/Db/CollaborationRepository.php | 2 +-
lib/Db/GuestRating.php | 15 ++-
lib/Db/GuestRatingMapper.php | 38 +++++++-
lib/Db/PurgeRepository.php | 58 ++++++++----
lib/Db/ReviewRoundRepository.php | 29 ++++--
lib/Domain/CollaborationActor.php | 2 +-
lib/Migration/Version000130Date20260903.php | 93 +++++++++++++++++++
lib/Service/ActivityService.php | 27 +++++-
lib/Service/CollaborationService.php | 37 +++++---
lib/Service/GuestRatingAggregator.php | 8 +-
lib/Service/GuestRatingService.php | 53 +++++++++--
lib/Service/ReviewWorkflowService.php | 8 +-
package-lock.json | 4 +-
package.json | 2 +-
src/components/AdminSettingsApp.vue | 3 +-
src/components/CullingWorkspace.vue | 4 +-
src/components/PersonalSettingsApp.vue | 3 +-
src/components/PublicLightbox.vue | 6 +-
src/components/PublicLightboxComments.vue | 8 +-
src/components/PublicLinkManager.vue | 41 +++++++-
src/components/SelectionManager.vue | 2 +-
src/types.ts | 2 +-
tests/Unit/Db/PurgeRepositoryContractTest.php | 10 ++
tests/Unit/Domain/CollaborationActorTest.php | 1 +
.../Integration/IntegrationContractTest.php | 9 ++
.../AuthenticatedCollaborationSessionTest.php | 57 ++++++++++++
tests/Unit/Service/GuestRatingServiceTest.php | 23 ++++-
36 files changed, 524 insertions(+), 109 deletions(-)
create mode 100644 lib/Migration/Version000130Date20260903.php
create mode 100644 tests/Unit/Service/AuthenticatedCollaborationSessionTest.php
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c16cdfd..533b9ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 0.8.3 — 2026-09-03
+
+- make signed-in Nextcloud accounts and public guests first-class collaboration
+ actors across comments, selections, private ratings, picks, and review rounds
+- preserve account UIDs in storage while displaying current Nextcloud names,
+ enforce actor-specific uniqueness, and purge account-authored collaboration
+ data when its Nextcloud account is deleted
+- retain the existing guest session, export, rating, and review behavior for
+ backward compatibility
+
## 0.8.2 — 2026-08-19
- rebuild public galleries as platform-adaptive Ionic apps with Geist typography,
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 82e67ba..d8a6291 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -16,7 +16,7 @@ Veröffentliche vorhandene Nextcloud-Ordner als individuell gestaltbare
Kundengalerien. Sammle Favoriten, Bearbeitungsstände, Kommentare, Anmerkungen
und benannte Auswahlen, ohne Originaldateien zu kopieren oder zu verändern.
]]>
- 0.8.2
+ 0.8.3
AGPL-3.0-or-later
soerennb
ProofingGallery
diff --git a/l10n/de.js b/l10n/de.js
index bc1a7eb..a6f4c3e 100644
--- a/l10n/de.js
+++ b/l10n/de.js
@@ -252,6 +252,7 @@ OC.L10N.register("proofing_gallery", {
"Cinematic cover": "Großes Titelbild",
"Clear": "Leeren",
"Click the image anywhere to add a point comment.": "Klicke auf eine beliebige Stelle im Bild, um einen Punktkommentar hinzuzufügen.",
+ "Client / Finals": "Kunde / Finale",
"Client average": "Kundendurchschnitt",
"Client comments": "Kundenkommentare",
"Client decisions": "Kundenentscheidungen",
@@ -335,6 +336,7 @@ OC.L10N.register("proofing_gallery", {
"CSV": "CSV",
"CSV preview copied.": "CSV-Vorschau kopiert.",
"Cull": "Sichten",
+ "Culling tools": "Sichtungswerkzeuge",
"Curated collection": "Kuratierte Sammlung",
"Current folder": "Aktueller Ordner",
"Custom domain": "Benutzerdefinierte Domain",
@@ -439,6 +441,7 @@ OC.L10N.register("proofing_gallery", {
"Exit fullscreen": "Vollbild beenden",
"Expires on": "Läuft ab am",
"Expires on (optional)": "Gültig bis (optional)",
+ "Export": "Export",
"Export app data (NDJSON)": "App-Daten exportieren (NDJSON)",
"Export composer": "Export-Konfigurator",
"Export my data": "Meine Daten exportieren",
@@ -480,6 +483,7 @@ OC.L10N.register("proofing_gallery", {
"Folder view": "Ordneransicht",
"Follow device": "Geräteeinstellung verwenden",
"For a field-wise merge, choose the source of truth for each value.": "Wähle für einen feldweisen Abgleich die maßgebliche Quelle jedes Werts.",
+ "Former user": "Ehemaliger Benutzer",
"Full frame": "Vollformat",
"Full screen": "Vollbild",
"Fullscreen": "Vollbild",
@@ -1067,6 +1071,7 @@ OC.L10N.register("proofing_gallery", {
"Selected files": "Ausgewählte Dateien",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Ausgewählte Informationen erscheinen in der Fotoansicht. Änderungen an dieser Auswahl öffnen die entsprechende Vorschauszene.",
"Selected story photos": "Ausgewählte Story-Fotos",
+ "Selection": "Auswahl",
"Selection deleted.": "Auswahl gelöscht.",
"Selection name": "Name der Auswahl",
"Selection saved": "Auswahl gespeichert",
diff --git a/l10n/de.json b/l10n/de.json
index bdb4f13..977121d 100644
--- a/l10n/de.json
+++ b/l10n/de.json
@@ -253,6 +253,7 @@
"Cinematic cover": "Großes Titelbild",
"Clear": "Leeren",
"Click the image anywhere to add a point comment.": "Klicke auf eine beliebige Stelle im Bild, um einen Punktkommentar hinzuzufügen.",
+ "Client / Finals": "Kunde / Finale",
"Client average": "Kundendurchschnitt",
"Client comments": "Kundenkommentare",
"Client decisions": "Kundenentscheidungen",
@@ -336,6 +337,7 @@
"CSV": "CSV",
"CSV preview copied.": "CSV-Vorschau kopiert.",
"Cull": "Sichten",
+ "Culling tools": "Sichtungswerkzeuge",
"Curated collection": "Kuratierte Sammlung",
"Current folder": "Aktueller Ordner",
"Custom domain": "Benutzerdefinierte Domain",
@@ -440,6 +442,7 @@
"Exit fullscreen": "Vollbild beenden",
"Expires on": "Läuft ab am",
"Expires on (optional)": "Gültig bis (optional)",
+ "Export": "Export",
"Export app data (NDJSON)": "App-Daten exportieren (NDJSON)",
"Export composer": "Export-Konfigurator",
"Export my data": "Meine Daten exportieren",
@@ -481,6 +484,7 @@
"Folder view": "Ordneransicht",
"Follow device": "Geräteeinstellung verwenden",
"For a field-wise merge, choose the source of truth for each value.": "Wähle für einen feldweisen Abgleich die maßgebliche Quelle jedes Werts.",
+ "Former user": "Ehemaliger Benutzer",
"Full frame": "Vollformat",
"Full screen": "Vollbild",
"Fullscreen": "Vollbild",
@@ -1068,6 +1072,7 @@
"Selected files": "Ausgewählte Dateien",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Ausgewählte Informationen erscheinen in der Fotoansicht. Änderungen an dieser Auswahl öffnen die entsprechende Vorschauszene.",
"Selected story photos": "Ausgewählte Story-Fotos",
+ "Selection": "Auswahl",
"Selection deleted.": "Auswahl gelöscht.",
"Selection name": "Name der Auswahl",
"Selection saved": "Auswahl gespeichert",
diff --git a/l10n/en.js b/l10n/en.js
index 9e90797..831088f 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -252,6 +252,7 @@ OC.L10N.register("proofing_gallery", {
"Cinematic cover": "Cinematic cover",
"Clear": "Clear",
"Click the image anywhere to add a point comment.": "Click the image anywhere to add a point comment.",
+ "Client / Finals": "Client / Finals",
"Client average": "Client average",
"Client comments": "Client comments",
"Client decisions": "Client decisions",
@@ -335,6 +336,7 @@ OC.L10N.register("proofing_gallery", {
"CSV": "CSV",
"CSV preview copied.": "CSV preview copied.",
"Cull": "Cull",
+ "Culling tools": "Culling tools",
"Curated collection": "Curated collection",
"Current folder": "Current folder",
"Custom domain": "Custom domain",
@@ -439,6 +441,7 @@ OC.L10N.register("proofing_gallery", {
"Exit fullscreen": "Exit fullscreen",
"Expires on": "Expires on",
"Expires on (optional)": "Expires on (optional)",
+ "Export": "Export",
"Export app data (NDJSON)": "Export app data (NDJSON)",
"Export composer": "Export composer",
"Export my data": "Export my data",
@@ -480,6 +483,7 @@ OC.L10N.register("proofing_gallery", {
"Folder view": "Folder view",
"Follow device": "Follow device",
"For a field-wise merge, choose the source of truth for each value.": "For a field-wise merge, choose the source of truth for each value.",
+ "Former user": "Former user",
"Full frame": "Full frame",
"Full screen": "Full screen",
"Fullscreen": "Fullscreen",
@@ -1067,6 +1071,7 @@ OC.L10N.register("proofing_gallery", {
"Selected files": "Selected files",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Selected information appears in the photo viewer. Changing this selection opens that preview scene.",
"Selected story photos": "Selected story photos",
+ "Selection": "Selection",
"Selection deleted.": "Selection deleted.",
"Selection name": "Selection name",
"Selection saved": "Selection saved",
diff --git a/l10n/en.json b/l10n/en.json
index 1b1963d..6257c3c 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -253,6 +253,7 @@
"Cinematic cover": "Cinematic cover",
"Clear": "Clear",
"Click the image anywhere to add a point comment.": "Click the image anywhere to add a point comment.",
+ "Client / Finals": "Client / Finals",
"Client average": "Client average",
"Client comments": "Client comments",
"Client decisions": "Client decisions",
@@ -336,6 +337,7 @@
"CSV": "CSV",
"CSV preview copied.": "CSV preview copied.",
"Cull": "Cull",
+ "Culling tools": "Culling tools",
"Curated collection": "Curated collection",
"Current folder": "Current folder",
"Custom domain": "Custom domain",
@@ -440,6 +442,7 @@
"Exit fullscreen": "Exit fullscreen",
"Expires on": "Expires on",
"Expires on (optional)": "Expires on (optional)",
+ "Export": "Export",
"Export app data (NDJSON)": "Export app data (NDJSON)",
"Export composer": "Export composer",
"Export my data": "Export my data",
@@ -481,6 +484,7 @@
"Folder view": "Folder view",
"Follow device": "Follow device",
"For a field-wise merge, choose the source of truth for each value.": "For a field-wise merge, choose the source of truth for each value.",
+ "Former user": "Former user",
"Full frame": "Full frame",
"Full screen": "Full screen",
"Fullscreen": "Fullscreen",
@@ -1068,6 +1072,7 @@
"Selected files": "Selected files",
"Selected information appears in the photo viewer. Changing this selection opens that preview scene.": "Selected information appears in the photo viewer. Changing this selection opens that preview scene.",
"Selected story photos": "Selected story photos",
+ "Selection": "Selection",
"Selection deleted.": "Selection deleted.",
"Selection name": "Selection name",
"Selection saved": "Selection saved",
diff --git a/lib/Controller/CollaborationController.php b/lib/Controller/CollaborationController.php
index 55cb3cc..85ca432 100644
--- a/lib/Controller/CollaborationController.php
+++ b/lib/Controller/CollaborationController.php
@@ -97,12 +97,16 @@ public function state(int $cursor = 0, string $fileIds = ''): JSONResponse {
return true;
}));
$actor = $this->optionalActor();
- $guest = $actor?->isGuest() ? $this->optionalGuest() : null;
- $state['ratings'] = $guest === null || !$ratingEnabled
+ $state['ratings'] = $actor === null || !$ratingEnabled
? []
: array_values(array_map(
static fn ($rating): array => $rating->jsonSerialize(),
- array_filter($visibleFileIds === [] ? $this->guestRatings->forGuest($guest) : $this->guestRatings->forGuestFiles($guest, $visibleFileIds), fn ($rating): bool => $this->allowsFile($rating->getFileId())),
+ array_filter(
+ $visibleFileIds === []
+ ? $this->guestRatings->forActor($this->resolvedGallery()->getId(), $actor)
+ : $this->guestRatings->forActorFiles($this->resolvedGallery()->getId(), $actor, $visibleFileIds),
+ fn ($rating): bool => $this->allowsFile($rating->getFileId()),
+ ),
));
return new JSONResponse($state);
}
@@ -115,20 +119,23 @@ public function setRating(int $fileId, int $rating = 0, string $pick = 'none'):
if (!$this->ratingEnabled()) return new JSONResponse(['code' => 'policy_denied', 'message' => 'Guest ratings are disabled for this link'], Http::STATUS_FORBIDDEN);
if (!$this->allowsFile($fileId)) return new JSONResponse(['message' => 'Media not found'], Http::STATUS_NOT_FOUND);
try {
- if ($this->authenticated->actor() !== null) {
- return new JSONResponse(['code' => 'policy_denied', 'message' => 'Ratings are currently available to guest reviewers only'], Http::STATUS_FORBIDDEN);
- }
- $guest = $this->guests->authenticate($this->resolvedGallery(), $this->guestSecret($this->resolvedGallery()), $this->request->getHeader('X-Proofing-Nonce'));
+ $actor = $this->authenticateActor();
$permissions = $this->ratingPermissions();
$current = null;
- foreach ($this->guestRatings->forGuest($guest) as $saved) if ($saved->getFileId() === $fileId) { $current = $saved; break; }
+ foreach ($this->guestRatings->forActor($this->resolvedGallery()->getId(), $actor) as $saved) if ($saved->getFileId() === $fileId) { $current = $saved; break; }
$rating = $permissions['ratings'] ? $rating : ($current?->getRating() ?? 0);
$pick = $permissions['pick'] ? $pick : ($current?->getPickState() ?? 'none');
- $value = $this->collaboration->saveRating($this->resolvedPublicLink(), $this->resolvedGallery(), $guest, $fileId, $rating, $pick);
- $this->shareAudit->record($this->resolvedPublicLink(), 'feedback', $guest->getId(), fileId: $fileId);
+ $value = $this->collaboration->saveRating($this->resolvedPublicLink(), $this->resolvedGallery(), $actor, $fileId, $rating, $pick);
+ $this->shareAudit->record(
+ $this->resolvedPublicLink(),
+ 'feedback',
+ guestId: $actor->guestId(),
+ actorUid: $actor->userUid(),
+ fileId: $fileId,
+ );
return new JSONResponse($value);
} catch (DoesNotExistException) {
- return new JSONResponse(['code' => 'guest_session_required', 'message' => 'Guest session required'], Http::STATUS_UNAUTHORIZED);
+ return new JSONResponse(['code' => 'guest_session_required', 'message' => 'Collaboration identity required'], Http::STATUS_UNAUTHORIZED);
} catch (InvalidArgumentException $exception) {
if ($exception->getMessage() === 'Invalid request nonce') {
return new JSONResponse(['code' => 'invalid_nonce', 'message' => $exception->getMessage()], Http::STATUS_FORBIDDEN);
diff --git a/lib/Controller/PublicReviewController.php b/lib/Controller/PublicReviewController.php
index 06e2a57..886d68e 100644
--- a/lib/Controller/PublicReviewController.php
+++ b/lib/Controller/PublicReviewController.php
@@ -4,7 +4,9 @@
namespace OCA\ProofingGallery\Controller;
+use OCA\ProofingGallery\Domain\CollaborationActor;
use OCA\ProofingGallery\Exception\ReviewConflictException;
+use OCA\ProofingGallery\Service\AuthenticatedCollaborationSession;
use OCA\ProofingGallery\Service\GuestService;
use OCA\ProofingGallery\Service\PublicShareContextResolver;
use OCA\ProofingGallery\Service\ReviewWorkflowService;
@@ -25,6 +27,7 @@ public function __construct(
PublicShareContextResolver $contextResolver,
private ReviewWorkflowService $reviews,
private GuestService $guests,
+ private AuthenticatedCollaborationSession $authenticated,
) {
parent::__construct($request, $session, $contextResolver);
}
@@ -43,14 +46,10 @@ public function state(): JSONResponse {
public function submit(): JSONResponse {
try {
$context = $this->publicContext();
- $guest = $this->guests->authenticate(
- $context->gallery,
- $this->guestSecret($context->gallery),
- $this->request->getHeader('X-Proofing-Nonce'),
- );
- return new JSONResponse($this->reviews->submit($context->gallery, $context->link, $guest));
+ $actor = $this->authenticateActor($context->gallery);
+ return new JSONResponse($this->reviews->submit($context->gallery, $context->link, $actor));
} catch (DoesNotExistException) {
- return new JSONResponse(['code' => 'guest_session_required', 'message' => 'Guest session required'], Http::STATUS_UNAUTHORIZED);
+ return new JSONResponse(['code' => 'guest_session_required', 'message' => 'Collaboration identity required'], Http::STATUS_UNAUTHORIZED);
} catch (ReviewConflictException $exception) {
return new JSONResponse(['code' => 'review_conflict', 'message' => $exception->getMessage()], Http::STATUS_CONFLICT);
} catch (\InvalidArgumentException $exception) {
@@ -60,4 +59,14 @@ public function submit(): JSONResponse {
return new JSONResponse(['message' => $exception->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY);
}
}
+
+ private function authenticateActor(\OCA\ProofingGallery\Db\Gallery $gallery): CollaborationActor {
+ $actor = $this->authenticated->authenticate($gallery, $this->request->getHeader('X-Proofing-Nonce'));
+ if ($actor !== null) return $actor;
+ return CollaborationActor::guest($this->guests->authenticate(
+ $gallery,
+ $this->guestSecret($gallery),
+ $this->request->getHeader('X-Proofing-Nonce'),
+ ));
+ }
}
diff --git a/lib/Db/ActivityRepository.php b/lib/Db/ActivityRepository.php
index eecd242..65631be 100644
--- a/lib/Db/ActivityRepository.php
+++ b/lib/Db/ActivityRepository.php
@@ -12,12 +12,12 @@ public function __construct(private IDBConnection $db) {
}
/** @param array $payload */
- public function insert(int $galleryId, ?int $guestId, string $type, array $payload, int $now): int {
+ public function insert(int $galleryId, ?int $guestId, string $type, array $payload, int $now, ?string $actorUid = null): int {
$qb = $this->db->getQueryBuilder();
$qb->insert('proofing_events')->values([
'gallery_id' => $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT),
'guest_id' => $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT),
- 'actor_uid' => $qb->createNamedParameter(null),
+ 'actor_uid' => $qb->createNamedParameter($actorUid),
'event_type' => $qb->createNamedParameter($type),
'payload' => $qb->createNamedParameter(json_encode($payload, JSON_THROW_ON_ERROR)),
'created_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT),
diff --git a/lib/Db/CollaborationRepository.php b/lib/Db/CollaborationRepository.php
index e3c3b99..8b92b0b 100644
--- a/lib/Db/CollaborationRepository.php
+++ b/lib/Db/CollaborationRepository.php
@@ -524,7 +524,7 @@ private function userNames(array $uids): array {
*/
private function authorName(array $row, array $guestNames, array $userNames): string {
if ($row['actor_uid'] !== null && (string)$row['actor_uid'] !== '') {
- return $userNames[(string)$row['actor_uid']] ?? (string)$row['actor_uid'];
+ return $userNames[(string)$row['actor_uid']] ?? '';
}
return $row['guest_id'] === null ? 'Deleted user' : ($guestNames[(int)$row['guest_id']] ?? 'Deleted guest');
}
diff --git a/lib/Db/GuestRating.php b/lib/Db/GuestRating.php
index 4195047..ef681ff 100644
--- a/lib/Db/GuestRating.php
+++ b/lib/Db/GuestRating.php
@@ -12,8 +12,10 @@
* @method void setGalleryId(int $galleryId)
* @method int getPublicLinkId()
* @method void setPublicLinkId(int $publicLinkId)
- * @method int getGuestId()
- * @method void setGuestId(int $guestId)
+ * @method ?int getGuestId()
+ * @method void setGuestId(?int $guestId)
+ * @method ?string getActorUid()
+ * @method void setActorUid(?string $actorUid)
* @method int getFileId()
* @method void setFileId(int $fileId)
* @method int getRating()
@@ -26,7 +28,8 @@
final class GuestRating extends Entity implements \JsonSerializable {
protected int $galleryId = 0;
protected int $publicLinkId = 0;
- protected int $guestId = 0;
+ protected ?int $guestId = null;
+ protected ?string $actorUid = null;
protected int $fileId = 0;
protected int $rating = 0;
protected string $pickState = 'none';
@@ -37,6 +40,12 @@ public function __construct() {
$this->addType('rating', Types::INTEGER);
}
+ public function actorKey(): string {
+ return $this->getGuestId() !== null
+ ? 'guest:' . $this->getGuestId()
+ : 'user:' . ($this->getActorUid() ?? '');
+ }
+
/** @return array{fileId: int, rating: int, pick: string, updatedAt: int} */
public function jsonSerialize(): array {
return [
diff --git a/lib/Db/GuestRatingMapper.php b/lib/Db/GuestRatingMapper.php
index ac9a0f5..eb786e2 100644
--- a/lib/Db/GuestRatingMapper.php
+++ b/lib/Db/GuestRatingMapper.php
@@ -18,21 +18,31 @@ public function __construct(IDBConnection $db) {
/** @throws DoesNotExistException|MultipleObjectsReturnedException */
public function findGuestFile(int $galleryId, int $guestId, int $fileId): GuestRating {
+ return $this->findActorFile($galleryId, $guestId, null, $fileId);
+ }
+
+ /** @throws DoesNotExistException|MultipleObjectsReturnedException */
+ public function findActorFile(int $galleryId, ?int $guestId, ?string $actorUid, int $fileId): GuestRating {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->tableName)
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
- ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
+ $this->actorWhere($qb, $guestId, $actorUid);
return $this->findEntity($qb);
}
/** @return list */
public function findForGuest(int $galleryId, int $guestId): array {
+ return $this->findForActor($galleryId, $guestId, null);
+ }
+
+ /** @return list */
+ public function findForActor(int $galleryId, ?int $guestId, ?string $actorUid): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->tableName)
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
- ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)))
->orderBy('file_id', 'ASC');
+ $this->actorWhere($qb, $guestId, $actorUid);
return $this->findEntities($qb);
}
@@ -40,14 +50,21 @@ public function findForGuest(int $galleryId, int $guestId): array {
* @return list
*/
public function findForGuestFiles(int $galleryId, int $guestId, array $fileIds): array {
+ return $this->findForActorFiles($galleryId, $guestId, null, $fileIds);
+ }
+
+ /** @param list $fileIds
+ * @return list
+ */
+ public function findForActorFiles(int $galleryId, ?int $guestId, ?string $actorUid, array $fileIds): array {
$fileIds = array_values(array_unique(array_filter(array_map('intval', $fileIds), static fn (int $id): bool => $id > 0)));
if ($fileIds === []) return [];
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->tableName)
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
- ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->in('file_id', $qb->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY)))
->orderBy('file_id', 'ASC');
+ $this->actorWhere($qb, $guestId, $actorUid);
return $this->findEntities($qb);
}
@@ -56,7 +73,7 @@ public function findForGallery(int $galleryId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->tableName)
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
- ->orderBy('file_id', 'ASC')->addOrderBy('guest_id', 'ASC');
+ ->orderBy('file_id', 'ASC')->addOrderBy('guest_id', 'ASC')->addOrderBy('actor_uid', 'ASC');
return $this->findEntities($qb);
}
@@ -90,9 +107,20 @@ public function findForGalleryFiles(int $galleryId, array $fileIds): array {
$qb->select('*')->from($this->tableName)
->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->in('file_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))
- ->orderBy('file_id', 'ASC')->addOrderBy('guest_id', 'ASC');
+ ->orderBy('file_id', 'ASC')->addOrderBy('guest_id', 'ASC')->addOrderBy('actor_uid', 'ASC');
array_push($result, ...$this->findEntities($qb));
}
return $result;
}
+
+ private function actorWhere(IQueryBuilder $qb, ?int $guestId, ?string $actorUid): void {
+ if (($guestId === null) === ($actorUid === null)) {
+ throw new \InvalidArgumentException('Rating actor must be either a guest or a user');
+ }
+ if ($guestId !== null) {
+ $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)));
+ return;
+ }
+ $qb->andWhere($qb->expr()->eq('actor_uid', $qb->createNamedParameter($actorUid)));
+ }
}
diff --git a/lib/Db/PurgeRepository.php b/lib/Db/PurgeRepository.php
index 4e05321..71c7849 100644
--- a/lib/Db/PurgeRepository.php
+++ b/lib/Db/PurgeRepository.php
@@ -157,24 +157,43 @@ public function ownedGalleryIds(string $userId): array {
}
public function deletePrincipal(string $type, string $id): int {
- $qb = $this->db->getQueryBuilder();
- $deleted = $qb->delete('proofing_managers')
- ->where($qb->expr()->eq('principal_type', $qb->createNamedParameter($type)))
- ->andWhere($qb->expr()->eq('user_uid', $qb->createNamedParameter($id)))->executeStatement();
- if ($type !== 'user') return $deleted;
- foreach ([
- ['proofing_presets', 'owner_uid'],
- ['proofing_inv_templates', 'owner_uid'],
- ['proofing_media_cull', 'owner_uid'],
- ['proofing_agent_requests', 'user_uid'],
- ['proofing_notify_subs', 'user_uid'],
- ['proofing_native_notify', 'user_uid'],
- ['proofing_ext_resources', 'user_uid'],
- ] as [$table, $column]) {
+ $this->db->beginTransaction();
+ try {
$qb = $this->db->getQueryBuilder();
- $deleted += $qb->delete($table)->where($qb->expr()->eq($column, $qb->createNamedParameter($id)))->executeStatement();
+ $deleted = $qb->delete('proofing_managers')
+ ->where($qb->expr()->eq('principal_type', $qb->createNamedParameter($type)))
+ ->andWhere($qb->expr()->eq('user_uid', $qb->createNamedParameter($id)))->executeStatement();
+ if ($type === 'user') {
+ $comments = $this->actorParentIds('proofing_comments', $id);
+ $selections = $this->actorParentIds('proofing_selections', $id);
+ $deleted += $this->deleteIds('proofing_annotations', 'comment_id', $comments)
+ + $this->deleteIds('proofing_selection_items', 'selection_id', $selections);
+ foreach (['proofing_feedback', 'proofing_comments', 'proofing_selections', 'proofing_guest_ratings', 'proofing_events', 'proofing_share_audit'] as $table) {
+ $qb = $this->db->getQueryBuilder();
+ $deleted += $qb->delete($table)->where($qb->expr()->eq('actor_uid', $qb->createNamedParameter($id)))->executeStatement();
+ }
+ $qb = $this->db->getQueryBuilder();
+ $qb->update('proofing_review_rounds')->set('submitted_by_actor_uid', $qb->createNamedParameter(null))
+ ->where($qb->expr()->eq('submitted_by_actor_uid', $qb->createNamedParameter($id)))->executeStatement();
+ foreach ([
+ ['proofing_presets', 'owner_uid'],
+ ['proofing_inv_templates', 'owner_uid'],
+ ['proofing_media_cull', 'owner_uid'],
+ ['proofing_agent_requests', 'user_uid'],
+ ['proofing_notify_subs', 'user_uid'],
+ ['proofing_native_notify', 'user_uid'],
+ ['proofing_ext_resources', 'user_uid'],
+ ] as [$table, $column]) {
+ $qb = $this->db->getQueryBuilder();
+ $deleted += $qb->delete($table)->where($qb->expr()->eq($column, $qb->createNamedParameter($id)))->executeStatement();
+ }
+ }
+ $this->db->commit();
+ return $deleted;
+ } catch (\Throwable $exception) {
+ $this->db->rollBack();
+ throw $exception;
}
- return $deleted;
}
/** @return array>> */
@@ -224,6 +243,13 @@ private function guestParentIds(string $table, int $guestId): array {
->where($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)))->executeQuery()));
}
+ /** @return list */
+ private function actorParentIds(string $table, string $actorUid): array {
+ $qb = $this->db->getQueryBuilder();
+ return array_map('intval', QueryResult::column($qb->select('id')->from($table)
+ ->where($qb->expr()->eq('actor_uid', $qb->createNamedParameter($actorUid)))->executeQuery()));
+ }
+
/** @param list $ids */
private function deleteIds(string $table, string $column, array $ids): int {
if ($ids === []) return 0;
diff --git a/lib/Db/ReviewRoundRepository.php b/lib/Db/ReviewRoundRepository.php
index 21a78ba..0633ef2 100644
--- a/lib/Db/ReviewRoundRepository.php
+++ b/lib/Db/ReviewRoundRepository.php
@@ -6,30 +6,31 @@
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
+use OCP\IUserManager;
final class ReviewRoundRepository {
- public function __construct(private IDBConnection $db) {
+ public function __construct(private IDBConnection $db, private IUserManager $users) {
}
/** @return array|null */
public function current(int $publicLinkId): ?array {
$qb = $this->db->getQueryBuilder();
- $row = QueryResult::row($qb->select('r.*', 'g.display_name AS submitted_by')
+ $row = QueryResult::row($qb->select('r.*', 'g.display_name AS submitted_by_guest')
->from('proofing_review_rounds', 'r')
->leftJoin('r', 'proofing_guests', 'g', $qb->expr()->eq('g.id', 'r.submitted_by_guest_id'))
->where($qb->expr()->eq('r.public_link_id', $qb->createNamedParameter($publicLinkId, IQueryBuilder::PARAM_INT)))
->orderBy('r.round_number', 'DESC')->setMaxResults(1)->executeQuery());
- return $row === false ? null : $row;
+ return $row === false ? null : $this->withSubmitter($row);
}
/** @return list> */
public function history(int $publicLinkId): array {
$qb = $this->db->getQueryBuilder();
- return QueryResult::rows($qb->select('r.*', 'g.display_name AS submitted_by')
+ return array_map($this->withSubmitter(...), QueryResult::rows($qb->select('r.*', 'g.display_name AS submitted_by_guest')
->from('proofing_review_rounds', 'r')
->leftJoin('r', 'proofing_guests', 'g', $qb->expr()->eq('g.id', 'r.submitted_by_guest_id'))
->where($qb->expr()->eq('r.public_link_id', $qb->createNamedParameter($publicLinkId, IQueryBuilder::PARAM_INT)))
- ->orderBy('r.round_number', 'DESC')->executeQuery());
+ ->orderBy('r.round_number', 'DESC')->executeQuery()));
}
public function create(int $galleryId, int $publicLinkId, int $number, ?string $dueDate, int $now): void {
@@ -41,6 +42,7 @@ public function create(int $galleryId, int $publicLinkId, int $number, ?string $
'status' => $qb->createNamedParameter('awaiting_feedback'),
'due_date' => $qb->createNamedParameter($dueDate),
'submitted_by_guest_id' => $qb->createNamedParameter(null),
+ 'submitted_by_actor_uid' => $qb->createNamedParameter(null),
'submitted_at' => $qb->createNamedParameter(null),
'decided_at' => $qb->createNamedParameter(null),
'created_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT),
@@ -57,11 +59,13 @@ public function updateDueDate(int $id, ?string $dueDate, int $now): void {
->executeStatement();
}
- public function submit(int $id, int $guestId, int $now): bool {
+ public function submit(int $id, ?int $guestId, ?string $actorUid, int $now): bool {
+ if (($guestId === null) === ($actorUid === null)) throw new \InvalidArgumentException('Review actor must be either a guest or a user');
$qb = $this->db->getQueryBuilder();
return $qb->update('proofing_review_rounds')
->set('status', $qb->createNamedParameter('submitted'))
->set('submitted_by_guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))
+ ->set('submitted_by_actor_uid', $qb->createNamedParameter($actorUid))
->set('submitted_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))
->set('updated_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
@@ -69,6 +73,19 @@ public function submit(int $id, int $guestId, int $now): bool {
->executeStatement() === 1;
}
+ /** @param array $row
+ * @return array
+ */
+ private function withSubmitter(array $row): array {
+ $guestName = $row['submitted_by_guest'] ?? null;
+ $uid = $row['submitted_by_actor_uid'] ?? null;
+ $row['submitted_by'] = is_string($guestName) && $guestName !== ''
+ ? $guestName
+ : (is_string($uid) && $uid !== '' ? $this->users->get($uid)?->getDisplayName() : null);
+ unset($row['submitted_by_guest']);
+ return $row;
+ }
+
public function decide(int $id, string $from, string $to, int $now): bool {
$qb = $this->db->getQueryBuilder();
return $qb->update('proofing_review_rounds')
diff --git a/lib/Domain/CollaborationActor.php b/lib/Domain/CollaborationActor.php
index 9263cf9..f8aa209 100644
--- a/lib/Domain/CollaborationActor.php
+++ b/lib/Domain/CollaborationActor.php
@@ -36,7 +36,7 @@ public static function guest(Guest $guest): self {
}
public static function user(IUser $user): self {
- return new self(null, $user->getUID(), 'account', $user->getDisplayName(), 0);
+ return new self(null, $user->getUID(), $user->getUID(), $user->getDisplayName(), 0);
}
public function guestId(): ?int {
diff --git a/lib/Migration/Version000130Date20260903.php b/lib/Migration/Version000130Date20260903.php
new file mode 100644
index 0000000..1175540
--- /dev/null
+++ b/lib/Migration/Version000130Date20260903.php
@@ -0,0 +1,93 @@
+ $options */
+ public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ // Older builds did not enforce account feedback uniqueness. Keep the newest
+ // value if concurrent requests created duplicate account rows.
+ $qb = $this->db->getQueryBuilder();
+ $duplicates = QueryResult::rows($qb->select('gallery_id', 'file_id', 'kind', 'actor_uid')
+ ->selectAlias($qb->func()->max('id'), 'keep_id')
+ ->from('proofing_feedback')
+ ->where($qb->expr()->isNotNull('actor_uid'))
+ ->groupBy('gallery_id', 'file_id', 'kind', 'actor_uid')
+ ->having($qb->expr()->gt($qb->func()->count('*'), $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT)))
+ ->executeQuery());
+ foreach ($duplicates as $row) {
+ $delete = $this->db->getQueryBuilder();
+ $delete->delete('proofing_feedback')
+ ->where($delete->expr()->eq('gallery_id', $delete->createNamedParameter((int)$row['gallery_id'], IQueryBuilder::PARAM_INT)))
+ ->andWhere($delete->expr()->eq('file_id', $delete->createNamedParameter((int)$row['file_id'], IQueryBuilder::PARAM_INT)))
+ ->andWhere($delete->expr()->eq('kind', $delete->createNamedParameter((string)$row['kind'])))
+ ->andWhere($delete->expr()->eq('actor_uid', $delete->createNamedParameter((string)$row['actor_uid'])))
+ ->andWhere($delete->expr()->neq('id', $delete->createNamedParameter((int)$row['keep_id'], IQueryBuilder::PARAM_INT)))
+ ->executeStatement();
+ }
+ }
+
+ /** @param array $options */
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+ $changed = false;
+
+ $feedback = $schema->getTable('proofing_feedback');
+ if (!$feedback->hasIndex('proof_feedback_actor_kind')) {
+ $feedback->addUniqueIndex(['gallery_id', 'file_id', 'kind', 'actor_uid'], 'proof_feedback_actor_kind');
+ $changed = true;
+ }
+ foreach ([
+ 'proofing_comments' => ['proof_comment_actor', ['gallery_id', 'actor_uid', 'created_at']],
+ 'proofing_selections' => ['proof_selection_actor', ['gallery_id', 'actor_uid', 'updated_at']],
+ 'proofing_events' => ['proof_event_actor', ['gallery_id', 'actor_uid', 'created_at']],
+ ] as $tableName => [$indexName, $columns]) {
+ $table = $schema->getTable($tableName);
+ if (!$table->hasIndex($indexName)) {
+ $table->addIndex($columns, $indexName);
+ $changed = true;
+ }
+ }
+
+ $rounds = $schema->getTable('proofing_review_rounds');
+ if (!$rounds->hasColumn('submitted_by_actor_uid')) {
+ $rounds->addColumn('submitted_by_actor_uid', Types::STRING, ['length' => 64, 'notnull' => false]);
+ $changed = true;
+ }
+ if (!$rounds->hasIndex('proof_review_actor')) {
+ $rounds->addIndex(['submitted_by_actor_uid'], 'proof_review_actor');
+ $changed = true;
+ }
+
+ $ratings = $schema->getTable('proofing_guest_ratings');
+ if (!$ratings->hasColumn('actor_uid')) {
+ $ratings->addColumn('actor_uid', Types::STRING, ['length' => 64, 'notnull' => false]);
+ $changed = true;
+ }
+ if ($ratings->getColumn('guest_id')->getNotnull()) {
+ $ratings->changeColumn('guest_id', ['notnull' => false]);
+ $changed = true;
+ }
+ if (!$ratings->hasIndex('proof_actor_rating_file')) {
+ $ratings->addUniqueIndex(['gallery_id', 'actor_uid', 'file_id'], 'proof_actor_rating_file');
+ $changed = true;
+ }
+
+ return $changed ? $schema : null;
+ }
+}
diff --git a/lib/Service/ActivityService.php b/lib/Service/ActivityService.php
index 752e636..1e90391 100644
--- a/lib/Service/ActivityService.php
+++ b/lib/Service/ActivityService.php
@@ -7,16 +7,26 @@
use OCA\ProofingGallery\Db\ActivityRepository;
use OCA\ProofingGallery\Db\Gallery;
use OCA\ProofingGallery\Db\Guest;
+use OCA\ProofingGallery\Domain\CollaborationActor;
use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IUserManager;
final class ActivityService {
public function __construct(
private ActivityRepository $repository,
private ITimeFactory $clock,
private NotificationService $notifications,
+ private IUserManager $users,
) {
}
+ /** @param array $payload */
+ public function recordActor(Gallery $gallery, CollaborationActor $actor, string $type, array $payload): void {
+ $now = $this->clock->getTime();
+ $eventId = $this->repository->insert($gallery->getId(), $actor->guestId(), $type, $payload, $now, $actor->userUid());
+ $this->notifications->queue($gallery, $eventId, $type, $now);
+ }
+
/** @param array $payload */
public function record(Gallery $gallery, ?Guest $guest, string $type, array $payload): void {
$now = $this->clock->getTime();
@@ -33,10 +43,10 @@ public function recordOnce(Gallery $gallery, string $type, string $dedupeKey, ar
/** @return list> */
public function list(Gallery $gallery, int $cursor = 0, string $type = ''): array {
- return array_map(static fn (array $row): array => [
+ return array_map(fn (array $row): array => [
'id' => (int)$row['id'],
'type' => $row['event_type'],
- 'actor' => $row['display_name'] ?? $row['actor_uid'] ?? 'Gallery manager',
+ 'actor' => $this->actorName($row),
'payload' => json_decode($row['payload'], true, flags: JSON_THROW_ON_ERROR),
'createdAt' => (int)$row['created_at'],
], $this->repository->list($gallery->getId(), $cursor, $type));
@@ -49,11 +59,20 @@ public function page(Gallery $gallery, int $limit, ?string $cursor, string $type
$rows = $this->repository->page($gallery->getId(), $cursors->decode($cursor, $scope), $type, $limit + 1);
$hasMore = count($rows) > $limit;
if ($hasMore) array_pop($rows);
- $items = array_map(static fn (array $row): array => [
- 'id' => (int)$row['id'], 'type' => $row['event_type'], 'actor' => $row['display_name'] ?? $row['actor_uid'] ?? 'Gallery manager',
+ $items = array_map(fn (array $row): array => [
+ 'id' => (int)$row['id'], 'type' => $row['event_type'], 'actor' => $this->actorName($row),
'payload' => json_decode($row['payload'], true, flags: JSON_THROW_ON_ERROR), 'createdAt' => (int)$row['created_at'],
], $rows);
$last = $rows === [] ? null : $rows[array_key_last($rows)];
return ['items' => $items, 'total' => $this->repository->countGallery($gallery->getId(), $type), 'nextCursor' => $hasMore && $last !== null ? $cursors->encode($scope, (int)$last['id']) : null];
}
+
+ /** @param array $row */
+ private function actorName(array $row): string {
+ if (isset($row['display_name']) && is_string($row['display_name']) && $row['display_name'] !== '') return $row['display_name'];
+ if (isset($row['actor_uid']) && is_string($row['actor_uid']) && $row['actor_uid'] !== '') {
+ return $this->users->get($row['actor_uid'])?->getDisplayName() ?? 'Former user';
+ }
+ return 'Gallery manager';
+ }
}
diff --git a/lib/Service/CollaborationService.php b/lib/Service/CollaborationService.php
index 5a3843c..9349179 100644
--- a/lib/Service/CollaborationService.php
+++ b/lib/Service/CollaborationService.php
@@ -171,10 +171,11 @@ public function toggleLike(Gallery $gallery, CollaborationActor $actor, int $fil
});
}
- public function saveRating(PublicLink $link, Gallery $gallery, Guest $guest, int $fileId, int $rating, string $pick): GuestRating {
- return $this->atomic(function () use ($link, $gallery, $guest, $fileId, $rating, $pick): GuestRating {
- $value = $this->guestRatings->save($link, $guest, $fileId, $rating, $pick);
- $this->event($gallery, CollaborationActor::guest($guest), 'rating.changed', ['fileId' => $fileId]);
+ public function saveRating(PublicLink $link, Gallery $gallery, CollaborationActor $actor, int $fileId, int $rating, string $pick): GuestRating {
+ if ($link->getGalleryId() !== $gallery->getId()) throw new InvalidArgumentException('Public link does not belong to this gallery');
+ return $this->atomic(function () use ($link, $gallery, $actor, $fileId, $rating, $pick): GuestRating {
+ $value = $this->guestRatings->saveForActor($link, $actor, $fileId, $rating, $pick);
+ $this->event($gallery, $actor, 'rating.changed', ['fileId' => $fileId]);
return $value;
});
}
@@ -387,13 +388,23 @@ public function exportActorSelection(Gallery $gallery, CollaborationActor $actor
if ($row === null || ($this->settings($gallery)->review->visibility === FeedbackVisibility::Private && !$actor->owns($row))) {
throw new InvalidArgumentException('Selection not found');
}
+ $fileIds = [];
$names = [];
foreach ($this->repository->selectionFileIds((int)$row['id']) as $fileId) {
- try { $names[] = $this->resolveMedia($gallery, $fileId)->getName(); } catch (\Throwable) {}
+ try {
+ $fileIds[] = $fileId;
+ $names[] = $this->resolveMedia($gallery, $fileId)->getName();
+ } catch (\Throwable) {}
}
$base = preg_replace('/[^a-z0-9._-]+/i', '-', (string)$row['name']) ?: 'selection';
if ($format === 'csv' || $format === 'preview') {
- $content = "\xEF\xBB\xBF" . $this->csv->encode([['filename'], ...array_map(static fn (string $name): array => [$name], $names)]);
+ $fields = array_values(array_unique(array_intersect(['filename', 'rating', 'pick'], array_map('strval', $requestedFields))));
+ if ($fields === []) $fields = ['filename'];
+ $rows = $this->composeExportRows($gallery, null, $fileIds, $fields, (string)$row['name'], $actor);
+ $content = "\xEF\xBB\xBF" . $this->csv->encode([$fields, ...array_map(
+ static fn (array $values): array => array_map(static fn (string $field): string => (string)($values[$field] ?? ''), $fields),
+ $rows,
+ )]);
return ['content' => $content, 'filename' => $base . ($format === 'preview' ? '-preview.csv' : '.csv'), 'mimeType' => 'text/csv; charset=utf-8'];
}
return match ($format) {
@@ -408,13 +419,17 @@ public function exportActorSelection(Gallery $gallery, CollaborationActor $actor
* @param list $fields
* @return list>
*/
- private function composeExportRows(Gallery $gallery, ?Guest $guest, array $fileIds, array $fields, string $selectionName): array {
+ private function composeExportRows(Gallery $gallery, ?Guest $guest, array $fileIds, array $fields, string $selectionName, ?CollaborationActor $actor = null): array {
if ($fileIds === []) return [];
- $culls = $guest === null ? $this->culling->forFiles($gallery->getOwnerUid(), $fileIds) : [];
- $aggregates = $guest === null ? array_column($this->guestRatings->aggregate($gallery, $fileIds)['items'], null, 'fileId') : [];
- $guestValues = $guest === null ? [] : array_column(array_map(static fn (\OCA\ProofingGallery\Db\GuestRating $value): array => $value->jsonSerialize(), $this->guestRatings->forGuest($guest)), null, 'fileId');
+ $isOwner = $guest === null && $actor === null;
+ $culls = $isOwner ? $this->culling->forFiles($gallery->getOwnerUid(), $fileIds) : [];
+ $aggregates = $isOwner ? array_column($this->guestRatings->aggregate($gallery, $fileIds)['items'], null, 'fileId') : [];
+ $ratingValues = $guest !== null
+ ? $this->guestRatings->forGuestFiles($guest, $fileIds)
+ : ($actor === null ? [] : $this->guestRatings->forActorFiles($gallery->getId(), $actor, $fileIds));
+ $guestValues = array_column(array_map(static fn (\OCA\ProofingGallery\Db\GuestRating $value): array => $value->jsonSerialize(), $ratingValues), null, 'fileId');
$comments = [];
- if ($guest === null && in_array('comments', $fields, true)) {
+ if ($isOwner && in_array('comments', $fields, true)) {
$comments = $this->repository->commentsByFileIds($gallery->getId(), $fileIds);
}
$root = $gallery->getSourceType() === 'folder' ? $this->folders->resolveFolder($gallery->getOwnerUid(), $gallery->getFolderId()) : null;
diff --git a/lib/Service/GuestRatingAggregator.php b/lib/Service/GuestRatingAggregator.php
index 758e347..8e29948 100644
--- a/lib/Service/GuestRatingAggregator.php
+++ b/lib/Service/GuestRatingAggregator.php
@@ -9,10 +9,10 @@
final class GuestRatingAggregator {
/**
* @param list $values
- * @param array $guestNames
+ * @param array $actorNames
* @return array
*/
- public function summarize(int $fileId, array $values, array $guestNames): array {
+ public function summarize(int $fileId, array $values, array $actorNames): array {
if ($values === []) throw new \InvalidArgumentException('Cannot summarize an empty guest rating set');
$distribution = array_fill(0, 6, 0);
$picks = ['none' => 0, 'pick' => 0, 'reject' => 0];
@@ -26,7 +26,9 @@ public function summarize(int $fileId, array $values, array $guestNames): array
$updatedAt = max($updatedAt, $value->getUpdatedAt());
$individuals[] = [
'guestId' => $value->getGuestId(),
- 'name' => ($guestNames[$value->getGuestId()] ?? '') ?: 'Guest',
+ 'actorUid' => $value->getActorUid(),
+ 'actorKind' => $value->getGuestId() === null ? 'user' : 'guest',
+ 'name' => ($actorNames[$value->actorKey()] ?? '') ?: ($value->getGuestId() === null ? 'Account' : 'Guest'),
...$value->jsonSerialize(),
];
}
diff --git a/lib/Service/GuestRatingService.php b/lib/Service/GuestRatingService.php
index ce7cf34..e0ec617 100644
--- a/lib/Service/GuestRatingService.php
+++ b/lib/Service/GuestRatingService.php
@@ -10,8 +10,10 @@
use OCA\ProofingGallery\Db\GuestRating;
use OCA\ProofingGallery\Db\GuestRatingMapper;
use OCA\ProofingGallery\Db\PublicLink;
+use OCA\ProofingGallery\Domain\CollaborationActor;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IUserManager;
final class GuestRatingService {
public function __construct(
@@ -21,19 +23,27 @@ public function __construct(
private \OCP\IDBConnection $db,
private GuestRatingAggregator $aggregator,
private CapabilityPolicyService $capabilities,
+ private IUserManager $users,
) {
}
public function save(PublicLink $link, Guest $guest, int $fileId, int $rating, string $pick = 'none'): GuestRating {
+ if ($guest->getGalleryId() !== $link->getGalleryId()) {
+ throw new \InvalidArgumentException('Guest and public link do not belong to the same active gallery');
+ }
+ return $this->saveForActor($link, CollaborationActor::guest($guest), $fileId, $rating, $pick);
+ }
+
+ public function saveForActor(PublicLink $link, CollaborationActor $actor, int $fileId, int $rating, string $pick = 'none'): GuestRating {
$this->capabilities->assertFeature('guestRatings');
if ($rating < 0 || $rating > 5 || !in_array($pick, ['none', 'pick', 'reject'], true)) {
- throw new \InvalidArgumentException('Invalid guest rating');
+ throw new \InvalidArgumentException('Invalid reviewer rating');
}
- if ($guest->getGalleryId() !== $link->getGalleryId() || $link->getStatus() !== 'active') {
- throw new \InvalidArgumentException('Guest and public link do not belong to the same active gallery');
+ if ($link->getStatus() !== 'active') {
+ throw new \InvalidArgumentException('Public link is not active');
}
try {
- $value = $this->ratings->findGuestFile($link->getGalleryId(), $guest->getId(), $fileId);
+ $value = $this->ratings->findActorFile($link->getGalleryId(), $actor->guestId(), $actor->userUid(), $fileId);
$value->setPublicLinkId($link->getId());
$value->setRating($rating);
$value->setPickState($pick);
@@ -43,7 +53,8 @@ public function save(PublicLink $link, Guest $guest, int $fileId, int $rating, s
$value = new GuestRating();
$value->setGalleryId($link->getGalleryId());
$value->setPublicLinkId($link->getId());
- $value->setGuestId($guest->getId());
+ $value->setGuestId($actor->guestId());
+ $value->setActorUid($actor->userUid());
$value->setFileId($fileId);
$value->setRating($rating);
$value->setPickState($pick);
@@ -54,16 +65,28 @@ public function save(PublicLink $link, Guest $guest, int $fileId, int $rating, s
/** @return list */
public function forGuest(Guest $guest): array {
+ return $this->forActor($guest->getGalleryId(), CollaborationActor::guest($guest));
+ }
+
+ /** @return list */
+ public function forActor(int $galleryId, CollaborationActor $actor): array {
$this->capabilities->assertFeature('guestRatings');
- return $this->ratings->findForGuest($guest->getGalleryId(), $guest->getId());
+ return $this->ratings->findForActor($galleryId, $actor->guestId(), $actor->userUid());
}
/** @param list $fileIds
* @return list
*/
public function forGuestFiles(Guest $guest, array $fileIds): array {
+ return $this->forActorFiles($guest->getGalleryId(), CollaborationActor::guest($guest), $fileIds);
+ }
+
+ /** @param list $fileIds
+ * @return list
+ */
+ public function forActorFiles(int $galleryId, CollaborationActor $actor, array $fileIds): array {
$this->capabilities->assertFeature('guestRatings');
- return $this->ratings->findForGuestFiles($guest->getGalleryId(), $guest->getId(), $fileIds);
+ return $this->ratings->findForActorFiles($galleryId, $actor->guestId(), $actor->userUid(), $fileIds);
}
/**
@@ -74,22 +97,32 @@ public function aggregate(\OCA\ProofingGallery\Db\Gallery $gallery, array $fileI
$this->capabilities->assertFeature('guestRatings');
$grouped = [];
$guests = [];
+ $actorNames = [];
$values = $fileIds === [] ? $this->ratings->findForGallery($gallery->getId()) : $this->ratings->findForGalleryFiles($gallery->getId(), $fileIds);
foreach ($values as $value) {
$grouped[$value->getFileId()][] = $value;
- $guests[$value->getGuestId()] = '';
+ if ($value->getGuestId() !== null) {
+ $guests[$value->getGuestId()] = '';
+ $actorNames[$value->actorKey()] = '';
+ } elseif ($value->getActorUid() !== null) {
+ $user = $this->users->get($value->getActorUid());
+ $actorNames[$value->actorKey()] = $user?->getDisplayName() ?? '';
+ }
}
if ($guests !== []) {
foreach (array_chunk(array_keys($guests), 500) as $guestIds) {
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'display_name')->from('proofing_guests')
->where($qb->expr()->in('id', $qb->createNamedParameter($guestIds, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT_ARRAY)));
- foreach (QueryResult::rows($qb->executeQuery()) as $row) $guests[(int)$row['id']] = (string)$row['display_name'];
+ foreach (QueryResult::rows($qb->executeQuery()) as $row) {
+ $guests[(int)$row['id']] = (string)$row['display_name'];
+ $actorNames['guest:' . (int)$row['id']] = (string)$row['display_name'];
+ }
}
}
$items = [];
foreach ($grouped as $fileId => $values) {
- $items[] = $this->aggregator->summarize((int)$fileId, $values, $guests);
+ $items[] = $this->aggregator->summarize((int)$fileId, $values, $actorNames);
}
return ['items' => $items, 'guests' => $guests];
}
diff --git a/lib/Service/ReviewWorkflowService.php b/lib/Service/ReviewWorkflowService.php
index a0e80b0..93c4bd9 100644
--- a/lib/Service/ReviewWorkflowService.php
+++ b/lib/Service/ReviewWorkflowService.php
@@ -6,11 +6,11 @@
use OCA\ProofingGallery\Db\CollaborationRepository;
use OCA\ProofingGallery\Db\Gallery;
-use OCA\ProofingGallery\Db\Guest;
use OCA\ProofingGallery\Db\PublicLink;
use OCA\ProofingGallery\Db\PublicLinkMapper;
use OCA\ProofingGallery\Db\ReviewRoundRepository;
use OCA\ProofingGallery\Exception\ReviewConflictException;
+use OCA\ProofingGallery\Domain\CollaborationActor;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\TTransactional;
use OCP\AppFramework\Utility\ITimeFactory;
@@ -65,16 +65,16 @@ public function synchronize(PublicLink $link): void {
}
/** @return array */
- public function submit(Gallery $gallery, PublicLink $link, Guest $guest): array {
+ public function submit(Gallery $gallery, PublicLink $link, CollaborationActor $actor): array {
$this->assertLink($gallery, $link);
if (!$link->getReviewEnabled()) throw new \InvalidArgumentException('Review submission is disabled for this link');
$current = $this->ensure($link);
$now = $this->clock->getTime();
- if (!$this->rounds->submit((int)$current['id'], (int)$guest->getId(), $now)) {
+ if (!$this->rounds->submit((int)$current['id'], $actor->guestId(), $actor->userUid(), $now)) {
throw new ReviewConflictException('This review round is no longer open');
}
$this->collaboration->markResponseReceived((int)$gallery->getId(), $now);
- $this->activity->record($gallery, $guest, 'review.submitted', ['publicLinkId' => (int)$link->getId(), 'round' => (int)$current['round_number']]);
+ $this->activity->recordActor($gallery, $actor, 'review.submitted', ['publicLinkId' => (int)$link->getId(), 'round' => (int)$current['round_number']]);
$this->integrations->emit('review.submitted', (int)$gallery->getId(), ['publicLinkId' => (int)$link->getId(), 'round' => (int)$current['round_number']]);
return $this->publicState($link);
}
diff --git a/package-lock.json b/package-lock.json
index 877ef2e..fd7bf7f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "proofing_gallery",
- "version": "0.8.2",
+ "version": "0.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proofing_gallery",
- "version": "0.8.2",
+ "version": "0.8.3",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
diff --git a/package.json b/package.json
index fe890d3..8c0e853 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "proofing_gallery",
- "version": "0.8.2",
+ "version": "0.8.3",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
diff --git a/src/components/AdminSettingsApp.vue b/src/components/AdminSettingsApp.vue
index 79fcf12..05a236b 100644
--- a/src/components/AdminSettingsApp.vue
+++ b/src/components/AdminSettingsApp.vue
@@ -12,6 +12,7 @@ import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
import { adminSettingsCategoryPath, normalizeAdminSettingsCategory } from '../domain/adminSettingsNavigation.ts'
import type { AdminSettingsCategory as Category } from '../domain/adminSettingsNavigation.ts'
+import { galleryPurposeLabels } from '../domain/gallerySettingsOptions.ts'
import type { AdminDomain, AdminDomainPage, AdminSettingsState } from '../types/adminSettings.ts'
import AdminDocumentation from './AdminDocumentation.vue'
import AdminGalleryRollout from './AdminGalleryRollout.vue'
@@ -307,7 +308,7 @@ onBeforeUnmount(() => {
- {{ t('proofing_gallery', 'Default purpose') }}{{ purpose }}
+ {{ t('proofing_gallery', 'Default purpose') }}{{ label }}
{{ t('proofing_gallery', 'Public language') }}{{ t('proofing_gallery', 'Automatic') }} Deutsch English
{{ t('proofing_gallery', 'Theme') }}{{ t('proofing_gallery', 'Automatic') }} {{ t('proofing_gallery', 'Light') }} {{ t('proofing_gallery', 'Dark') }}
{{ t('proofing_gallery', 'Layout') }}{{ t('proofing_gallery', 'Grid') }} {{ t('proofing_gallery', 'Masonry') }} {{ t('proofing_gallery', 'List') }} {{ t('proofing_gallery', 'Story') }}
diff --git a/src/components/CullingWorkspace.vue b/src/components/CullingWorkspace.vue
index 732aa7d..609fce0 100644
--- a/src/components/CullingWorkspace.vue
+++ b/src/components/CullingWorkspace.vue
@@ -460,7 +460,7 @@ onBeforeUnmount(() => {
-
- {{ comment.body }} -
- - {{ comment.author }} · {{ new Date(comment.createdAt * 1000).toLocaleString() }} -