defmodule FeedPugWeb.NewsfeedLive do
@moduledoc """
The aggregated newsfeed as a master–detail reader: a left column of entry
titles (newest first) and a right column showing the selected entry's content.
New items from background polling and changes to the followed group set appear
live. An "unread only" toggle filters the list.
"""
use FeedPugWeb, :live_view
alias FeedPug.{Feeds, Groups, Reactions, Timelines}
@page_size 40
@impl true
def mount(_params, _session, socket) do
scope = socket.assigns.current_scope
socket =
socket
|> assign(
selected: nil,
selected_id: nil,
unread_only: false,
reaction_filter: nil,
query: "",
active_slice_id: nil,
slices: Timelines.list_slices(scope),
reactions: Reactions.ensure_default_reactions(scope)
)
|> load_feed_set()
{:ok, load_first_page(socket)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} wide>
<div class="flex flex-wrap items-center justify-between gap-2">
<h1 class="text-2xl font-bold">Newsfeed</h1>
<div class="flex items-center gap-3">
<form phx-change="search" phx-submit="search">
<input
type="search"
name="q"
value={@query}
placeholder="Search entries…"
phx-debounce="300"
autocomplete="off"
class="input input-bordered input-sm w-44 sm:w-56"
/>
</form>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@unread_only}
phx-click="toggle_unread_only"
/> Unread only
</label>
<span class="text-sm opacity-60">{@unread_count} unread · {@feed_count} feeds</span>
<button :if={@unread_count > 0} phx-click="mark_all_read" class="btn btn-ghost btn-xs gap-1">
<.icon name="hero-check-circle-micro" class="size-4" /> Mark all read
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-sm opacity-50">Views:</span>
<div :for={slice <- @slices} class="join">
<button
phx-click="apply_slice"
phx-value-id={slice.id}
class={[
"btn btn-xs join-item",
(@active_slice_id == slice.id && "btn-primary") || "btn-ghost"
]}
>
{slice.name}
</button>
<button
phx-click="delete_slice"
phx-value-id={slice.id}
data-confirm={"Delete the \"#{slice.name}\" view?"}
class="btn btn-ghost btn-xs join-item text-error"
title="Delete view"
>
×
</button>
</div>
<details class="dropdown">
<summary class="btn btn-ghost btn-xs gap-1">
<.icon name="hero-bookmark-micro" class="size-4" /> Save view
</summary>
<div class="dropdown-content z-10 mt-1 w-64 space-y-2 rounded-box border border-base-300 bg-base-200 p-3 shadow-lg">
<p class="text-xs opacity-60">Save the current filters as a named view.</p>
<form phx-submit="save_slice" class="flex items-center gap-1">
<input
name="slice[name]"
maxlength="60"
required
placeholder="View name"
class="input input-bordered input-xs flex-1"
/>
<button class="btn btn-primary btn-xs">Save</button>
</form>
</div>
</details>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<details :if={@sources != []} class="dropdown">
<summary class="btn btn-ghost btn-xs gap-1">
<.icon name="hero-funnel-micro" class="size-4" /> Groups
<span
:if={not all_sources_selected?(@selected_source_keys, @source_keys)}
class="badge badge-primary badge-xs"
>
{MapSet.size(@selected_source_keys)}
</span>
</summary>
<div class="dropdown-content z-10 mt-1 max-h-80 w-64 space-y-1 overflow-y-auto rounded-box border border-base-300 bg-base-200 p-3 shadow-lg">
<div class="flex justify-between pb-1 text-xs opacity-60">
<button type="button" phx-click="select_all_sources" class="link link-hover">
All
</button>
<button type="button" phx-click="select_no_sources" class="link link-hover">
None
</button>
</div>
<div :for={source <- @sources} class="group flex items-center gap-2 text-sm">
<label class="flex min-w-0 flex-1 cursor-pointer items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-xs"
checked={MapSet.member?(@selected_source_keys, source.key)}
phx-click="toggle_source"
phx-value-key={source.key}
/>
<span class="truncate">{source.label}</span>
<span :if={source.kind == :follow} class="badge badge-ghost badge-xs">followed</span>
</label>
<button
type="button"
phx-click="only_source"
phx-value-key={source.key}
class="link link-hover shrink-0 text-xs opacity-0 group-hover:opacity-60 focus:opacity-60"
title="Show only this group"
>
only
</button>
</div>
</div>
</details>
<span :if={@sources != []} class="mx-1 h-4 w-px bg-base-content/15"></span>
<span class="mr-1 text-sm opacity-50">Saved:</span>
<button
:for={r <- @reactions}
phx-click="filter_reaction"
phx-value-emoji={r.emoji}
title={r.label}
class={["btn btn-xs", (@reaction_filter == r.emoji && "btn-primary") || "btn-ghost"]}
>
{r.emoji}
</button>
<details class="dropdown">
<summary class="btn btn-ghost btn-xs gap-1">
<.icon name="hero-cog-6-tooth-micro" class="size-4" /> palette
</summary>
<div class="dropdown-content z-10 mt-1 w-72 space-y-2 rounded-box border border-base-300 bg-base-200 p-3 shadow-lg">
<p class="text-xs font-semibold opacity-60">Reaction palette</p>
<ul class="space-y-1">
<li
:for={r <- @reactions}
class="flex items-center justify-between text-sm"
>
<span>{r.emoji} <span class="opacity-60">{r.label}</span></span>
<button
phx-click="delete_reaction"
phx-value-id={r.id}
class="btn btn-ghost btn-xs text-error"
>
remove
</button>
</li>
</ul>
<form phx-submit="add_reaction" class="flex items-center gap-1">
<input
name="reaction[emoji]"
maxlength="16"
placeholder="😀"
required
class="input input-bordered input-xs w-14"
/>
<input
name="reaction[label]"
placeholder="label (optional)"
class="input input-bordered input-xs flex-1"
/>
<button class="btn btn-primary btn-xs">Add</button>
</form>
</div>
</details>
</div>
<p
:if={@feed_count == 0 and is_nil(@reaction_filter)}
class="rounded-lg bg-base-200 p-6 text-center opacity-70"
>
Your newsfeed is empty. Add feeds in
<.link navigate={~p"/groups"} class="link link-primary">Groups</.link>
or follow someone in <.link navigate={~p"/discover"} class="link link-primary">Discover</.link>.
</p>
<div :if={@feed_count > 0 or @reaction_filter} class="grid gap-4 lg:grid-cols-[22rem_1fr]">
<%!-- Master: titles list --%>
<div class="rounded-lg border border-base-content/10 lg:max-h-[78vh] lg:overflow-y-auto">
<ul id="items" phx-update="stream" class="divide-y divide-base-content/10">
<li
:for={{dom_id, item} <- @streams.items}
id={dom_id}
phx-click="select"
phx-value-id={item.id}
class={[
"flex cursor-pointer items-start gap-2 px-3 py-2.5 transition-colors hover:bg-base-200/60",
item.id == @selected_id && "bg-base-200",
item.read && "opacity-60"
]}
>
<span
class={[
"mt-1.5 size-2 shrink-0 rounded-full",
(!item.read && "bg-primary") || "bg-transparent"
]}
aria-hidden="true"
/>
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<p class={["truncate text-sm", !item.read && "font-semibold"]}>
{item.title || "(untitled)"}
</p>
<span :if={item.reactions != []} class="shrink-0 text-xs leading-none">
{Enum.join(item.reactions)}
</span>
</div>
<div class="mt-0.5 flex items-center gap-1.5 text-xs opacity-50">
<.favicon feed={item.feed} class="size-3.5 rounded-sm" />
<span class="truncate">{item.feed.title || item.feed.url}</span>
<span class="shrink-0">· {format_time(item.published_at)}</span>
</div>
</div>
</li>
</ul>
<div :if={@has_more} class="p-3 text-center">
<button phx-click="load_more" class="btn btn-outline btn-xs">Load more</button>
</div>
</div>
<%!-- Detail: selected entry content --%>
<article class="rounded-lg border border-base-content/10 p-5 lg:max-h-[78vh] lg:overflow-y-auto">
<%= if @selected do %>
<h2 class="text-xl font-bold leading-tight">
<a href={@selected.url} target="_blank" rel="noopener" class="link link-hover">
{@selected.title || "(untitled)"}
</a>
</h2>
<div class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs opacity-60">
<.favicon feed={@selected.feed} class="size-4 rounded-sm" />
<span>{@selected.feed.title || @selected.feed.url}</span>
<span :if={@selected.author}>· {@selected.author}</span>
<span>· {format_time(@selected.published_at)}</span>
</div>
<div class="mt-4 flex flex-wrap items-center gap-1">
<span class="mr-1 text-xs opacity-50">Save:</span>
<button
:for={r <- @reactions}
phx-click="toggle_reaction"
phx-value-emoji={r.emoji}
title={r.label}
class={[
"btn btn-sm",
(r.emoji in @selected.reactions && "btn-primary") ||
"btn-ghost border border-base-content/10"
]}
>
{r.emoji}
</button>
</div>
<div class="feed-content mt-4 text-sm">{raw(content_html(@selected))}</div>
<div class="mt-5">
<a
href={@selected.url}
target="_blank"
rel="noopener"
class="btn btn-primary btn-sm gap-1"
>
<.icon name="hero-arrow-top-right-on-square-micro" class="size-4" /> Open original
</a>
</div>
<% else %>
<div class="flex h-full min-h-40 items-center justify-center text-center text-sm opacity-50">
Select an entry to read it here.
</div>
<% end %>
</article>
</div>
</Layouts.app>
"""
end
@impl true
def handle_event("select", %{"id" => id}, socket) do
id = String.to_integer(id)
user_id = socket.assigns.current_scope.user.id
prev = socket.assigns.selected
Feeds.mark_read(user_id, id)
item = %{Feeds.get_item!(id) | read: true}
item = hd(Reactions.put_reactions(socket.assigns.current_scope, [item]))
socket =
assign(socket,
selected: item,
selected_id: id,
unread_count: Feeds.unread_count(user_id, socket.assigns.feed_ids)
)
socket =
if socket.assigns.unread_only do
# In unread-only mode a just-read item leaves the list.
stream_delete(socket, :items, item)
else
socket = stream_insert(socket, :items, item)
if prev && prev.id != id, do: stream_insert(socket, :items, prev), else: socket
end
{:noreply, socket}
end
def handle_event("search", %{"q" => q}, socket) do
{:noreply, socket |> assign(:query, q) |> load_first_page(reset: true)}
end
def handle_event("toggle_unread_only", _params, socket) do
{:noreply,
socket
|> assign(unread_only: !socket.assigns.unread_only, active_slice_id: nil)
|> load_first_page(reset: true)}
end
def handle_event("toggle_reaction", %{"emoji" => emoji}, socket) do
case socket.assigns.selected do
nil ->
{:noreply, socket}
item ->
Reactions.toggle_item_reaction(socket.assigns.current_scope, item.id, emoji)
reactions = toggle_member(item.reactions, emoji)
item = %{item | reactions: reactions}
socket = assign(socket, :selected, item)
# If filtering by this emoji and it was just removed, drop the row.
socket =
if socket.assigns.reaction_filter == emoji and emoji not in reactions,
do: stream_delete(socket, :items, item),
else: stream_insert(socket, :items, item)
{:noreply, socket}
end
end
def handle_event("toggle_source", %{"key" => key}, socket) do
selected = toggle_member_set(socket.assigns.selected_source_keys, key)
{:noreply, reload_with_sources(socket, selected)}
end
def handle_event("select_all_sources", _params, socket) do
{:noreply, reload_with_sources(socket, socket.assigns.source_keys)}
end
def handle_event("select_no_sources", _params, socket) do
{:noreply, reload_with_sources(socket, MapSet.new())}
end
def handle_event("only_source", %{"key" => key}, socket) do
{:noreply, reload_with_sources(socket, MapSet.new([key]))}
end
def handle_event("filter_reaction", %{"emoji" => emoji}, socket) do
filter = if socket.assigns.reaction_filter == emoji, do: nil, else: emoji
{:noreply,
socket
|> assign(reaction_filter: filter, active_slice_id: nil)
|> load_first_page(reset: true)}
end
def handle_event("apply_slice", %{"id" => id}, socket) do
slice = Timelines.get_slice!(socket.assigns.current_scope, id)
{:noreply,
socket
|> assign(
selected_source_keys: MapSet.new(slice.source_keys),
unread_only: slice.unread_only,
reaction_filter: slice.reaction_emoji,
active_slice_id: slice.id
)
|> load_feed_set()
|> load_first_page(reset: true)}
end
def handle_event("save_slice", %{"slice" => %{"name" => name}}, socket) do
attrs = %{
name: name,
source_keys: MapSet.to_list(socket.assigns.selected_source_keys),
unread_only: socket.assigns.unread_only,
reaction_emoji: socket.assigns.reaction_filter
}
case Timelines.create_slice(socket.assigns.current_scope, attrs) do
{:ok, slice} ->
{:noreply,
socket
|> assign(
slices: Timelines.list_slices(socket.assigns.current_scope),
active_slice_id: slice.id
)
|> put_flash(:info, "Saved view \"#{slice.name}\"")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Couldn't save (name blank or already used?)")}
end
end
def handle_event("delete_slice", %{"id" => id}, socket) do
{:ok, _} = Timelines.delete_slice(socket.assigns.current_scope, id)
active =
if to_string(socket.assigns.active_slice_id) == id,
do: nil,
else: socket.assigns.active_slice_id
{:noreply,
assign(socket,
slices: Timelines.list_slices(socket.assigns.current_scope),
active_slice_id: active
)}
end
def handle_event("add_reaction", %{"reaction" => %{"emoji" => emoji} = params}, socket) do
case Reactions.add_reaction(socket.assigns.current_scope, emoji, params["label"]) do
{:ok, _} ->
{:noreply, assign_palette(socket)}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Couldn't add that reaction (already in palette?)")}
end
end
def handle_event("delete_reaction", %{"id" => id}, socket) do
{:ok, _} = Reactions.delete_reaction(socket.assigns.current_scope, id)
socket = assign_palette(socket)
# Clear the filter if it pointed at the deleted emoji.
if socket.assigns.reaction_filter in Enum.map(socket.assigns.reactions, & &1.emoji) do
{:noreply, socket}
else
{:noreply, socket |> assign(:reaction_filter, nil) |> load_first_page(reset: true)}
end
end
def handle_event("load_more", _params, socket) do
items = list_items(socket, before: socket.assigns.cursor)
{:noreply,
socket
|> stream(:items, items)
|> assign(cursor: cursor_from(items, socket.assigns.cursor), has_more: full_page?(items))}
end
def handle_event("mark_all_read", _params, socket) do
Feeds.mark_all_read(socket.assigns.current_scope.user.id, socket.assigns.feed_ids)
{:noreply, socket |> assign(:unread_count, 0) |> load_first_page(reset: true)}
end
@impl true
def handle_info(:newsfeed_changed, socket) do
{:noreply, socket |> load_feed_set() |> load_first_page(reset: true)}
end
def handle_info({:new_items, feed_id, items}, socket) do
if feed_id in socket.assigns.feed_ids do
items = Enum.map(items, &FeedPug.Repo.preload(&1, :feed))
{:noreply,
socket
|> assign(:unread_count, socket.assigns.unread_count + length(items))
|> then(fn s -> Enum.reduce(items, s, &stream_insert(&2, :items, &1, at: 0)) end)}
else
{:noreply, socket}
end
end
## Helpers
defp load_feed_set(socket) do
scope = socket.assigns.current_scope
sources = Groups.list_newsfeed_sources(scope)
all_keys = MapSet.new(sources, & &1.key)
selected = reconcile_sources(socket, all_keys)
feed_ids =
sources
|> Enum.filter(&MapSet.member?(selected, &1.key))
|> Enum.flat_map(& &1.feed_ids)
|> Enum.uniq()
if connected?(socket) do
resubscribe(socket.assigns[:feed_ids] || [], feed_ids, scope)
end
assign(socket,
sources: sources,
source_keys: all_keys,
selected_source_keys: selected,
feed_ids: feed_ids,
feed_count: length(feed_ids),
unread_count: Feeds.unread_count(scope.user.id, feed_ids)
)
end
# First load selects everything; later loads keep the user's choice while
# auto-including any newly-appeared source.
defp reconcile_sources(socket, all_keys) do
case socket.assigns[:selected_source_keys] do
nil ->
all_keys
selected ->
new_keys = MapSet.difference(all_keys, socket.assigns[:source_keys] || MapSet.new())
selected |> MapSet.intersection(all_keys) |> MapSet.union(new_keys)
end
end
defp load_first_page(socket, opts \\ []) do
items = list_items(socket, [])
socket
|> stream(:items, items, reset: Keyword.get(opts, :reset, false))
|> assign(cursor: cursor_from(items, nil), has_more: full_page?(items))
end
defp list_items(socket, extra) do
scope = socket.assigns.current_scope
case socket.assigns.reaction_filter do
nil ->
Feeds.list_newsfeed_items(
socket.assigns.feed_ids,
[
limit: @page_size,
user_id: scope.user.id,
unread_only: socket.assigns.unread_only,
query: socket.assigns.query
] ++ extra
)
|> then(&Reactions.put_reactions(scope, &1))
emoji ->
# "Saved" view: items the user reacted with `emoji`, across all feeds.
Reactions.list_reacted_items(
scope,
emoji,
[limit: @page_size, query: socket.assigns.query] ++ extra
)
end
end
defp full_page?(items), do: length(items) == @page_size
defp all_sources_selected?(selected, all), do: MapSet.equal?(selected, all)
defp assign_palette(socket) do
assign(socket, :reactions, Reactions.list_reactions(socket.assigns.current_scope))
end
defp toggle_member(list, value) do
if value in list, do: List.delete(list, value), else: list ++ [value]
end
defp toggle_member_set(set, value) do
if MapSet.member?(set, value), do: MapSet.delete(set, value), else: MapSet.put(set, value)
end
defp reload_with_sources(socket, selected) do
socket
|> assign(selected_source_keys: selected, active_slice_id: nil)
|> load_feed_set()
|> load_first_page(reset: true)
end
defp resubscribe(old_ids, new_ids, scope) do
Phoenix.PubSub.subscribe(FeedPug.PubSub, Groups.newsfeed_topic(scope.user.id))
for id <- old_ids -- new_ids,
do: Phoenix.PubSub.unsubscribe(FeedPug.PubSub, Feeds.feed_topic(id))
for id <- new_ids -- old_ids,
do: Phoenix.PubSub.subscribe(FeedPug.PubSub, Feeds.feed_topic(id))
end
defp cursor_from([], previous), do: previous
defp cursor_from(items, _previous) do
last = List.last(items)
{last.sort_at, last.id}
end
defp format_time(nil), do: ""
defp format_time(dt), do: Calendar.strftime(dt, "%b %-d, %Y")
end
neiam
/feedpug
feedpub
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
21.7 KiB · text
History
7ad1745