mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 02:41:09 +02:00
feat(mana/web/news): "interested" keeps article visible + saved badge
Switches the feed engine to a softer reaction model: ❤️ Interessiert no longer hides the article from the feed, only adds it to the reading list and bumps the topic + source weights. The article keeps its slot in the ranked feed and gets a "❤️ gespeichert" badge in the card meta + a tinted card background so the user can see at a glance "yep, this is already in my reading list". The previous behavior — interested = save + remove from feed — was modeled on a Pocket-style "save and move on" pattern, but turns out to be confusing in a discovery-feed context: tapping a positive signal made the article disappear, which feels like punishment. Variante B (this commit) makes the destructive vs non-destructive split explicit: 👎 Nicht für mich and 🚫 Quelle ausblenden are the ones that hide articles, ❤️ is purely additive. ═══ Engine ═══ `scoreArticle()` now reads `dismissedIds` (the set of articles with not_interested or hidden reactions) for the hard-hide filter instead of the old `reactedIds` (which lumped all reaction kinds together). `interestedIds` is passed alongside so views can render the badge without re-deriving from the raw reactions array. `buildReactionSets()` is the new helper that splits the reactions into the two sets in one pass. `buildReactedIds()` is kept as a deprecated alias that returns just the dismissed set — same effect on the feed filter for any not-yet-migrated caller, and any old "interested = hidden" behavior is now lost (which is the goal). ═══ UI ═══ The feed page card body gets a `.is-saved` modifier that tints the background, the card meta row gets a saved-badge pill, and the interested button shows "Gespeichert" + a filled-in active state + disabled cursor when the article is already in the reading list. A second click on an already-saved article is a no-op now. The workbench ListView and the dashboard NewsUnreadWidget got the same engine update so the three surfaces stay in sync — the badge UI itself is only on the main feed for now since the workbench card is too narrow to fit it cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
55bf493f44
commit
acfbcfe689
5 changed files with 106 additions and 19 deletions
|
|
@ -24,7 +24,7 @@
|
|||
useReactions,
|
||||
formatRelativeTime,
|
||||
} from '$lib/modules/news/queries';
|
||||
import { rankFeed, buildReactedIds } from '$lib/modules/news/feed-engine';
|
||||
import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine';
|
||||
import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte';
|
||||
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
|
||||
import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte';
|
||||
|
|
@ -45,8 +45,10 @@
|
|||
const pool = $derived(pool$.value);
|
||||
const reactions = $derived(reactions$.value);
|
||||
|
||||
const reactedIds = $derived(buildReactedIds(reactions));
|
||||
const ranked = $derived(prefs.onboardingCompleted ? rankFeed(pool, { prefs, reactedIds }) : []);
|
||||
const { dismissedIds, interestedIds } = $derived(buildReactionSets(reactions));
|
||||
const ranked = $derived(
|
||||
prefs.onboardingCompleted ? rankFeed(pool, { prefs, dismissedIds, interestedIds }) : []
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
feedCacheStore.start();
|
||||
|
|
|
|||
|
|
@ -42,15 +42,59 @@ function recencyScore(publishedAt: string | null): number {
|
|||
|
||||
export interface ScoreContext {
|
||||
prefs: Preferences;
|
||||
/** Set of curatedArticleIds that already have any reaction. */
|
||||
reactedIds: ReadonlySet<string>;
|
||||
/**
|
||||
* Set of curatedArticleIds the user has actively dismissed
|
||||
* (`not_interested`, `hidden`, or via `source_blocked`). Used as a
|
||||
* hard hide-from-feed filter.
|
||||
*
|
||||
* `interested` reactions are NOT in this set on purpose — those
|
||||
* articles stay visible in the feed (with a saved-badge) so the
|
||||
* user can keep reading and clicking around without articles
|
||||
* disappearing the moment they tap "❤️". The reading list remains
|
||||
* the source of truth for "what did I save".
|
||||
*/
|
||||
dismissedIds: ReadonlySet<string>;
|
||||
/** Set of curatedArticleIds the user marked as interested. */
|
||||
interestedIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** Build the lookup set once and reuse across all scoreArticle calls. */
|
||||
/**
|
||||
* Split the user's reactions into two sets: dismissed (hard-hide
|
||||
* from feed) and interested (keep visible, badge in UI). Built once
|
||||
* per render and reused across all scoreArticle calls.
|
||||
*
|
||||
* `source_blocked` reactions are NOT added to dismissedIds even
|
||||
* though they hide articles — the source-level filter in
|
||||
* `scoreArticle` handles those via `prefs.blockedSources` instead,
|
||||
* so adding them here would be a no-op duplicate.
|
||||
*/
|
||||
export function buildReactionSets(reactions: readonly Reaction[]): {
|
||||
dismissedIds: Set<string>;
|
||||
interestedIds: Set<string>;
|
||||
} {
|
||||
const dismissedIds = new Set<string>();
|
||||
const interestedIds = new Set<string>();
|
||||
for (const r of reactions) {
|
||||
if (r.reaction === 'interested') {
|
||||
interestedIds.add(r.articleId);
|
||||
} else if (r.reaction === 'not_interested' || r.reaction === 'hidden') {
|
||||
dismissedIds.add(r.articleId);
|
||||
}
|
||||
}
|
||||
return { dismissedIds, interestedIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Kept for backwards compat with the dashboard widget.
|
||||
* New call sites should use `buildReactionSets` and the
|
||||
* dismissedIds/interestedIds shape instead. This helper now returns
|
||||
* only the dismissed ids — same effect on the feed filter, but it
|
||||
* means widgets that haven't migrated still hide
|
||||
* `not_interested`/`hidden` articles correctly while leaving
|
||||
* `interested` articles visible (matching the new feed behavior).
|
||||
*/
|
||||
export function buildReactedIds(reactions: readonly Reaction[]): Set<string> {
|
||||
const set = new Set<string>();
|
||||
for (const r of reactions) set.add(r.articleId);
|
||||
return set;
|
||||
return buildReactionSets(reactions).dismissedIds;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,7 +102,7 @@ export function buildReactedIds(reactions: readonly Reaction[]): Set<string> {
|
|||
* should be hidden entirely. Callers sort by descending score.
|
||||
*/
|
||||
export function scoreArticle(article: LocalCachedArticle, ctx: ScoreContext): number | null {
|
||||
const { prefs, reactedIds } = ctx;
|
||||
const { prefs, dismissedIds } = ctx;
|
||||
|
||||
if (prefs.selectedTopics.length > 0 && !prefs.selectedTopics.includes(article.topic as never)) {
|
||||
return null;
|
||||
|
|
@ -70,7 +114,10 @@ export function scoreArticle(article: LocalCachedArticle, ctx: ScoreContext): nu
|
|||
) {
|
||||
return null;
|
||||
}
|
||||
if (reactedIds.has(article.id)) return null;
|
||||
// Only ACTIVELY DISMISSED articles are filtered out. `interested`
|
||||
// reactions stay visible in the feed (the user's read state is
|
||||
// communicated via the badge in the UI, not by hiding the card).
|
||||
if (dismissedIds.has(article.id)) return null;
|
||||
|
||||
const topicW = prefs.topicWeights[article.topic] ?? TOPIC_WEIGHT_DEFAULT;
|
||||
const sourceW = prefs.sourceWeights[article.sourceSlug] ?? TOPIC_WEIGHT_DEFAULT;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export {
|
|||
rankFeed,
|
||||
scoreArticle,
|
||||
buildReactedIds,
|
||||
buildReactionSets,
|
||||
applyReaction,
|
||||
TOPIC_WEIGHT_DEFAULT,
|
||||
} from './feed-engine';
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
DEFAULT_PREFERENCES,
|
||||
} from '$lib/modules/news/collections';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { rankFeed, buildReactedIds } from '$lib/modules/news/feed-engine';
|
||||
import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine';
|
||||
import { toPreferences, toReaction, formatRelativeTime } from '$lib/modules/news/queries';
|
||||
import { PREFERENCES_ID, type LocalCachedArticle } from '$lib/modules/news/types';
|
||||
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
|
||||
return {
|
||||
prefs,
|
||||
ranked: rankFeed(pool, { prefs, reactedIds: buildReactedIds(reactions) }),
|
||||
ranked: rankFeed(pool, { prefs, ...buildReactionSets(reactions) }),
|
||||
};
|
||||
}).subscribe({
|
||||
next: ({ prefs, ranked }) => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
useReactions,
|
||||
formatRelativeTime,
|
||||
} from '$lib/modules/news/queries';
|
||||
import { rankFeed, buildReactedIds } from '$lib/modules/news/feed-engine';
|
||||
import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine';
|
||||
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
|
||||
import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte';
|
||||
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
|
||||
|
|
@ -102,12 +102,14 @@
|
|||
}
|
||||
|
||||
// ─── Feed branch ──────────────────────────────────────────
|
||||
const reactedIds = $derived(buildReactedIds(reactions));
|
||||
const { dismissedIds, interestedIds } = $derived(buildReactionSets(reactions));
|
||||
// Treat the local "just finished" override as fully onboarded so the
|
||||
// feed renders immediately after the user clicks Fertig, before the
|
||||
// liveQuery has had a chance to refresh prefs.
|
||||
const isOnboarded = $derived(prefs.onboardingCompleted || onboardingJustFinished);
|
||||
const ranked = $derived(isOnboarded ? rankFeed(pool, { prefs, reactedIds }) : []);
|
||||
const ranked = $derived(
|
||||
isOnboarded ? rankFeed(pool, { prefs, dismissedIds, interestedIds }) : []
|
||||
);
|
||||
|
||||
async function react(
|
||||
article: LocalCachedArticle,
|
||||
|
|
@ -313,6 +315,7 @@
|
|||
{:else}
|
||||
<div class="card-grid">
|
||||
{#each ranked as { article } (article.id)}
|
||||
{@const isSaved = interestedIds.has(article.id)}
|
||||
<article class="card">
|
||||
{#if article.imageUrl}
|
||||
<button
|
||||
|
|
@ -324,7 +327,7 @@
|
|||
<img src={article.imageUrl} alt="" loading="lazy" />
|
||||
</button>
|
||||
{/if}
|
||||
<div class="card-body">
|
||||
<div class="card-body" class:is-saved={isSaved}>
|
||||
<div class="card-meta">
|
||||
<span class="source">{article.siteName}</span>
|
||||
<span class="dot">·</span>
|
||||
|
|
@ -333,6 +336,9 @@
|
|||
<span class="dot">·</span>
|
||||
<span>{article.readingTimeMinutes} min</span>
|
||||
{/if}
|
||||
{#if isSaved}
|
||||
<span class="saved-badge" title="In deiner Leseliste">❤️ gespeichert</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button type="button" class="card-title-btn" onclick={() => openReader(article)}>
|
||||
{article.title}
|
||||
|
|
@ -344,10 +350,14 @@
|
|||
<button
|
||||
type="button"
|
||||
class="reaction-btn interested"
|
||||
class:active={isSaved}
|
||||
onclick={() => react(article, 'interested')}
|
||||
title="Speichern + mehr davon"
|
||||
title={isSaved
|
||||
? 'Schon gespeichert — nochmal klicken bestätigt nur'
|
||||
: 'In Leseliste speichern + mehr davon zeigen'}
|
||||
disabled={isSaved}
|
||||
>
|
||||
❤️ Interessiert
|
||||
❤️ {isSaved ? 'Gespeichert' : 'Interessiert'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -716,4 +726,31 @@
|
|||
.reaction-btn.interested:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.reaction-btn.interested.active {
|
||||
background: hsl(var(--color-primary) / 0.18);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: default;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.reaction-btn.interested.active:hover {
|
||||
filter: none;
|
||||
}
|
||||
.saved-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
}
|
||||
.card-body.is-saved {
|
||||
/* Subtle visual cue that this article is in the reading list,
|
||||
* but still readable as a feed card. */
|
||||
background: hsl(var(--color-primary) / 0.04);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue