Follow source/topic — account-backed personalization (v1)
Per Codex — turn accounts into a real reason to return, without an algorithmic feed. Durable interests (sources + tags), not moods. * DB: user_follows (user_id, kind source|tag, value, unique). * queries.feed gains follow_sources/follow_tags → the Following feed is "articles from a followed source OR carrying a followed tag", still respecting calm filters/boundaries. * API: GET/POST/DELETE /api/follows (sign-in required; source ids validated); /api/feed?following=true resolves the user's follows (anon → empty, not error). * Frontend: follows store (followKeys + toggleFollow, mirrors savedIds); a Follow button on source + tag/topic views; a "Following" lane in the nav with a tailored empty state; a Following management section in Account (unfollow). Digest "From what you follow" deferred to v2 (brief stays first). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,10 @@ import { getJSON, postJSON, delJSON } from './api.js';
|
|||||||
|
|
||||||
export const auth = $state({ user: null, ready: false });
|
export const auth = $state({ user: null, ready: false });
|
||||||
export const savedIds = new SvelteSet(); // reactive set of saved article ids
|
export const savedIds = new SvelteSet(); // reactive set of saved article ids
|
||||||
|
export const followKeys = new SvelteSet(); // reactive set of "kind:value" follows
|
||||||
|
|
||||||
|
const fkey = (kind, value) => `${kind}:${String(value).toLowerCase()}`;
|
||||||
|
export const isFollowing = (kind, value) => followKeys.has(fkey(kind, value));
|
||||||
|
|
||||||
export async function refresh() {
|
export async function refresh() {
|
||||||
try {
|
try {
|
||||||
@@ -13,8 +17,35 @@ export async function refresh() {
|
|||||||
} finally {
|
} finally {
|
||||||
auth.ready = true;
|
auth.ready = true;
|
||||||
}
|
}
|
||||||
if (auth.user) await loadSaved();
|
if (auth.user) { await loadSaved(); await loadFollows(); }
|
||||||
else savedIds.clear();
|
else { savedIds.clear(); followKeys.clear(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadFollows() {
|
||||||
|
try {
|
||||||
|
const list = await getJSON('/api/follows');
|
||||||
|
followKeys.clear();
|
||||||
|
for (const f of list) followKeys.add(fkey(f.kind, f.value));
|
||||||
|
} catch {
|
||||||
|
/* not signed in / transient */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic follow/unfollow of a source or tag; reverts on failure.
|
||||||
|
export async function toggleFollow(kind, value) {
|
||||||
|
if (!auth.user) return false;
|
||||||
|
const key = fkey(kind, value);
|
||||||
|
const willFollow = !followKeys.has(key);
|
||||||
|
if (willFollow) followKeys.add(key);
|
||||||
|
else followKeys.delete(key);
|
||||||
|
try {
|
||||||
|
if (willFollow) await postJSON('/api/follows', { kind, value: String(value) });
|
||||||
|
else await delJSON(`/api/follows?kind=${kind}&value=${encodeURIComponent(value)}`);
|
||||||
|
} catch {
|
||||||
|
if (willFollow) followKeys.delete(key);
|
||||||
|
else followKeys.add(key);
|
||||||
|
}
|
||||||
|
return followKeys.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSaved() {
|
export async function loadSaved() {
|
||||||
@@ -52,6 +83,7 @@ export async function verifyToken(token) {
|
|||||||
const res = await postJSON('/api/auth/email/verify', { token });
|
const res = await postJSON('/api/auth/email/verify', { token });
|
||||||
auth.user = res.user;
|
auth.user = res.user;
|
||||||
await loadSaved();
|
await loadSaved();
|
||||||
|
await loadFollows();
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,4 +100,5 @@ export async function logout() {
|
|||||||
export function clearLocal() {
|
export function clearLocal() {
|
||||||
auth.user = null;
|
auth.user = null;
|
||||||
savedIds.clear();
|
savedIds.clear();
|
||||||
|
followKeys.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
import SavedFlyout from '$lib/components/SavedFlyout.svelte';
|
import SavedFlyout from '$lib/components/SavedFlyout.svelte';
|
||||||
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
import { auth, refresh as refreshAuth, isFollowing, toggleFollow, followKeys } from '$lib/auth.svelte.js';
|
||||||
import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
|
import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
|
||||||
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
|
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
|
||||||
import { trackVisit, track } from '$lib/analytics.js';
|
import { trackVisit, track } from '$lib/analytics.js';
|
||||||
@@ -118,6 +118,7 @@
|
|||||||
selected === 'today' ? 'Highlights from Today'
|
selected === 'today' ? 'Highlights from Today'
|
||||||
: selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
|
: selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
|
||||||
: selected === 'latest' ? 'Latest'
|
: selected === 'latest' ? 'Latest'
|
||||||
|
: selected === 'following' ? 'Following'
|
||||||
: currentTag ? humanize(currentTag)
|
: currentTag ? humanize(currentTag)
|
||||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||||
);
|
);
|
||||||
@@ -125,11 +126,13 @@
|
|||||||
selected === 'today' ? localDateLabel(brief)
|
selected === 'today' ? localDateLabel(brief)
|
||||||
: selected.startsWith('source:') ? 'Latest from this source'
|
: selected.startsWith('source:') ? 'Latest from this source'
|
||||||
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
||||||
|
: selected === 'following' ? 'From the sources & topics you follow'
|
||||||
: currentTag ? (tagFamily?.description ?? '')
|
: currentTag ? (tagFamily?.description ?? '')
|
||||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||||
);
|
);
|
||||||
let activeTab = $derived(
|
let activeTab = $derived(
|
||||||
selected === 'today' ? 'today' : selected === 'latest' ? 'latest' : 'browse'
|
selected === 'today' ? 'today' : selected === 'latest' ? 'latest'
|
||||||
|
: selected === 'following' ? 'following' : 'browse'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Customizable nav rail: the pinned lanes (Highlights + Latest) are always
|
// Customizable nav rail: the pinned lanes (Highlights + Latest) are always
|
||||||
@@ -149,7 +152,13 @@
|
|||||||
prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
|
prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
|
||||||
);
|
);
|
||||||
let navLanes = $derived(
|
let navLanes = $derived(
|
||||||
lanePool ? [...lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : []
|
lanePool
|
||||||
|
? [
|
||||||
|
...lanePool.pinned,
|
||||||
|
...(auth.user ? [{ key: 'following', label: 'Following' }] : []),
|
||||||
|
...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean),
|
||||||
|
]
|
||||||
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
function saveLanes(keys) {
|
function saveLanes(keys) {
|
||||||
@@ -170,6 +179,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A source or tag view can be followed (durable interest).
|
||||||
|
let followTarget = $derived(
|
||||||
|
selected.startsWith('source:') ? { kind: 'source', value: selected.slice(7), noun: 'source' }
|
||||||
|
: selected.startsWith('tag:') ? { kind: 'tag', value: selected.slice(4), noun: 'topic' }
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
function viewFilter(key = selected) {
|
function viewFilter(key = selected) {
|
||||||
if (key === 'today') return {};
|
if (key === 'today') return {};
|
||||||
const m = moods.find((x) => x.key === key);
|
const m = moods.find((x) => x.key === key);
|
||||||
@@ -211,6 +227,10 @@
|
|||||||
const q = P.param(prefs.data);
|
const q = P.param(prefs.data);
|
||||||
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
||||||
}
|
}
|
||||||
|
if (key === 'following') {
|
||||||
|
const q = P.param(prefs.data);
|
||||||
|
return `/api/feed?following=true&sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
||||||
|
}
|
||||||
if (key.startsWith('tag:')) {
|
if (key.startsWith('tag:')) {
|
||||||
const q = P.param(prefs.data);
|
const q = P.param(prefs.data);
|
||||||
return `/api/feed?limit=${PAGE}&offset=${offset}&tag=${encodeURIComponent(key.slice(4))}${q ? '&' + q : ''}${exq}`;
|
return `/api/feed?limit=${PAGE}&offset=${offset}&tag=${encodeURIComponent(key.slice(4))}${q ? '&' + q : ''}${exq}`;
|
||||||
@@ -429,12 +449,20 @@
|
|||||||
<h1>{viewLabel}</h1>
|
<h1>{viewLabel}</h1>
|
||||||
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if selected !== 'today'}
|
<div class="vh-actions">
|
||||||
<button class="viewback" onclick={goBack} aria-label="Go back">
|
{#if auth.user && followTarget}
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
<button class="followbtn" class:on={isFollowing(followTarget.kind, followTarget.value)}
|
||||||
Back
|
onclick={() => toggleFollow(followTarget.kind, followTarget.value)}>
|
||||||
</button>
|
{isFollowing(followTarget.kind, followTarget.value) ? '✓ Following' : 'Follow ' + followTarget.noun}
|
||||||
{/if}
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if selected !== 'today'}
|
||||||
|
<button class="viewback" onclick={goBack} aria-label="Go back">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if selected === 'today'}
|
{#if selected === 'today'}
|
||||||
@@ -478,6 +506,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="endcap rise">✦ you're all caught up ✦</p>
|
<p class="endcap rise">✦ you're all caught up ✦</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if selected === 'following'}
|
||||||
|
<p class="muted center pad">
|
||||||
|
{#if auth.user}Nothing here yet — open a source or a grouping and tap <strong>Follow</strong> to fill this lane with what you care about.
|
||||||
|
{:else}Sign in and follow a few sources or topics, and this becomes your own calm lane.{/if}
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
|
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -526,6 +559,15 @@
|
|||||||
}
|
}
|
||||||
.viewback:hover { border-color: var(--accent); }
|
.viewback:hover { border-color: var(--accent); }
|
||||||
.viewback svg { width: 16px; height: 16px; display: block; }
|
.viewback svg { width: 16px; height: 16px; display: block; }
|
||||||
|
.vh-actions { flex-shrink: 0; display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
||||||
|
.followbtn {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
|
||||||
|
background: none; border: 1px solid var(--accent); color: var(--accent-deep);
|
||||||
|
border-radius: 999px; padding: 6px 15px; font-size: 0.85rem; cursor: pointer;
|
||||||
|
transition: background 0.14s ease, color 0.14s ease;
|
||||||
|
}
|
||||||
|
.followbtn:hover { background: var(--accent-soft); }
|
||||||
|
.followbtn.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
.view-head h1 { font-size: clamp(2.1rem, 5.5vw, 2.8rem); line-height: 1.05; text-transform: capitalize; }
|
.view-head h1 { font-size: clamp(2.1rem, 5.5vw, 2.8rem); line-height: 1.05; text-transform: capitalize; }
|
||||||
.view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; }
|
.view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; }
|
||||||
.view-head .vh-text::after {
|
.view-head .vh-text::after {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getJSON, postJSON } from '$lib/api.js';
|
import { getJSON, postJSON } from '$lib/api.js';
|
||||||
import { auth, savedIds, refresh } from '$lib/auth.svelte.js';
|
import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js';
|
||||||
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
|
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
|
||||||
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
||||||
import { track } from '$lib/analytics.js';
|
import { track } from '$lib/analytics.js';
|
||||||
@@ -22,11 +22,22 @@
|
|||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{ key: 'profile', label: 'Profile' },
|
{ key: 'profile', label: 'Profile' },
|
||||||
{ key: 'saved', label: 'Saved' },
|
{ key: 'saved', label: 'Saved' },
|
||||||
|
{ key: 'following', label: 'Following' },
|
||||||
{ key: 'history', label: 'History' },
|
{ key: 'history', label: 'History' },
|
||||||
{ key: 'lanes', label: 'Lanes' },
|
{ key: 'lanes', label: 'Lanes' },
|
||||||
{ key: 'boundaries', label: 'Boundaries' },
|
{ key: 'boundaries', label: 'Boundaries' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let follows = $state([]);
|
||||||
|
let followsReady = $state(false);
|
||||||
|
async function loadFollowsList() {
|
||||||
|
try { follows = await getJSON('/api/follows'); } catch { follows = []; }
|
||||||
|
}
|
||||||
|
async function removeFollow(f) {
|
||||||
|
follows = follows.filter((x) => !(x.kind === f.kind && x.value === f.value)); // optimistic
|
||||||
|
await toggleFollow(f.kind, f.value); // syncs the shared followKeys store too
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!auth.ready) await refresh();
|
if (!auth.ready) await refresh();
|
||||||
initPrefs();
|
initPrefs();
|
||||||
@@ -63,6 +74,10 @@
|
|||||||
savedReady = true;
|
savedReady = true;
|
||||||
getJSON('/api/saved').then((r) => (savedItems = r.items)).catch(() => {});
|
getJSON('/api/saved').then((r) => (savedItems = r.items)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
if (section === 'following' && auth.user && !followsReady) {
|
||||||
|
followsReady = true;
|
||||||
|
loadFollowsList();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let savedShown = $derived(savedItems.filter((a) => savedIds.has(a.id)));
|
let savedShown = $derived(savedItems.filter((a) => savedIds.has(a.id)));
|
||||||
</script>
|
</script>
|
||||||
@@ -138,6 +153,28 @@
|
|||||||
<p class="muted">Loading…</p>
|
<p class="muted">Loading…</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{:else if section === 'following'}
|
||||||
|
{#if !auth.user}
|
||||||
|
<p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p>
|
||||||
|
{:else}
|
||||||
|
<h2>Following</h2>
|
||||||
|
<p class="dnote">Sources and topics you follow feed your <a href="/?view=following">Following</a> lane. Open any source or grouping and tap <strong>Follow</strong> to add it.</p>
|
||||||
|
{#if follows.length}
|
||||||
|
<ul class="follows">
|
||||||
|
{#each follows as f (f.kind + ':' + f.value)}
|
||||||
|
<li>
|
||||||
|
<span class="fmeta"><span class="fkind">{f.kind}</span> <span class="fname">{f.kind === 'tag' ? f.name.replace(/-/g, ' ') : f.name}</span></span>
|
||||||
|
<button class="funfollow" onclick={() => removeFollow(f)}>Unfollow</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if followsReady}
|
||||||
|
<p class="empty">You're not following anything yet.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- profile -->
|
<!-- profile -->
|
||||||
{#if auth.user}
|
{#if auth.user}
|
||||||
@@ -196,6 +233,19 @@
|
|||||||
.dtoggle:hover { border-color: var(--accent-deep); }
|
.dtoggle:hover { border-color: var(--accent-deep); }
|
||||||
.dtoggle.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
.dtoggle.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
.dtoggle:disabled { opacity: 0.6; cursor: default; }
|
.dtoggle:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
ul.follows { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
ul.follows li {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 11px 14px;
|
||||||
|
}
|
||||||
|
.fmeta { min-width: 0; }
|
||||||
|
.fkind { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
|
||||||
|
.fname { font-weight: 600; color: var(--ink); text-transform: capitalize; margin-left: 6px; }
|
||||||
|
.funfollow {
|
||||||
|
flex-shrink: 0; background: none; border: 1px solid var(--line); color: var(--muted);
|
||||||
|
border-radius: 999px; padding: 5px 13px; font-size: 0.8rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.funfollow:hover { border-color: #9a3b3b; color: #9a3b3b; }
|
||||||
.phead { display: flex; align-items: baseline; justify-content: space-between; }
|
.phead { display: flex; align-items: baseline; justify-content: space-between; }
|
||||||
.phead h2 { font-size: 1.3rem; }
|
.phead h2 { font-size: 1.3rem; }
|
||||||
.link { background: none; border: none; color: var(--accent-deep); font-size: 0.85rem; text-decoration: underline; cursor: pointer; }
|
.link { background: none; border: none; color: var(--accent-deep); font-size: 0.85rem; text-decoration: underline; cursor: pointer; }
|
||||||
|
|||||||
@@ -415,6 +415,11 @@ class DigestBody(BaseModel):
|
|||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class FollowBody(BaseModel):
|
||||||
|
kind: str # 'source' | 'tag'
|
||||||
|
value: str # source id (as text) or tag key
|
||||||
|
|
||||||
|
|
||||||
class SourceReviewBody(BaseModel):
|
class SourceReviewBody(BaseModel):
|
||||||
flag: bool = False
|
flag: bool = False
|
||||||
reason: str | None = None
|
reason: str | None = None
|
||||||
@@ -640,6 +645,62 @@ def create_app() -> FastAPI:
|
|||||||
user = _require_user(conn, request)
|
user = _require_user(conn, request)
|
||||||
return queries.saved_ids(conn, user["id"])
|
return queries.saved_ids(conn, user["id"])
|
||||||
|
|
||||||
|
# --- Follows: durable source / tag interests (require sign-in) ---
|
||||||
|
|
||||||
|
def _follows_for(conn, user_id: int) -> list[dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT kind, value FROM user_follows WHERE user_id = ? ORDER BY created_at DESC", (user_id,)
|
||||||
|
).fetchall()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = {"kind": r["kind"], "value": r["value"], "name": r["value"]}
|
||||||
|
if r["kind"] == "source" and r["value"].isdigit():
|
||||||
|
src = conn.execute("SELECT name FROM sources WHERE id = ?", (int(r["value"]),)).fetchone()
|
||||||
|
if src:
|
||||||
|
d["name"] = src["name"]
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
@app.get("/api/follows")
|
||||||
|
def follows_list(request: Request) -> list[dict]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = _require_user(conn, request)
|
||||||
|
return _follows_for(conn, user["id"])
|
||||||
|
|
||||||
|
@app.post("/api/follows")
|
||||||
|
def follow_add(body: FollowBody, request: Request) -> dict:
|
||||||
|
if body.kind not in ("source", "tag"):
|
||||||
|
raise HTTPException(status_code=422, detail="kind must be 'source' or 'tag'")
|
||||||
|
value = (body.value or "").strip()
|
||||||
|
if body.kind == "tag":
|
||||||
|
value = value.lower()
|
||||||
|
if not value:
|
||||||
|
raise HTTPException(status_code=422, detail="value is required")
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = _require_user(conn, request)
|
||||||
|
if body.kind == "source":
|
||||||
|
if not value.isdigit() or not conn.execute(
|
||||||
|
"SELECT 1 FROM sources WHERE id = ?", (int(value),)
|
||||||
|
).fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="source not found")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO user_follows (user_id, kind, value) VALUES (?, ?, ?)",
|
||||||
|
(user["id"], body.kind, value),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "kind": body.kind, "value": value}
|
||||||
|
|
||||||
|
@app.delete("/api/follows")
|
||||||
|
def follow_remove(request: Request, kind: str = Query(...), value: str = Query(...)) -> dict:
|
||||||
|
v = value.strip().lower() if kind == "tag" else value.strip()
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = _require_user(conn, request)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM user_follows WHERE user_id = ? AND kind = ? AND value = ?", (user["id"], kind, v)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
@app.post("/api/saved/{article_id}")
|
@app.post("/api/saved/{article_id}")
|
||||||
def save_article(article_id: int, request: Request) -> dict:
|
def save_article(article_id: int, request: Request) -> dict:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -1309,6 +1370,8 @@ def create_app() -> FastAPI:
|
|||||||
tag: str | None = Query(None, description="grouping tag to browse"),
|
tag: str | None = Query(None, description="grouping tag to browse"),
|
||||||
source_id: int | None = Query(None, ge=1, description="show only this source's articles"),
|
source_id: int | None = Query(None, ge=1, description="show only this source's articles"),
|
||||||
sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"),
|
sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"),
|
||||||
|
following: bool = Query(False, description="restrict to the signed-in user's followed sources/tags"),
|
||||||
|
request: Request = None,
|
||||||
) -> FeedResponse:
|
) -> FeedResponse:
|
||||||
if topic and topic.lower() not in TOPICS:
|
if topic and topic.lower() not in TOPICS:
|
||||||
raise HTTPException(400, f"unknown topic: {topic}")
|
raise HTTPException(400, f"unknown topic: {topic}")
|
||||||
@@ -1322,6 +1385,15 @@ def create_app() -> FastAPI:
|
|||||||
# word-boundary avoid-terms and dismissals need a Python pass.
|
# word-boundary avoid-terms and dismissals need a Python pass.
|
||||||
kw = _prefs_sql_kw(fp, now)
|
kw = _prefs_sql_kw(fp, now)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
if following:
|
||||||
|
user = _current_user(conn, request)
|
||||||
|
if not user:
|
||||||
|
return FeedResponse(topic=topic, flavor=flavor, count=0, items=[])
|
||||||
|
frows = conn.execute(
|
||||||
|
"SELECT kind, value FROM user_follows WHERE user_id = ?", (user["id"],)
|
||||||
|
).fetchall()
|
||||||
|
kw["follow_sources"] = [int(r["value"]) for r in frows if r["kind"] == "source" and r["value"].isdigit()]
|
||||||
|
kw["follow_tags"] = [r["value"] for r in frows if r["kind"] == "tag"]
|
||||||
if fp.avoid_terms or excl:
|
if fp.avoid_terms or excl:
|
||||||
# Over-fetch enough to cover what the Python pass might remove.
|
# Over-fetch enough to cover what the Python pass might remove.
|
||||||
fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))
|
fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))
|
||||||
|
|||||||
@@ -257,6 +257,15 @@ CREATE TABLE IF NOT EXISTS feedback_replies (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_feedback_replies_fid ON feedback_replies(feedback_id);
|
CREATE INDEX IF NOT EXISTS idx_feedback_replies_fid ON feedback_replies(feedback_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_follows (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL, -- 'source' | 'tag'
|
||||||
|
value TEXT NOT NULL, -- source id (as text) or tag key
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, kind, value)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS digest_sends (
|
CREATE TABLE IF NOT EXISTS digest_sends (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ def feed(
|
|||||||
tag: str | None = None,
|
tag: str | None = None,
|
||||||
source_id: int | None = None,
|
source_id: int | None = None,
|
||||||
sort: str = "ranked",
|
sort: str = "ranked",
|
||||||
|
follow_sources: list[int] | None = None,
|
||||||
|
follow_tags: list[str] | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return articles with categorical filters applied in SQL.
|
"""Return articles with categorical filters applied in SQL.
|
||||||
|
|
||||||
@@ -114,6 +116,23 @@ def feed(
|
|||||||
clauses.append("a.source_id = ?")
|
clauses.append("a.source_id = ?")
|
||||||
params.append(source_id)
|
params.append(source_id)
|
||||||
|
|
||||||
|
# "Following" feed: articles from a followed source OR carrying a followed tag.
|
||||||
|
# Passing either list (even empty) switches to following mode; no follows → none.
|
||||||
|
if follow_sources is not None or follow_tags is not None:
|
||||||
|
ors: list[str] = []
|
||||||
|
for sid in follow_sources or []:
|
||||||
|
params.append(sid)
|
||||||
|
if follow_sources:
|
||||||
|
ors.append(f"a.source_id IN ({','.join('?' * len(follow_sources))})")
|
||||||
|
ftags = [t.lower() for t in (follow_tags or [])]
|
||||||
|
if ftags:
|
||||||
|
ors.append(
|
||||||
|
f"EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id "
|
||||||
|
f"AND at.tag IN ({','.join('?' * len(ftags))}))"
|
||||||
|
)
|
||||||
|
params.extend(ftags)
|
||||||
|
clauses.append("(" + " OR ".join(ors) + ")" if ors else "0")
|
||||||
|
|
||||||
where = "WHERE " + " AND ".join(clauses)
|
where = "WHERE " + " AND ".join(clauses)
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
|||||||
@@ -235,3 +235,33 @@ def test_digest_toggle_and_unsubscribe(tmp_path, monkeypatch):
|
|||||||
assert TestClient(app).post(f"/api/digest/unsubscribe?u={uid}&t={tok}").json() == {"ok": True}
|
assert TestClient(app).post(f"/api/digest/unsubscribe?u={uid}&t={tok}").json() == {"ok": True}
|
||||||
assert tc.get("/api/auth/me").json()["digest_enabled"] is False
|
assert tc.get("/api/auth/me").json()["digest_enabled"] is False
|
||||||
assert TestClient(app).post("/api/account/digest", json={"enabled": True}).status_code == 401 # gated
|
assert TestClient(app).post("/api/account/digest", json={"enabled": True}).status_code == 401 # gated
|
||||||
|
|
||||||
|
|
||||||
|
def test_follows_and_following_feed(tmp_path, monkeypatch):
|
||||||
|
import os, sqlite3
|
||||||
|
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||||
|
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url) VALUES (2,'Other','http://o/f')")
|
||||||
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (2,2,'http://o/2','t2','h2')")
|
||||||
|
c.execute("INSERT INTO article_scores (article_id,accepted,topic) VALUES (2,1,'tech')")
|
||||||
|
c.commit(); c.close()
|
||||||
|
tc = _signin(app, api, "reader@x.com")
|
||||||
|
# no follows yet → empty following feed (not an error)
|
||||||
|
assert tc.get("/api/feed?following=true").json()["count"] == 0
|
||||||
|
# follow source 1 → only its article
|
||||||
|
assert tc.post("/api/follows", json={"kind": "source", "value": "1"}).json()["ok"] is True
|
||||||
|
assert any(x["value"] == "1" and x["name"] == "S" for x in tc.get("/api/follows").json())
|
||||||
|
assert [i["id"] for i in tc.get("/api/feed?following=true").json()["items"]] == [1]
|
||||||
|
# follow source 2 too → both
|
||||||
|
tc.post("/api/follows", json={"kind": "source", "value": "2"})
|
||||||
|
assert {i["id"] for i in tc.get("/api/feed?following=true").json()["items"]} == {1, 2}
|
||||||
|
# follow tag works too (article 1 carries 'science')
|
||||||
|
tc.post("/api/follows", json={"kind": "tag", "value": "Science"}) # normalized lower
|
||||||
|
assert any(x["kind"] == "tag" and x["value"] == "science" for x in tc.get("/api/follows").json())
|
||||||
|
# unfollow source 2 (DELETE via query) → back to {1}
|
||||||
|
tc.delete("/api/follows?kind=source&value=2")
|
||||||
|
assert {i["id"] for i in tc.get("/api/feed?following=true").json()["items"]} == {1}
|
||||||
|
# anon: following feed empty, follows API gated, bad source 404
|
||||||
|
assert TestClient(app).get("/api/feed?following=true").json()["count"] == 0
|
||||||
|
assert TestClient(app).get("/api/follows").status_code == 401
|
||||||
|
assert tc.post("/api/follows", json={"kind": "source", "value": "999"}).status_code == 404
|
||||||
|
|||||||
Reference in New Issue
Block a user