diff --git a/.trivyignore b/.trivyignore
index ef9b391..c87e1df 100644
--- a/.trivyignore
+++ b/.trivyignore
@@ -13,3 +13,6 @@ GHSA-qffp-2rhf-9h96
# tar hardlink path traversal via drive-relative linkpath (npm bundled)
CVE-2026-29786
+
+# tar node-tar <7.5.11 vulnerability (npm bundled)
+CVE-2026-31802
diff --git a/CREDITS.md b/CREDITS.md
index 55ccd45..73fe238 100644
--- a/CREDITS.md
+++ b/CREDITS.md
@@ -8,6 +8,15 @@
Brand icons (TikTok, Instagram, Facebook, Spotify, Apple Music, YouTube) sourced from [Simple Icons](https://simpleicons.org) — released under [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/).
+## Clout Tier Icons
+
+Icons from [Flaticon](https://www.flaticon.com), used under the [Flaticon License](https://www.flaticon.com/legal):
+
+- **Fresh** — [Toy Blocks](https://www.flaticon.com/free-icon/toy-blocks_10527515) by Freepik
+- **Rising** — [Development](https://www.flaticon.com/free-icon/development_3657083) by Freepik
+- **Viral** — [Trending](https://www.flaticon.com/free-icon/trending_18302932) by Freepik
+- **Iconic** — [Trophy](https://www.flaticon.com/free-icon/trophy_2641497) by Freepik
+
## Fonts
Loaded via [Google Fonts](https://fonts.google.com), licensed under the [SIL Open Font License 1.1](https://opensource.org/licenses/OFL-1.1):
diff --git a/docs/api.md b/docs/api.md
index abc8ac0..950b0fc 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -237,6 +237,34 @@ Extends the trim deadline for a music clip in `pending_trim` status. The client
Response: { "ok": true }
```
+## Clout (Reputation)
+
+| Method | Path | Description |
+|--------|------|-------------|
+| GET | `/api/clout` | Get user's clout score, tier, and breakdown |
+
+### GET /api/clout
+Returns the user's clout score and tier when queue pacing is enabled. Clout is computed from the engagement on the user's last 10 matured clips (48h+ old). Users with fewer than 10 matured clips default to Rising tier.
+```
+Response: {
+ "enabled": true,
+ "score": 0.8,
+ "tier": "viral",
+ "tierName": "Viral",
+ "cooldownMinutes": 120,
+ "burstSize": 3,
+ "queueLimit": null,
+ "icon": "/icons/clout/viral.png",
+ "breakdown": [{ "clipId": "...", "score": 2 }, ...],
+ "nextTier": { "tier": "iconic", "tierName": "Iconic", "minScore": 1.0, "burst": 5, "queueLimit": null, "icon": "..." },
+ "underperforming": [{ "clipId": "...", "title": "...", "platform": "tiktok", "originalUrl": "...", "thumbnailPath": "..." }]
+}
+```
+
+**Tiers:** Fresh (<0.4) → Rising (0.4–0.6) → Viral (0.7–0.9) → Iconic (≥1.0). Each tier adjusts cooldown multiplier, burst size, and queue depth limit.
+
+**Per-clip scoring:** 0 = no reactions/favorites from others, 1 = reaction or favorite but no comment, 2 = reaction/favorite AND comment. Self-interactions excluded.
+
## Queue Management
Manage queued clips when share pacing is enabled.
diff --git a/docs/data-model.md b/docs/data-model.md
index 7868ede..8f6304a 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -249,3 +249,4 @@ users 1──∞ verification_codes
- **Duplicate URL prevention:** A unique index on `(group_id, original_url)` prevents the same link from being shared twice within a group.
- **Music clip trim workflow:** Music clips enter `pending_trim` status after download. The user can trim audio via the trim UI or skip trimming. If neither occurs before `trim_deadline`, the clip auto-publishes to `ready` status via the scheduler.
- **Dismissed clips:** The `dismissed_clips` table tracks clips dismissed by users in the catch-up modal. Users can dismiss unwatched clips in bulk, then restore them later from the Skipped Clips viewer in settings.
+- **Clout (reputation):** Computed on-demand from existing tables — no new schema. A user's clout score is the rolling average of per-clip engagement scores (0/1/2) for their last 10 matured clips (48h+ old). Scores are derived from `reactions`, `favorites`, and `comments` tables, excluding self-interactions. Tiers (Fresh/Rising/Viral/Iconic) determine queue cooldown multiplier, burst size, and queue depth limits.
diff --git a/package-lock.json b/package-lock.json
index e353382..26d842a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -218,6 +218,7 @@
"integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@algolia/client-common": "5.49.1",
"@algolia/requester-browser-xhr": "5.49.1",
@@ -2551,6 +2552,7 @@
"integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -2593,6 +2595,7 @@
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"deepmerge": "^4.3.1",
@@ -2632,6 +2635,7 @@
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -3023,6 +3027,7 @@
"integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -3109,6 +3114,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -3709,6 +3715,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3761,6 +3768,7 @@
"integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@algolia/abtesting": "1.15.1",
"@algolia/client-abtesting": "5.49.1",
@@ -3961,6 +3969,7 @@
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -4200,6 +4209,7 @@
"integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.1.2",
"@chevrotain/gast": "11.1.2",
@@ -4676,6 +4686,7 @@
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10"
}
@@ -5120,6 +5131,7 @@
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true,
"license": "ISC",
+ "peer": true,
"engines": {
"node": ">=12"
}
@@ -5802,6 +5814,7 @@
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -6258,6 +6271,7 @@
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"tabbable": "^6.4.0"
}
@@ -8202,6 +8216,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8373,6 +8388,7 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -8682,6 +8698,7 @@
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -9305,6 +9322,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz",
"integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -9645,6 +9663,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9829,6 +9848,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -10857,6 +10877,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -10917,6 +10938,7 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
@@ -11050,6 +11072,7 @@
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
diff --git a/src/lib/components/CloutChangeModal.svelte b/src/lib/components/CloutChangeModal.svelte
new file mode 100644
index 0000000..47e8cda
--- /dev/null
+++ b/src/lib/components/CloutChangeModal.svelte
@@ -0,0 +1,545 @@
+
+
+{#if change}
+
+
+
+
+ 0 ? `translateY(${dragY}px)` : undefined}
+ onclick={(e) => e.stopPropagation()}
+ >
+
+
+
+ {#if !showTips}
+ {@const final = TIER_INFO[change.newTier] ?? TIER_INFO.fresh}
+
+
+ {#key displayTier.icon}
+
+ {/key}
+
+
{final.label}
+
+ {#if isSameRank}
+ Your rank
+ {:else if isRankUp}
+ You ranked up!
+ {:else}
+ Your rank changed
+ {/if}
+
+
+ {#if isSameRank && change.newTier === 'iconic'}
+ Your clips hit different. Maximum speed unlocked.
+ {:else if isSameRank && change.newTier === 'viral'}
+ The group loves your taste. Keep it coming.
+ {:else if isSameRank && change.newTier === 'rising'}
+ You're building momentum. Keep sharing hits.
+ {:else if isSameRank}
+ Share clips the group engages with to climb up.
+ {:else if change.newTier === 'iconic'}
+ Your clips hit different. Maximum speed unlocked.
+ {:else if change.newTier === 'viral' && isRankUp}
+ The group loves your taste. Keep it coming.
+ {:else if change.newTier === 'viral'}
+ Still solid — get more comments to climb back.
+ {:else if change.newTier === 'rising' && isRankUp}
+ You're building momentum. Nice picks.
+ {:else if change.newTier === 'rising'}
+ Share clips the group reacts to and you'll bounce back.
+ {:else if isRankUp}
+ Your clips are starting to land.
+ {:else}
+ Share clips the group engages with to climb back up.
+ {/if}
+
+
+ {formatCooldown(change.cooldownMinutes)} between clips
+ ·
+ {change.burstSize} per burst
+
+
+
+ {#if !isRankUp}
+
+ {tipsLoading ? 'Loading...' : 'How to rank up'}
+
+ {/if}
+ {:else if tipsData}
+
+ {/if}
+
+
+{/if}
+
+
diff --git a/src/lib/components/CloutTipsView.svelte b/src/lib/components/CloutTipsView.svelte
new file mode 100644
index 0000000..845dee3
--- /dev/null
+++ b/src/lib/components/CloutTipsView.svelte
@@ -0,0 +1,486 @@
+
+
+
+ {#if nextTier}
+ {@const currentIdx = TIER_ORDER.indexOf(currentTier)}
+ {@const nextIdx = TIER_ORDER.indexOf(nextTier.tier)}
+
+
+ {#each TIER_ORDER as tier, i (tier)}
+ {@const isCurrent = i === currentIdx}
+ {@const isNext = i === nextIdx}
+ {@const isPast = i < currentIdx}
+
nextIdx}
+ >
+
+
{TIER_INFO[tier]?.label}
+
+ {#if i < TIER_ORDER.length - 1}
+
+ {/if}
+ {/each}
+
+ {#if neededUpgrades > 0}
+
+ {neededUpgrades} clip{neededUpgrades === 1 ? '' : 's'} need better engagement to reach
+ {nextTier.tierName}
+
+ {/if}
+
+
+
+
How scoring works
+
+
+ 0
+ No reactions or favorites
+
+
+ 1
+ Got a reaction or favorite
+
+
+ 2
+ Reaction/fave and a comment
+
+
+
+
+
+
At {nextTier.tierName} you'll get
+
+
+ {nextTier.burst}
+ per burst
+
+
+
+ {nextTier.queueLimit ?? '∞'}
+ {nextTier.queueLimit ? 'max queued' : 'queue depth'}
+
+
+
+ {:else}
+ {@const currentTierInfo = TIER_INFO[currentTier]}
+
+
+
+
+
You're at the top
+
Maximum speed unlocked. Keep sharing clips the group loves.
+
+ {/if}
+
+ {#if underperforming.length > 0}
+
+ {/if}
+
+
+
diff --git a/src/lib/components/QueueCloutBanner.svelte b/src/lib/components/QueueCloutBanner.svelte
new file mode 100644
index 0000000..04d029e
--- /dev/null
+++ b/src/lib/components/QueueCloutBanner.svelte
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+ {clout.tierName}
+
+ {formatCooldown(clout.cooldownMinutes)} between clips · {clout.burstSize} per burst
+
+
+
+ {#if clout.breakdown.length > 0}
+
+ {#each clout.breakdown as entry (entry.clipId)}
+ 0} class:full={entry.score === 2}>
+ {/each}
+
+ {clout.breakdown.filter((b) => b.score > 0).length}/{clout.breakdown.length} got reactions
+
+
+ {:else if clout.score === -1}
+
Share more clips to build your score
+ {/if}
+
+
+
diff --git a/src/lib/components/QueueSheet.svelte b/src/lib/components/QueueSheet.svelte
index 97c678d..0555245 100644
--- a/src/lib/components/QueueSheet.svelte
+++ b/src/lib/components/QueueSheet.svelte
@@ -5,6 +5,7 @@
import { toast } from '$lib/stores/toasts';
import { fetchQueueCount } from '$lib/stores/queue';
import { basename } from '$lib/utils';
+ import QueueCloutBanner from './QueueCloutBanner.svelte';
import ClockIcon from 'phosphor-svelte/lib/ClockIcon';
import TrashIcon from 'phosphor-svelte/lib/TrashIcon';
import QueueIcon from 'phosphor-svelte/lib/QueueIcon';
@@ -13,6 +14,9 @@
const { ondismiss }: { ondismiss: () => void } = $props();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- API response passed through to QueueCloutBanner
+ let clout = $state(null);
+
interface QueueItem {
id: string;
clipId: string;
@@ -40,6 +44,7 @@
$effect(() => {
loadQueue();
+ loadClout();
});
async function loadQueue() {
@@ -56,6 +61,18 @@
loading = false;
}
+ async function loadClout() {
+ try {
+ const res = await fetch('/api/clout');
+ if (res.ok) {
+ const data = await res.json();
+ if (data.enabled) clout = data;
+ }
+ } catch {
+ // silently fail
+ }
+ }
+
function handleDragStart(e: PointerEvent, index: number) {
e.stopPropagation();
e.preventDefault();
@@ -145,8 +162,9 @@
title: 'How the queue works',
message:
'Your first few clips go straight to the feed. ' +
- 'After that, extras land here and get shared on a timer — ' +
- 'so your group gets a steady stream instead of everything at once.\n\n' +
+ 'After that, extras land here and get shared on a timer.\n\n' +
+ 'Your queue speed is based on how your clips land with the group — ' +
+ 'more reactions and comments means faster sharing.\n\n' +
'Drag to reorder, or tap the trash icon to remove.',
confirmLabel: 'Got it',
cancelLabel: 'Close'
@@ -192,6 +210,10 @@
{/snippet}
+ {#if clout}
+
+ {/if}
+
{#if loading}
diff --git a/src/lib/components/settings/SharePacingPicker.svelte b/src/lib/components/settings/SharePacingPicker.svelte
index cb0cb73..76e8d26 100644
--- a/src/lib/components/settings/SharePacingPicker.svelte
+++ b/src/lib/components/settings/SharePacingPicker.svelte
@@ -5,18 +5,21 @@
currentMode,
currentBurst,
currentCooldown,
- currentDailyLimit
+ currentDailyLimit,
+ currentCloutEnabled
}: {
currentMode: string;
currentBurst: number;
currentCooldown: number;
currentDailyLimit: number | null;
+ currentCloutEnabled: boolean;
} = $props();
let mode = $state(currentMode);
let burst = $state(currentBurst);
let cooldown = $state(currentCooldown);
let dailyLimit = $state(currentDailyLimit === null ? '' : String(currentDailyLimit));
+ let cloutEnabled = $state(currentCloutEnabled);
let saving = $state(false);
const cooldownOptions = [
@@ -73,6 +76,11 @@
if (parsed !== null && (isNaN(parsed) || parsed < 1)) return;
save({ dailyShareLimit: parsed });
}
+
+ function toggleClout() {
+ cloutEnabled = !cloutEnabled;
+ save({ cloutEnabled });
+ }
@@ -172,6 +180,23 @@
{/each}
+
+
+ Reputation adjustments
+ Adjust pacing based on engagement
+
+
+
+
+
{/if}
@@ -363,4 +388,54 @@
opacity: 0.5;
cursor: not-allowed;
}
+
+ .toggle-label-group {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ .toggle-hint {
+ font-size: 0.6875rem;
+ font-weight: 400;
+ color: var(--text-muted);
+ }
+
+ .toggle {
+ position: relative;
+ width: 44px;
+ height: 26px;
+ border-radius: 13px;
+ border: none;
+ background: var(--border);
+ cursor: pointer;
+ padding: 0;
+ flex-shrink: 0;
+ transition: background 0.2s ease;
+ }
+
+ .toggle.on {
+ background: var(--accent-primary);
+ }
+
+ .toggle:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .toggle-thumb {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: var(--constant-white, #fff);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+ transition: transform 0.2s ease;
+ }
+
+ .toggle.on .toggle-thumb {
+ transform: translateX(18px);
+ }
diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts
index ef41983..56d3e46 100644
--- a/src/lib/server/api-utils.ts
+++ b/src/lib/server/api-utils.ts
@@ -218,10 +218,12 @@ export async function notifyClipOwner(opts: {
opts.type === 'comment' || opts.type === 'reply'
? `/?clip=${opts.clipId}&comments=true`
: `/?clip=${opts.clipId}`;
- const image =
- opts.actorAvatarPath && env.ORIGIN
+ let image: string | undefined;
+ if (env.ORIGIN) {
+ image = opts.actorAvatarPath
? `${env.ORIGIN}/api/profile/avatar/${opts.actorAvatarPath}`
- : undefined;
+ : `${env.ORIGIN}/api/profile/avatar/initials/${encodeURIComponent(opts.actorUsername)}`;
+ }
sendNotification(opts.recipientId, {
title: opts.pushTitle,
body: opts.pushBody,
diff --git a/src/lib/server/clout.ts b/src/lib/server/clout.ts
new file mode 100644
index 0000000..f83e6d5
--- /dev/null
+++ b/src/lib/server/clout.ts
@@ -0,0 +1,222 @@
+import { db } from '$lib/server/db';
+import { clips, reactions, favorites, comments } from '$lib/server/db/schema';
+import { eq, and, ne, sql, desc, lte } from 'drizzle-orm';
+import { createLogger } from '$lib/server/logger';
+
+const log = createLogger('clout');
+
+const MATURITY_HOURS = 48;
+const WINDOW_SIZE = 10;
+
+export const TIERS = {
+ iconic: {
+ name: 'Iconic',
+ minScore: 1.0,
+ cooldownMultiplier: 0.5,
+ burst: 5,
+ queueLimit: null, // uncapped
+ icon: '/icons/clout/iconic.png'
+ },
+ viral: {
+ name: 'Viral',
+ minScore: 0.7,
+ cooldownMultiplier: 1.0,
+ burst: 3,
+ queueLimit: null,
+ icon: '/icons/clout/viral.png'
+ },
+ rising: {
+ name: 'Rising',
+ minScore: 0.4,
+ cooldownMultiplier: 2.0,
+ burst: 2,
+ queueLimit: 10,
+ icon: '/icons/clout/rising.png'
+ },
+ fresh: {
+ name: 'Fresh',
+ minScore: 0,
+ cooldownMultiplier: 3.0,
+ burst: 1,
+ queueLimit: 6,
+ icon: '/icons/clout/fresh.png'
+ }
+} as const;
+
+export type TierKey = keyof typeof TIERS;
+
+export interface TierConfig {
+ name: string;
+ minScore: number;
+ cooldownMultiplier: number;
+ burst: number;
+ queueLimit: number | null;
+ icon: string;
+}
+
+export interface ClipBreakdown {
+ clipId: string;
+ score: number;
+ title: string | null;
+ platform: string;
+ originalUrl: string;
+ thumbnailPath: string | null;
+}
+
+export interface CloutResult {
+ score: number;
+ tier: TierKey;
+ tierConfig: TierConfig;
+ breakdown: ClipBreakdown[];
+ cooldownMinutes: number;
+ burstSize: number;
+ queueLimit: number | null;
+}
+
+/**
+ * Get the clout tier for a given score.
+ */
+export function getCloutTier(score: number): { key: TierKey; config: TierConfig } {
+ if (score >= TIERS.iconic.minScore) return { key: 'iconic', config: TIERS.iconic };
+ if (score >= TIERS.viral.minScore) return { key: 'viral', config: TIERS.viral };
+ if (score >= TIERS.rising.minScore) return { key: 'rising', config: TIERS.rising };
+ return { key: 'fresh', config: TIERS.fresh };
+}
+
+const TIER_ORDER: TierKey[] = ['fresh', 'rising', 'viral', 'iconic'];
+
+/**
+ * Get the next tier above the given one, or null if already at top.
+ */
+export function getNextTier(currentTier: TierKey): { key: TierKey; config: TierConfig } | null {
+ const idx = TIER_ORDER.indexOf(currentTier);
+ if (idx < 0 || idx >= TIER_ORDER.length - 1) return null;
+ const nextKey = TIER_ORDER[idx + 1];
+ return { key: nextKey, config: TIERS[nextKey] };
+}
+
+/**
+ * Compute clout score for a user in a group.
+ *
+ * Scoring per clip (0 / 1 / 2):
+ * 0 = no reactions or favorites from others
+ * 1 = at least 1 reaction or favorite, but no comments from others
+ * 2 = at least 1 reaction/fav AND at least 1 comment from others
+ *
+ * Returns the rolling average of the last 10 matured clips (48h+ old).
+ * Users with <10 matured clips default to Rising tier.
+ */
+export function getCloutScore(
+ userId: string,
+ groupId: string,
+ baseCooldownMinutes: number
+): CloutResult {
+ const maturityCutoff = new Date(Date.now() - MATURITY_HOURS * 60 * 60 * 1000);
+
+ // Get the user's last WINDOW_SIZE matured clips
+ const maturedClips = db
+ .select({
+ id: clips.id,
+ title: clips.title,
+ platform: clips.platform,
+ originalUrl: clips.originalUrl,
+ thumbnailPath: clips.thumbnailPath
+ })
+ .from(clips)
+ .where(
+ and(
+ eq(clips.addedBy, userId),
+ eq(clips.groupId, groupId),
+ eq(clips.status, 'ready'),
+ lte(clips.createdAt, maturityCutoff)
+ )
+ )
+ .orderBy(desc(clips.createdAt))
+ .limit(WINDOW_SIZE)
+ .all();
+
+ // Not enough matured clips — default to Rising
+ if (maturedClips.length < WINDOW_SIZE) {
+ const { key, config } = getCloutTier(TIERS.rising.minScore);
+ const cooldownMinutes = Math.round(baseCooldownMinutes * config.cooldownMultiplier);
+ return {
+ score: -1, // sentinel: not enough data
+ tier: key,
+ tierConfig: config,
+ breakdown: [],
+ cooldownMinutes,
+ burstSize: config.burst,
+ queueLimit: config.queueLimit
+ };
+ }
+
+ const breakdown: ClipBreakdown[] = [];
+
+ for (const clip of maturedClips) {
+ const clipId = clip.id;
+ // Count reactions from others
+ const [reactionResult] = db
+ .select({ count: sql`count(*)` })
+ .from(reactions)
+ .where(and(eq(reactions.clipId, clipId), ne(reactions.userId, userId)))
+ .all();
+
+ // Count favorites from others
+ const [favResult] = db
+ .select({ count: sql`count(*)` })
+ .from(favorites)
+ .where(and(eq(favorites.clipId, clipId), ne(favorites.userId, userId)))
+ .all();
+
+ // Count comments from others
+ const [commentResult] = db
+ .select({ count: sql`count(*)` })
+ .from(comments)
+ .where(and(eq(comments.clipId, clipId), ne(comments.userId, userId)))
+ .all();
+
+ const hasReactionOrFav = (reactionResult?.count ?? 0) + (favResult?.count ?? 0) > 0;
+ const hasComment = (commentResult?.count ?? 0) > 0;
+
+ let score: number;
+ if (hasReactionOrFav && hasComment) {
+ score = 2;
+ } else if (hasReactionOrFav) {
+ score = 1;
+ } else {
+ score = 0;
+ }
+
+ breakdown.push({
+ clipId,
+ score,
+ title: clip.title,
+ platform: clip.platform,
+ originalUrl: clip.originalUrl,
+ thumbnailPath: clip.thumbnailPath
+ });
+ }
+
+ const totalScore = breakdown.reduce((sum, b) => sum + b.score, 0);
+ const averageScore = totalScore / breakdown.length;
+ // Round to 1 decimal place
+ const score = Math.round(averageScore * 10) / 10;
+
+ const { key, config } = getCloutTier(score);
+ const cooldownMinutes = Math.round(baseCooldownMinutes * config.cooldownMultiplier);
+
+ log.info(
+ { userId, score, tier: key, cooldownMinutes, burst: config.burst },
+ 'clout score computed'
+ );
+
+ return {
+ score,
+ tier: key,
+ tierConfig: config,
+ breakdown,
+ cooldownMinutes,
+ burstSize: config.burst,
+ queueLimit: config.queueLimit
+ };
+}
diff --git a/src/lib/server/db/migrations/0030_mean_bruce_banner.sql b/src/lib/server/db/migrations/0030_mean_bruce_banner.sql
new file mode 100644
index 0000000..232ddf6
--- /dev/null
+++ b/src/lib/server/db/migrations/0030_mean_bruce_banner.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `clout_tier` text;--> statement-breakpoint
+ALTER TABLE `users` ADD `clout_change_shown_at` integer;
\ No newline at end of file
diff --git a/src/lib/server/db/migrations/0031_futuristic_shard.sql b/src/lib/server/db/migrations/0031_futuristic_shard.sql
new file mode 100644
index 0000000..bb812e9
--- /dev/null
+++ b/src/lib/server/db/migrations/0031_futuristic_shard.sql
@@ -0,0 +1 @@
+ALTER TABLE `groups` ADD `clout_enabled` integer DEFAULT true NOT NULL;
\ No newline at end of file
diff --git a/src/lib/server/db/migrations/meta/0030_snapshot.json b/src/lib/server/db/migrations/meta/0030_snapshot.json
new file mode 100644
index 0000000..c6f23e3
--- /dev/null
+++ b/src/lib/server/db/migrations/meta/0030_snapshot.json
@@ -0,0 +1,1482 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "967989f4-845f-42ca-bef2-653bcf815e91",
+ "prevId": "93f4d71d-17b3-408a-9632-8533a9836edb",
+ "tables": {
+ "clip_queue": {
+ "name": "clip_queue",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "clip_queue_user_group": {
+ "name": "clip_queue_user_group",
+ "columns": [
+ "user_id",
+ "group_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "clip_queue_clip_id_clips_id_fk": {
+ "name": "clip_queue_clip_id_clips_id_fk",
+ "tableFrom": "clip_queue",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "clip_queue_user_id_users_id_fk": {
+ "name": "clip_queue_user_id_users_id_fk",
+ "tableFrom": "clip_queue",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "clip_queue_group_id_groups_id_fk": {
+ "name": "clip_queue_group_id_groups_id_fk",
+ "tableFrom": "clip_queue",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "clips": {
+ "name": "clips",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "original_url": {
+ "name": "original_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "video_path": {
+ "name": "video_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thumbnail_path": {
+ "name": "thumbnail_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "platform": {
+ "name": "platform",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'downloading'"
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'video'"
+ },
+ "audio_path": {
+ "name": "audio_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "artist": {
+ "name": "artist",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "album_art": {
+ "name": "album_art",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "spotify_url": {
+ "name": "spotify_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "apple_music_url": {
+ "name": "apple_music_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "youtube_music_url": {
+ "name": "youtube_music_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_size_bytes": {
+ "name": "file_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "creator_name": {
+ "name": "creator_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "creator_url": {
+ "name": "creator_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_view_count": {
+ "name": "source_view_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trim_deadline": {
+ "name": "trim_deadline",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "clips_group_url": {
+ "name": "clips_group_url",
+ "columns": [
+ "group_id",
+ "original_url"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "clips_group_id_groups_id_fk": {
+ "name": "clips_group_id_groups_id_fk",
+ "tableFrom": "clips",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "clips_added_by_users_id_fk": {
+ "name": "clips_added_by_users_id_fk",
+ "tableFrom": "clips",
+ "tableTo": "users",
+ "columnsFrom": [
+ "added_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_hearts": {
+ "name": "comment_hearts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "comment_hearts_unique": {
+ "name": "comment_hearts_unique",
+ "columns": [
+ "comment_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "comment_hearts_comment_id_comments_id_fk": {
+ "name": "comment_hearts_comment_id_comments_id_fk",
+ "tableFrom": "comment_hearts",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comment_hearts_user_id_users_id_fk": {
+ "name": "comment_hearts_user_id_users_id_fk",
+ "tableFrom": "comment_hearts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_views": {
+ "name": "comment_views",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "comment_views_unique": {
+ "name": "comment_views_unique",
+ "columns": [
+ "clip_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "comment_views_clip_id_clips_id_fk": {
+ "name": "comment_views_clip_id_clips_id_fk",
+ "tableFrom": "comment_views",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comment_views_user_id_users_id_fk": {
+ "name": "comment_views_user_id_users_id_fk",
+ "tableFrom": "comment_views",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "gif_url": {
+ "name": "gif_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "comments_clip_id_clips_id_fk": {
+ "name": "comments_clip_id_clips_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comments_user_id_users_id_fk": {
+ "name": "comments_user_id_users_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "dismissed_clips": {
+ "name": "dismissed_clips",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dismissed_at": {
+ "name": "dismissed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "dismissed_clips_unique": {
+ "name": "dismissed_clips_unique",
+ "columns": [
+ "clip_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "dismissed_clips_clip_id_clips_id_fk": {
+ "name": "dismissed_clips_clip_id_clips_id_fk",
+ "tableFrom": "dismissed_clips",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "dismissed_clips_user_id_users_id_fk": {
+ "name": "dismissed_clips_user_id_users_id_fk",
+ "tableFrom": "dismissed_clips",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "favorites": {
+ "name": "favorites",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "favorites_clip_id_clips_id_fk": {
+ "name": "favorites_clip_id_clips_id_fk",
+ "tableFrom": "favorites",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "favorites_user_id_users_id_fk": {
+ "name": "favorites_user_id_users_id_fk",
+ "tableFrom": "favorites",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groups": {
+ "name": "groups",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invite_code": {
+ "name": "invite_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retention_days": {
+ "name": "retention_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_storage_mb": {
+ "name": "max_storage_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_file_size_mb": {
+ "name": "max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 500
+ },
+ "accent_color": {
+ "name": "accent_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'coral'"
+ },
+ "download_provider": {
+ "name": "download_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "platform_filter_mode": {
+ "name": "platform_filter_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "platform_filter_list": {
+ "name": "platform_filter_list",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "daily_share_limit": {
+ "name": "daily_share_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_pacing_mode": {
+ "name": "share_pacing_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'off'"
+ },
+ "share_burst": {
+ "name": "share_burst",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2
+ },
+ "share_cooldown_minutes": {
+ "name": "share_cooldown_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 120
+ },
+ "shortcut_token": {
+ "name": "shortcut_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shortcut_url": {
+ "name": "shortcut_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "groups_invite_code_unique": {
+ "name": "groups_invite_code_unique",
+ "columns": [
+ "invite_code"
+ ],
+ "isUnique": true
+ },
+ "groups_shortcut_token_unique": {
+ "name": "groups_shortcut_token_unique",
+ "columns": [
+ "shortcut_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "new_adds": {
+ "name": "new_adds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "reactions": {
+ "name": "reactions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "comments": {
+ "name": "comments",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "mentions": {
+ "name": "mentions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "daily_reminder": {
+ "name": "daily_reminder",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notification_preferences_user_id_users_id_fk": {
+ "name": "notification_preferences_user_id_users_id_fk",
+ "tableFrom": "notification_preferences",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emoji": {
+ "name": "emoji",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "comment_preview": {
+ "name": "comment_preview",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "notifications_user_created": {
+ "name": "notifications_user_created",
+ "columns": [
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "notifications_clip_id_clips_id_fk": {
+ "name": "notifications_clip_id_clips_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "notifications_actor_id_users_id_fk": {
+ "name": "notifications_actor_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "push_subscriptions": {
+ "name": "push_subscriptions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keys_p256dh": {
+ "name": "keys_p256dh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keys_auth": {
+ "name": "keys_auth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "push_subscriptions_user_id_users_id_fk": {
+ "name": "push_subscriptions_user_id_users_id_fk",
+ "tableFrom": "push_subscriptions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reactions": {
+ "name": "reactions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emoji": {
+ "name": "emoji",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "reactions_unique": {
+ "name": "reactions_unique",
+ "columns": [
+ "clip_id",
+ "user_id",
+ "emoji"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "reactions_clip_id_clips_id_fk": {
+ "name": "reactions_clip_id_clips_id_fk",
+ "tableFrom": "reactions",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "reactions_user_id_users_id_fk": {
+ "name": "reactions_user_id_users_id_fk",
+ "tableFrom": "reactions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "theme_preference": {
+ "name": "theme_preference",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "auto_scroll": {
+ "name": "auto_scroll",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "muted_by_default": {
+ "name": "muted_by_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "feed_sort_order": {
+ "name": "feed_sort_order",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'oldest'"
+ },
+ "avatar_path": {
+ "name": "avatar_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_legacy_share_at": {
+ "name": "last_legacy_share_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "used_new_share_flow": {
+ "name": "used_new_share_flow",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "clout_tier": {
+ "name": "clout_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "clout_change_shown_at": {
+ "name": "clout_change_shown_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "removed_at": {
+ "name": "removed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_phone_unique": {
+ "name": "users_phone_unique",
+ "columns": [
+ "phone"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "users_group_id_groups_id_fk": {
+ "name": "users_group_id_groups_id_fk",
+ "tableFrom": "users",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification_codes": {
+ "name": "verification_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "verification_codes_user_id_users_id_fk": {
+ "name": "verification_codes_user_id_users_id_fk",
+ "tableFrom": "verification_codes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "watched": {
+ "name": "watched",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "watch_percent": {
+ "name": "watch_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "watched_at": {
+ "name": "watched_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "watched_clip_user": {
+ "name": "watched_clip_user",
+ "columns": [
+ "clip_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "watched_clip_id_clips_id_fk": {
+ "name": "watched_clip_id_clips_id_fk",
+ "tableFrom": "watched",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "watched_user_id_users_id_fk": {
+ "name": "watched_user_id_users_id_fk",
+ "tableFrom": "watched",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/src/lib/server/db/migrations/meta/0031_snapshot.json b/src/lib/server/db/migrations/meta/0031_snapshot.json
new file mode 100644
index 0000000..e57fdd7
--- /dev/null
+++ b/src/lib/server/db/migrations/meta/0031_snapshot.json
@@ -0,0 +1,1490 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "10513a08-c2e1-4d2f-99fb-3daf5dd552e4",
+ "prevId": "967989f4-845f-42ca-bef2-653bcf815e91",
+ "tables": {
+ "clip_queue": {
+ "name": "clip_queue",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "clip_queue_user_group": {
+ "name": "clip_queue_user_group",
+ "columns": [
+ "user_id",
+ "group_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "clip_queue_clip_id_clips_id_fk": {
+ "name": "clip_queue_clip_id_clips_id_fk",
+ "tableFrom": "clip_queue",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "clip_queue_user_id_users_id_fk": {
+ "name": "clip_queue_user_id_users_id_fk",
+ "tableFrom": "clip_queue",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "clip_queue_group_id_groups_id_fk": {
+ "name": "clip_queue_group_id_groups_id_fk",
+ "tableFrom": "clip_queue",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "clips": {
+ "name": "clips",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "original_url": {
+ "name": "original_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "video_path": {
+ "name": "video_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thumbnail_path": {
+ "name": "thumbnail_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "platform": {
+ "name": "platform",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'downloading'"
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'video'"
+ },
+ "audio_path": {
+ "name": "audio_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "artist": {
+ "name": "artist",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "album_art": {
+ "name": "album_art",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "spotify_url": {
+ "name": "spotify_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "apple_music_url": {
+ "name": "apple_music_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "youtube_music_url": {
+ "name": "youtube_music_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_size_bytes": {
+ "name": "file_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "creator_name": {
+ "name": "creator_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "creator_url": {
+ "name": "creator_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_view_count": {
+ "name": "source_view_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trim_deadline": {
+ "name": "trim_deadline",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "clips_group_url": {
+ "name": "clips_group_url",
+ "columns": [
+ "group_id",
+ "original_url"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "clips_group_id_groups_id_fk": {
+ "name": "clips_group_id_groups_id_fk",
+ "tableFrom": "clips",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "clips_added_by_users_id_fk": {
+ "name": "clips_added_by_users_id_fk",
+ "tableFrom": "clips",
+ "tableTo": "users",
+ "columnsFrom": [
+ "added_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_hearts": {
+ "name": "comment_hearts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "comment_hearts_unique": {
+ "name": "comment_hearts_unique",
+ "columns": [
+ "comment_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "comment_hearts_comment_id_comments_id_fk": {
+ "name": "comment_hearts_comment_id_comments_id_fk",
+ "tableFrom": "comment_hearts",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comment_hearts_user_id_users_id_fk": {
+ "name": "comment_hearts_user_id_users_id_fk",
+ "tableFrom": "comment_hearts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_views": {
+ "name": "comment_views",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "comment_views_unique": {
+ "name": "comment_views_unique",
+ "columns": [
+ "clip_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "comment_views_clip_id_clips_id_fk": {
+ "name": "comment_views_clip_id_clips_id_fk",
+ "tableFrom": "comment_views",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comment_views_user_id_users_id_fk": {
+ "name": "comment_views_user_id_users_id_fk",
+ "tableFrom": "comment_views",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "gif_url": {
+ "name": "gif_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "comments_clip_id_clips_id_fk": {
+ "name": "comments_clip_id_clips_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comments_user_id_users_id_fk": {
+ "name": "comments_user_id_users_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "dismissed_clips": {
+ "name": "dismissed_clips",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dismissed_at": {
+ "name": "dismissed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "dismissed_clips_unique": {
+ "name": "dismissed_clips_unique",
+ "columns": [
+ "clip_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "dismissed_clips_clip_id_clips_id_fk": {
+ "name": "dismissed_clips_clip_id_clips_id_fk",
+ "tableFrom": "dismissed_clips",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "dismissed_clips_user_id_users_id_fk": {
+ "name": "dismissed_clips_user_id_users_id_fk",
+ "tableFrom": "dismissed_clips",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "favorites": {
+ "name": "favorites",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "favorites_clip_id_clips_id_fk": {
+ "name": "favorites_clip_id_clips_id_fk",
+ "tableFrom": "favorites",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "favorites_user_id_users_id_fk": {
+ "name": "favorites_user_id_users_id_fk",
+ "tableFrom": "favorites",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groups": {
+ "name": "groups",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invite_code": {
+ "name": "invite_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retention_days": {
+ "name": "retention_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_storage_mb": {
+ "name": "max_storage_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_file_size_mb": {
+ "name": "max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 500
+ },
+ "accent_color": {
+ "name": "accent_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'coral'"
+ },
+ "download_provider": {
+ "name": "download_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "platform_filter_mode": {
+ "name": "platform_filter_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "platform_filter_list": {
+ "name": "platform_filter_list",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "daily_share_limit": {
+ "name": "daily_share_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_pacing_mode": {
+ "name": "share_pacing_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'off'"
+ },
+ "share_burst": {
+ "name": "share_burst",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2
+ },
+ "share_cooldown_minutes": {
+ "name": "share_cooldown_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 120
+ },
+ "clout_enabled": {
+ "name": "clout_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "shortcut_token": {
+ "name": "shortcut_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shortcut_url": {
+ "name": "shortcut_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "groups_invite_code_unique": {
+ "name": "groups_invite_code_unique",
+ "columns": [
+ "invite_code"
+ ],
+ "isUnique": true
+ },
+ "groups_shortcut_token_unique": {
+ "name": "groups_shortcut_token_unique",
+ "columns": [
+ "shortcut_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "new_adds": {
+ "name": "new_adds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "reactions": {
+ "name": "reactions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "comments": {
+ "name": "comments",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "mentions": {
+ "name": "mentions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "daily_reminder": {
+ "name": "daily_reminder",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notification_preferences_user_id_users_id_fk": {
+ "name": "notification_preferences_user_id_users_id_fk",
+ "tableFrom": "notification_preferences",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emoji": {
+ "name": "emoji",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "comment_preview": {
+ "name": "comment_preview",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "notifications_user_created": {
+ "name": "notifications_user_created",
+ "columns": [
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "notifications_clip_id_clips_id_fk": {
+ "name": "notifications_clip_id_clips_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "notifications_actor_id_users_id_fk": {
+ "name": "notifications_actor_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "push_subscriptions": {
+ "name": "push_subscriptions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keys_p256dh": {
+ "name": "keys_p256dh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keys_auth": {
+ "name": "keys_auth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "push_subscriptions_user_id_users_id_fk": {
+ "name": "push_subscriptions_user_id_users_id_fk",
+ "tableFrom": "push_subscriptions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reactions": {
+ "name": "reactions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emoji": {
+ "name": "emoji",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "reactions_unique": {
+ "name": "reactions_unique",
+ "columns": [
+ "clip_id",
+ "user_id",
+ "emoji"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "reactions_clip_id_clips_id_fk": {
+ "name": "reactions_clip_id_clips_id_fk",
+ "tableFrom": "reactions",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "reactions_user_id_users_id_fk": {
+ "name": "reactions_user_id_users_id_fk",
+ "tableFrom": "reactions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "theme_preference": {
+ "name": "theme_preference",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "auto_scroll": {
+ "name": "auto_scroll",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "muted_by_default": {
+ "name": "muted_by_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "feed_sort_order": {
+ "name": "feed_sort_order",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'oldest'"
+ },
+ "avatar_path": {
+ "name": "avatar_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_legacy_share_at": {
+ "name": "last_legacy_share_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "used_new_share_flow": {
+ "name": "used_new_share_flow",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "clout_tier": {
+ "name": "clout_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "clout_change_shown_at": {
+ "name": "clout_change_shown_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "removed_at": {
+ "name": "removed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_phone_unique": {
+ "name": "users_phone_unique",
+ "columns": [
+ "phone"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "users_group_id_groups_id_fk": {
+ "name": "users_group_id_groups_id_fk",
+ "tableFrom": "users",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification_codes": {
+ "name": "verification_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "verification_codes_user_id_users_id_fk": {
+ "name": "verification_codes_user_id_users_id_fk",
+ "tableFrom": "verification_codes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "watched": {
+ "name": "watched",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "watch_percent": {
+ "name": "watch_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "watched_at": {
+ "name": "watched_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "watched_clip_user": {
+ "name": "watched_clip_user",
+ "columns": [
+ "clip_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "watched_clip_id_clips_id_fk": {
+ "name": "watched_clip_id_clips_id_fk",
+ "tableFrom": "watched",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "watched_user_id_users_id_fk": {
+ "name": "watched_user_id_users_id_fk",
+ "tableFrom": "watched",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/src/lib/server/db/migrations/meta/_journal.json b/src/lib/server/db/migrations/meta/_journal.json
index c927dfa..42aea4c 100644
--- a/src/lib/server/db/migrations/meta/_journal.json
+++ b/src/lib/server/db/migrations/meta/_journal.json
@@ -211,6 +211,20 @@
"when": 1773098368569,
"tag": "0029_chunky_sentry",
"breakpoints": true
+ },
+ {
+ "idx": 30,
+ "version": "6",
+ "when": 1773193628213,
+ "tag": "0030_mean_bruce_banner",
+ "breakpoints": true
+ },
+ {
+ "idx": 31,
+ "version": "6",
+ "when": 1773193972601,
+ "tag": "0031_futuristic_shard",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 54461eb..1cb47db 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -15,6 +15,7 @@ export const groups = sqliteTable('groups', {
sharePacingMode: text('share_pacing_mode').notNull().default('off'),
shareBurst: integer('share_burst').notNull().default(2),
shareCooldownMinutes: integer('share_cooldown_minutes').notNull().default(120),
+ cloutEnabled: integer('clout_enabled', { mode: 'boolean' }).notNull().default(true),
shortcutToken: text('shortcut_token').unique(),
shortcutUrl: text('shortcut_url'),
createdBy: text('created_by'),
@@ -35,6 +36,8 @@ export const users = sqliteTable('users', {
avatarPath: text('avatar_path'),
lastLegacyShareAt: integer('last_legacy_share_at', { mode: 'timestamp' }),
usedNewShareFlow: integer('used_new_share_flow', { mode: 'boolean' }).notNull().default(false),
+ cloutTier: text('clout_tier'),
+ cloutChangeShownAt: integer('clout_change_shown_at', { mode: 'timestamp' }),
removedAt: integer('removed_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
diff --git a/src/lib/server/mentions.ts b/src/lib/server/mentions.ts
index 10a435e..5777bc3 100644
--- a/src/lib/server/mentions.ts
+++ b/src/lib/server/mentions.ts
@@ -70,10 +70,12 @@ export async function notifyMentions(opts: {
clip?.thumbnailPath && env.ORIGIN
? `${env.ORIGIN}/api/thumbnails/${basename(clip.thumbnailPath)}`
: undefined;
- const icon =
- opts.actorAvatarPath && env.ORIGIN
+ let icon: string | undefined;
+ if (env.ORIGIN) {
+ icon = opts.actorAvatarPath
? `${env.ORIGIN}/api/profile/avatar/${opts.actorAvatarPath}`
- : undefined;
+ : `${env.ORIGIN}/api/profile/avatar/initials/${encodeURIComponent(opts.actorUsername)}`;
+ }
// Look up all active members in the group
const groupMembers = await db.query.users.findMany({
diff --git a/src/lib/server/push.ts b/src/lib/server/push.ts
index 3447549..4ea3fd8 100644
--- a/src/lib/server/push.ts
+++ b/src/lib/server/push.ts
@@ -38,9 +38,14 @@ async function getUnwatchedCount(userId: string, groupId: string): Promise {
clip.thumbnailPath && env.ORIGIN
? `${env.ORIGIN}/api/thumbnails/${basename(clip.thumbnailPath)}`
: undefined;
- const icon = avatarIconUrl(uploader.avatarPath);
+ const icon = avatarIconUrl(uploader.avatarPath, uploader.username);
// Fallback body when no title: use platform name (e.g. "New TikTok")
const platformLabels: Record = {
diff --git a/src/lib/server/queue.ts b/src/lib/server/queue.ts
index 7e7e13c..4779c6f 100644
--- a/src/lib/server/queue.ts
+++ b/src/lib/server/queue.ts
@@ -7,7 +7,7 @@ import { v4 as uuid } from 'uuid';
const log = createLogger('queue');
-const MAX_QUEUE_DEPTH = 10;
+const DEFAULT_QUEUE_DEPTH = 10;
/**
* Check if a user still has burst slots available (instant shares).
@@ -76,7 +76,8 @@ export function enqueueClip(
userId: string,
groupId: string,
cooldownMinutes: number,
- burst = 1
+ burst = 1,
+ queueLimit: number | null = DEFAULT_QUEUE_DEPTH
): { id: string; scheduledAt: Date; position: number } | null {
// Check queue depth
const [countResult] = db
@@ -86,7 +87,8 @@ export function enqueueClip(
.all();
const currentCount = countResult?.count ?? 0;
- if (currentCount >= MAX_QUEUE_DEPTH) {
+ const effectiveLimit = queueLimit ?? Infinity;
+ if (currentCount >= effectiveLimit) {
return null; // Queue full
}
diff --git a/src/lib/server/share-limit.ts b/src/lib/server/share-limit.ts
index 8fef03a..fa27782 100644
--- a/src/lib/server/share-limit.ts
+++ b/src/lib/server/share-limit.ts
@@ -3,6 +3,7 @@ import { db } from '$lib/server/db';
import { clips } from '$lib/server/db/schema';
import { eq, and, gte, sql } from 'drizzle-orm';
import { checkBurstAvailable } from '$lib/server/queue';
+import { getCloutScore } from '$lib/server/clout';
/**
* Calculate the start of "today" in the user's timezone as a UTC Date.
@@ -143,6 +144,13 @@ export type SharePacingResult =
scheduledAt?: Date;
nextSlotAt?: Date;
queueFull?: boolean;
+ clout?: {
+ cooldownMinutes: number;
+ burstSize: number;
+ queueLimit: number | null;
+ tier: string;
+ tierName: string;
+ };
};
interface GroupPacingConfig {
@@ -150,6 +158,7 @@ interface GroupPacingConfig {
dailyShareLimit: number | null;
shareBurst: number;
shareCooldownMinutes: number;
+ cloutEnabled: boolean;
}
/**
@@ -185,6 +194,23 @@ export function checkSharePacing(
};
}
case 'queue': {
+ if (group.cloutEnabled) {
+ const clout = getCloutScore(userId, groupId, group.shareCooldownMinutes);
+ const burst = checkBurstAvailable(userId, groupId, clout.burstSize, clout.cooldownMinutes);
+ return {
+ mode: 'queue',
+ queued: !burst.available,
+ nextSlotAt: burst.nextSlotAt ?? undefined,
+ clout: {
+ cooldownMinutes: clout.cooldownMinutes,
+ burstSize: clout.burstSize,
+ queueLimit: clout.queueLimit,
+ tier: clout.tier,
+ tierName: clout.tierConfig.name
+ }
+ };
+ }
+ // Clout disabled — use base group settings directly
const burst = checkBurstAvailable(
userId,
groupId,
diff --git a/src/lib/stores/cloutChange.ts b/src/lib/stores/cloutChange.ts
new file mode 100644
index 0000000..36755bd
--- /dev/null
+++ b/src/lib/stores/cloutChange.ts
@@ -0,0 +1,12 @@
+import { writable } from 'svelte/store';
+
+export interface CloutChange {
+ previousTier: string;
+ newTier: string;
+ previousTierName: string;
+ newTierName: string;
+ cooldownMinutes: number;
+ burstSize: number;
+}
+
+export const cloutChange = writable(null);
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index b3e7bda..1cc8f6c 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -12,6 +12,7 @@
import { initAudioContext } from '$lib/audio/normalizer';
import { feedUiHidden } from '$lib/stores/uiHidden';
import { fetchGroupMembers } from '$lib/stores/members';
+ import { cloutChange } from '$lib/stores/cloutChange';
import ActivitySheet from '$lib/components/ActivitySheet.svelte';
import QueueSheet from '$lib/components/QueueSheet.svelte';
import AddVideoModal from '$lib/components/AddVideoModal.svelte';
@@ -33,9 +34,42 @@
return '';
});
+ const TIER_NAMES: Record = {
+ fresh: 'Fresh',
+ rising: 'Rising',
+ viral: 'Viral',
+ iconic: 'Iconic'
+ };
+
+ async function checkCloutTier() {
+ try {
+ const res = await fetch('/api/clout');
+ if (!res.ok) return;
+ const data = await res.json();
+ if (!data.enabled) return;
+
+ // Server tells us if a tier change modal should be shown (3-day cooldown)
+ if (data.tierChanged && data.lastTier) {
+ cloutChange.set({
+ previousTier: data.lastTier,
+ newTier: data.tier,
+ previousTierName: TIER_NAMES[data.lastTier] ?? data.lastTier,
+ newTierName: data.tierName,
+ cooldownMinutes: data.cooldownMinutes,
+ burstSize: data.burstSize
+ });
+ // Acknowledge so it won't show again on other devices
+ fetch('/api/clout', { method: 'POST' }).catch(() => {});
+ }
+ } catch {
+ // silently fail
+ }
+ }
+
onMount(() => {
startPolling();
fetchGroupMembers();
+ checkCloutTier();
// Measure actual bottom nav height and expose as CSS variable.
// This adapts to the real safe-area insets on each device instead of
diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte
index 9ca56a5..8268d74 100644
--- a/src/routes/(app)/+page.svelte
+++ b/src/routes/(app)/+page.svelte
@@ -832,13 +832,23 @@
'comments:',
deepComments
);
- overlayOpenComments = deepComments;
- clipOverlaySignal.set(deepClipId);
// Clean URL without triggering navigation
const clean = new URL(window.location.href);
clean.searchParams.delete('clip');
clean.searchParams.delete('comments');
history.replaceState(null, '', clean.pathname + clean.search || '/');
+ // Clear the notification stash so the layout's visibilitychange handler
+ // doesn't double-process this same deep-link.
+ caches
+ .open('notification-click')
+ .then((c) => c.delete('/__notification_url'))
+ .catch(() => {});
+ // Defer overlay open until after SvelteKit's router is initialized —
+ // pushState throws if called before the router is ready (cold start).
+ setTimeout(() => {
+ overlayOpenComments = deepComments;
+ clipOverlaySignal.set(deepClipId);
+ }, 0);
}
loadInitialClips();
diff --git a/src/routes/(app)/me/+page.svelte b/src/routes/(app)/me/+page.svelte
index 8b8c173..fb4faf8 100644
--- a/src/routes/(app)/me/+page.svelte
+++ b/src/routes/(app)/me/+page.svelte
@@ -17,6 +17,7 @@
import HeartIcon from 'phosphor-svelte/lib/HeartIcon';
import UploadSimpleIcon from 'phosphor-svelte/lib/UploadSimpleIcon';
import CameraIcon from 'phosphor-svelte/lib/CameraIcon';
+ import { cloutChange } from '$lib/stores/cloutChange';
const user = $derived(page.data.user);
const group = $derived(page.data.group);
@@ -25,6 +26,14 @@
const gifEnabled = $derived(!!page.data.gifEnabled);
let stats = $state<{ uploads: number; saves: number; minutesWatched: number } | null>(null);
+ let clout = $state<{
+ enabled: boolean;
+ tier?: string;
+ tierName?: string;
+ icon?: string;
+ cooldownMinutes?: number;
+ burstSize?: number;
+ } | null>(null);
async function loadStats() {
try {
@@ -35,6 +44,15 @@
}
}
+ async function loadClout() {
+ try {
+ const res = await fetch('/api/clout');
+ if (res.ok) clout = await res.json();
+ } catch {
+ /* non-critical */
+ }
+ }
+
function formatWatchTime(minutes: number | null | undefined): string {
if (minutes === null || minutes === undefined) return '--';
if (minutes < 1) return '<1m';
@@ -42,6 +60,18 @@
return `${(minutes / 60).toFixed(1)}h`;
}
+ function showCloutModal() {
+ if (!clout?.enabled || !clout.tier || !clout.tierName) return;
+ cloutChange.set({
+ previousTier: clout.tier,
+ newTier: clout.tier,
+ previousTierName: clout.tierName,
+ newTierName: clout.tierName,
+ cooldownMinutes: clout.cooldownMinutes ?? 0,
+ burstSize: clout.burstSize ?? 1
+ });
+ }
+
// Avatar state
let avatarCropImage = $state(null);
let avatarOverride = $state(undefined);
@@ -152,6 +182,7 @@
onMount(() => {
loadFaves();
loadStats();
+ loadClout();
});
@@ -182,7 +213,14 @@
{#if avatarPath}
Remove photo
{/if}
- @{user?.username}
+
+
{user?.username}
+ {#if clout?.enabled && clout.tier && clout.icon}
+
+
+
+ {/if}
+
@@ -328,6 +366,11 @@
cursor: pointer;
padding: 0;
}
+ .name-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ }
.profile-username {
font-family: var(--font-display);
font-size: 1.25rem;
@@ -335,6 +378,23 @@
color: var(--text-primary);
letter-spacing: -0.02em;
}
+ .rank-badge {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background: none;
+ border: none;
+ cursor: pointer;
+ }
+ .rank-badge:active {
+ transform: scale(0.93);
+ }
+ .rank-icon {
+ width: 22px;
+ height: 22px;
+ object-fit: contain;
+ }
.stats-row {
display: flex;
align-items: center;
diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte
index c033342..74fd464 100644
--- a/src/routes/(app)/settings/+page.svelte
+++ b/src/routes/(app)/settings/+page.svelte
@@ -423,6 +423,7 @@
currentBurst={group.shareBurst}
currentCooldown={group.shareCooldownMinutes}
currentDailyLimit={group.dailyShareLimit}
+ currentCloutEnabled={group.cloutEnabled}
/>
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 03d19d2..5265e8a 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -7,6 +7,7 @@
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import InstallBanner from '$lib/components/InstallBanner.svelte';
import SwUpdateToast from '$lib/components/SwUpdateToast.svelte';
+ import CloutChangeModal from '$lib/components/CloutChangeModal.svelte';
import {
isStandalone,
detectStandaloneMode,
@@ -16,6 +17,57 @@
const { children } = $props();
+ const NOTIFICATION_CLICK_CACHE = 'notification-click';
+ const NOTIFICATION_CLICK_KEY = '/__notification_url';
+
+ /** Shared handler for notification deep-link URLs (used by postMessage and visibilitychange). */
+ function handleNotificationUrl(url: string) {
+ const parsed = new URL(url, window.location.origin);
+ const clipId = parsed.searchParams.get('clip');
+ const comments = parsed.searchParams.get('comments') === 'true';
+
+ if (clipId && window.location.pathname === '/') {
+ console.log('[Layout] on feed, opening overlay via signal:', clipId);
+ clipOverlaySignal.set(clipId);
+ if (comments) {
+ setTimeout(() => openCommentsSignal.set(clipId), 150);
+ }
+ } else {
+ console.log('[Layout] navigating to feed with deep link:', url);
+ // eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() expects route ID, not URL with query params
+ goto(url);
+ }
+ }
+
+ /** Clear the notification URL stash (called after successful postMessage delivery). */
+ async function clearNotificationStash() {
+ try {
+ const cache = await caches.open(NOTIFICATION_CLICK_CACHE);
+ await cache.delete(NOTIFICATION_CLICK_KEY);
+ } catch {
+ /* ignore */
+ }
+ }
+
+ /**
+ * Check for a stashed notification URL from the service worker.
+ * Covers frozen/backgrounded PWA clients where postMessage was silently lost.
+ */
+ async function checkStashedNotification() {
+ try {
+ const cache = await caches.open(NOTIFICATION_CLICK_CACHE);
+ const res = await cache.match(NOTIFICATION_CLICK_KEY);
+ if (!res) return;
+ const { url, ts } = await res.json();
+ await cache.delete(NOTIFICATION_CLICK_KEY);
+ if (Date.now() - ts > 30_000) return;
+ console.log('[Layout] found stashed notification URL:', url);
+ handleNotificationUrl(url);
+ } catch {
+ /* ignore */
+ }
+ }
+
onMount(() => {
isStandalone.set(detectStandaloneMode());
initInstallPrompt();
@@ -28,35 +80,35 @@
if (accent) updateFavicon(accent);
// Listen for push notification clicks forwarded by the service worker.
- // Uses postMessage instead of client.navigate() to avoid full-page reloads
- // that race with SvelteKit hydration on Android PWA.
+ // Uses postMessage for smooth in-app navigation when the app is active.
function handleSwMessage(event: MessageEvent) {
if (event.data?.type !== 'NOTIFICATION_CLICK') return;
const url = event.data.url || '/';
console.log('[Layout] NOTIFICATION_CLICK received, url:', url);
+ clearNotificationStash();
+ handleNotificationUrl(url);
+ }
- const parsed = new URL(url, window.location.origin);
- const clipId = parsed.searchParams.get('clip');
- const comments = parsed.searchParams.get('comments') === 'true';
-
- if (clipId && window.location.pathname === '/') {
- // Already on feed — open overlay directly via signal (same as ActivitySheet)
- console.log('[Layout] on feed, opening overlay via signal:', clipId);
- clipOverlaySignal.set(clipId);
- if (comments) {
- setTimeout(() => openCommentsSignal.set(clipId), 150);
- }
- } else {
- // On a different page — use SvelteKit client-side navigation
- console.log('[Layout] navigating to feed with deep link:', url);
- // eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() expects route ID, not URL with query params
- goto(url);
+ // Fallback for frozen/backgrounded PWA clients: when the page becomes
+ // visible again, check if the SW stashed a notification URL in the Cache API.
+ function handleVisibilityChange() {
+ if (document.visibilityState === 'visible') {
+ checkStashedNotification();
}
}
navigator.serviceWorker?.addEventListener('message', handleSwMessage);
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+
+ // Also check on mount — covers the case where the app was just opened
+ // from a killed state via openWindow and the stash wasn't cleared yet.
+ // Delay slightly so the feed page's own deep-link handler (which reads
+ // ?clip= and clears the stash) has time to run first, avoiding duplicates.
+ setTimeout(() => checkStashedNotification(), 100);
+
return () => {
navigator.serviceWorker?.removeEventListener('message', handleSwMessage);
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
};
});
@@ -66,6 +118,7 @@
+