slax/lib/slax_web/live/chat_room_live.ex

571 lines
17 KiB
Elixir

defmodule SlaxWeb.ChatRoomLive do
@moduledoc false
use SlaxWeb, :live_view
require Logger
import SlaxWeb.RoomComponents
alias Slax.Accounts
alias Slax.Accounts.User
alias Slax.Chat
alias Slax.Chat.Message
alias Slax.Chat.Room
alias SlaxWeb.OnlineUsers
@impl Phoenix.LiveView
def render(assigns) do
~H"""
<div class="flex flex-col flex-shrink-0 w-64 bg-slate-100">
<div class="flex justify-between items-center flex-shrink-0 h-16 border-b border-slate-300 px-4">
<div class="flex flex-col gap-1.5">
<h1 class="text-lg font-bold text-gray-800">Slax</h1>
</div>
</div>
<div clas="mt-4 overflow-auto">
<div class="flex items-center h-8 px-3 group">
<.toggler on_click={toggle_rooms()} dom_id="rooms-toggler" text="Rooms" />
</div>
<div id="rooms-list">
<.room_link :for={room <- @rooms} room={room} active={room.id == @room.id} />
<div class="relative">
<button
class="flex items-center peer h-8 text-sm pl-8 pr-3 hover:bg-slate-300 cursor-pointer w-full"
phx-click={JS.toggle(to: "#sidebar-rooms-menu")}
>
<.icon name="hero-plus" class="h-4 w-4 relative top-px" />
<span class="ml-2 leading-none">Add rooms</span>
</button>
<div
id="sidebar-rooms-menu"
class="hidden cursor-default absolute top-8 right-2 bg-white border-slate-200 border py-3 rounded-lg"
phx-click-away={JS.hide()}
>
<div class="w-full text-left">
<.link
class="block select-none cursor-pointer whitespace-nowrap text-gray-800 hover:text-white px-6 py-1 hover:bg-sky-600"
navigate={~p"/rooms"}
>
Browse rooms
</.link>
<.link
class="block select-none cursor-pointer whitespace-nowrap text-gray-800 hover:text-white px-6 py-1 hover:bg-sky-600"
navigate={~p"/rooms/#{@room}/new"}
>
Create a new room
</.link>
</div>
</div>
</div>
</div>
<div class="mt-4">
<div class="flex items-center h-8 px-3 group">
<div class="flex items-center flex-grow focus:outline-none">
<.toggler on_click={toggle_users()} dom_id="users-toggler" text="Users" />
</div>
</div>
</div>
<div id="users-list">
<.user
:for={user <- @users}
user={user}
online={OnlineUsers.online?(@online_users, user.id)}
/>
</div>
</div>
</div>
<div class="flex flex-col flex-grow shadow-lg">
<div class="flex justify-between items-center flex-shrink-0 h-16 bg-white border-b border-slate-300 px-4">
<div class="flex flex-col gap-1.5">
<h1 class="text-sm font-bold leading-none">
#{@room.name}
<.link
:if={@joined?}
class="font-normal text-xs text-blue-600 hover:text-blue-700"
navigate={~p"/rooms/#{@room}/edit"}
>
Edit
</.link>
</h1>
<div class="text-xs leading-none h-3.5" phx-click="toggle-topic">
<%= if @hide_topic? do %>
<span class="text-slate-600">[Topic hidden]</span>
<% else %>
{@room.topic}
<% end %>
</div>
</div>
<ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
<%= if @current_user do %>
<li class="text-[0.8125rem] leading-6 text-zinc-900">
{username(@current_user)}
</li>
<li>
<.link
href={~p"/users/settings"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Settings
</.link>
</li>
<li>
<.link
href={~p"/users/log_out"}
method="delete"
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Log out
</.link>
</li>
<% else %>
<li>
<.link
href={~p"/users/register"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Register
</.link>
</li>
<li>
<.link
href={~p"/users/log_in"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Log in
</.link>
</li>
<% end %>
</ul>
</div>
<div
id="room-messages"
class="flex flex-col flex-grow overflow-auto"
phx-hook="RoomMessages"
phx-update="stream"
>
<%= for {dom_id, message} <- @streams.messages do %>
<%= if message == :unread_marker do %>
<div id={dom_id} class="w-full flex text-red-500 items-center gap-3 pr-5">
<div class="w-full h-px grow bg-red-500"></div>
<div class="text-sm">New</div>
</div>
<% else %>
<.message
current_user={@current_user}
dom_id={dom_id}
message={message}
timezone={@timezone}
/>
<% end %>
<% end %>
</div>
<div :if={@joined?} class="h-12 bg-white px-4 pb-4">
<.form
id="new-message-form"
for={@new_message_form}
phx-change="validate-message"
phx-submit="submit-message"
class="flex items-center border-2 border-slate-300 rounded-sm p-1"
>
<textarea
class="flex-grow text-sm px-3 border-1 border-slate-300 mx-1 resize-none"
cols=""
id="chat-message-textarea"
name={@new_message_form[:body].name}
placeholder={"Message ##{@room.name}"}
phx-debounce
phx-hook="ChatMessageTextarea"
rows="1"
><%= Phoenix.HTML.Form.normalize_value("textarea", @new_message_form[:body].value) %></textarea>
<button class="flex-shrink flex items-center justify-center h-6 w-6 rounded hover:bg-slate-200">
<.icon name="hero-paper-airplane" class="h-4 w-4" />
</button>
</.form>
</div>
<div
:if={!@joined?}
class="flex justify-around mx-5 mb-5 p-6 bg-slate-100 border-slate-300 border rounded-lg"
>
<div class="max-w-3-xl text-center">
<div class="mb-4">
<h1 class="text-xl font-semibold">{@room.name}</h1>
<p :if={@room.topic} class="text-sm mt-1 text-gray-600">{@room.topic}</p>
</div>
<div class="flex items-center justify-around">
<buttom
phx-click="join-room"
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500"
>
Join Room
</buttom>
</div>
<div class="mt-4">
<.link
navigate={~p"/rooms"}
href="#"
class="text-sm text-slate-500 underline hover:text-slate-600"
>
Back to All Rooms
</.link>
</div>
</div>
</div>
</div>
<.modal
id="new-room-modal"
show={@live_action == :new}
on_cancel={JS.navigate(~p"/rooms/#{@room}")}
>
<.header>New chat room</.header>
<.room_form form={@new_room_form} />
</.modal>
"""
end
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
rooms = Chat.list_joined_rooms_with_unread_count(socket.assigns.current_user)
users = Accounts.list_users()
timezone = get_connect_params(socket)["timezone"]
if connected?(socket) do
OnlineUsers.track(self(), socket.assigns.current_user)
end
OnlineUsers.subscribe()
Enum.each(rooms, &Chat.subscribe_to_room/1)
socket =
socket
|> assign(
hide_topic?: false,
online_users: OnlineUsers.list(),
rooms: rooms,
timezone: timezone,
users: users
)
|> assign_room_form(Chat.change_room(%Room{}))
|> stream_configure(:messages,
dom_id: fn
%Message{id: id} -> "messages-#{id}"
:unread_marker -> "messages-unread-marker"
end
)
{:ok, socket}
end
@impl Phoenix.LiveView
def handle_params(params, _session, socket) do
room =
case Map.fetch(params, "id") do
{:ok, id} ->
Chat.get_room!(id)
:error ->
Chat.get_first_room!()
end
last_read_id = Chat.get_last_read_id(room, socket.assigns.current_user)
messages = room |> Chat.list_messages_in_room() |> maybe_insert_unread_marker(last_read_id)
Chat.update_last_read_id(room, socket.assigns.current_user)
{:noreply,
socket
|> assign(
hide_topic?: false,
joined?: Chat.joined?(room, socket.assigns.current_user),
page_title: "# #{room.name}",
room: room
)
|> stream(:messages, messages, reset: true)
|> assign_message_form(Chat.change_message(%Message{}))
|> push_event("scroll_messages_to_bottom", %{})
|> update(:rooms, fn rooms ->
room_id = room.id
Enum.map(rooms, fn
%Room{id: ^room_id} = room ->
%Room{room | unread_message_count: 0}
other_room ->
other_room
end)
end)}
end
@impl Phoenix.LiveView
def handle_event("toggle-topic", _params, socket) do
{:noreply, update(socket, :hide_topic?, &(!&1))}
end
@impl Phoenix.LiveView
def handle_event("validate-message", %{"message" => message_params}, socket) do
changeset = Chat.change_message(%Message{}, message_params)
{:noreply, assign_message_form(socket, changeset)}
end
@impl Phoenix.LiveView
def handle_event("validate-room", %{"room" => room_params}, socket) do
changeset =
socket.assigns.room
|> Chat.change_room(room_params)
|> Map.put(:action, :validate)
{:noreply, assign_room_form(socket, changeset)}
end
@impl Phoenix.LiveView
def handle_event("submit-message", %{"message" => message_params}, socket) do
%{current_user: user, room: room} = socket.assigns
socket =
if Chat.joined?(room, user) do
case Chat.create_message(room, user, message_params) do
{:ok, _message} ->
assign_message_form(socket, Chat.change_message(%Message{}))
{:error, changeset} ->
assign_message_form(socket, changeset)
end
else
socket
end
{:noreply, socket}
end
@impl Phoenix.LiveView
def handle_event("delete-message", %{"id" => message_id}, socket) do
Chat.delete_message_by_id(message_id, socket.assigns.current_user)
{:noreply, socket}
end
@impl Phoenix.LiveView
def handle_event("join-room", _, socket) do
current_user = socket.assigns.current_user
room = socket.assigns.room
Chat.join_room!(room, current_user)
Chat.subscribe_to_room(room)
socket =
assign(socket, joined?: true, rooms: Chat.list_joined_rooms_with_unread_count(current_user))
{:noreply, socket}
end
@impl Phoenix.LiveView
def handle_event("save-room", %{"room" => room_params}, socket) do
case Chat.create_room(room_params) do
{:ok, room} ->
Chat.join_room!(room, socket.assigns.current_user)
{:noreply,
socket
|> put_flash(:info, "Created room")
|> push_navigate(to: ~p"/rooms/#{room}")}
{:error, changeset} ->
{:noreply, assign_room_form(socket, changeset)}
end
end
@impl Phoenix.LiveView
def handle_info({:new_message, message}, socket) do
room = socket.assigns.room
socket =
cond do
message.room_id == room.id ->
Chat.update_last_read_id(room, socket.assigns.current_user)
socket
|> stream_insert(:messages, message)
|> push_event("scroll_messages_to_bottom", %{})
message.user_id != socket.assigns.current_user.id ->
update(socket, :rooms, fn rooms ->
room_id = message.room_id
Enum.map(rooms, fn
%Room{id: ^room_id} = room ->
%Room{room | unread_message_count: room.unread_message_count + 1}
other_room ->
other_room
end)
end)
true ->
socket
end
{:noreply, socket}
end
@impl Phoenix.LiveView
def handle_info({:deleted_message, message}, socket) do
{:noreply, stream_delete(socket, :messages, message)}
end
@impl Phoenix.LiveView
def handle_info(%{event: "presence_diff", payload: diff}, socket) do
online_users = OnlineUsers.update(socket.assigns.online_users, diff)
{:noreply, assign(socket, online_users: online_users)}
end
defp assign_room_form(socket, changeset) do
assign(socket, :new_room_form, to_form(changeset))
end
attr :active, :boolean, required: true
attr :room, Room, required: true
@spec room_link(Phoenix.LiveView.Socket.assigns()) :: Phoenix.LiveView.Rendered.t()
defp room_link(assigns) do
~H"""
<.link
class={[
"flex items-center h-8 text-sm pl-8 pr-3",
(@active && "bg-slate-300") || "hover:bg-slate-300"
]}
patch={~p"/rooms/#{@room}"}
>
<.icon name="hero-hashtag" class="h-4 w-4" />
<span class={["ml-2 leading-none", @active && "font-bold"]}>
{@room.name}
</span>
<.unread_message_count count={@room.unread_message_count} />
</.link>
"""
end
attr :count, :integer, required: true
defp unread_message_count(assigns) do
~H"""
<span
:if={@count > 0}
class="flex items-center justifiy-center bg-blue-500 rounded-full font-medium h-5 px-2 ml-auto text-xs text-white"
>
{@count}
</span>
"""
end
attr :user, User, required: true
attr :online, :boolean, default: false
defp user(assigns) do
~H"""
<.link class="flex items-center h-8 hover:bg-gray-300 text-sm pl-8 pr-3" href="#">
<div class="flex justify-center w-4">
<%= if @online do %>
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
<% else %>
<span class="w-2 h-2 rounded-full border-2 border-gray-500"></span>
<% end %>
</div>
<span class="ml-2 leading-none">{username(@user)}</span>
</.link>
"""
end
attr :dom_id, :string, required: true
attr :on_click, JS, required: true
attr :text, :string, required: true
defp toggler(assigns) do
~H"""
<button id={@dom_id} phx-click={@on_click} class="flex items-center flex-grow focus:outline-none">
<.icon id={@dom_id <> "-chevron-down"} name="hero-chevron-down" class="h-4 w-4" />
<.icon
id={@dom_id <> "-chevron-right"}
name="hero-chevron-right"
class="h-4 w-4"
style="display: none;"
/>
<span class="ml-2 leading-none font-medium text-sm">{@text}</span>
</button>
"""
end
defp toggle_rooms do
JS.toggle(to: "#rooms-toggler-chevron-down")
|> JS.toggle(to: "#rooms-toggler-chevron-right")
|> JS.toggle(to: "#rooms-list")
end
defp toggle_users do
JS.toggle(to: "#users-toggler-chevron-down")
|> JS.toggle(to: "#users-toggler-chevron-right")
|> JS.toggle(to: "#users-list")
end
attr :current_user, User, required: true
attr :dom_id, :string, required: true
attr :message, Message, required: true
attr :timezone, :string, required: true
defp message(assigns) do
~H"""
<div id={@dom_id} class="group relative flex px-4 py-3">
<button
:if={@current_user.id == @message.user_id}
class="absolute top-4 right-4 text-red-500 hover:text-red-800 cursor-pointer hidden group-hover:block"
data-confirm="Are you sure?"
phx-click="delete-message"
phx-value-id={@message.id}
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
<img class="h-10 w-10 rounded flex-shrink-0" src={~p"/images/one_ring.jpg"} />
<div class="ml-2">
<div class="-mt-1">
<.link class="text-sm font-semibold hover:underline">
<span>{username(@message.user)}</span>
</.link>
<span :if={@timezone} class="ml-1 text-xs text-gray-500">
{message_timestamp(@message, @timezone)}
</span>
<p class="text-sm">{@message.body}</p>
</div>
</div>
</div>
"""
end
defp username(user) do
user.email |> String.split("@") |> List.first() |> String.capitalize()
end
defp message_timestamp(message, timezone) do
message.inserted_at
|> Timex.Timezone.convert(timezone)
|> Timex.format!("%-l:%M %p", :strftime)
end
defp assign_message_form(socket, changeset) do
assign(socket, :new_message_form, to_form(changeset))
end
defp maybe_insert_unread_marker(messages, nil) do
messages
end
defp maybe_insert_unread_marker(messages, last_read_id) do
{read, unread} = Enum.split_while(messages, &(&1.id <= last_read_id))
if unread == [] do
read
else
read ++ [:unread_marker | unread]
end
end
end