blonk is a radar for your web, where you follow vibes for cool blips on the radar
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

implement blip tags, on atproto. create app level concepts for them as well

+443
blonk.db

This is a binary file and will not be displayed.

+44
elixir_blonk/lib/elixir_blonk/atproto/client.ex
··· 13 13 @vibe_member_nsid "com.blonk.vibeMember" 14 14 @groove_nsid "com.blonk.groove" 15 15 @comment_nsid "com.blonk.comment" 16 + @tag_nsid "com.blonk.tag" 17 + @blip_tag_nsid "com.blonk.blipTag" 16 18 17 19 # Configuration 18 20 @service Application.compile_env(:elixir_blonk, :atproto_service, "https://bsky.social") ··· 281 283 %{"replies" => replies} when is_list(replies) -> length(replies) 282 284 _ -> 0 283 285 end 286 + end 287 + 288 + @doc """ 289 + Creates a tag record in ATProto. 290 + """ 291 + def create_tag(client, tag) do 292 + record = %{ 293 + "$type" => @tag_nsid, 294 + name: tag.name, 295 + description: tag.description, 296 + author: tag.author_did, 297 + createdAt: DateTime.to_iso8601(tag.indexed_at || DateTime.utc_now()) 298 + } 299 + |> compact_record() 300 + 301 + create_record(client, @tag_nsid, record) 302 + end 303 + 304 + @doc """ 305 + Creates a blip-tag association record in ATProto. 306 + """ 307 + def create_blip_tag(client, blip_tag) do 308 + # Get the blip and tag records 309 + blip = ElixirBlonk.Blips.get_blip!(blip_tag.blip_id) 310 + tag = ElixirBlonk.Tags.get_tag!(blip_tag.tag_id) 311 + 312 + record = %{ 313 + "$type" => @blip_tag_nsid, 314 + blip: %{ 315 + uri: blip.uri, 316 + cid: blip.cid 317 + }, 318 + tag: %{ 319 + uri: tag.uri, 320 + cid: tag.cid 321 + }, 322 + author: blip_tag.author_did, 323 + createdAt: DateTime.to_iso8601(DateTime.utc_now()) 324 + } 325 + |> compact_record() 326 + 327 + create_record(client, @blip_tag_nsid, record) 284 328 end 285 329 end
+145
elixir_blonk/lib/elixir_blonk/blip_tags.ex
··· 1 + defmodule ElixirBlonk.BlipTags do 2 + @moduledoc """ 3 + The BlipTags context for managing blip-tag associations. 4 + """ 5 + 6 + import Ecto.Query, warn: false 7 + require Logger 8 + alias ElixirBlonk.Repo 9 + 10 + alias ElixirBlonk.BlipTags.BlipTag 11 + alias ElixirBlonk.{Blips, Tags} 12 + 13 + @doc """ 14 + Associates a tag with a blip. 15 + """ 16 + def create_blip_tag(blip_id, tag_id, author_did) do 17 + attrs = %{ 18 + blip_id: blip_id, 19 + tag_id: tag_id, 20 + author_did: author_did 21 + } 22 + 23 + # First create in local database 24 + with {:ok, blip_tag} <- %BlipTag{} 25 + |> BlipTag.changeset(attrs) 26 + |> Repo.insert() do 27 + 28 + # Increment tag usage count 29 + tag = Tags.get_tag!(tag_id) 30 + Tags.increment_usage_count(tag) 31 + 32 + # Then try to create in ATProto if enabled 33 + if Application.get_env(:elixir_blonk, :atproto_enabled, true) do 34 + Task.Supervisor.start_child(ElixirBlonk.TaskSupervisor, fn -> 35 + create_blip_tag_in_atproto(blip_tag) 36 + end) 37 + end 38 + 39 + {:ok, blip_tag} 40 + end 41 + end 42 + 43 + @doc """ 44 + Removes a tag association from a blip. 45 + """ 46 + def delete_blip_tag(blip_id, tag_id) do 47 + blip_tag = 48 + BlipTag 49 + |> where([bt], bt.blip_id == ^blip_id and bt.tag_id == ^tag_id) 50 + |> Repo.one() 51 + 52 + if blip_tag do 53 + # Decrement tag usage count 54 + tag = Tags.get_tag!(tag_id) 55 + if tag.usage_count > 0 do 56 + Tags.update_tag(tag, %{usage_count: tag.usage_count - 1}) 57 + end 58 + 59 + Repo.delete(blip_tag) 60 + else 61 + {:error, :not_found} 62 + end 63 + end 64 + 65 + @doc """ 66 + Gets all tags for a blip. 67 + """ 68 + def get_tags_for_blip(blip_id) do 69 + BlipTag 70 + |> where([bt], bt.blip_id == ^blip_id) 71 + |> join(:inner, [bt], t in assoc(bt, :tag)) 72 + |> select([bt, t], t) 73 + |> Repo.all() 74 + end 75 + 76 + @doc """ 77 + Gets all blips for a tag. 78 + """ 79 + def get_blips_for_tag(tag_id) do 80 + BlipTag 81 + |> where([bt], bt.tag_id == ^tag_id) 82 + |> join(:inner, [bt], b in assoc(bt, :blip)) 83 + |> select([bt, b], b) 84 + |> preload([bt, b], b: :vibe) 85 + |> Repo.all() 86 + end 87 + 88 + @doc """ 89 + Associates multiple tags with a blip by tag names. 90 + Creates tags if they don't exist. 91 + """ 92 + def associate_tags_with_blip(blip_id, tag_names, author_did) when is_list(tag_names) do 93 + Enum.each(tag_names, fn tag_name -> 94 + # Find or create the tag 95 + {:ok, tag} = Tags.find_or_create_tag(tag_name, author_did) 96 + 97 + # Associate with blip (ignore if already exists) 98 + case create_blip_tag(blip_id, tag.id, author_did) do 99 + {:ok, _} -> :ok 100 + {:error, _} -> :ok # Likely already exists 101 + end 102 + end) 103 + end 104 + 105 + @doc """ 106 + Removes all tag associations for a blip. 107 + """ 108 + def remove_all_tags_from_blip(blip_id) do 109 + blip_tags = 110 + BlipTag 111 + |> where([bt], bt.blip_id == ^blip_id) 112 + |> Repo.all() 113 + 114 + # Decrement usage counts for all associated tags 115 + Enum.each(blip_tags, fn blip_tag -> 116 + tag = Tags.get_tag!(blip_tag.tag_id) 117 + if tag.usage_count > 0 do 118 + Tags.update_tag(tag, %{usage_count: tag.usage_count - 1}) 119 + end 120 + end) 121 + 122 + # Delete all associations 123 + BlipTag 124 + |> where([bt], bt.blip_id == ^blip_id) 125 + |> Repo.delete_all() 126 + end 127 + 128 + # Private functions 129 + 130 + defp create_blip_tag_in_atproto(blip_tag) do 131 + with {:ok, client} <- ElixirBlonk.ATProto.SessionManager.get_client(), 132 + {:ok, %{uri: uri, cid: cid}} <- ElixirBlonk.ATProto.Client.create_blip_tag(client, blip_tag) do 133 + 134 + # Update local record with ATProto URI and CID 135 + blip_tag 136 + |> BlipTag.changeset(%{uri: uri, cid: cid}) 137 + |> Repo.update() 138 + 139 + Logger.info("Created blip-tag association in ATProto: #{uri}") 140 + else 141 + {:error, reason} -> 142 + Logger.error("Failed to create blip-tag association in ATProto: #{inspect(reason)}") 143 + end 144 + end 145 + end
+26
elixir_blonk/lib/elixir_blonk/blip_tags/blip_tag.ex
··· 1 + defmodule ElixirBlonk.BlipTags.BlipTag do 2 + use Ecto.Schema 3 + import Ecto.Changeset 4 + 5 + @primary_key {:id, :binary_id, autogenerate: true} 6 + @foreign_key_type :binary_id 7 + schema "blip_tags" do 8 + field :uri, :string 9 + field :cid, :string 10 + field :author_did, :string 11 + 12 + belongs_to :blip, ElixirBlonk.Blips.Blip 13 + belongs_to :tag, ElixirBlonk.Tags.Tag 14 + 15 + timestamps(type: :utc_datetime) 16 + end 17 + 18 + @doc false 19 + def changeset(blip_tag, attrs) do 20 + blip_tag 21 + |> cast(attrs, [:uri, :cid, :author_did, :blip_id, :tag_id]) 22 + |> validate_required([:author_did, :blip_id, :tag_id]) 23 + |> unique_constraint(:uri) 24 + |> unique_constraint([:blip_id, :tag_id], message: "tag already associated with this blip") 25 + end 26 + end
+150
elixir_blonk/lib/elixir_blonk/tags.ex
··· 1 + defmodule ElixirBlonk.Tags do 2 + @moduledoc """ 3 + The Tags context for managing tag records in ATProto. 4 + """ 5 + 6 + import Ecto.Query, warn: false 7 + require Logger 8 + alias ElixirBlonk.Repo 9 + 10 + alias ElixirBlonk.Tags.Tag 11 + 12 + @doc """ 13 + Returns the list of tags. 14 + """ 15 + def list_tags do 16 + Tag 17 + |> order_by([t], desc: t.usage_count) 18 + |> Repo.all() 19 + end 20 + 21 + @doc """ 22 + Returns the list of tags by author. 23 + """ 24 + def list_tags_by_author(author_did) do 25 + Tag 26 + |> where([t], t.author_did == ^author_did) 27 + |> order_by([t], desc: t.usage_count) 28 + |> Repo.all() 29 + end 30 + 31 + @doc """ 32 + Gets a single tag by ID. 33 + """ 34 + def get_tag!(id), do: Repo.get!(Tag, id) 35 + 36 + @doc """ 37 + Gets a tag by name and author. 38 + """ 39 + def get_tag_by_name_and_author(name, author_did) do 40 + Tag 41 + |> where([t], t.name == ^name and t.author_did == ^author_did) 42 + |> Repo.one() 43 + end 44 + 45 + @doc """ 46 + Gets a tag by URI. 47 + """ 48 + def get_tag_by_uri(uri) do 49 + Repo.get_by(Tag, uri: uri) 50 + end 51 + 52 + @doc """ 53 + Searches for tags by name. 54 + """ 55 + def search_tags(query) do 56 + search_term = "%#{query}%" 57 + 58 + Tag 59 + |> where([t], ilike(t.name, ^search_term) or ilike(t.description, ^search_term)) 60 + |> order_by([t], desc: t.usage_count) 61 + |> Repo.all() 62 + end 63 + 64 + @doc """ 65 + Creates a tag. 66 + """ 67 + def create_tag(attrs \\ %{}) do 68 + # First create in local database 69 + with {:ok, tag} <- %Tag{} 70 + |> Tag.changeset(attrs) 71 + |> Repo.insert() do 72 + 73 + # Then try to create in ATProto if enabled 74 + if Application.get_env(:elixir_blonk, :atproto_enabled, true) do 75 + Task.Supervisor.start_child(ElixirBlonk.TaskSupervisor, fn -> 76 + create_tag_in_atproto(tag) 77 + end) 78 + end 79 + 80 + {:ok, tag} 81 + end 82 + end 83 + 84 + @doc """ 85 + Finds or creates a tag by name and author. 86 + """ 87 + def find_or_create_tag(name, author_did, description \\ nil) do 88 + case get_tag_by_name_and_author(name, author_did) do 89 + nil -> 90 + create_tag(%{ 91 + name: name, 92 + author_did: author_did, 93 + description: description, 94 + indexed_at: DateTime.utc_now() 95 + }) 96 + 97 + existing_tag -> 98 + {:ok, existing_tag} 99 + end 100 + end 101 + 102 + @doc """ 103 + Updates a tag. 104 + """ 105 + def update_tag(%Tag{} = tag, attrs) do 106 + tag 107 + |> Tag.changeset(attrs) 108 + |> Repo.update() 109 + end 110 + 111 + @doc """ 112 + Increments the usage count for a tag. 113 + """ 114 + def increment_usage_count(%Tag{} = tag) do 115 + update_tag(tag, %{usage_count: tag.usage_count + 1}) 116 + end 117 + 118 + @doc """ 119 + Deletes a tag. 120 + """ 121 + def delete_tag(%Tag{} = tag) do 122 + Repo.delete(tag) 123 + end 124 + 125 + @doc """ 126 + Gets the most popular tags. 127 + """ 128 + def get_popular_tags(limit \\ 20) do 129 + Tag 130 + |> where([t], t.usage_count > 0) 131 + |> order_by([t], desc: t.usage_count) 132 + |> limit(^limit) 133 + |> Repo.all() 134 + end 135 + 136 + # Private functions 137 + 138 + defp create_tag_in_atproto(tag) do 139 + with {:ok, client} <- ElixirBlonk.ATProto.SessionManager.get_client(), 140 + {:ok, %{uri: uri, cid: cid}} <- ElixirBlonk.ATProto.Client.create_tag(client, tag) do 141 + 142 + # Update local record with ATProto URI and CID 143 + update_tag(tag, %{uri: uri, cid: cid}) 144 + Logger.info("Created tag in ATProto: #{uri}") 145 + else 146 + {:error, reason} -> 147 + Logger.error("Failed to create tag in ATProto: #{inspect(reason)}") 148 + end 149 + end 150 + end
+32
elixir_blonk/lib/elixir_blonk/tags/tag.ex
··· 1 + defmodule ElixirBlonk.Tags.Tag do 2 + use Ecto.Schema 3 + import Ecto.Changeset 4 + 5 + @primary_key {:id, :binary_id, autogenerate: true} 6 + @foreign_key_type :binary_id 7 + schema "tags" do 8 + field :uri, :string 9 + field :cid, :string 10 + field :name, :string 11 + field :description, :string 12 + field :author_did, :string 13 + field :usage_count, :integer, default: 0 14 + field :indexed_at, :utc_datetime 15 + 16 + many_to_many :blips, ElixirBlonk.Blips.Blip, join_through: "blip_tags" 17 + 18 + timestamps(type: :utc_datetime) 19 + end 20 + 21 + @doc false 22 + def changeset(tag, attrs) do 23 + tag 24 + |> cast(attrs, [:uri, :cid, :name, :description, :author_did, :usage_count, :indexed_at]) 25 + |> validate_required([:name, :author_did]) 26 + |> validate_length(:name, min: 1, max: 50) 27 + |> validate_length(:description, max: 280) 28 + |> validate_format(:name, ~r/^[a-zA-Z0-9_]+$/, message: "can only contain letters, numbers, and underscores") 29 + |> unique_constraint(:uri) 30 + |> unique_constraint([:name, :author_did], message: "tag name already exists for this author") 31 + end 32 + end
+24
elixir_blonk/priv/repo/migrations/20250620043347_create_tags.exs
··· 1 + defmodule ElixirBlonk.Repo.Migrations.CreateTags do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:tags, primary_key: false) do 6 + add :id, :binary_id, primary_key: true 7 + add :uri, :string 8 + add :cid, :string 9 + add :name, :string, null: false 10 + add :description, :string 11 + add :author_did, :string, null: false 12 + add :usage_count, :integer, default: 0 13 + add :indexed_at, :utc_datetime 14 + 15 + timestamps(type: :utc_datetime) 16 + end 17 + 18 + create unique_index(:tags, [:uri]) 19 + create unique_index(:tags, [:name, :author_did]) 20 + create index(:tags, [:author_did]) 21 + create index(:tags, [:usage_count]) 22 + create index(:tags, [:name]) 23 + end 24 + end
+22
elixir_blonk/priv/repo/migrations/20250620043400_create_blip_tags.exs
··· 1 + defmodule ElixirBlonk.Repo.Migrations.CreateBlipTags do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:blip_tags, primary_key: false) do 6 + add :id, :binary_id, primary_key: true 7 + add :uri, :string 8 + add :cid, :string 9 + add :author_did, :string, null: false 10 + add :blip_id, references(:blips, on_delete: :delete_all, type: :binary_id), null: false 11 + add :tag_id, references(:tags, on_delete: :delete_all, type: :binary_id), null: false 12 + 13 + timestamps(type: :utc_datetime) 14 + end 15 + 16 + create unique_index(:blip_tags, [:uri]) 17 + create unique_index(:blip_tags, [:blip_id, :tag_id]) 18 + create index(:blip_tags, [:blip_id]) 19 + create index(:blip_tags, [:tag_id]) 20 + create index(:blip_tags, [:author_did]) 21 + end 22 + end