fix(feedback): three guest-mode bugs reported in #538

Picks up three of the four bugs from @Tietge86's report. Bug 4
(recovery flow) needs network-tab data from the reporter before a fix
makes sense; commented on the issue asking for the HTTP status from
POST /gallery/:slug/guest/recover.

Bug 1 — "Liked" filter empty in guest identity mode (GalleryView.tsx)

  The feedback filter was scoping by `photo.like_count > 0`, which is
  the global aggregate across all guests. In guest identity mode the
  filter intent is "show MY picks", so a guest who'd liked photos that
  nobody else had touched got an empty grid.

  Fix: pull the current guest's interactions from /my-feedback (already
  keyed by x-guest-token in the api interceptor) into per-type
  photo-id Sets and filter against those when identity_mode === 'guest'.
  Falls back to the aggregate-count check in simple mode where there's
  no per-person identity to scope by. Same per-guest scoping applied to
  the chip-count labels ("Liked (N)" etc.) so the chip number matches
  what the filter actually surfaces — otherwise the chip says one
  count globally and the filter shows a different (smaller) one, which
  is the same UX cliff #538 originally surfaced.

  The /my-feedback query is gated on isGuestIdentityMode (not on
  filterType being feedback-related) so the chip counts are populated
  on first render. One extra request per gallery load in guest mode;
  payload is tiny.

Bug 2 — Liked state on PhotoLikes button invisible

  bg-red-50 text-red-600 is barely visible against most themes,
  especially dark + brand-coloured backgrounds. Switch to the same
  filled state the lightbox toolbar already uses
  (bg-red-500/80 text-white) so the like registers visually.
  Heart icon's fill-current was already there for the liked state —
  unchanged.

Bug 3 — Aggregate like count leaks in lightbox toolbar

  PhotoLightbox.tsx rendered {likeCount} unconditionally next to the
  inline heart button. When the admin has show_feedback_to_guests off,
  guests still saw how many other guests had liked a photo (the count
  is an admin-only metric in that mode). Gate the span on
  feedbackSettings?.show_feedback_to_guests, matching how the rest of
  the lightbox toolbar treats that toggle. Also added show_feedback_to_guests
  to the local feedbackSettings TS type (backend already returns it).

Tests: tsc --noEmit clean. No new unit tests — bug 1 is data-flow
plumbing best validated via manual / e2e (the existing my-feedback
endpoint and feedbackService are already covered upstream); bugs 2/3
are CSS and a conditional render.

Refs: #538 (bugs 1, 2, 3 of 4)
This commit is contained in:
Paul Nothaft
2026-05-20 16:42:22 +02:00
parent cf10da29ca
commit 5311588baf
3 changed files with 94 additions and 21 deletions
+80 -18
View File
@@ -23,6 +23,7 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { api } from '../../config/api';
import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { feedbackService } from '../../services/feedback.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -229,6 +230,46 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
}, [feedbackSettings]);
// In guest identity mode both the "Liked / Favorited / Rated /
// Commented" filters AND the matching chip-count labels need to scope
// to the *current guest's* interactions, not the global aggregates on
// each photo row (#538 bug 1). Pull the current guest's feedback via
// /my-feedback (already keyed by x-guest-token in the api interceptor)
// and build per-type photo-id sets so both consumers below can do
// O(1) lookups.
//
// Always-on in guest mode (not gated on the active filter) because
// the chip counts render whether or not a feedback filter is selected
// — gating on filterType would leave "Liked (0)" stale until the user
// clicks the chip, which is the same UX cliff bug 1 was reporting.
const isGuestIdentityMode = feedbackSettings?.identity_mode === 'guest';
const { data: myFeedbackRows } = useQuery<Array<{
photo_id: number;
feedback_type: 'like' | 'favorite' | 'rating' | 'comment';
}>>({
queryKey: ['my-feedback', slug],
queryFn: () => feedbackService.getMyFeedback(slug),
enabled: isGuestIdentityMode && !!slug,
staleTime: 30 * 1000,
});
const myFeedbackPhotoIds = useMemo(() => {
const sets = {
liked: new Set<number>(),
favorited: new Set<number>(),
rated: new Set<number>(),
commented: new Set<number>(),
};
if (!myFeedbackRows) return sets;
for (const row of myFeedbackRows) {
if (row.feedback_type === 'like') sets.liked.add(row.photo_id);
else if (row.feedback_type === 'favorite') sets.favorited.add(row.photo_id);
else if (row.feedback_type === 'rating') sets.rated.add(row.photo_id);
else if (row.feedback_type === 'comment') sets.commented.add(row.photo_id);
}
return sets;
}, [myFeedbackRows]);
// Apply branding settings
useEffect(() => {
if (settingsData) {
@@ -444,19 +485,33 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
);
}
// Apply feedback filter
// Apply feedback filter. In guest identity mode the filter has to
// scope to the *current guest's* interactions (#538 bug 1) — the
// aggregate counts on each photo row are global across all guests,
// which gave an empty grid when the guest had liked photos that
// nobody else had touched. Falls back to the aggregate-count check
// in simple/non-guest mode where there's no per-person identity to
// scope by.
switch (filterType) {
case 'liked':
photos = photos.filter(photo => (photo.like_count || 0) > 0);
photos = isGuestIdentityMode
? photos.filter(photo => myFeedbackPhotoIds.liked.has(photo.id))
: photos.filter(photo => (photo.like_count || 0) > 0);
break;
case 'favorited':
photos = photos.filter(photo => (photo.favorite_count || 0) > 0);
photos = isGuestIdentityMode
? photos.filter(photo => myFeedbackPhotoIds.favorited.has(photo.id))
: photos.filter(photo => (photo.favorite_count || 0) > 0);
break;
case 'rated':
photos = photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
photos = isGuestIdentityMode
? photos.filter(photo => myFeedbackPhotoIds.rated.has(photo.id))
: photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
break;
case 'commented':
photos = photos.filter(photo => (photo.comment_count || 0) > 0);
photos = isGuestIdentityMode
? photos.filter(photo => myFeedbackPhotoIds.commented.has(photo.id))
: photos.filter(photo => (photo.comment_count || 0) > 0);
break;
default:
break;
@@ -502,22 +557,29 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter]);
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds]);
const likeCount = useMemo(
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
[data?.photos]
);
// Counts shown in the filter chips ("Liked (N)", etc.). In guest
// mode these need to mirror the per-guest filter behaviour above —
// otherwise the chip says "Liked (5)" globally but clicking it
// surfaces 3 (the guest's own subset), which is the same confusing
// mismatch #538 reported for the filter itself. Fall back to the
// global aggregate in simple mode where no per-person identity
// exists.
const likeCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.liked.size;
return data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
const favoriteCount = useMemo(
() => data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0,
[data?.photos]
);
const favoriteCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.favorited.size;
return data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
const ratedCount = useMemo(
() => data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0,
[data?.photos]
);
const ratedCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.rated.size;
return data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
// Check if downloads are allowed (both event setting and not expired)
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
@@ -78,6 +78,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
allow_likes?: boolean;
allow_ratings?: boolean;
allow_comments?: boolean;
show_feedback_to_guests?: boolean;
require_name_email?: boolean;
} | null>(null);
const [myLiked, setMyLiked] = useState<boolean>(false);
@@ -663,7 +664,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
>
<Heart className={`w-5 h-5 ${myLiked ? 'text-white' : 'text-white'}`} />
</button>
{/* Aggregate like count is admin-only when the admin
has hidden feedback from guests (#538 bug 3). Without
this gate, a guest could see how many other guests
liked a photo even with show_feedback_to_guests off. */}
{feedbackSettings?.show_feedback_to_guests && (
<span className="text-white text-xs min-w-[1.5rem] text-center select-none">{likeCount}</span>
)}
</div>
)}
@@ -111,8 +111,12 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
onClick={handleLikeClick}
disabled={isSubmitting}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
// Filled solid-red state matches the lightbox toolbar (#538 bug
// 2). The previous `bg-red-50 text-red-600` was almost invisible
// — particularly on dark themes and coloured gallery backgrounds
// — so the user couldn't tell the like had registered.
isLiked
? 'bg-red-50 text-red-600 hover:bg-red-100'
? 'bg-red-500/80 text-white hover:bg-red-500'
: 'bg-surface text-muted-theme hover:bg-black/10'
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}