Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/algora/admin/admin.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
92 changes: 77 additions & 15 deletions lib/algora/chat/chat.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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} =
Expand All @@ -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
Expand All @@ -48,11 +86,24 @@ 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

# TODO: filter by user_id
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)
|> preload(:participants)
|> 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

Expand All @@ -62,18 +113,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.contractor]) do
nil -> create_direct_thread(contract.client, contract.contractor)
thread -> {:ok, thread}
end
Expand All @@ -83,4 +136,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
6 changes: 3 additions & 3 deletions lib/algora_web/components/layouts/user.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
<ul role="list" class="space-y-1">
<%= for contact <- @contacts do %>
<%= for %{thread: thread, contact: contact, path: path} <- @threads do %>
<li>
<.link
class="flex justify-center p-2"
navigate={Algora.Accounts.User.url(contact)}
navigate={path}
title={Algora.Accounts.User.handle(contact)}
>
<div class="relative inline-block">
Expand Down
17 changes: 16 additions & 1 deletion lib/algora_web/live/admin/nav.ex
Original file line number Diff line number Diff line change
@@ -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)}
Expand Down
186 changes: 186 additions & 0 deletions lib/algora_web/live/chat/thread_live.ex
Original file line number Diff line number Diff line change
@@ -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"""
<div class="pr-80">
<div class="flex flex-col h-[calc(100vh-4rem)]">
<div class="flex-none border-b border-border bg-card/50 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex -space-x-2">
<%= 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)}
</.avatar_fallback>
</.avatar>
<% end %>
</div>
<div>
<h2 class="text-lg font-semibold">{@thread.title}</h2>
<p class="text-xs text-muted-foreground">
{@thread.participants
|> Enum.map(& &1.user.name)
|> Algora.Util.format_name_list()}
</p>
</div>
</div>
</div>
</div>

<.scroll_area
class="flex flex-1 flex-col-reverse gap-6 p-4"
id="messages-container"
phx-hook="ScrollToBottom"
>
<div class="space-y-6">
<%= 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 %>
<div class="flex items-center justify-center">
<div class="rounded-full bg-background px-2 py-1 text-xs text-muted-foreground">
{date}
</div>
</div>

<div class="flex flex-col gap-6">
<%= for message <- Enum.sort_by(messages, & &1.inserted_at, Date) do %>
<div class="group flex gap-3">
<.avatar class="h-8 w-8">
<.avatar_image src={message.sender.avatar_url} />
<.avatar_fallback>
{Algora.Util.initials(message.sender.name)}
</.avatar_fallback>
</.avatar>
<div class="max-w-[80%] relative rounded-2xl rounded-tl-none bg-muted p-3">
{message.content}
<div class="text-[10px] mt-1 text-muted-foreground">
{message.inserted_at
|> DateTime.to_time()
|> Time.to_string()
|> String.slice(0..4)}
</div>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
</.scroll_area>

<div class="flex-none bg-card/50 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<form phx-submit="send_message" class="flex items-center gap-2">
<div class="relative flex-1">
<.input
id="message-input"
type="text"
name="message"
value=""
placeholder="Type a message..."
autocomplete="off"
class="flex-1 pr-24"
phx-hook="ClearInput"
/>
<div class="absolute top-1/2 right-2 flex -translate-y-1/2 gap-1">
<.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>
</div>
</div>
<.button type="submit" size="icon">
<.icon name="tabler-send" class="h-4 w-4" />
</.button>
</form>
<div id="emoji-picker-container" class="bottom-[80px] absolute right-4 hidden">
<emoji-picker></emoji-picker>
</div>
</div>
</div>

<aside class="fixed top-[4rem] right-0 z-20 h-full w-72 border-l border-border bg-card/50 p-6 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="space-y-6">
<div>
<h3 class="mb-4 text-sm font-medium">Participants</h3>
<div class="space-y-4">
<%= for participant <- @thread.participants do %>
<div class="flex items-center gap-3">
<.avatar class="h-10 w-10">
<.avatar_image src={participant.user.avatar_url} alt={participant.user.name} />
<.avatar_fallback>
{Algora.Util.initials(participant.user.name)}
</.avatar_fallback>
</.avatar>
<div>
<p class="text-sm font-medium leading-none">{participant.user.name}</p>
<p :if={participant.user.last_active_at} class="text-xs text-muted-foreground">
Active {Algora.Util.time_ago(participant.user.last_active_at)}
</p>
</div>
</div>
<% end %>
</div>
</div>
</div>
</aside>
</div>
"""
end
end
Loading