diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 2be7a2e..1af33ae 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -139,4 +139,90 @@ defmodule Nulla.ActivityPub do to: activity.to ) end + + @spec following(String.t(), Nulla.Models.User.t(), Integer.t()) :: Jason.OrderedObject.t() + def following(domain, user, total) do + Jason.OrderedObject.new( + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://#{domain}/@#{user.username}/following", + type: "OrderedCollection", + totalItems: total, + first: "https://#{domain}/@#{user.username}/following?page=1" + ) + end + + @spec following(String.t(), Nulla.Models.User.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) :: Jason.OrderedObject.t() + def following(domain, user, total, following_list, page, offset) when is_integer(page) and page > 0 do + data = [ + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://#{domain}/@#{user.username}/following?page=#{page}", + type: "OrderedCollectionPage", + totalItems: total, + next: "https://#{domain}/@#{user.username}/following?page=#{page + 1}", + prev: "https://#{domain}/@#{user.username}/following?page=#{page - 1}", + partOf: "https://#{domain}/@#{user.username}/following", + orderedItems: following_list + ] + + data = + if page <= 1 do + Keyword.delete(data, :prev) + else + data + end + + data = + if page * offset > total do + data + |> Keyword.delete(:next) + |> Keyword.delete(:prev) + else + data + end + + Jason.OrderedObject.new(data) + end + + @spec followers(String.t(), Nulla.Models.User.t(), Integer.t()) :: Jason.OrderedObject.t() + def followers(domain, user, total) do + Jason.OrderedObject.new( + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://#{domain}/@#{user.username}/followers", + type: "OrderedCollection", + totalItems: total, + first: "https://#{domain}/@#{user.username}/followers?page=1" + ) + end + + @spec followers(String.t(), Nulla.Models.User.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) :: Jason.OrderedObject.t() + def followers(domain, user, total, followers_list, page, offset) when is_integer(page) and page > 0 do + data = [ + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://#{domain}/@#{user.username}/followers?page=#{page}", + type: "OrderedCollectionPage", + totalItems: total, + next: "https://#{domain}/@#{user.username}/followers?page=#{page + 1}", + prev: "https://#{domain}/@#{user.username}/followers?page=#{page - 1}", + partOf: "https://#{domain}/@#{user.username}/followers", + orderedItems: followers_list + ] + + data = + if page <= 1 do + Keyword.delete(data, :prev) + else + data + end + + data = + if page * offset > total do + data + |> Keyword.delete(:next) + |> Keyword.delete(:prev) + else + data + end + + Jason.OrderedObject.new(data) + end end diff --git a/lib/nulla/models/instance_settings.ex b/lib/nulla/models/instance_settings.ex index 5ed8860..de903c7 100644 --- a/lib/nulla/models/instance_settings.ex +++ b/lib/nulla/models/instance_settings.ex @@ -11,6 +11,7 @@ defmodule Nulla.Models.InstanceSettings do field :registration, :boolean, default: false field :max_characters, :integer, default: 5000 field :max_upload_size, :integer, default: 50 + field :offset, :integer, default: 100 field :public_key, :string field :private_key, :string end @@ -25,6 +26,7 @@ defmodule Nulla.Models.InstanceSettings do :registration, :max_characters, :max_upload_size, + :offset, :public_key, :private_key ]) @@ -35,6 +37,7 @@ defmodule Nulla.Models.InstanceSettings do :registration, :max_characters, :max_upload_size, + :offset, :public_key, :private_key ]) diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex new file mode 100644 index 0000000..cf01214 --- /dev/null +++ b/lib/nulla/utils.ex @@ -0,0 +1,87 @@ +defmodule Nulla.Utils do + import Ecto.Query + alias Nulla.Repo + alias Nulla.Models.User + alias Nulla.Models.Follow + alias Nulla.Models.InstanceSettings + + def count_following_by_username!(username) do + case Repo.get_by(User, username: username) do + nil -> + {:error, :user_not_found} + + %User{id: user_id} -> + count = + Follow + |> where([f], f.user_id == ^user_id) + |> select([f], count(f.id)) + |> Repo.one() + + count + end + end + + def get_following_users_by_username!(username, page) when is_integer(page) and page > 0 do + case Repo.get_by(User, username: username) do + nil -> + {:error, :user_not_found} + + %User{id: user_id} -> + instance_settings = InstanceSettings.get_instance_settings!() + per_page = instance_settings.offset + offset = (page - 1) * per_page + + query = + from [f, u] in + from(f in Follow, + join: u in User, on: u.id == f.target_id, + where: f.user_id == ^user_id, + order_by: [asc: u.inserted_at], + offset: ^offset, + limit: ^per_page, + select: u + ) + + users = Repo.all(query) + + users + end + end + + def count_followers_by_username!(username) do + case Repo.get_by(User, username: username) do + nil -> + 0 + + %User{id: user_id} -> + from(f in Follow, where: f.target_id == ^user_id) + |> select([f], count(f.id)) + |> Repo.one() + end + end + + def get_followers_by_username!(username, page) when is_integer(page) and page > 0 do + case Repo.get_by(User, username: username) do + nil -> + {:error, :user_not_found} + + %User{id: user_id} -> + instance_settings = InstanceSettings.get_instance_settings!() + per_page = instance_settings.offset + offset = (page - 1) * per_page + + query = + from f in Follow, + where: f.target_id == ^user_id, + join: u in User, on: u.id == f.user_id, + order_by: [asc: u.inserted_at], + offset: ^offset, + limit: ^per_page, + select: u + + users = Repo.all(query) + + users + end + end +end diff --git a/lib/nulla_web/components/templates/user/show.html.heex b/lib/nulla_web/components/templates/user/show.html.heex index 9184f8b..5a41a2f 100644 --- a/lib/nulla_web/components/templates/user/show.html.heex +++ b/lib/nulla_web/components/templates/user/show.html.heex @@ -66,9 +66,9 @@ <% end %>
- 1.7K Posts - 33 Following - 31 Followers + {length(@notes)} Posts + {@following} Following + {@followers} Followers
diff --git a/lib/nulla_web/controllers/follow_controller.ex b/lib/nulla_web/controllers/follow_controller.ex new file mode 100644 index 0000000..ff82b6a --- /dev/null +++ b/lib/nulla_web/controllers/follow_controller.ex @@ -0,0 +1,65 @@ +defmodule NullaWeb.FollowController do + use NullaWeb, :controller + alias Nulla.Models.User + alias Nulla.Models.InstanceSettings + alias Nulla.ActivityPub + alias Nulla.Utils + + def following(conn, %{"username" => username, "page" => page_param}) do + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + offset = instance_settings.offset + user = User.get_user_by_username!(username) + total = Utils.count_following_by_username!(user.username) + page = + case Integer.parse(page_param) do + {int, _} when int > 0 -> int + _ -> 1 + end + following_list = Utils.get_following_users_by_username!(user.username, page) + + conn + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.following(domain, user, total, following_list, page, offset)) + end + + def following(conn, %{"username" => username}) do + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + user = User.get_user_by_username!(username) + total = Utils.count_following_by_username!(user.username) + + conn + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.following(domain, user, total)) + end + + def followers(conn, %{"username" => username, "page" => page_param}) do + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + offset = instance_settings.offset + user = User.get_user_by_username!(username) + total = Utils.count_followers_by_username!(user.username) + page = + case Integer.parse(page_param) do + {int, _} when int > 0 -> int + _ -> 1 + end + followers_list = Utils.get_followers_by_username!(user.username, page) + + conn + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.followers(domain, user, total, followers_list, page, offset)) + end + + def followers(conn, %{"username" => username}) do + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + user = User.get_user_by_username!(username) + total = Utils.count_followers_by_username!(user.username) + + conn + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.followers(domain, user, total)) + end +end diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex index 4c93d99..45ebd86 100644 --- a/lib/nulla_web/controllers/user_controller.ex +++ b/lib/nulla_web/controllers/user_controller.ex @@ -4,6 +4,7 @@ defmodule NullaWeb.UserController do alias Nulla.Models.Note alias Nulla.Models.InstanceSettings alias Nulla.ActivityPub + alias Nulla.Utils def show(conn, %{"username" => username}) do accept = List.first(get_req_header(conn, "accept")) @@ -17,7 +18,19 @@ defmodule NullaWeb.UserController do |> put_resp_content_type("application/activity+json") |> json(ActivityPub.user(domain, user)) else - render(conn, :show, domain: domain, user: user, notes: notes, layout: false) + following = Utils.count_following_by_username!(user.username) + followers = Utils.count_followers_by_username!(user.username) + + render( + conn, + :show, + domain: domain, + user: user, + notes: notes, + following: following, + followers: followers, + layout: false + ) end end end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 783534f..04268ed 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -22,6 +22,8 @@ defmodule NullaWeb.Router do pipe_through :browser get "/@:username", UserController, :show + get "/@:username/following", FollowController, :following + get "/@:username/followers", FollowController, :followers get "/@:username/:note_id", NoteController, :show end diff --git a/priv/repo/migrations/20250527054942_create_instance_settings.exs b/priv/repo/migrations/20250527054942_create_instance_settings.exs index b316d05..cc3aad5 100644 --- a/priv/repo/migrations/20250527054942_create_instance_settings.exs +++ b/priv/repo/migrations/20250527054942_create_instance_settings.exs @@ -9,6 +9,7 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do add :registration, :boolean, default: false, null: false add :max_characters, :integer, default: 5000, null: false add :max_upload_size, :integer, default: 50, null: false + add :offset, :integer, default: 100, null: false add :public_key, :string add :private_key, :string