Skip to content

Instantly share code, notes, and snippets.

@mayel
Last active December 22, 2024 16:59
Show Gist options
  • Save mayel/692cf6d5149aa3d175fef51d13f24b2f to your computer and use it in GitHub Desktop.
Save mayel/692cf6d5149aa3d175fef51d13f24b2f to your computer and use it in GitHub Desktop.
defmodule Bonfire.Social.Feeds.Funnel do
@moduledoc """
Determines the appropriate filters, joins, and/or preloads for feed queries based.
Helper for `Bonfire.Social.Feeds` and `Bonfire.Social.FeedActivities`, and `Bonfire.Social.Activities`.
"""
use Bonfire.Common.E
alias Bonfire.Common.Config
def preload_presets do
Config.get([__MODULE__, :preload_presets],
# Default groupings, TODO: move to config
%{
thread_postload: [
:with_replied,
:with_object_more
],
feed: [
:with_subject,
:feed_by_subject,
:with_replied
],
feed_postload: [
:with_thread_name,
:with_reply_to,
:with_media,
:with_parent,
:maybe_with_labelled
],
feed_metadata: [
:with_subject,
:with_creator,
:with_thread_name
],
feed_by_subject: [
:with_creator,
:feed_by_creator
],
feed_by_creator: [
:with_object_more,
:with_media
],
notifications: [
:feed_by_subject,
:with_reply_to,
:with_seen
],
object_with_creator: [
:with_object_posts,
:with_creator
],
posts_with_reply_to: [
:with_subject,
:with_object_posts
],
posts_with_thread: [
:with_subject,
:with_object_posts,
:with_replied,
:with_thread_name
],
posts: [
:with_subject,
:with_object_posts
],
default: [
:with_subject,
:with_object_posts,
:with_replied
]
})
end
@doc """
Maps high-level preload keys to their corresponding detailed preload lists.
## Examples
# Single preload key
iex> map_activity_preloads([:feed])
[
:with_subject,
:with_creator,
:with_object_more,
:with_media,
:with_replied
]
# Multiple preload keys
iex> map_activity_preloads([:feed, :notifications])
[
:with_subject,
:with_creator,
:with_object_more,
:with_media,
:with_replied,
:with_reply_to,
:with_seen
]
# With :all key it includes all defined preloads
iex> map_activity_preloads([:all])
[
:with_subject,
:with_object_posts,
:with_replied,
:with_creator,
:with_object_more,
:with_media,
:with_reply_to,
:with_seen,
:with_thread_name,
:with_parent,
:maybe_with_labelled
]
# With unknown key
iex> map_activity_preloads([:unknown_key])
[:unknown_key]
# Empty list returns empty list
iex> map_activity_preloads([])
[]
# Removes duplicates when preload lists overlap
iex> map_activity_preloads([:posts, :posts_with_thread])
[:with_subject, :with_object_posts, :with_replied, :with_thread_name]
"""
def map_activity_preloads(preloads, preload_presets \\ preload_presets())
def map_activity_preloads(preloads, preload_presets)
when is_list(preloads) and is_map(preload_presets) do
if Enum.member?(preloads, :all) do
Map.keys(preload_presets)
else
preloads
end
|> do_map_preloads(preload_presets, MapSet.new())
end
defp do_map_preloads(preloads, mappings, seen) when is_list(preloads) do
preloads
|> Enum.flat_map(fn preload ->
if MapSet.member?(seen, preload) do
# Prevent infinite recursion
[]
else
case Map.get(mappings, preload) do
expanded when is_list(expanded) ->
# Add current preload to seen set to prevent cycles
seen = MapSet.put(seen, preload)
# Recursively expand any mapped keys in the result
do_map_preloads(expanded, mappings, seen)
_ ->
# Not a mapped key, return as-is
[preload]
end
end
end)
|> Enum.uniq()
end
def preloads_from_filters_rules do
# Default Rules, TODO: move to config
Config.get([__MODULE__, :preload_rules], [
%{
description:
"All Activities (default preloads, should be excluded in other rules as needed)",
match: %{},
include: [
:with_subject,
:with_creator,
:with_object_more,
:with_media,
:with_reply_to,
:with_peered
]
},
# Specific Feeds
%{
description: "My Feed (Activities of people I follow)",
match: %{feed_name: "my"},
include: []
},
%{
description: "Explore Feed (All activities)",
match: %{feed_name: "explore"},
include: []
},
%{
description: "Local Feed (From the local instance)",
match: %{feed_name: "local"},
include: [],
exclude: [:with_peered]
},
%{
description: "Remote Feed (From the Fediverse)",
match: %{feed_name: ["remote"]},
include: [],
exclude: [:with_peered]
},
%{
description: "Notifications Feed (Only for me)",
match: %{feed_name: "notifications"},
include: [:with_seen]
},
%{
description: "Messages Feed (Only for me)",
match: %{feed_name: "messages"},
include: [:with_seen, :tags],
exclude: [:with_object, :with_object_more]
},
# Custom Feeds
%{
description: "A Specific User's Activities",
match: %{subject_usernames: "*"},
include: [:with_creator],
exclude: [:with_subject, :with_peered]
},
%{
description: "Requests for Me",
match: %{feed_name: "notifications", activity_types: ["request"]},
include: [],
exclude: [:with_object, :with_object_more, :with_media, :with_reply_to]
},
%{
description: "Followed by a Specific User",
match: %{activity_types: "follow", subject_usernames: "*"},
include: [:with_object, :with_peered],
exclude: [:with_subject, :with_object_more, :with_media, :with_reply_to]
},
%{
description: "Followers of a Specific User",
match: %{object_types: "follow", object_usernames: "*"},
include: [:with_subject],
exclude: [:with_object, :with_object_more, :with_peered, :with_media, :with_reply_to]
},
%{
description: "Activities with a Specific Hashtag or @ mention",
match: %{tags: "*"},
include: [],
exclude: [:with_subject]
},
%{
description: "Created by a Specific User",
match: %{creator_usernames: "*"},
exclude: [:with_creator, :with_subject, :with_peered]
},
# Different Types of Feeds
%{
description: "By object type",
match: %{object_types: "*"},
include: [:with_object_more],
exclude: []
},
%{
description: "Posts",
match: %{object_types: "post"},
include: [:with_creator, :with_post_content, :with_media, :with_peered],
exclude: [:with_object, :with_object_more]
},
%{
description: "Media",
match: %{media_types: "*"},
include: [:with_creator, :with_peered],
exclude: [:with_subject, :with_media, :with_object, :with_object_more]
}
])
end
@doc """
Computes the list of preloads to apply based on the provided filters.
Returns a list of preload atoms.
Uses rules defined in configuration rather than code.
Multiple rules can match and their preloads will be merged, with exclusions applied last.
## Examples
iex> filters = %{feed_name: "remote"}
iex> preloads_from_filters(filters)
[:with_creator, :with_media, :with_object_more, :with_reply_to, :with_subject]
iex> filters = %{feed_name: :remote}
iex> preloads_from_filters(filters)
[:with_creator, :with_media, :with_object_more, :with_reply_to, :with_subject]
iex> filters = %{feed_name: ["remote"]}
iex> preloads_from_filters(filters)
[:with_creator, :with_media, :with_object_more, :with_reply_to, :with_subject]
iex> filters = %{feed_name: [:remote]}
iex> preloads_from_filters(filters)
[:with_creator, :with_media, :with_object_more, :with_reply_to, :with_subject]
iex> filters = %{subject_usernames: ["alice"]}
iex> preloads_from_filters(filters)
[:with_creator, :with_media, :with_object_more, :with_reply_to]
iex> filters = %{feed_name: "unknown"}
iex> preloads_from_filters(filters)
[
:with_creator,
:with_media,
:with_object_more,
:with_peered,
:with_reply_to,
:with_subject
]
"""
def preloads_from_filters(feed_filters) when is_map(feed_filters) do
preloads_from_filters_rules()
|> find_matching_rules(feed_filters)
|> merge_rules()
|> apply_exclusions()
|> Enum.sort()
end
defp find_matching_rules(rules, feed_filters) do
Enum.filter(rules, &matches_filter?(&1.match, feed_filters))
end
@doc """
Match feed filters against rule criteria.
## Examples
iex> matches_filter?(%{types: "*"}, %{types: "post"})
true
iex> matches_filter?(%{types: ["post", "comment"]}, %{types: ["comment", "reaction"]})
true
iex> matches_filter?(%{types: "post"}, %{types: ["comment", "post"]})
true
iex> matches_filter?(%{types: :post}, %{types: ["comment", "post"]})
true
iex> matches_filter?(%{types: "post"}, %{types: [:comment, :post]})
true
iex> matches_filter?(%{types: ["post"]}, %{types: "post"})
true
iex> matches_filter?(%{types: "post"}, %{types: "comment"})
false
iex> matches_filter?(%{types: :post}, %{types: "post"})
true
"""
def matches_filter?(rule_match_criteria, feed_filters) do
Enum.all?(rule_match_criteria, fn {key, rule_value} ->
with filter_value <- ed(feed_filters, key, nil) do
cond do
is_nil(rule_value) and is_nil(filter_value) ->
true
is_nil(filter_value) ->
false
# Wildcard match
rule_value == "*" ->
true
# Direct match
filter_value == rule_value ->
true
# Both are lists - check for any intersection
is_list(rule_value) and is_list(filter_value) ->
rule_set = MapSet.new(rule_value, &normalize_value/1)
filter_set = MapSet.new(filter_value, &normalize_value/1)
not MapSet.disjoint?(rule_set, filter_set)
# Rule is list, filter is single - check membership
is_list(rule_value) ->
MapSet.new(rule_value, &normalize_value/1)
|> MapSet.member?(normalize_value(filter_value))
# Filter is list, rule is single - check membership
is_list(filter_value) ->
MapSet.new(filter_value, &normalize_value/1)
|> MapSet.member?(normalize_value(rule_value))
# String equality after normalization
true ->
normalize_value(filter_value) == normalize_value(rule_value)
end
end
end)
end
# Helper to normalize values to strings for comparison
defp normalize_value(value) when is_binary(value), do: value
defp normalize_value(value), do: to_string(value)
defp merge_rules([]), do: %{include: [], exclude: []}
defp merge_rules(rules) do
Enum.reduce(rules, %{include: [], exclude: []}, fn rule, acc ->
%{
include: acc.include ++ Map.get(rule, :include, []),
exclude: acc.exclude ++ Map.get(rule, :exclude, [])
}
end)
end
defp apply_exclusions(%{include: includes, exclude: excludes}) do
includes
|> MapSet.new()
|> MapSet.difference(MapSet.new(excludes))
|> MapSet.to_list()
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment