diff --git a/docs/api.md b/docs/api.md
index c4fae60..4a17623 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -161,9 +161,9 @@ Returns dismissed clips with thumbnail, platform, uploader info, and dismissal t
### POST /api/clips/[id]/watched
```
Request: { "watchPercent": 85 } (optional, 0–100)
-Response: { "watched": true, "credits": 3 }
+Response: { "watched": true, "credits": 3, "nextPostAt": null }
```
-`credits` is the user's reciprocity balance after this watch. The first watch of another member's clip earns a credit (subject to the group's `ratio` and the launch cutoff); self-watches and repeat watches don't.
+`credits` is the user's reciprocity balance after this watch. The first watch of another member's clip earns a credit (subject to the group's `ratio` and the launch cutoff); self-watches and repeat watches don't. `nextPostAt` is an epoch-ms timestamp for when the user may post again if they're currently blocked by the group's daily post cap, else `null` — surfaced here so the feed keeps the post gate suppressed as the rolling-24h window slides.
### PATCH /api/clips/[id]/watched
Updates watch percent without marking the clip as watched. Only updates existing watched records — does not create new ones. Used for periodic progress tracking while the user is still viewing.
@@ -281,11 +281,12 @@ Response: { "count": 5 }
```
### POST /api/queue/post
-Posts selected queued clips to the feed, spending one credit each. Only the caller's own `'queued'` entries are eligible; the server self-limits to available credits (extra IDs are skipped once credits run out).
+Posts selected queued clips to the feed, spending one credit each. Only the caller's own `'queued'` entries are eligible; the server self-limits to available credits (extra IDs are skipped once credits run out) and to the group's daily post cap.
```
Request: { "ids": ["entry-id-1", "entry-id-2"] }
-Response: { "posted": ["clip-id-1", "clip-id-2"], "credits": 3, "queueLength": 0 }
+Response: { "posted": ["clip-id-1", "clip-id-2"], "credits": 3, "queueLength": 0, "nextPostAt": null }
```
+`nextPostAt` is an epoch-ms timestamp for when the daily post cap next frees a slot, or `null` when the user isn't capped. When fewer clips post than were requested and `nextPostAt` is set, the daily cap (not credits) stopped the rest — the client surfaces a "you can post again in …" countdown instead of failing silently.
### DELETE /api/queue/[id]
Cancels a single queued clip. Only the uploader can cancel.
diff --git a/docs/architecture.md b/docs/architecture.md
index 32294e7..03f8df7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -98,7 +98,8 @@ scrolly/
│ │ │ ├── AddVideo.svelte # Add video form
│ │ │ ├── AddVideoModal.svelte # Modal wrapper for AddVideo
│ │ │ ├── AvatarCropModal.svelte # Profile picture crop UI
-│ │ │ ├── QueueSheet.svelte # Bottom sheet: multi-select + post queued clips
+│ │ │ ├── QueueSheet.svelte # Bottom sheet: multi-select + post queued clips (manual access)
+│ │ │ ├── QueueGateSlide.svelte # Full-screen reciprocity gate slide (skip/post; NOT a sheet)
│ │ │ ├── CreditDots.svelte # Tappable credits indicator (opens CreditInfoModal)
│ │ │ ├── CreditInfoModal.svelte # Explains the watch-to-post economy
│ │ │ ├── CatchUpModal.svelte # Catch-up modal for bulk unwatched clips
diff --git a/e2e/reciprocity/gate-slide.spec.ts b/e2e/reciprocity/gate-slide.spec.ts
new file mode 100644
index 0000000..a92b043
--- /dev/null
+++ b/e2e/reciprocity/gate-slide.spec.ts
@@ -0,0 +1,103 @@
+import { test, expect } from '../fixtures/test';
+import { seedUser, seedClip } from '../helpers/seed';
+import * as schema from '../../src/lib/server/db/schema';
+import { eq } from 'drizzle-orm';
+import { v4 as uuid } from 'uuid';
+import type { E2eDb } from '../fixtures/db';
+
+/**
+ * The reciprocity feed gate: once a user has banked the cap AND has clips waiting in their
+ * queue, a dedicated full-screen QueueGateSlide takes over instead of the next reel. It is NOT
+ * a bottom sheet — the only way off is the SKIP (soft nudge) or POST button.
+ */
+test.describe('reciprocity: feed gate slide', () => {
+ function setCredits(db: E2eDb, userId: string, n: number) {
+ (db as any)
+ .update(schema.users)
+ .set({ postCredits: n })
+ .where(eq(schema.users.id, userId))
+ .run();
+ }
+
+ function seedQueued(db: E2eDb, groupId: string, userId: string) {
+ const clip = seedClip(db, { groupId, addedBy: userId, status: 'queued' });
+ (db as any)
+ .insert(schema.clipQueue)
+ .values({ id: uuid(), clipId: clip.id, userId, groupId, createdAt: new Date() })
+ .run();
+ return clip;
+ }
+
+ // Cap is 5 by default; sit the user at the cap with clips waiting → the gate should engage.
+ async function primeGate(db: E2eDb, loggedIn: { userId: string; groupId: string }) {
+ setCredits(db, loggedIn.userId, 5);
+ const other = seedUser(db, { groupId: loggedIn.groupId });
+ seedClip(db, { groupId: loggedIn.groupId, addedBy: other.id, status: 'ready' });
+ seedQueued(db, loggedIn.groupId, loggedIn.userId);
+ seedQueued(db, loggedIn.groupId, loggedIn.userId);
+ }
+
+ test('engages at the cap and SKIP dismisses it (soft nudge)', async ({ page, loggedIn, db }) => {
+ await primeGate(db, loggedIn);
+ await page.goto('/');
+
+ const gate = page.locator('.gate[role="dialog"]');
+ await expect(gate).toBeVisible({ timeout: 8000 });
+ await expect(gate).toContainText('Post one to keep scrolling');
+ await expect(gate.locator('.queue-item')).toHaveCount(2);
+
+ // It must NOT be the drag-to-dismiss bottom sheet.
+ await expect(page.locator('.base-sheet')).toHaveCount(0);
+
+ // Retry the dismiss — a tap landing during the entrance animation can be dropped under
+ // heavy parallel CPU load. Skipping is idempotent, so a repeat tap is harmless.
+ const skipBtn = gate.getByRole('button', { name: 'Skip for now' });
+ await expect(async () => {
+ if (await gate.isVisible()) await skipBtn.click({ timeout: 2000 }).catch(() => {});
+ await expect(gate).toBeHidden({ timeout: 2000 });
+ }).toPass({ timeout: 12000 });
+ });
+
+ test('POST publishes a queued clip and dismisses the gate', async ({
+ page,
+ request,
+ loggedIn,
+ db
+ }) => {
+ await primeGate(db, loggedIn);
+ await page.goto('/');
+
+ const gate = page.locator('.gate[role="dialog"]');
+ await expect(gate).toBeVisible({ timeout: 8000 });
+
+ await gate.locator('.select-box').first().click();
+ const postBtn = gate.locator('.post-btn');
+ await expect(postBtn).toBeEnabled();
+ await expect(postBtn).toContainText('Post 1');
+
+ // Wait for the post round-trip so we don't race the response.
+ await Promise.all([
+ page.waitForResponse(
+ (r) => r.url().includes('/api/queue/post') && r.request().method() === 'POST'
+ ),
+ postBtn.click()
+ ]);
+
+ // Gate dismisses and stays gone (the credit spent is earned back by watching the next reel,
+ // but the post grace keeps the gate from instantly re-opening). One clip left the queue.
+ await expect(gate).toBeHidden({ timeout: 8000 });
+ const count = await (await request.get('/api/queue/count')).json();
+ expect(count.count).toBe(1);
+ });
+
+ test('never engages when the queue is empty', async ({ page, loggedIn, db }) => {
+ setCredits(db, loggedIn.userId, 5); // at the cap, but nothing queued
+ const other = seedUser(db, { groupId: loggedIn.groupId });
+ seedClip(db, { groupId: loggedIn.groupId, addedBy: other.id, status: 'ready' });
+ await page.goto('/');
+
+ // Give the feed a moment to settle, then assert the gate stayed away.
+ await page.waitForTimeout(1500);
+ await expect(page.locator('.gate[role="dialog"]')).toHaveCount(0);
+ });
+});
diff --git a/e2e/visual/queue-interactions.spec.ts b/e2e/visual/queue-interactions.spec.ts
index adb58ea..59d2385 100644
--- a/e2e/visual/queue-interactions.spec.ts
+++ b/e2e/visual/queue-interactions.spec.ts
@@ -1,6 +1,7 @@
import { test, expect } from '../fixtures/test';
import { seedClip } from '../helpers/seed';
import * as schema from '../../src/lib/server/db/schema';
+import { eq } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
import { mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
@@ -46,6 +47,10 @@ function seedQueue(
.run();
ids.push(c.id);
}
+ // These specs cover the MANUAL queue sheet (opened via ?queue=true), not the reciprocity
+ // feed gate. Drop credits below the cap (default 5) so gateActive stays false and the
+ // full-screen QueueGateSlide doesn't auto-appear over the sheet. 4 is enough to post 2.
+ (db as any).update(schema.users).set({ postCredits: 4 }).where(eq(schema.users.id, userId)).run();
return ids;
}
diff --git a/src/lib/__tests__/utils.test.ts b/src/lib/__tests__/utils.test.ts
index 00cf53d..21bd067 100644
--- a/src/lib/__tests__/utils.test.ts
+++ b/src/lib/__tests__/utils.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
-import { basename, relativeTime } from '../utils';
+import { basename, relativeTime, formatPostCooldown } from '../utils';
describe('basename', () => {
it('returns filename from path', () => {
@@ -81,3 +81,29 @@ describe('relativeTime', () => {
vi.useRealTimers();
});
});
+
+describe('formatPostCooldown', () => {
+ const NOW = 1_000_000_000_000;
+
+ it('returns "now" when the timestamp is in the past', () => {
+ expect(formatPostCooldown(NOW - 5000, NOW)).toBe('now');
+ expect(formatPostCooldown(NOW, NOW)).toBe('now');
+ });
+
+ it('rounds sub-minute waits up to a minute label', () => {
+ expect(formatPostCooldown(NOW + 30 * 1000, NOW)).toBe('1m');
+ });
+
+ it('formats minutes only when under an hour', () => {
+ expect(formatPostCooldown(NOW + 12 * 60 * 1000, NOW)).toBe('12m');
+ });
+
+ it('formats whole hours without a minutes part', () => {
+ expect(formatPostCooldown(NOW + 3 * 60 * 60 * 1000, NOW)).toBe('3h');
+ });
+
+ it('formats hours and minutes together', () => {
+ const ms = (3 * 60 + 20) * 60 * 1000;
+ expect(formatPostCooldown(NOW + ms, NOW)).toBe('3h 20m');
+ });
+});
diff --git a/src/lib/components/QueueGateSlide.svelte b/src/lib/components/QueueGateSlide.svelte
new file mode 100644
index 0000000..a63ce71
--- /dev/null
+++ b/src/lib/components/QueueGateSlide.svelte
@@ -0,0 +1,523 @@
+
+
+
+
+
+
+ {#if loading}
+
Loading your queue…
+ {:else if items.length === 0}
+
+ Your queue is empty
+ Skip for now and add clips when you find them.
+
+ {:else}
+ {#if items.length > 1}
+
+ {/if}
+
+ {#if loadingMore}
+
Loading…
+ {/if}
+ {/if}
+
+
+
+
+
+{#if showInfo}
+ (showInfo = false)} />
+{/if}
+
+
diff --git a/src/lib/components/QueueSheet.svelte b/src/lib/components/QueueSheet.svelte
index 09aec64..4e2d589 100644
--- a/src/lib/components/QueueSheet.svelte
+++ b/src/lib/components/QueueSheet.svelte
@@ -6,8 +6,8 @@
import { confirm } from '$lib/stores/confirm';
import { toast } from '$lib/stores/toasts';
import { fetchQueueCount, queueCount } from '$lib/stores/queue';
- import { credits, reciprocityCap, setCredits } from '$lib/stores/credits';
- import { basename } from '$lib/utils';
+ import { credits, reciprocityCap, setCredits, setNextPostAt } from '$lib/stores/credits';
+ import { basename, formatPostCooldown } from '$lib/utils';
import QueueIcon from 'phosphor-svelte/lib/QueueIcon';
import TrashIcon from 'phosphor-svelte/lib/TrashIcon';
import QuestionIcon from 'phosphor-svelte/lib/QuestionIcon';
@@ -108,16 +108,34 @@
}
const data = await res.json();
setCredits(data.credits);
+ setNextPostAt(data.nextPostAt);
queueCount.set(data.queueLength);
// `data.posted` is the clipIds that were published — drop those entries.
const postedClipIds: string[] = data.posted ?? [];
items = items.filter((i) => !postedClipIds.includes(i.clipId));
selected.clear();
- toast.success(
- postedClipIds.length === 1
- ? 'Posted to the feed'
- : `Posted ${postedClipIds.length} to the feed`
- );
+
+ // Fewer posted than selected + a daily-cap timestamp → the rolling-24h ceiling stopped
+ // the rest. Explain when they can post again instead of failing silently ("Posted 0").
+ const cappedShort = postedClipIds.length < ids.length && typeof data.nextPostAt === 'number';
+ if (postedClipIds.length === 0) {
+ toast.error(
+ cappedShort
+ ? `Daily limit reached — you can post again in ${formatPostCooldown(data.nextPostAt)}`
+ : 'Could not post right now'
+ );
+ return; // keep the sheet open so they can see the queue / retry
+ } else if (cappedShort) {
+ toast.success(
+ `Posted ${postedClipIds.length} — daily limit reached, more in ${formatPostCooldown(data.nextPostAt)}`
+ );
+ } else {
+ toast.success(
+ postedClipIds.length === 1
+ ? 'Posted to the feed'
+ : `Posted ${postedClipIds.length} to the feed`
+ );
+ }
ondismiss();
} catch {
toast.error('Failed to post clips');
diff --git a/src/lib/feed.ts b/src/lib/feed.ts
index 7ada4e0..96ee577 100644
--- a/src/lib/feed.ts
+++ b/src/lib/feed.ts
@@ -1,7 +1,7 @@
import type { FeedClip } from '$lib/types';
import { fetchUnwatchedCount } from '$lib/stores/notifications';
import { clearPushNotifications } from '$lib/push';
-import { setCredits } from '$lib/stores/credits';
+import { setCredits, setNextPostAt } from '$lib/stores/credits';
export type FeedFilter = 'all' | 'unwatched' | 'watched' | 'favorites' | 'uploads';
export type FeedSort = 'oldest' | 'round-robin' | 'best';
@@ -44,6 +44,7 @@ export async function markClipWatched(clipId: string): Promise {
try {
const data = await res.json();
setCredits(data.credits);
+ setNextPostAt(data.nextPostAt);
} catch {
/* no body — ignore */
}
diff --git a/src/lib/server/__tests__/credits.test.ts b/src/lib/server/__tests__/credits.test.ts
index 8cfc20d..0256248 100644
--- a/src/lib/server/__tests__/credits.test.ts
+++ b/src/lib/server/__tests__/credits.test.ts
@@ -14,9 +14,15 @@ const {
spendCredit,
earnCredit,
grantWatchCredit,
- dailyPostsRemaining
+ dailyPostsRemaining,
+ nextPostAvailableAt
} = await import('../credits');
+const DAY_MS = 24 * 60 * 60 * 1000;
+// SQLite stores timestamps at second precision, so build test dates on whole-second
+// boundaries to make round-tripped createdAt values compare exactly.
+const secondsAgo = (s: number) => new Date(Math.floor(Date.now() / 1000) * 1000 - s * 1000);
+
function setup(
opts: {
seed?: number;
@@ -246,3 +252,37 @@ describe('dailyPostsRemaining (daily cap)', () => {
expect(dailyPostsRemaining(userId, groupId)).toBe(0);
});
});
+
+describe('nextPostAvailableAt (daily-cap countdown)', () => {
+ it('returns null when the group has no daily cap', () => {
+ const { groupId, userId } = setup({ dailyPostLimit: null });
+ insertClip(groupId, userId);
+ expect(nextPostAvailableAt(userId, groupId)).toBeNull();
+ });
+
+ it('returns null when the user is still under the cap', () => {
+ const { groupId, userId } = setup({ dailyPostLimit: 3 });
+ insertClip(groupId, userId);
+ insertClip(groupId, userId); // 2 of 3 — can still post
+ expect(nextPostAvailableAt(userId, groupId)).toBeNull();
+ });
+
+ it('at the cap, frees up 24h after the OLDEST post', () => {
+ const { groupId, userId } = setup({ dailyPostLimit: 2 });
+ const oldest = secondsAgo(10 * 3600); // 10h ago
+ const newer = secondsAgo(3600); // 1h ago
+ insertClip(groupId, userId, oldest);
+ insertClip(groupId, userId, newer);
+ expect(nextPostAvailableAt(userId, groupId)).toBe(oldest.getTime() + DAY_MS);
+ });
+
+ it('over the cap (limit lowered after the fact), uses the rank-(count−limit) post', () => {
+ const { groupId, userId } = setup({ dailyPostLimit: 1 });
+ const t0 = secondsAgo(5 * 3600); // oldest
+ const t1 = secondsAgo(3 * 3600); // binding post (frees the 1 slot)
+ insertClip(groupId, userId, t0);
+ insertClip(groupId, userId, t1);
+ // count=2, limit=1 → binding = rows[count - limit] = rows[1] = t1
+ expect(nextPostAvailableAt(userId, groupId)).toBe(t1.getTime() + DAY_MS);
+ });
+});
diff --git a/src/lib/server/credits.ts b/src/lib/server/credits.ts
index e527da6..5ec9981 100644
--- a/src/lib/server/credits.ts
+++ b/src/lib/server/credits.ts
@@ -1,6 +1,6 @@
import { db } from '$lib/server/db';
import { users, groups, watched, clips } from '$lib/server/db/schema';
-import { eq, and, ne, gte, sql } from 'drizzle-orm';
+import { eq, and, ne, gte, asc, sql } from 'drizzle-orm';
import { createLogger } from '$lib/server/logger';
const log = createLogger('credits');
@@ -75,6 +75,35 @@ export function dailyPostsRemaining(userId: string, groupId: string): number | n
return Math.max(0, dailyPostLimit - postsInLast24h(userId));
}
+/**
+ * Epoch-ms timestamp when a daily-capped user can post again, or null if they aren't
+ * currently capped (no group limit, or still under it → can post now).
+ *
+ * A post counts against the cap for 24h. With the user sitting at exactly `limit` posts
+ * in the window, the *oldest* one ages out first and frees a slot — so the answer is that
+ * post's `createdAt + 24h`. If the count exceeds the limit (e.g. the host lowered it after
+ * the fact), the binding post is the one at rank `count - limit` from the oldest, since that
+ * many must expire before the count drops below the limit.
+ */
+export function nextPostAvailableAt(userId: string, groupId: string): number | null {
+ const { dailyPostLimit } = getReciprocityConfig(groupId);
+ if (dailyPostLimit === null) return null;
+
+ const since = new Date(Date.now() - DAY_MS);
+ const rows = db
+ .select({ createdAt: clips.createdAt })
+ .from(clips)
+ .where(and(eq(clips.addedBy, userId), eq(clips.status, 'ready'), gte(clips.createdAt, since)))
+ .orderBy(asc(clips.createdAt))
+ .all();
+
+ if (rows.length < dailyPostLimit) return null; // under the cap — can post now
+
+ // rows ascending (oldest first); the post at index (count - limit) frees a slot on expiry.
+ const binding = rows[rows.length - dailyPostLimit];
+ return binding.createdAt.getTime() + DAY_MS;
+}
+
/**
* Current post-credit balance for a user (0 if user is missing).
*/
diff --git a/src/lib/stores/credits.ts b/src/lib/stores/credits.ts
index 7f61661..f212f4e 100644
--- a/src/lib/stores/credits.ts
+++ b/src/lib/stores/credits.ts
@@ -7,18 +7,42 @@ export const credits = writable(0);
/** The group's max credits (cap). Seeded from page.data on mount. */
export const reciprocityCap = writable(5);
+/**
+ * Epoch-ms timestamp when the user may post again, or null when they aren't capped (no group
+ * daily limit, or still under it). Set from the layout seed and refreshed off watch/post
+ * responses as the rolling-24h window slides.
+ */
+export const nextPostAt = writable(null);
+
+/**
+ * Whether the user is currently blocked by the group's rolling-24h daily post cap. A capped
+ * user can't post anything (queue or direct), so the feed gate is pointless for them — it gets
+ * suppressed and a countdown is shown only when they actively try to post.
+ */
+export const dailyCapped = derived(nextPostAt, ($next) => $next !== null);
+
/**
* The soft feed gate. Active when the user has earned the max credits AND has clips
- * waiting in their queue — they must post one to keep scrolling. With an empty queue
- * the gate never fires (credits simply stop accruing past the cap), so a user is never
- * trapped with nothing to post.
+ * waiting in their queue — they must post one (or skip) to keep scrolling. With an empty
+ * queue the gate never fires (credits simply stop accruing past the cap), and a daily-capped
+ * user is also exempt since they have no way to post right now.
*/
export const gateActive = derived(
- [credits, reciprocityCap, queueCount],
- ([$credits, $cap, $queue]) => $credits >= $cap && $queue > 0
+ [credits, reciprocityCap, queueCount, dailyCapped],
+ ([$credits, $cap, $queue, $capped]) => $credits >= $cap && $queue > 0 && !$capped
);
/** Update the balance from an API response that reports `credits`. */
export function setCredits(value: number | undefined): void {
if (typeof value === 'number') credits.set(value);
}
+
+/**
+ * Update daily-cap state from an API response that reports `nextPostAt`. Pass the raw value
+ * (a future epoch-ms when capped, or null when not). `undefined` means the response didn't
+ * carry the field — leave the current state untouched.
+ */
+export function setNextPostAt(value: number | null | undefined): void {
+ if (value === undefined) return;
+ nextPostAt.set(typeof value === 'number' ? value : null);
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index a03a972..05093a5 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -3,6 +3,23 @@ export function basename(filepath: string): string {
return filepath.split('/').pop() || filepath;
}
+/**
+ * Format the wait until a future epoch-ms as a short, friendly duration ("3h 20m", "12m",
+ * "under a minute"). Used to tell a daily-capped user when they can post again. Returns
+ * "now" if the timestamp is already in the past.
+ */
+export function formatPostCooldown(nextPostAt: number, now: number = Date.now()): string {
+ const ms = nextPostAt - now;
+ if (ms <= 0) return 'now';
+ const totalMinutes = Math.ceil(ms / 60000);
+ if (totalMinutes < 1) return 'under a minute';
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ if (hours === 0) return `${minutes}m`;
+ if (minutes === 0) return `${hours}h`;
+ return `${hours}h ${minutes}m`;
+}
+
export function relativeTime(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts
index 306b107..ae14e28 100644
--- a/src/routes/(app)/+layout.server.ts
+++ b/src/routes/(app)/+layout.server.ts
@@ -2,6 +2,7 @@ import { redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
import type { LayoutServerLoad } from './$types';
import { env } from '$env/dynamic/private';
+import { nextPostAvailableAt } from '$lib/server/credits';
export const load: LayoutServerLoad = async ({ locals, url }) => {
const returnTo = url.search ? url.pathname + url.search : '';
@@ -15,6 +16,9 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
user: locals.user,
group: locals.group,
vapidPublicKey: env.VAPID_PUBLIC_KEY || '',
- gifEnabled: !!env.GIPHY_API_KEY || dev
+ gifEnabled: !!env.GIPHY_API_KEY || dev,
+ // Seed the daily-cap state so the post gate is suppressed from first paint when the
+ // user is already at their rolling-24h ceiling (null = not capped).
+ nextPostAt: locals.group ? nextPostAvailableAt(locals.user.id, locals.group.id) : null
};
};
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index e982dd3..e7198e6 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -12,7 +12,7 @@
stopPolling
} from '$lib/stores/notifications';
import { queueCount, fetchQueueCount } from '$lib/stores/queue';
- import { credits, reciprocityCap } from '$lib/stores/credits';
+ import { credits, reciprocityCap, setNextPostAt } from '$lib/stores/credits';
import { globalMuted } from '$lib/stores/mute';
import { initAudioContext } from '$lib/audio/normalizer';
import { fetchGroupMembers } from '$lib/stores/members';
@@ -66,6 +66,7 @@
// Seed reciprocity credit state for the feed gate
if (user) credits.set(user.postCredits ?? 0);
if (page.data?.group) reciprocityCap.set(page.data.group.reciprocityCap ?? 5);
+ setNextPostAt(page.data?.nextPostAt ?? null);
fetchQueueCount();
// Initialize AudioContext on first user interaction (for volume normalization)
diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte
index 3c859dd..aed7f4e 100644
--- a/src/routes/(app)/+page.svelte
+++ b/src/routes/(app)/+page.svelte
@@ -9,6 +9,7 @@
import CheckCircleIcon from 'phosphor-svelte/lib/CheckCircleIcon';
import { addVideoModalOpen } from '$lib/stores/addVideoModal';
import ClipOverlay from '$lib/components/ClipOverlay.svelte';
+ import QueueGateSlide from '$lib/components/QueueGateSlide.svelte';
import ShortcutUpgradeBanner from '$lib/components/ShortcutUpgradeBanner.svelte';
import { addToast, toast, clipReadySignal, clipOverlaySignal } from '$lib/stores/toasts';
import {
@@ -25,7 +26,6 @@
import { feedUiHidden } from '$lib/stores/uiHidden';
import { anySheetOpen } from '$lib/stores/sheetOpen';
import { gateActive } from '$lib/stores/credits';
- import { queueSheetOpen } from '$lib/stores/queueSheet';
import { get } from 'svelte/store';
import { onMount } from 'svelte';
import { page } from '$app/state';
@@ -197,32 +197,58 @@
}
}
- // Reciprocity gate: once you've banked the max credits AND have clips waiting in your
- // queue, you must post one before scrolling forward to the next clip. Backward scroll is
- // always allowed; an empty queue never gates.
- let gateShownForEpisode = $state(false);
-
- function openGate() {
- gateShownForEpisode = true;
- queueSheetOpen.set(true);
+ // Reciprocity gate: once you've banked the max credits AND have clips waiting in your queue,
+ // a dedicated post slide takes over instead of the next clip. You either POST a queued clip
+ // or SKIP. Backward scroll is always allowed; an empty queue (or a daily-capped user, who
+ // can't post anyway) never gates. SKIP is a soft nudge — the gate re-arms after a short
+ // grace window of further clips rather than blocking the very next swipe.
+ const GATE_SKIP_GRACE = 3;
+ let showGate = $state(false);
+ let skipGraceUntil = $state(-1);
+
+ // The gate blocks forward motion only when it's active AND we're past any post-skip grace.
+ function gateShouldBlock(): boolean {
+ return get(gateActive) && activeIndex >= skipGraceUntil;
}
- // Re-arm the gate after the user posts (credits drop below cap) or empties the queue.
+ // Drive the gate slide reactively.
+ //
+ // `skipGraceUntil` is a FORWARD high-water mark on activeIndex, not a flag to reset: after a
+ // skip or a post we set it to activeIndex + GRACE, and the gate stays suppressed until the
+ // user scrolls past it. We deliberately do NOT clear it when `gateActive` momentarily goes
+ // false — because posting drops a credit (gate off) and then watching the very next reel earns
+ // it straight back to the cap (gate on again). Without a persistent grace that would re-open
+ // the gate on the next swipe, an endless post→scroll→gate loop. The grace expires naturally as
+ // activeIndex advances, so it re-arms after a few clips.
$effect(() => {
- if (!$gateActive) gateShownForEpisode = false;
+ if (!$gateActive || activeIndex < skipGraceUntil) {
+ showGate = false;
+ return;
+ }
+ // Past the grace window and gated. Only ever flip ON here (never off based on anySheetOpen,
+ // or the gate's own sheet-depth registration would immediately hide it).
+ if (!overlayActive && !get(anySheetOpen)) showGate = true;
});
- // Surface the post sheet the moment the gate engages, so the block is explained rather
- // than just feeling stuck. Only auto-opens once per episode and never over another sheet.
- $effect(() => {
- if ($gateActive && !gateShownForEpisode && !get(anySheetOpen)) openGate();
- });
+ function skipGate() {
+ // Soft nudge: keep scrolling for a few clips before the gate comes back.
+ skipGraceUntil = activeIndex + GATE_SKIP_GRACE;
+ showGate = false;
+ }
+
+ function handleGatePosted() {
+ // Arm the skip grace from here so the gate doesn't immediately re-open after the credit a
+ // post spends is earned right back by watching the next reel. Then advance as if scrolled.
+ skipGraceUntil = activeIndex + GATE_SKIP_GRACE;
+ showGate = false;
+ scrollToIndex(activeIndex + 1);
+ }
function scrollToIndex(index: number) {
if (!scrollContainer || index < 0) return;
- // Forward advance while gated → open the post sheet instead of moving on.
- if (index > activeIndex && get(gateActive)) {
- openGate();
+ // Forward advance while the gate is blocking → show the post slide instead of moving on.
+ if (index > activeIndex && gateShouldBlock()) {
+ showGate = true;
return;
}
const slots = scrollContainer.querySelectorAll('.reel-slot');
@@ -946,7 +972,7 @@
{:else}
-