Newsfeed: drill into a single feed from its favicon/title

0bf367a · gmorell · 2026-06-25 15:57

8 files +117 -10
Message
{commit_body(@commit)}

Files changed

modified lib/feed_pug_web/live/newsfeed_live.ex
+82 −10
@@ -22,6 +22,7 @@ defmodule FeedPugWeb.NewsfeedLive do
22 22 selected_id: nil,
23 23 unread_only: false,
24 24 reaction_filter: nil,
25 + feed_filter: nil,
25 26 query: "",
26 27 active_slice_id: nil,
27 28 slices: Timelines.list_slices(scope),
@@ -214,6 +215,23 @@ defmodule FeedPugWeb.NewsfeedLive do
214 215 or follow someone in <.link navigate={~p"/discover"} class="link link-primary">Discover</.link>.
215 216 </p>
216 217
218 + <div
219 + :if={@feed_filter}
220 + class="mt-3 flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm"
221 + >
222 + <.favicon feed={@feed_filter} class="size-4 rounded-sm" />
223 + <span class="min-w-0 truncate">
224 + Showing only <span class="font-semibold">{@feed_filter.title || @feed_filter.url}</span>
225 + </span>
226 + <button
227 + type="button"
228 + phx-click="clear_feed_filter"
229 + class="btn btn-ghost btn-xs ml-auto gap-1"
230 + >
231 + <.icon name="hero-x-mark-micro" class="size-4" /> Clear
232 + </button>
233 + </div>
234 +
217 235 <div :if={@feed_count > 0 or @reaction_filter} class="grid gap-4 lg:grid-cols-[22rem_1fr]">
218 236 <%!-- Master: titles list --%>
219 237 <div class="rounded-lg border border-base-content/10 lg:max-h-[78vh] lg:overflow-y-auto">
@@ -246,8 +264,16 @@ defmodule FeedPugWeb.NewsfeedLive do
246 264 </span>
247 265 </div>
248 266 <div class="mt-0.5 flex items-center gap-1.5 text-xs opacity-50">
249 − <.favicon feed={item.feed} class="size-3.5 rounded-sm" />
250 − <span class="truncate">{item.feed.title || item.feed.url}</span>
267 + <button
268 + type="button"
269 + phx-click="filter_feed"
270 + phx-value-feed-id={item.feed.id}
271 + title={"Only show #{item.feed.title || item.feed.url}"}
272 + class="flex min-w-0 items-center gap-1.5 hover:text-primary hover:opacity-100"
273 + >
274 + <.favicon feed={item.feed} class="size-3.5 rounded-sm" />
275 + <span class="truncate">{item.feed.title || item.feed.url}</span>
276 + </button>
251 277 <span class="shrink-0">· {format_time(item.published_at)}</span>
252 278 </div>
253 279 </div>
@@ -268,8 +294,16 @@ defmodule FeedPugWeb.NewsfeedLive do
268 294 </a>
269 295 </h2>
270 296 <div class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs opacity-60">
271 − <.favicon feed={@selected.feed} class="size-4 rounded-sm" />
272 − <span>{@selected.feed.title || @selected.feed.url}</span>
297 + <button
298 + type="button"
299 + phx-click="filter_feed"
300 + phx-value-feed-id={@selected.feed.id}
301 + title={"Only show #{@selected.feed.title || @selected.feed.url}"}
302 + class="flex items-center gap-2 hover:text-primary hover:opacity-100"
303 + >
304 + <.favicon feed={@selected.feed} class="size-4 rounded-sm" />
305 + <span>{@selected.feed.title || @selected.feed.url}</span>
306 + </button>
273 307 <span :if={@selected.author}>· {@selected.author}</span>
274 308 <span>· {format_time(@selected.published_at)}</span>
275 309 </div>
@@ -393,7 +427,36 @@ defmodule FeedPugWeb.NewsfeedLive do
393 427
394 428 {:noreply,
395 429 socket
396 − |> assign(reaction_filter: filter, active_slice_id: nil)
430 + |> assign(reaction_filter: filter, active_slice_id: nil, feed_filter: nil)
431 + |> load_first_page(reset: true)}
432 + end
433 +
434 + # Drill into a single feed (clicked its favicon/title in a row). Only honour
435 + # feeds that are actually in the user's current set.
436 + def handle_event("filter_feed", %{"feed-id" => id}, socket) do
437 + if id in socket.assigns.feed_ids do
438 + feed = Feeds.get_feed!(id)
439 + user_id = socket.assigns.current_scope.user.id
440 +
441 + {:noreply,
442 + socket
443 + |> assign(feed_filter: feed, reaction_filter: nil, active_slice_id: nil)
444 + |> assign(unread_count: Feeds.unread_count(user_id, [feed.id]))
445 + |> load_first_page(reset: true)}
446 + else
447 + {:noreply, socket}
448 + end
449 + end
450 +
451 + def handle_event("clear_feed_filter", _params, socket) do
452 + user_id = socket.assigns.current_scope.user.id
453 +
454 + {:noreply,
455 + socket
456 + |> assign(
457 + feed_filter: nil,
458 + unread_count: Feeds.unread_count(user_id, socket.assigns.feed_ids)
459 + )
397 460 |> load_first_page(reset: true)}
398 461 end
399 462
@@ -406,7 +469,8 @@ defmodule FeedPugWeb.NewsfeedLive do
406 469 selected_source_keys: MapSet.new(slice.source_keys),
407 470 unread_only: slice.unread_only,
408 471 reaction_filter: slice.reaction_emoji,
409 − active_slice_id: slice.id
472 + active_slice_id: slice.id,
473 + feed_filter: nil
410 474 )
411 475 |> load_feed_set()
412 476 |> load_first_page(reset: true)}
@@ -482,7 +546,7 @@ defmodule FeedPugWeb.NewsfeedLive do
482 546 end
483 547
484 548 def handle_event("mark_all_read", _params, socket) do
485 − Feeds.mark_all_read(socket.assigns.current_scope.user.id, socket.assigns.feed_ids)
549 + Feeds.mark_all_read(socket.assigns.current_scope.user.id, current_feed_ids(socket))
486 550 {:noreply, socket |> assign(:unread_count, 0) |> load_first_page(reset: true)}
487 551 end
488 552
@@ -492,7 +556,9 @@ defmodule FeedPugWeb.NewsfeedLive do
492 556 end
493 557
494 558 def handle_info({:new_items, feed_id, items}, socket) do
495 − if feed_id in socket.assigns.feed_ids do
559 + in_view? = feed_id in current_feed_ids(socket)
560 +
561 + if in_view? do
496 562 items = Enum.map(items, &FeedPug.Repo.preload(&1, :feed))
497 563
498 564 {:noreply,
@@ -553,13 +619,19 @@ defmodule FeedPugWeb.NewsfeedLive do
553 619 |> assign(cursor: cursor_from(items, nil), has_more: full_page?(items))
554 620 end
555 621
622 + # The feed set the timeline currently lists from: a single feed when the user
623 + # has drilled into one (clicked its favicon/title), otherwise the full
624 + # source-selected set.
625 + defp current_feed_ids(%{assigns: %{feed_filter: %{id: id}}}), do: [id]
626 + defp current_feed_ids(socket), do: socket.assigns.feed_ids
627 +
556 628 defp list_items(socket, extra) do
557 629 scope = socket.assigns.current_scope
558 630
559 631 case socket.assigns.reaction_filter do
560 632 nil ->
561 633 Feeds.list_newsfeed_items(
562 − socket.assigns.feed_ids,
634 + current_feed_ids(socket),
563 635 [
564 636 limit: @page_size,
565 637 user_id: scope.user.id,
@@ -597,7 +669,7 @@ defmodule FeedPugWeb.NewsfeedLive do
597 669
598 670 defp reload_with_sources(socket, selected) do
599 671 socket
600 − |> assign(selected_source_keys: selected, active_slice_id: nil)
672 + |> assign(selected_source_keys: selected, active_slice_id: nil, feed_filter: nil)
601 673 |> load_feed_set()
602 674 |> load_first_page(reset: true)
603 675 end
modified test/feed_pug_web/live/newsfeed_live_test.exs
+35 −0
@@ -156,6 +156,41 @@ defmodule FeedPugWeb.NewsfeedLiveTest do
156 156 refute has_element?(lv, "#items-#{nitem.id}")
157 157 end
158 158
159 + test "clicking a feed's favicon/title drills into only that feed", %{conn: conn, scope: scope} do
160 + comics = comics(scope)
161 + # Both feeds live in the SAME group, so this is a per-feed filter, not a group one.
162 + {:ok, agf} = Groups.add_feed_to_group(scope, comics, "https://ex.com/a.xml")
163 + {:ok, bgf} = Groups.add_feed_to_group(scope, comics, "https://ex.com/b.xml")
164 +
165 + {_n, [aitem]} =
166 + Feeds.store_items(Feeds.get_feed!(agf.feed_id), [
167 + %{guid: "a", title: "Alpha Post", published_at: ~U[2024-01-01 00:00:00Z]}
168 + ])
169 +
170 + {_n, [bitem]} =
171 + Feeds.store_items(Feeds.get_feed!(bgf.feed_id), [
172 + %{guid: "b", title: "Beta Post", published_at: ~U[2024-01-02 00:00:00Z]}
173 + ])
174 +
175 + {:ok, lv, _html} = live(conn, ~p"/")
176 + assert has_element?(lv, "#items-#{aitem.id}")
177 + assert has_element?(lv, "#items-#{bitem.id}")
178 +
179 + # Click feed A's title in its row (nested button must filter, not select-row).
180 + lv
181 + |> element("#items-#{aitem.id} button[phx-value-feed-id='#{agf.feed_id}']")
182 + |> render_click()
183 +
184 + assert render(lv) =~ "Showing only"
185 + assert has_element?(lv, "#items-#{aitem.id}")
186 + refute has_element?(lv, "#items-#{bitem.id}")
187 +
188 + # Clearing restores the full set.
189 + lv |> element("button[phx-click='clear_feed_filter']") |> render_click()
190 + assert has_element?(lv, "#items-#{aitem.id}")
191 + assert has_element?(lv, "#items-#{bitem.id}")
192 + end
193 +
159 194 test "search filters the timeline by entry text", %{conn: conn, scope: scope} do
160 195 {:ok, gf} = Groups.add_feed_to_group(scope, comics(scope), "https://ex.com/a.xml")
161 196 feed = Feeds.get_feed!(gf.feed_id)

Parents: 0f5a3d2