From e72a00ed79fbd00079c5c371b8a1375abd6c2b0a Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 24 Mar 2025 21:25:33 +0200 Subject: [PATCH 1/5] feat: implement chat functionality with admin threads and message broadcasting --- lib/algora/admin/admin.ex | 10 ++ lib/algora/chat/chat.ex | 71 +++++++-- lib/algora_web/live/org/dashboard_live.ex | 170 +++++++++++++++++++++- test/algora/chat_test.exs | 2 +- 4 files changed, 239 insertions(+), 14 deletions(-) diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index a25b0bea3..2409ecb90 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -330,6 +330,16 @@ defmodule Algora.Admin do |> Algora.Repo.update() end + def admins_last_active do + Algora.Repo.one( + from u in User, + where: u.is_admin == true, + order_by: [desc: u.last_active_at], + select: u.last_active_at, + limit: 1 + ) + end + def setup_test_account(user_handle) do with account_id when is_binary(account_id) <- Algora.config([:stripe, :test_account_id]), {:ok, user} <- Repo.fetch_by(User, handle: user_handle), diff --git a/lib/algora/chat/chat.ex b/lib/algora/chat/chat.ex index 41a6eaff7..697434667 100644 --- a/lib/algora/chat/chat.ex +++ b/lib/algora/chat/chat.ex @@ -8,6 +8,14 @@ defmodule Algora.Chat do alias Algora.Chat.Thread alias Algora.Repo + def broadcast(message) do + Phoenix.PubSub.broadcast(Algora.PubSub, "chat:thread:#{message.thread_id}", message) + end + + def subscribe(thread_id) do + Phoenix.PubSub.subscribe(Algora.PubSub, "chat:thread:#{thread_id}") + end + def create_direct_thread(user_1, user_2) do Repo.transaction(fn -> {:ok, thread} = @@ -30,14 +38,44 @@ defmodule Algora.Chat do end) end + def create_admin_thread(user, admins) do + Repo.transaction(fn -> + {:ok, thread} = + %Thread{} + |> Thread.changeset(%{title: "Chat with Algora founders"}) + |> Repo.insert() + + participants = Enum.uniq_by([user | admins], & &1.id) + # Add participants + for u <- participants do + %Participant{} + |> Participant.changeset(%{ + thread_id: thread.id, + user_id: u.id, + last_read_at: DateTime.utc_now() + }) + |> Repo.insert!() + end + + thread + end) + end + def send_message(thread_id, sender_id, content) do - %Message{} - |> Message.changeset(%{ - thread_id: thread_id, - sender_id: sender_id, - content: content - }) - |> Repo.insert() + case %Message{} + |> Message.changeset(%{ + thread_id: thread_id, + sender_id: sender_id, + content: content + }) + |> Repo.insert() do + {:ok, message} -> + message |> Repo.preload(:sender) |> broadcast() + {:ok, message} + + {:error, changeset} -> + {:error, changeset} + end end def list_messages(thread_id, limit \\ 50) do @@ -62,18 +100,20 @@ defmodule Algora.Chat do |> Repo.update_all(set: [last_read_at: DateTime.utc_now()]) end - def get_thread_for_users(user1_id, user2_id) do + def get_thread_for_users(users) do + participants = Enum.uniq_by(users, & &1.id) + Thread |> join(:inner, [t], p in Participant, on: p.thread_id == t.id) - |> where([t, p], p.user_id in [^user1_id, ^user2_id]) + |> where([t, p], p.user_id in ^Enum.map(participants, & &1.id)) |> group_by([t], t.id) - |> having([t, p], count(p.id) == 2) + |> having([t, p], count(p.id) == ^length(participants)) |> limit(1) |> Repo.one() end def get_or_create_thread(contract) do - case get_thread_for_users(contract.client_id, contract.contractor_id) do + case get_thread_for_users([contract.client, contract.contract]) do nil -> create_direct_thread(contract.client, contract.contractor) thread -> {:ok, thread} end @@ -83,4 +123,13 @@ defmodule Algora.Chat do {:ok, thread} = get_or_create_thread(contract) thread end + + def get_or_create_admin_thread(current_user) do + admins = Repo.all(from u in User, where: u.is_admin == true) + + case get_thread_for_users([current_user] ++ admins) do + nil -> create_admin_thread(current_user, admins) + thread -> {:ok, thread} + end + end end diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index d5300356e..a4719fcab 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -12,6 +12,7 @@ defmodule AlgoraWeb.Org.DashboardLive do alias Algora.Bounties alias Algora.Bounties.Bounty alias Algora.Bounties.Claim + alias Algora.Chat alias Algora.Contracts alias Algora.Github alias Algora.Organizations @@ -55,8 +56,11 @@ defmodule AlgoraWeb.Org.DashboardLive do Workspace.list_contributors(current_org.provider_login) end + admins_last_active = Algora.Admin.admins_last_active() + {:ok, socket + |> assign(:admins_last_active, admins_last_active) |> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user)) |> assign(:installations, installations) |> assign(:experts, experts) @@ -74,7 +78,11 @@ defmodule AlgoraWeb.Org.DashboardLive do |> assign_login_form(User.login_changeset(%User{}, %{})) |> assign_payable_bounties() |> assign_contracts() - |> assign_achievements()} + |> assign_achievements() + # Will be initialized when chat starts + |> assign(:thread, nil) + |> assign(:messages, []) + |> assign(:show_chat, false)} else {:ok, redirect(socket, to: ~p"/org/#{current_org.handle}/home")} end @@ -521,6 +529,15 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + @impl true + def handle_info(%Chat.Message{} = message, socket) do + if message.id in Enum.map(socket.assigns.messages, & &1.id) do + {:noreply, socket} + else + {:noreply, assign(socket, :messages, socket.assigns.messages ++ [message])} + end + end + @impl true def handle_event("install_app" = event, unsigned_params, socket) do {:noreply, @@ -775,6 +792,46 @@ defmodule AlgoraWeb.Org.DashboardLive do end} end + @impl true + def handle_event("start_chat", _params, socket) do + # Get or create thread between user and founders + {:ok, thread} = Chat.get_or_create_admin_thread(socket.assigns.current_user) + + if connected?(socket) do + Phoenix.PubSub.subscribe(Algora.PubSub, "chat:thread:#{thread.id}") + end + + messages = thread.id |> Chat.list_messages() |> Repo.preload(:sender) + + {:noreply, + socket + |> assign(:thread, thread) + |> assign(:messages, messages) + |> assign(:show_chat, true)} + end + + @impl true + def handle_event("close_chat", _params, socket) do + {:noreply, assign(socket, :show_chat, false)} + end + + @impl true + def handle_event("send_message", %{"message" => content}, socket) do + {:ok, message} = + Chat.send_message( + socket.assigns.thread.id, + socket.assigns.current_user.id, + content + ) + + message = Repo.preload(message, :sender) + + {:noreply, + socket + |> Phoenix.Component.update(:messages, &(&1 ++ [message])) + |> push_event("clear-input", %{selector: "#message-input"})} + end + @impl true def handle_event(_event, _params, socket) do {:noreply, socket} @@ -1351,7 +1408,9 @@ defmodule AlgoraWeb.Org.DashboardLive do class="relative z-0 inline-block size-6 rounded-full ring-2 ring-background" /> - Chat with founders +
<.icon name="tabler-brand-x" class="size-6 text-muted-foreground" /> @algoraio @@ -1363,6 +1422,113 @@ defmodule AlgoraWeb.Org.DashboardLive do <.icon name="tabler-mail" class="size-6 text-muted-foreground" /> support@algora.io
+ + <%= if @show_chat do %> +
+
+
+
+ <.avatar class="relative z-10 h-8 w-8 ring-2 ring-background"> + <.avatar_image src="https://github.com/ioannisflo.png" alt="Ioannis Florokapis" /> + + <.avatar class="relative z-0 h-8 w-8 ring-2 ring-background"> + <.avatar_image src="https://github.com/zcesur.png" alt="Zafer Cesur" /> + +
+
+

Algora Founders

+

+ Active {Algora.Util.time_ago(@admins_last_active)} +

+
+
+ <.button variant="ghost" size="icon" phx-click="close_chat"> + <.icon name="tabler-x" class="h-4 w-4" /> + +
+ + <.scroll_area + class="flex h-full flex-1 flex-col-reverse gap-6 p-4" + id="messages-container" + phx-hook="ScrollToBottom" + > +
+ <%= for {date, messages} <- @messages + |> Enum.group_by(fn msg -> + case Date.diff(Date.utc_today(), DateTime.to_date(msg.inserted_at)) do + 0 -> "Today" + 1 -> "Yesterday" + n when n <= 7 -> Calendar.strftime(msg.inserted_at, "%A") + _ -> Calendar.strftime(msg.inserted_at, "%b %d") + end + end) + |> Enum.sort_by(fn {_, msgs} -> hd(msgs).inserted_at end, Date) do %> +
+
+ {date} +
+
+ +
+ <%= for message <- Enum.sort_by(messages, & &1.inserted_at, Date) do %> +
+ <.avatar class="h-8 w-8"> + <.avatar_image src={message.sender.avatar_url} /> + <.avatar_fallback> + {Algora.Util.initials(message.sender.name)} + + +
+ {message.content} +
+ {message.inserted_at + |> DateTime.to_time() + |> Time.to_string() + |> String.slice(0..4)} +
+
+
+ <% end %> +
+ <% end %> +
+ + +
+
+
+ <.input + id="message-input" + type="text" + name="message" + value="" + placeholder="Type a message..." + autocomplete="off" + class="flex-1 pr-24" + phx-hook="ClearInput" + /> +
+ <.button + type="button" + variant="ghost" + size="icon-sm" + phx-hook="EmojiPicker" + id="emoji-trigger" + > + <.icon name="tabler-mood-smile" class="h-4 w-4" /> + +
+
+ <.button type="submit" size="icon"> + <.icon name="tabler-send" class="h-4 w-4" /> + +
+ +
+
+ <% end %> """ end diff --git a/test/algora/chat_test.exs b/test/algora/chat_test.exs index 1c5a0088f..ef4cb5f99 100644 --- a/test/algora/chat_test.exs +++ b/test/algora/chat_test.exs @@ -13,7 +13,7 @@ defmodule Algora.ChatTest do {:ok, message_2} = Chat.send_message(thread.id, user_2.id, "there") assert thread.id |> Chat.list_messages() |> Enum.map(& &1.id) == [message_1.id, message_2.id] assert Chat.mark_as_read(thread.id, user_1.id) == {1, nil} - assert Chat.get_thread_for_users(user_1.id, user_2.id).id == thread.id + assert Chat.get_thread_for_users([user_1, user_2]).id == thread.id assert user_1.id |> Chat.list_threads() |> Enum.map(& &1.id) == [thread.id] assert user_2.id |> Chat.list_threads() |> Enum.map(& &1.id) == [thread.id] end From dafe82d64ee4d5767644f066361b4cd4f638104a Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 24 Mar 2025 21:34:52 +0200 Subject: [PATCH 2/5] fix typo --- lib/algora/chat/chat.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora/chat/chat.ex b/lib/algora/chat/chat.ex index 697434667..aee5e9498 100644 --- a/lib/algora/chat/chat.ex +++ b/lib/algora/chat/chat.ex @@ -113,7 +113,7 @@ defmodule Algora.Chat do end def get_or_create_thread(contract) do - case get_thread_for_users([contract.client, contract.contract]) do + case get_thread_for_users([contract.client, contract.contractor]) do nil -> create_direct_thread(contract.client, contract.contractor) thread -> {:ok, thread} end From 031e35ca3d8a755d2bb6f7e7e2692c064c39ac71 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 24 Mar 2025 23:26:12 +0200 Subject: [PATCH 3/5] feat: add chat thread live view and update routing for chat functionality --- lib/algora/chat/chat.ex | 12 +- .../components/layouts/user.html.heex | 6 +- lib/algora_web/live/admin/nav.ex | 17 +- lib/algora_web/live/chat/thread_live.ex | 186 ++++++++++++++++++ lib/algora_web/router.ex | 1 + 5 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 lib/algora_web/live/chat/thread_live.ex diff --git a/lib/algora/chat/chat.ex b/lib/algora/chat/chat.ex index aee5e9498..c2d94ab16 100644 --- a/lib/algora/chat/chat.ex +++ b/lib/algora/chat/chat.ex @@ -86,11 +86,15 @@ defmodule Algora.Chat do |> Repo.all() end - def list_threads(user_id) do + def get_thread(thread_id) do + Repo.get(Thread, thread_id) + end + + def list_threads(_user_id) do Thread - |> join(:inner, [t], p in Participant, on: p.thread_id == t.id) - |> where([_t, p], p.user_id == ^user_id) - |> preload(:participants) + # |> join(:inner, [t], p in Participant, on: p.thread_id == t.id) + # |> where([_t, p], p.user_id == ^user_id) + |> preload(participants: :user) |> Repo.all() end diff --git a/lib/algora_web/components/layouts/user.html.heex b/lib/algora_web/components/layouts/user.html.heex index 3104a7887..2eca6d79d 100644 --- a/lib/algora_web/components/layouts/user.html.heex +++ b/lib/algora_web/components/layouts/user.html.heex @@ -106,14 +106,14 @@ <.separator :if={index != length(@nav) - 1} class="mx-auto my-4 w-2/3" /> <% end %> - <%= if @contacts != [] do %> + <%= if assigns[:threads] && assigns[:threads] != [] do %> <.separator class="mx-auto my-4 w-2/3" />
    - <%= for contact <- @contacts do %> + <%= for %{thread: thread, contact: contact, path: path} <- @threads do %>
  • <.link class="flex justify-center p-2" - navigate={Algora.Accounts.User.url(contact)} + navigate={path} title={Algora.Accounts.User.handle(contact)} >
    diff --git a/lib/algora_web/live/admin/nav.ex b/lib/algora_web/live/admin/nav.ex index d41b91391..7ce6f004f 100644 --- a/lib/algora_web/live/admin/nav.ex +++ b/lib/algora_web/live/admin/nav.ex @@ -1,13 +1,28 @@ defmodule AlgoraWeb.Admin.Nav do @moduledoc false use Phoenix.Component + use AlgoraWeb, :verified_routes import Phoenix.LiveView + alias Algora.Chat + def on_mount(:default, _params, _session, socket) do + threads = + socket.assigns.current_user.id + |> Chat.list_threads() + |> Enum.flat_map(fn thread -> + case Enum.find(thread.participants, fn p -> !p.user.is_admin end) do + nil -> [] + contact -> [%{thread: thread, contact: contact.user, path: ~p"/admin/chat/#{thread.id}"}] + end + end) + + dbg(threads) + {:cont, socket - |> assign(:contacts, []) + |> assign(:threads, threads) |> assign(:admin_page?, true) |> assign_nav_items() |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3)} diff --git a/lib/algora_web/live/chat/thread_live.ex b/lib/algora_web/live/chat/thread_live.ex new file mode 100644 index 000000000..b1e00b8d0 --- /dev/null +++ b/lib/algora_web/live/chat/thread_live.ex @@ -0,0 +1,186 @@ +defmodule AlgoraWeb.Chat.ThreadLive do + @moduledoc false + use AlgoraWeb, :live_view + + alias Algora.Chat + alias Algora.Chat.Message + alias Algora.Repo + + @impl true + def mount(%{"id" => thread_id}, _session, socket) do + if connected?(socket) do + Chat.subscribe(thread_id) + end + + thread = thread_id |> Chat.get_thread() |> Repo.preload(participants: :user) + messages = thread_id |> Chat.list_messages() |> Repo.preload(:sender) + + {:ok, + socket + |> assign(:thread, thread) + |> assign(:thread_id, thread_id) + |> assign(:messages, messages)} + end + + @impl true + def handle_event("send_message", %{"message" => content}, socket) do + {:ok, message} = + Chat.send_message( + socket.assigns.thread_id, + socket.assigns.current_user.id, + content + ) + + {:noreply, push_event(socket, "clear-input", %{selector: "#message-input"})} + end + + @impl true + def handle_info(%Message{} = message, socket) do + if message.id in Enum.map(socket.assigns.messages, & &1.id) do + {:noreply, socket} + else + {:noreply, assign(socket, :messages, socket.assigns.messages ++ [message])} + end + end + + @impl true + def render(assigns) do + ~H""" +
    +
    +
    +
    +
    +
    + <%= for participant <- @thread.participants do %> + <.avatar class="relative z-10 h-8 w-8 ring-2 ring-background"> + <.avatar_image src={participant.user.avatar_url} alt={participant.user.name} /> + <.avatar_fallback> + {Algora.Util.initials(participant.user.name)} + + + <% end %> +
    +
    +

    {@thread.title}

    +

    + {@thread.participants + |> Enum.map(& &1.user.name) + |> Algora.Util.format_name_list()} +

    +
    +
    +
    +
    + + <.scroll_area + class="flex flex-1 flex-col-reverse gap-6 p-4" + id="messages-container" + phx-hook="ScrollToBottom" + > +
    + <%= for {date, messages} <- @messages + |> Enum.group_by(fn msg -> + case Date.diff(Date.utc_today(), DateTime.to_date(msg.inserted_at)) do + 0 -> "Today" + 1 -> "Yesterday" + n when n <= 7 -> Calendar.strftime(msg.inserted_at, "%A") + _ -> Calendar.strftime(msg.inserted_at, "%b %d") + end + end) + |> Enum.sort_by(fn {_, msgs} -> hd(msgs).inserted_at end, Date) do %> +
    +
    + {date} +
    +
    + +
    + <%= for message <- Enum.sort_by(messages, & &1.inserted_at, Date) do %> +
    + <.avatar class="h-8 w-8"> + <.avatar_image src={message.sender.avatar_url} /> + <.avatar_fallback> + {Algora.Util.initials(message.sender.name)} + + +
    + {message.content} +
    + {message.inserted_at + |> DateTime.to_time() + |> Time.to_string() + |> String.slice(0..4)} +
    +
    +
    + <% end %> +
    + <% end %> +
    + + +
    +
    +
    + <.input + id="message-input" + type="text" + name="message" + value="" + placeholder="Type a message..." + autocomplete="off" + class="flex-1 pr-24" + phx-hook="ClearInput" + /> +
    + <.button + type="button" + variant="ghost" + size="icon-sm" + phx-hook="EmojiPicker" + id="emoji-trigger" + > + <.icon name="tabler-mood-smile" class="h-4 w-4" /> + +
    +
    + <.button type="submit" size="icon"> + <.icon name="tabler-send" class="h-4 w-4" /> + +
    + +
    +
    + + +
    + """ + end +end diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 2a4ec30db..b02cf7df1 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -50,6 +50,7 @@ defmodule AlgoraWeb.Router do on_mount: [{AlgoraWeb.UserAuth, :ensure_admin}, AlgoraWeb.Admin.Nav] do live "/", Admin.AdminLive live "/leaderboard", Admin.LeaderboardLive + live "/chat/:id", Chat.ThreadLive end oban_dashboard("/oban", resolver: AlgoraWeb.ObanDashboardResolver) From 247845496b6df5f36dbbf6d0c2dc1dfdbaaf4388 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 24 Mar 2025 23:34:48 +0200 Subject: [PATCH 4/5] feat: enhance thread listing by including last message timestamps for improved sorting --- lib/algora/chat/chat.ex | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/algora/chat/chat.ex b/lib/algora/chat/chat.ex index c2d94ab16..08983dbee 100644 --- a/lib/algora/chat/chat.ex +++ b/lib/algora/chat/chat.ex @@ -91,9 +91,17 @@ defmodule Algora.Chat do end def list_threads(_user_id) do + last_message_query = + from m in Message, + select: %{ + thread_id: m.thread_id, + last_message_at: max(m.inserted_at) + }, + group_by: m.thread_id + Thread - # |> join(:inner, [t], p in Participant, on: p.thread_id == t.id) - # |> where([_t, p], p.user_id == ^user_id) + |> join(:left, [t], lm in subquery(last_message_query), on: t.id == lm.thread_id) + |> order_by([t, lm], desc: lm.last_message_at) |> preload(participants: :user) |> Repo.all() end From 6a5683e549f6076470d101557c8f2b103cd9634d Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 24 Mar 2025 23:35:43 +0200 Subject: [PATCH 5/5] feat: add TODO for filtering threads by user_id in list_threads function --- lib/algora/chat/chat.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/algora/chat/chat.ex b/lib/algora/chat/chat.ex index 08983dbee..bd45843a4 100644 --- a/lib/algora/chat/chat.ex +++ b/lib/algora/chat/chat.ex @@ -90,6 +90,7 @@ defmodule Algora.Chat do Repo.get(Thread, thread_id) end + # TODO: filter by user_id def list_threads(_user_id) do last_message_query = from m in Message,