diff --git a/config/config.exs b/config/config.exs index 69d5db09..18a3fd9a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -30,7 +30,8 @@ config :ash_hq, AshHq.Blog, AshHq.Docs, AshHq.Github, - AshHq.MailingList + AshHq.MailingList, + AshHq.Discord ] config :ash_hq, AshHq.Repo, diff --git a/lib/ash_hq/discord/discord.ex b/lib/ash_hq/discord/discord.ex new file mode 100644 index 00000000..4bc59614 --- /dev/null +++ b/lib/ash_hq/discord/discord.ex @@ -0,0 +1,14 @@ +defmodule AshHq.Discord do + @moduledoc "Discord api import & interactions" + use Ash.Api + + resources do + resource AshHq.Discord.Attachment + resource AshHq.Discord.Channel + resource AshHq.Discord.Message + resource AshHq.Discord.Reaction + resource AshHq.Discord.Tag + resource AshHq.Discord.Thread + resource AshHq.Discord.ThreadTag + end +end diff --git a/lib/ash_hq/discord/listener.ex b/lib/ash_hq/discord/listener.ex deleted file mode 100644 index f661e256..00000000 --- a/lib/ash_hq/discord/listener.ex +++ /dev/null @@ -1,138 +0,0 @@ -defmodule AshHq.Discord.Listener do - @moduledoc """ - Does nothing for now. Eventually will support slash commands to search AshHQ from discord. - """ - use Nostrum.Consumer - - import Bitwise - - @user_id 1_066_406_803_769_933_834 - @server_id 711_271_361_523_351_632 - - def start_link do - Consumer.start_link(__MODULE__) - end - - def search_results!(interaction) do - search = - interaction.data.options - |> Enum.find_value(fn option -> - if option.name == "search" do - option.value - end - end) - - item_list = AshHq.Docs.Indexer.search(search) - - item_list = Enum.take(item_list, 10) - - count = - case Enum.count(item_list) do - 10 -> - "the top 10" - - other -> - "#{other}" - end - - """ - Found #{count} results for "#{search}": - - #{Enum.map_join(item_list, "\n", &render_search_result(&1))} - """ - end - - defp render_search_result(item) do - link = - case item do - %AshHq.Docs.Guide{} -> - Path.join("https://ash-hq.org", AshHqWeb.DocRoutes.doc_link(item)) - - item -> - AshHqWeb.DocRoutes.doc_link(item) - end - - case item do - %{name: name} -> - "* #{name}: #{link}" - - _ -> - "* forum message: #{link}" - end - end - - def handle_event({:INTERACTION_CREATE, %Nostrum.Struct.Interaction{} = interaction, _ws_state}) do - public = - interaction.data.options - |> Enum.find_value(fn option -> - if option.name == "public" do - option.value - end - end) - - response = %{ - # ChannelMessageWithSource - type: 4, - data: %{ - content: search_results!(interaction), - flags: - if public do - 1 <<< 2 - else - 1 <<< 6 ||| 1 <<< 2 - end - } - } - - Nostrum.Api.create_interaction_response(interaction, response) - end - - def handle_event({:READY, _msg, _ws_state}) do - # What is happening? For some reason startup is getting timeouts at the ecto pool? - Task.async(fn -> - :timer.sleep(:timer.seconds(30)) - rebuild() - end) - end - - # Default event handler, if you don't include this, your consumer WILL crash if - # you don't have a method definition for each event type. - def handle_event(_event) do - :noop - end - - def rebuild do - if Application.get_env(:ash_hq, :discord_bot) do - build_search_action() - end - end - - defp build_search_action do - command = %{ - name: "ash_hq_search", - description: "Search AshHq Documentation", - options: [ - %{ - # ApplicationCommandType::STRING - type: 3, - name: "search", - description: "what you want to search for", - required: true - }, - %{ - # ApplicationCommandType::Boolean - type: 5, - name: "public", - description: "If the results should be shown publicly in the channel", - required: false - } - ] - } - - Nostrum.Api.create_guild_application_command( - @user_id, - @server_id, - command - ) - end -end diff --git a/lib/ash_hq/discord/resources/attachment.ex b/lib/ash_hq/discord/resources/attachment.ex new file mode 100644 index 00000000..54d124fa --- /dev/null +++ b/lib/ash_hq/discord/resources/attachment.ex @@ -0,0 +1,35 @@ +defmodule AshHq.Discord.Attachment do + @moduledoc "A discord attachment on a message" + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + actions do + defaults [:create, :read, :update, :destroy] + end + + attributes do + integer_primary_key :id, generated?: false, writable?: true + attribute :filename, :string + attribute :size, :integer + attribute :url, :string + attribute :proxy_url, :string + attribute :height, :integer + attribute :width, :integer + end + + relationships do + belongs_to :message, AshHq.Discord.Message do + allow_nil? false + attribute_type :integer + end + end + + postgres do + table "discord_attachments" + repo AshHq.Repo + + references do + reference :message, on_delete: :delete, on_update: :update + end + end +end diff --git a/lib/ash_hq/discord/resources/channel.ex b/lib/ash_hq/discord/resources/channel.ex new file mode 100644 index 00000000..a9725bd1 --- /dev/null +++ b/lib/ash_hq/discord/resources/channel.ex @@ -0,0 +1,43 @@ +defmodule AshHq.Discord.Channel do + @moduledoc """ + The channel is the discord forum channel. We explicitly configure which ones we import. + """ + + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + actions do + defaults [:create, :read, :update, :destroy] + + create :upsert do + upsert? true + end + end + + attributes do + integer_primary_key :id, writable?: true, generated?: false + + attribute :name, :string do + allow_nil? false + end + + attribute :order, :integer do + allow_nil? false + end + end + + relationships do + has_many :threads, AshHq.Discord.Thread + end + + postgres do + table "discord_channels" + repo AshHq.Repo + end + + code_interface do + define_for AshHq.Discord + define :read + define :upsert + end +end diff --git a/lib/ash_hq/discord/resources/message.ex b/lib/ash_hq/discord/resources/message.ex new file mode 100644 index 00000000..26d6eb7c --- /dev/null +++ b/lib/ash_hq/discord/resources/message.ex @@ -0,0 +1,96 @@ +defmodule AshHq.Discord.Message do + @moduledoc """ + Discord messages synchronized by the discord bot + """ + use Ash.Resource, + data_layer: AshPostgres.DataLayer, + extensions: [ + AshHq.Docs.Extensions.RenderMarkdown, + AshHq.Docs.Extensions.Search + ] + + actions do + defaults [:read, :destroy] + + create :create do + primary? true + argument :attachments, {:array, :map} + argument :reactions, {:array, :map} + change manage_relationship(:attachments, type: :direct_control) + + change manage_relationship(:reactions, + type: :direct_control, + use_identities: [:unique_message_emoji] + ) + end + + update :update do + primary? true + argument :attachments, {:array, :map} + argument :reactions, {:array, :map} + change manage_relationship(:attachments, type: :direct_control) + + change manage_relationship(:reactions, + type: :direct_control, + use_identities: [:unique_message_emoji] + ) + end + end + + render_markdown do + render_attributes content: :content_html + end + + search do + doc_attribute :content + + type "Forum" + + load_for_search [ + :channel_name, + :thread_name + ] + + has_name_attribute? false + weight_content(-0.7) + end + + attributes do + integer_primary_key :id, generated?: false, writable?: true + + attribute :author, :string do + allow_nil? false + end + + attribute :content, :string + attribute :content_html, :string + + attribute :timestamp, :utc_datetime do + allow_nil? false + end + end + + relationships do + belongs_to :thread, AshHq.Discord.Thread do + attribute_type :integer + allow_nil? false + end + + has_many :attachments, AshHq.Discord.Attachment + has_many :reactions, AshHq.Discord.Reaction + end + + postgres do + table "discord_messages" + repo AshHq.Repo + + references do + reference :thread, on_delete: :delete, on_update: :update + end + end + + aggregates do + first :channel_name, [:thread, :channel], :name + first :thread_name, [:thread], :name + end +end diff --git a/lib/ash_hq/discord/resources/reaction.ex b/lib/ash_hq/discord/resources/reaction.ex new file mode 100644 index 00000000..6a009a71 --- /dev/null +++ b/lib/ash_hq/discord/resources/reaction.ex @@ -0,0 +1,43 @@ +defmodule AshHq.Discord.Reaction do + @moduledoc """ + Reactions store emoji reaction counts. + """ + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + actions do + defaults [:create, :read, :update, :destroy] + end + + attributes do + uuid_primary_key :id + + attribute :count, :integer do + allow_nil? false + end + + attribute :emoji, :string do + allow_nil? false + end + end + + relationships do + belongs_to :message, AshHq.Discord.Message do + attribute_type :integer + allow_nil? false + end + end + + postgres do + table "discord_reactions" + repo AshHq.Repo + + references do + reference :message, on_delete: :delete, on_update: :update + end + end + + identities do + identity :unique_message_emoji, [:emoji, :message_id] + end +end diff --git a/lib/ash_hq/discord/resources/tag.ex b/lib/ash_hq/discord/resources/tag.ex new file mode 100644 index 00000000..73865544 --- /dev/null +++ b/lib/ash_hq/discord/resources/tag.ex @@ -0,0 +1,45 @@ +defmodule AshHq.Discord.Tag do + @moduledoc "A tag that can be applied to a post. Currently uses CSV data layer and therefore is static" + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + actions do + defaults [:create, :read, :update, :destroy] + + create :upsert do + upsert? true + upsert_identity :unique_name_per_channel + end + end + + attributes do + integer_primary_key :id, generated?: false, writable?: true + + attribute :name, :ci_string do + allow_nil? false + end + end + + relationships do + belongs_to :channel, AshHq.Discord.Channel do + attribute_type :integer + attribute_writable? true + end + end + + postgres do + table "discord_tags" + repo AshHq.Repo + end + + code_interface do + define_for AshHq.Discord + define :upsert, args: [:channel_id, :id, :name] + define :read + define :destroy + end + + identities do + identity :unique_name_per_channel, [:name, :channel_id] + end +end diff --git a/lib/ash_hq/discord/resources/thread.ex b/lib/ash_hq/discord/resources/thread.ex new file mode 100644 index 00000000..9d277da8 --- /dev/null +++ b/lib/ash_hq/discord/resources/thread.ex @@ -0,0 +1,109 @@ +defmodule AshHq.Discord.Thread do + @moduledoc """ + A thread is an individual forum post (because they are really just fancy threads) + """ + + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + import Ecto.Query + + actions do + defaults [:create, :read, :update, :destroy] + + read :feed do + pagination do + countable true + offset? true + default_limit 25 + end + + argument :channel, :integer do + allow_nil? false + end + + argument :tag_name, :string + + prepare build(sort: [create_timestamp: :desc]) + + filter expr( + channel_id == ^arg(:channel) and + (is_nil(^arg(:tag_name)) or tags.name == ^arg(:tag_name)) + ) + end + + create :upsert do + upsert? true + argument :messages, {:array, :map} + argument :tags, {:array, :integer} + + change manage_relationship(:messages, type: :direct_control) + + change fn changeset, _ -> + Ash.Changeset.after_action(changeset, fn changeset, thread -> + tags = Ash.Changeset.get_argument(changeset, :tags) || [] + + # Not optimized in `manage_relationship` + # bulk actions should make this unnecessary + to_delete = + from thread_tag in AshHq.Discord.ThreadTag, + where: thread_tag.thread_id == ^thread.id, + where: thread_tag.tag_id not in ^tags + + AshHq.Repo.delete_all(to_delete) + + Enum.map(tags, fn tag -> + AshHq.Discord.ThreadTag.tag!(thread.id, tag) + end) + + {:ok, thread} + end) + end + end + end + + attributes do + integer_primary_key :id, generated?: false, writable?: true + attribute :type, :integer + + attribute :name, :string do + allow_nil? false + end + + attribute :author, :string do + allow_nil? false + end + + attribute :create_timestamp, :utc_datetime do + allow_nil? false + end + end + + relationships do + has_many :messages, AshHq.Discord.Message + + belongs_to :channel, AshHq.Discord.Channel do + attribute_type :integer + allow_nil? false + attribute_writable? true + end + + many_to_many :tags, AshHq.Discord.Tag do + through AshHq.Discord.ThreadTag + source_attribute_on_join_resource :thread_id + destination_attribute_on_join_resource :tag_id + end + end + + postgres do + table "discord_threads" + repo AshHq.Repo + end + + code_interface do + define_for AshHq.Discord + define :upsert + define :by_id, action: :read, get_by: [:id] + define :feed, args: [:channel] + end +end diff --git a/lib/ash_hq/discord/resources/thread_tag.ex b/lib/ash_hq/discord/resources/thread_tag.ex new file mode 100644 index 00000000..14b9e6eb --- /dev/null +++ b/lib/ash_hq/discord/resources/thread_tag.ex @@ -0,0 +1,39 @@ +defmodule AshHq.Discord.ThreadTag do + @moduledoc "Joins a thread to a tag" + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + actions do + defaults [:read, :destroy] + + create :tag do + upsert? true + end + end + + relationships do + belongs_to :thread, AshHq.Discord.Thread do + primary_key? true + allow_nil? false + attribute_writable? true + attribute_type :integer + end + + belongs_to :tag, AshHq.Discord.Tag do + primary_key? true + allow_nil? false + attribute_writable? true + attribute_type :integer + end + end + + postgres do + table "discord_thread_tags" + repo AshHq.Repo + end + + code_interface do + define_for AshHq.Discord + define :tag, args: [:thread_id, :tag_id] + end +end diff --git a/lib/ash_hq/discord/supervisor.ex b/lib/ash_hq/discord/supervisor.ex deleted file mode 100644 index ff22ad27..00000000 --- a/lib/ash_hq/discord/supervisor.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule AshHq.Discord.Supervisor do - @moduledoc "Supervises the discord listener" - use Supervisor - - def start_link(args) do - Supervisor.start_link(__MODULE__, args, name: __MODULE__) - end - - @impl true - def init(_init_arg) do - children = [AshHq.Discord.Listener] - - Supervisor.init(children, strategy: :one_for_one) - end -end diff --git a/lib/ash_hq_web/pages/forum.ex b/lib/ash_hq_web/pages/forum.ex new file mode 100644 index 00000000..300c031c --- /dev/null +++ b/lib/ash_hq_web/pages/forum.ex @@ -0,0 +1,342 @@ +defmodule AshHqWeb.Pages.Forum do + @moduledoc "Forum page" + use Surface.LiveComponent + + require Ash.Query + + alias AshHqWeb.Components.Blog.Tag + alias AshHqWeb.Components.Forum.Attachment + alias Surface.Components.LivePatch + + import AshHqWeb.Tails + + prop(params, :map, default: %{}) + + data(thread, :any, default: nil) + data(threads, :any, default: []) + data(tag, :string, default: nil) + data(tags, :any, default: []) + data(channels, :any, default: []) + data(channel, :any, default: []) + + def render(assigns) do + ~F""" +
+
+
+ {#if @thread} +
+ +
+ + Back to {String.capitalize(@channel.name)} +
+
+ +
+ Discord App +
+
+ +
+ Discord Web +
+
+
+ + + + +
+

{@thread.name}

+
+
+ {@thread.author} +
+
+ {@thread.create_timestamp |> DateTime.to_date()} +
+ +
+ {#for tag <- @thread.tags} + + {/for} +
+
+
+ {#for message <- @thread.messages} +
+

+ {message.author}: +

{raw(message.content_html)} + {#for attachment <- message.attachments} + + {/for} +
+ {/for} +
+
+ {#else} +

+ Channels +

+
+ {#for channel <- @channels} + + {String.capitalize(channel.name)} + + {/for} +
+
+ {#if @threads.offset != 0} + +
+ Previous Page +
+
+ {/if} + + {#if @threads.more?} + +
+ Next Page +
+
+ {/if} +
+ + + + + {#if @tag} +

Showing {page_info(@threads)} with tag: {@tag}

+ {#else} +

Showing {page_info(@threads)}

+ {/if} +
+ {#for thread <- @threads.results} +
+

{thread.name}

+
+
+ {thread.author} +
+
+ {thread.create_timestamp |> DateTime.to_date()} +
+ + +
+ {#for tag <- thread.tags} + + {/for} +
+
+
+ {/for} +
+
+ {#if @threads.offset != 0} + +
+ Previous Page +
+
+ {/if} + + {#if @threads.more?} + +
+ Next Page +
+
+ {/if} +
+ {/if} +
+ {#if !@thread} +
+
+

All Tags:

+
+ {#for tag <- @tags} + + {/for} +
+
+
+ {/if} +
+
+ """ + end + + def update(assigns, socket) do + { + :ok, + socket + |> assign(assigns) + |> assign_channels() + |> assign_channel() + |> assign_tags() + |> assign_tag() + |> assign_thread() + |> assign_threads() + } + end + + defp assign_tags(socket) do + tags = + AshHq.Discord.Tag + |> Ash.Query.distinct(:name) + |> Ash.Query.filter(channel_id == ^socket.assigns.channel.id) + |> Ash.Query.select(:name) + |> Ash.Query.sort(:name) + |> AshHq.Discord.read!() + |> Enum.map(&to_string(&1.name)) + + assign(socket, :tags, tags) + end + + defp assign_tag(socket) do + tag = + if socket.assigns.params["tag"] do + Enum.find( + socket.assigns.tags, + &Ash.Type.CiString.equal?(&1, socket.assigns.params["tag"]) + ) + end + + assign(socket, :tag, tag) + end + + defp assign_thread(socket) do + if socket.assigns.params["id"] do + messages_query = + AshHq.Discord.Message + |> Ash.Query.sort(timestamp: :asc) + |> Ash.Query.deselect(:content) + |> Ash.Query.load(:attachments) + + assign( + socket, + :thread, + AshHq.Discord.Thread.by_id!(socket.assigns.params["id"], + load: [:tags, messages: messages_query] + ) + ) + else + assign(socket, :thread, nil) + end + end + + defp assign_threads(socket) do + assign( + socket, + :threads, + AshHq.Discord.Thread.feed!( + socket.assigns.channel.id, + %{tag_name: socket.assigns.tag}, + page: [offset: String.to_integer(socket.assigns.params["offset"] || "0"), count: true], + load: :tags + ) + ) + end + + defp assign_channels(socket) do + assign(socket, :channels, AshHq.Discord.Channel.read!() |> Enum.sort_by(& &1.order)) + end + + defp assign_channel(socket) do + channel_name = socket.assigns.params["channel"] || "showcase" + + channel = + Enum.find( + socket.assigns.channels, + &(&1.name == channel_name) + ) + + if is_nil(channel) do + raise Ash.Error.Query.NotFound.exception(primary_key: %{name: channel_name}) + end + + assign( + socket, + :channel, + channel + ) + end + + defp page_info(%{results: []}) do + "no threads " + end + + defp page_info(%{more?: false, offset: 0, count: count}) do + "all #{count} threads " + end + + defp page_info(%{more?: false, results: results, count: count}) do + "the last #{Enum.count(results)} of #{count} threads " + end + + defp page_info(%{offset: 0, limit: limit, count: count}) do + "the first #{limit} of #{count} threads " + end + + defp page_info(%{offset: offset, limit: limit, count: count}) do + "threads #{offset + 1} to #{offset + limit} of #{count}" + end +end diff --git a/lib/ash_hq_web/router.ex b/lib/ash_hq_web/router.ex index 8d8d9a84..4af7d2a5 100644 --- a/lib/ash_hq_web/router.ex +++ b/lib/ash_hq_web/router.ex @@ -61,6 +61,11 @@ defmodule AshHqWeb.Router do live("/docs/mix_task/:library/:version/:mix_task", AppViewLive, :docs_dsl) live("/docs/:library/:version", AppViewLive, :docs_dsl) + # for showing deprecated forum content + live("/forum", AppViewLive, :forum) + live("/forum/:channel", AppViewLive, :forum) + live("/forum/:channel/:id", AppViewLive, :forum) + get("/docs/dsl/:library/:version/:extension/*dsl_path", DslRedirectController, :show) get("/unsubscribe", MailingListController, :unsubscribe) diff --git a/lib/ash_hq_web/views/app_view_live.ex b/lib/ash_hq_web/views/app_view_live.ex index 5cbe7f9b..534aaa2d 100644 --- a/lib/ash_hq_web/views/app_view_live.ex +++ b/lib/ash_hq_web/views/app_view_live.ex @@ -6,7 +6,7 @@ defmodule AshHqWeb.AppViewLive do alias AshHqWeb.Components.AppView.TopBar alias AshHqWeb.Components.Search - alias AshHqWeb.Pages.{Blog, Community, Docs, Home, Media, UserSettings} + alias AshHqWeb.Pages.{Blog, Community, Docs, Forum, Home, Media, UserSettings} alias Phoenix.LiveView.JS alias Surface.Components.Context @@ -111,6 +111,8 @@ defmodule AshHqWeb.AppViewLive do {#match :media} + {#match :forum} + {/case} {#if @live_action != :docs_dsl}