From c56f92c78cc7a30ab4b8d0e9c9c5c08206012d52 Mon Sep 17 00:00:00 2001 From: gca Date: Sun, 5 Apr 2026 23:11:55 -0500 Subject: [PATCH 1/4] ci: add hotfix publish workflow for bypass docker builds --- .github/workflows/hotfix-publish.yml | 74 ++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/hotfix-publish.yml diff --git a/.github/workflows/hotfix-publish.yml b/.github/workflows/hotfix-publish.yml new file mode 100644 index 0000000..9ffebbc --- /dev/null +++ b/.github/workflows/hotfix-publish.yml @@ -0,0 +1,74 @@ +name: Hotfix Publish + +on: + workflow_dispatch: + inputs: + version: + description: 'Version tag (e.g. 1.29.2-hotfix.1)' + required: true + type: string + +concurrency: + group: docker-publish + cancel-in-progress: false + +permissions: read-all + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=${{ inputs.version }} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + APP_VERSION=${{ inputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Set package visibility to public + run: | + curl -sf -X PATCH \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/orgs/312-dev/packages/container/scrolly" \ + -d '{"visibility":"public"}' || true From e7a14064316f0cd73eda4200c902063be196b6f1 Mon Sep 17 00:00:00 2001 From: gca Date: Sun, 5 Apr 2026 23:11:33 -0500 Subject: [PATCH 2/4] fix: prevent pull-to-refresh and scroll during progress bar scrub --- src/lib/components/ProgressBar.svelte | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/components/ProgressBar.svelte b/src/lib/components/ProgressBar.svelte index 42c50dd..b3036b3 100644 --- a/src/lib/components/ProgressBar.svelte +++ b/src/lib/components/ProgressBar.svelte @@ -39,6 +39,11 @@ // Track pointer type so we know whether to clear hover on end let activePointerType: string | null = null; + /** Block native scroll/pull-to-refresh while scrubbing */ + function blockTouchMove(e: TouchEvent) { + e.preventDefault(); + } + function handlePointerDown(e: PointerEvent) { e.preventDefault(); e.stopPropagation(); @@ -52,9 +57,11 @@ window.addEventListener('pointermove', handleWindowPointerMove); window.addEventListener('pointerup', handleWindowPointerUp); window.addEventListener('pointercancel', handleWindowPointerUp); + window.addEventListener('touchmove', blockTouchMove, { passive: false }); } function handleWindowPointerMove(e: PointerEvent) { + e.preventDefault(); const t = getTimeFromX(e.clientX); scrubTime = t; onseek(t); @@ -65,6 +72,7 @@ window.removeEventListener('pointermove', handleWindowPointerMove); window.removeEventListener('pointerup', handleWindowPointerUp); window.removeEventListener('pointercancel', handleWindowPointerUp); + window.removeEventListener('touchmove', blockTouchMove); if (scrubTime !== null) onseek(scrubTime); scrubbing = false; scrubTime = null; From e9f2c9504237e72befa263311c47cc9b7aa0093d Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Mon, 13 Apr 2026 20:47:59 -0500 Subject: [PATCH 3/4] fix: keep activity notifications until manually dismissed Notifications in the activity feed now persist until the user taps the X button. Watching a clip and viewing its comments only mark the related notifications as read (removing the unread tint and badge) instead of deleting them outright. --- docs/notifications.md | 11 +++++++---- src/lib/components/ReelItem.svelte | 2 +- src/routes/api/clips/[id]/comments/viewed/+server.ts | 11 +++++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/notifications.md b/docs/notifications.md index 19304a1..72590ec 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -11,11 +11,14 @@ The in-app activity feed shows a chronological list of notifications via the `Ac All notification types are stored in the `notifications` database table with read/unread tracking. The bottom navigation shows an unread badge count. Users can dismiss individual notifications via the `DELETE /api/notifications/[id]` endpoint. -### Auto-Clear Behavior +### Read / Dismiss Behavior -Certain user actions automatically delete related notifications to keep the activity feed clean: -- **Watching a clip** deletes any `new_clip` notification for that clip -- **Viewing comments** on a clip deletes `comment`, `reply`, and `mention` notifications for that clip +Notifications persist in the activity feed until the user explicitly dismisses them with the `X` button. Opening the activity feed or interacting with a clip only marks notifications as **read** (removing the unread tint and badge); the rows remain visible: +- **Opening the activity feed** marks all unread notifications as read. +- **Watching a clip** marks that clip's `reaction` notifications as read (after a 3s dwell). +- **Viewing comments** on a clip marks that clip's `comment`, `reply`, and `mention` notifications as read. + +Manual deletion (single-row `DELETE /api/notifications/[id]`) is the only path that removes a notification from the feed. ### API Endpoints diff --git a/src/lib/components/ReelItem.svelte b/src/lib/components/ReelItem.svelte index 19434db..2e3a280 100644 --- a/src/lib/components/ReelItem.svelte +++ b/src/lib/components/ReelItem.svelte @@ -250,7 +250,7 @@ fetch('/api/notifications/mark-read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clipId: clip.id, type: 'reaction', delete: true }) + body: JSON.stringify({ clipId: clip.id, type: 'reaction' }) }) .then(() => fetchUnreadCount()) .catch(() => {}); diff --git a/src/routes/api/clips/[id]/comments/viewed/+server.ts b/src/routes/api/clips/[id]/comments/viewed/+server.ts index 6f6e63f..5ff6233 100644 --- a/src/routes/api/clips/[id]/comments/viewed/+server.ts +++ b/src/routes/api/clips/[id]/comments/viewed/+server.ts @@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { db } from '$lib/server/db'; import { commentViews, notifications } from '$lib/server/db/schema'; -import { eq, and, inArray } from 'drizzle-orm'; +import { eq, and, inArray, isNull } from 'drizzle-orm'; import { withClipAuth } from '$lib/server/api-utils'; export const POST: RequestHandler = withClipAuth(async ({ params }, { user }) => { @@ -24,14 +24,17 @@ export const POST: RequestHandler = withClipAuth(async ({ params }, { user }) => await db.insert(commentViews).values({ clipId, userId, viewedAt: now }); } - // Auto-clear comment/reply/mention notifications now that the user has viewed comments + // Mark comment/reply/mention notifications as read (but keep them in the + // activity feed — users dismiss them manually with the X button). await db - .delete(notifications) + .update(notifications) + .set({ readAt: now }) .where( and( eq(notifications.userId, userId), eq(notifications.clipId, clipId), - inArray(notifications.type, ['comment', 'reply', 'mention']) + inArray(notifications.type, ['comment', 'reply', 'mention']), + isNull(notifications.readAt) ) ); From f1e84756b2cf6c3931becebe30fb22b43b2c8fa8 Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Mon, 13 Apr 2026 20:51:40 -0500 Subject: [PATCH 4/4] fix: preserve special reaction when favoriting a clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Favoriting previously always inserted a paired ❤️ reaction, even when the user already had a non-❤️ reaction (e.g. 🔥). That left two reaction rows for the same user/clip, and the comments view — which dedupes to the latest reaction per user — showed ❤️ instead of their chosen emoji. Now favorite only creates a ❤️ reaction when the user has no existing reaction. If they've already reacted with any emoji, we preserve it and just mark the clip as favorited, maintaining the one-reaction-per-user invariant. --- src/routes/api/clips/[id]/favorite/+server.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/routes/api/clips/[id]/favorite/+server.ts b/src/routes/api/clips/[id]/favorite/+server.ts index c7ef89e..cdc16a1 100644 --- a/src/routes/api/clips/[id]/favorite/+server.ts +++ b/src/routes/api/clips/[id]/favorite/+server.ts @@ -62,15 +62,17 @@ export const POST: RequestHandler = withClipAuth(async ({ params }, { user, clip }) .run(); - // Also create ❤️ reaction if one doesn't already exist - const existingReaction = tx + // Only create a paired ❤️ reaction if this user has NO reaction yet. + // If they already reacted with any emoji (e.g. 🔥), preserve their + // chosen reaction — the favorite saves the clip without overwriting + // what they expressed. This also keeps the "one reaction per user + // per clip" invariant the comments view relies on. + const existingAnyReaction = tx .select() .from(reactions) - .where( - and(eq(reactions.clipId, clipId), eq(reactions.userId, userId), eq(reactions.emoji, '❤️')) - ) + .where(and(eq(reactions.clipId, clipId), eq(reactions.userId, userId))) .get(); - if (!existingReaction) { + if (!existingAnyReaction) { tx.insert(reactions) .values({ id: uuid(),