neiam
/feedpug
feedpub
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Newsfeed: drill into a single feed from its favicon/title
0bf367a · gmorell · 2026-06-25 15:57
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