Last active
December 22, 2024 16:59
-
-
Save mayel/692cf6d5149aa3d175fef51d13f24b2f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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