Skip to content

Commit e72a00e

Browse files
committed
feat: implement chat functionality with admin threads and message broadcasting
1 parent 16e0725 commit e72a00e

File tree

4 files changed

+239
-14
lines changed

4 files changed

+239
-14
lines changed

lib/algora/admin/admin.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,16 @@ defmodule Algora.Admin do
330330
|> Algora.Repo.update()
331331
end
332332

333+
def admins_last_active do
334+
Algora.Repo.one(
335+
from u in User,
336+
where: u.is_admin == true,
337+
order_by: [desc: u.last_active_at],
338+
select: u.last_active_at,
339+
limit: 1
340+
)
341+
end
342+
333343
def setup_test_account(user_handle) do
334344
with account_id when is_binary(account_id) <- Algora.config([:stripe, :test_account_id]),
335345
{:ok, user} <- Repo.fetch_by(User, handle: user_handle),

lib/algora/chat/chat.ex

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ defmodule Algora.Chat do
88
alias Algora.Chat.Thread
99
alias Algora.Repo
1010

11+
def broadcast(message) do
12+
Phoenix.PubSub.broadcast(Algora.PubSub, "chat:thread:#{message.thread_id}", message)
13+
end
14+
15+
def subscribe(thread_id) do
16+
Phoenix.PubSub.subscribe(Algora.PubSub, "chat:thread:#{thread_id}")
17+
end
18+
1119
def create_direct_thread(user_1, user_2) do
1220
Repo.transaction(fn ->
1321
{:ok, thread} =
@@ -30,14 +38,44 @@ defmodule Algora.Chat do
3038
end)
3139
end
3240

41+
def create_admin_thread(user, admins) do
42+
Repo.transaction(fn ->
43+
{:ok, thread} =
44+
%Thread{}
45+
|> Thread.changeset(%{title: "Chat with Algora founders"})
46+
|> Repo.insert()
47+
48+
participants = Enum.uniq_by([user | admins], & &1.id)
49+
# Add participants
50+
for u <- participants do
51+
%Participant{}
52+
|> Participant.changeset(%{
53+
thread_id: thread.id,
54+
user_id: u.id,
55+
last_read_at: DateTime.utc_now()
56+
})
57+
|> Repo.insert!()
58+
end
59+
60+
thread
61+
end)
62+
end
63+
3364
def send_message(thread_id, sender_id, content) do
34-
%Message{}
35-
|> Message.changeset(%{
36-
thread_id: thread_id,
37-
sender_id: sender_id,
38-
content: content
39-
})
40-
|> Repo.insert()
65+
case %Message{}
66+
|> Message.changeset(%{
67+
thread_id: thread_id,
68+
sender_id: sender_id,
69+
content: content
70+
})
71+
|> Repo.insert() do
72+
{:ok, message} ->
73+
message |> Repo.preload(:sender) |> broadcast()
74+
{:ok, message}
75+
76+
{:error, changeset} ->
77+
{:error, changeset}
78+
end
4179
end
4280

4381
def list_messages(thread_id, limit \\ 50) do
@@ -62,18 +100,20 @@ defmodule Algora.Chat do
62100
|> Repo.update_all(set: [last_read_at: DateTime.utc_now()])
63101
end
64102

65-
def get_thread_for_users(user1_id, user2_id) do
103+
def get_thread_for_users(users) do
104+
participants = Enum.uniq_by(users, & &1.id)
105+
66106
Thread
67107
|> join(:inner, [t], p in Participant, on: p.thread_id == t.id)
68-
|> where([t, p], p.user_id in [^user1_id, ^user2_id])
108+
|> where([t, p], p.user_id in ^Enum.map(participants, & &1.id))
69109
|> group_by([t], t.id)
70-
|> having([t, p], count(p.id) == 2)
110+
|> having([t, p], count(p.id) == ^length(participants))
71111
|> limit(1)
72112
|> Repo.one()
73113
end
74114

75115
def get_or_create_thread(contract) do
76-
case get_thread_for_users(contract.client_id, contract.contractor_id) do
116+
case get_thread_for_users([contract.client, contract.contract]) do
77117
nil -> create_direct_thread(contract.client, contract.contractor)
78118
thread -> {:ok, thread}
79119
end
@@ -83,4 +123,13 @@ defmodule Algora.Chat do
83123
{:ok, thread} = get_or_create_thread(contract)
84124
thread
85125
end
126+
127+
def get_or_create_admin_thread(current_user) do
128+
admins = Repo.all(from u in User, where: u.is_admin == true)
129+
130+
case get_thread_for_users([current_user] ++ admins) do
131+
nil -> create_admin_thread(current_user, admins)
132+
thread -> {:ok, thread}
133+
end
134+
end
86135
end

lib/algora_web/live/org/dashboard_live.ex

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule AlgoraWeb.Org.DashboardLive do
1212
alias Algora.Bounties
1313
alias Algora.Bounties.Bounty
1414
alias Algora.Bounties.Claim
15+
alias Algora.Chat
1516
alias Algora.Contracts
1617
alias Algora.Github
1718
alias Algora.Organizations
@@ -55,8 +56,11 @@ defmodule AlgoraWeb.Org.DashboardLive do
5556
Workspace.list_contributors(current_org.provider_login)
5657
end
5758

59+
admins_last_active = Algora.Admin.admins_last_active()
60+
5861
{:ok,
5962
socket
63+
|> assign(:admins_last_active, admins_last_active)
6064
|> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user))
6165
|> assign(:installations, installations)
6266
|> assign(:experts, experts)
@@ -74,7 +78,11 @@ defmodule AlgoraWeb.Org.DashboardLive do
7478
|> assign_login_form(User.login_changeset(%User{}, %{}))
7579
|> assign_payable_bounties()
7680
|> assign_contracts()
77-
|> assign_achievements()}
81+
|> assign_achievements()
82+
# Will be initialized when chat starts
83+
|> assign(:thread, nil)
84+
|> assign(:messages, [])
85+
|> assign(:show_chat, false)}
7886
else
7987
{:ok, redirect(socket, to: ~p"/org/#{current_org.handle}/home")}
8088
end
@@ -521,6 +529,15 @@ defmodule AlgoraWeb.Org.DashboardLive do
521529
end
522530
end
523531

532+
@impl true
533+
def handle_info(%Chat.Message{} = message, socket) do
534+
if message.id in Enum.map(socket.assigns.messages, & &1.id) do
535+
{:noreply, socket}
536+
else
537+
{:noreply, assign(socket, :messages, socket.assigns.messages ++ [message])}
538+
end
539+
end
540+
524541
@impl true
525542
def handle_event("install_app" = event, unsigned_params, socket) do
526543
{:noreply,
@@ -775,6 +792,46 @@ defmodule AlgoraWeb.Org.DashboardLive do
775792
end}
776793
end
777794

795+
@impl true
796+
def handle_event("start_chat", _params, socket) do
797+
# Get or create thread between user and founders
798+
{:ok, thread} = Chat.get_or_create_admin_thread(socket.assigns.current_user)
799+
800+
if connected?(socket) do
801+
Phoenix.PubSub.subscribe(Algora.PubSub, "chat:thread:#{thread.id}")
802+
end
803+
804+
messages = thread.id |> Chat.list_messages() |> Repo.preload(:sender)
805+
806+
{:noreply,
807+
socket
808+
|> assign(:thread, thread)
809+
|> assign(:messages, messages)
810+
|> assign(:show_chat, true)}
811+
end
812+
813+
@impl true
814+
def handle_event("close_chat", _params, socket) do
815+
{:noreply, assign(socket, :show_chat, false)}
816+
end
817+
818+
@impl true
819+
def handle_event("send_message", %{"message" => content}, socket) do
820+
{:ok, message} =
821+
Chat.send_message(
822+
socket.assigns.thread.id,
823+
socket.assigns.current_user.id,
824+
content
825+
)
826+
827+
message = Repo.preload(message, :sender)
828+
829+
{:noreply,
830+
socket
831+
|> Phoenix.Component.update(:messages, &(&1 ++ [message]))
832+
|> push_event("clear-input", %{selector: "#message-input"})}
833+
end
834+
778835
@impl true
779836
def handle_event(_event, _params, socket) do
780837
{:noreply, socket}
@@ -1351,7 +1408,9 @@ defmodule AlgoraWeb.Org.DashboardLive do
13511408
class="relative z-0 inline-block size-6 rounded-full ring-2 ring-background"
13521409
/>
13531410
</div>
1354-
Chat with founders
1411+
<button phx-click="start_chat" class="hover:underline">
1412+
Chat with founders
1413+
</button>
13551414
</div>
13561415
<div class="flex items-center gap-2">
13571416
<.icon name="tabler-brand-x" class="size-6 text-muted-foreground" /> @algoraio
@@ -1363,6 +1422,113 @@ defmodule AlgoraWeb.Org.DashboardLive do
13631422
<.icon name="tabler-mail" class="size-6 text-muted-foreground" /> [email protected]
13641423
</div>
13651424
</div>
1425+
1426+
<%= if @show_chat do %>
1427+
<div class="fixed bottom-0 right-96 w-[400px] h-[500px] flex flex-col border border-border bg-background rounded-t-lg shadow-lg">
1428+
<div class="flex flex-none items-center justify-between border-b border-border bg-card/50 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/60">
1429+
<div class="flex items-center gap-3">
1430+
<div class="flex -space-x-2">
1431+
<.avatar class="relative z-10 h-8 w-8 ring-2 ring-background">
1432+
<.avatar_image src="https://github.com/ioannisflo.png" alt="Ioannis Florokapis" />
1433+
</.avatar>
1434+
<.avatar class="relative z-0 h-8 w-8 ring-2 ring-background">
1435+
<.avatar_image src="https://github.com/zcesur.png" alt="Zafer Cesur" />
1436+
</.avatar>
1437+
</div>
1438+
<div>
1439+
<h2 class="text-lg font-semibold">Algora Founders</h2>
1440+
<p class="text-xs text-muted-foreground">
1441+
Active {Algora.Util.time_ago(@admins_last_active)}
1442+
</p>
1443+
</div>
1444+
</div>
1445+
<.button variant="ghost" size="icon" phx-click="close_chat">
1446+
<.icon name="tabler-x" class="h-4 w-4" />
1447+
</.button>
1448+
</div>
1449+
1450+
<.scroll_area
1451+
class="flex h-full flex-1 flex-col-reverse gap-6 p-4"
1452+
id="messages-container"
1453+
phx-hook="ScrollToBottom"
1454+
>
1455+
<div class="space-y-6">
1456+
<%= for {date, messages} <- @messages
1457+
|> Enum.group_by(fn msg ->
1458+
case Date.diff(Date.utc_today(), DateTime.to_date(msg.inserted_at)) do
1459+
0 -> "Today"
1460+
1 -> "Yesterday"
1461+
n when n <= 7 -> Calendar.strftime(msg.inserted_at, "%A")
1462+
_ -> Calendar.strftime(msg.inserted_at, "%b %d")
1463+
end
1464+
end)
1465+
|> Enum.sort_by(fn {_, msgs} -> hd(msgs).inserted_at end, Date) do %>
1466+
<div class="flex items-center justify-center">
1467+
<div class="rounded-full bg-background px-2 py-1 text-xs text-muted-foreground">
1468+
{date}
1469+
</div>
1470+
</div>
1471+
1472+
<div class="flex flex-col gap-6">
1473+
<%= for message <- Enum.sort_by(messages, & &1.inserted_at, Date) do %>
1474+
<div class="group flex gap-3">
1475+
<.avatar class="h-8 w-8">
1476+
<.avatar_image src={message.sender.avatar_url} />
1477+
<.avatar_fallback>
1478+
{Algora.Util.initials(message.sender.name)}
1479+
</.avatar_fallback>
1480+
</.avatar>
1481+
<div class="max-w-[80%] relative rounded-2xl rounded-tl-none bg-muted p-3">
1482+
{message.content}
1483+
<div class="text-[10px] mt-1 text-muted-foreground">
1484+
{message.inserted_at
1485+
|> DateTime.to_time()
1486+
|> Time.to_string()
1487+
|> String.slice(0..4)}
1488+
</div>
1489+
</div>
1490+
</div>
1491+
<% end %>
1492+
</div>
1493+
<% end %>
1494+
</div>
1495+
</.scroll_area>
1496+
1497+
<div class="mt-auto flex-none border-t border-border bg-card/50 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/60">
1498+
<form phx-submit="send_message" class="flex items-center gap-2">
1499+
<div class="relative flex-1">
1500+
<.input
1501+
id="message-input"
1502+
type="text"
1503+
name="message"
1504+
value=""
1505+
placeholder="Type a message..."
1506+
autocomplete="off"
1507+
class="flex-1 pr-24"
1508+
phx-hook="ClearInput"
1509+
/>
1510+
<div class="absolute top-1/2 right-2 flex -translate-y-1/2 gap-1">
1511+
<.button
1512+
type="button"
1513+
variant="ghost"
1514+
size="icon-sm"
1515+
phx-hook="EmojiPicker"
1516+
id="emoji-trigger"
1517+
>
1518+
<.icon name="tabler-mood-smile" class="h-4 w-4" />
1519+
</.button>
1520+
</div>
1521+
</div>
1522+
<.button type="submit" size="icon">
1523+
<.icon name="tabler-send" class="h-4 w-4" />
1524+
</.button>
1525+
</form>
1526+
<div id="emoji-picker-container" class="bottom-[80px] absolute right-4 hidden">
1527+
<emoji-picker></emoji-picker>
1528+
</div>
1529+
</div>
1530+
</div>
1531+
<% end %>
13661532
</aside>
13671533
"""
13681534
end

test/algora/chat_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ defmodule Algora.ChatTest do
1313
{:ok, message_2} = Chat.send_message(thread.id, user_2.id, "there")
1414
assert thread.id |> Chat.list_messages() |> Enum.map(& &1.id) == [message_1.id, message_2.id]
1515
assert Chat.mark_as_read(thread.id, user_1.id) == {1, nil}
16-
assert Chat.get_thread_for_users(user_1.id, user_2.id).id == thread.id
16+
assert Chat.get_thread_for_users([user_1, user_2]).id == thread.id
1717
assert user_1.id |> Chat.list_threads() |> Enum.map(& &1.id) == [thread.id]
1818
assert user_2.id |> Chat.list_threads() |> Enum.map(& &1.id) == [thread.id]
1919
end

0 commit comments

Comments
 (0)