diff --git a/assets/js/app.ts b/assets/js/app.ts
index 5deaf40..feed07e 100644
--- a/assets/js/app.ts
+++ b/assets/js/app.ts
@@ -1,7 +1,6 @@
 import "phoenix_html";
 import { Socket } from "phoenix";
 import { LiveSocket, type ViewHook } from "phoenix_live_view";
-import Chat from "./user_socket";
 import topbar from "../vendor/topbar";
 import videojs from "../vendor/video";
 import "../vendor/videojs-youtube";
@@ -10,6 +9,27 @@ import "../vendor/videojs-youtube";
 // TODO: enable strict mode
 // TODO: eliminate anys
 
+interface PhxEvent extends Event {
+  target: Element;
+  detail: Record<string, any>;
+}
+
+type PhxEventKey = `js:${string}` | `phx:${string}`;
+
+declare global {
+  interface Window {
+    liveSocket: LiveSocket;
+    addEventListener<K extends keyof WindowEventMap | PhxEventKey>(
+      type: K,
+      listener: (
+        this: Window,
+        ev: K extends keyof WindowEventMap ? WindowEventMap[K] : PhxEvent
+      ) => any,
+      options?: boolean | AddEventListenerOptions | undefined
+    ): void;
+  }
+}
+
 let isVisible = (el) =>
   !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0);
 
@@ -163,10 +183,20 @@ const Hooks = {
       };
 
       this.handleEvent("play_video", playVideo);
-      this.handleEvent("join_chat", Chat.join);
-      this.handleEvent("message_deleted", ({ id }) => {
-        document.querySelector(`#message-${id}`)?.remove();
-      });
+    },
+  },
+  Chat: {
+    mounted() {
+      this.el.scrollTo(0, this.el.scrollHeight);
+    },
+
+    updated() {
+      const pixelsBelowBottom =
+        this.el.scrollHeight - this.el.clientHeight - this.el.scrollTop;
+
+      if (pixelsBelowBottom < 200) {
+        this.el.scrollTo(0, this.el.scrollHeight);
+      }
     },
   },
   NavBar: {
diff --git a/assets/js/global.d.ts b/assets/js/global.d.ts
deleted file mode 100644
index 550114b..0000000
--- a/assets/js/global.d.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { type Channel } from "phoenix";
-import { type LiveSocket } from "phoenix_live_view";
-
-interface PhxEvent extends Event {
-  target: Element;
-  detail: Record<string, any>;
-}
-
-type PhxEventKey = `js:${string}` | `phx:${string}`;
-
-declare global {
-  interface Window {
-    liveSocket: LiveSocket;
-    userToken?: string;
-    channel?: Channel;
-    addEventListener<K extends keyof WindowEventMap | PhxEventKey>(
-      type: K,
-      listener: (
-        this: Window,
-        ev: K extends keyof WindowEventMap ? WindowEventMap[K] : PhxEvent
-      ) => any,
-      options?: boolean | AddEventListenerOptions | undefined
-    ): void;
-  }
-}
diff --git a/assets/js/user_socket.ts b/assets/js/user_socket.ts
deleted file mode 100644
index b07f9e0..0000000
--- a/assets/js/user_socket.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { type Channel, Socket } from "phoenix";
-
-const systemUser = (sender) => sender === "algora";
-
-const init = () => {
-  let socket = new Socket("/socket", { params: { token: window.userToken } });
-  socket.connect();
-
-  let channel: Channel;
-  let chatInput;
-  let chatMessages;
-  let handleSend;
-
-  const leave = (channel: Channel) => {
-    channel.leave();
-    if (chatInput) {
-      chatInput.value = "";
-      chatInput.removeEventListener("keypress", handleSend);
-    }
-  };
-
-  const join = ({ id }) => {
-    if (channel) {
-      leave(channel);
-    }
-
-    channel = socket.channel(`room:${id}`, {});
-    chatInput = document.querySelector("#chat-input");
-    chatMessages = document.querySelector("#chat-messages");
-    chatMessages.scrollTop = chatMessages.scrollHeight;
-
-    handleSend = (event) => {
-      if (event.key === "Enter" && chatInput.value.trim()) {
-        channel.push("new_msg", { body: chatInput.value });
-        chatInput.value = "";
-      }
-    };
-
-    if (chatInput) {
-      chatInput.addEventListener("keypress", handleSend);
-    }
-
-    channel.on("new_msg", (payload) => {
-      const messageItem = document.createElement("div");
-      messageItem.id = `message-${payload.id}`;
-      messageItem.className = "group hover:bg-white/5 relative px-4";
-
-      const senderItem = document.createElement("span");
-      senderItem.innerText = `${payload.user.handle}: `;
-      senderItem.className = `font-semibold ${
-        systemUser(payload.user.handle) ? "text-emerald-400" : "text-indigo-400"
-      }`;
-
-      const bodyItem = document.createElement("span");
-      bodyItem.innerText = `${payload.body}`;
-      bodyItem.className = "font-medium text-gray-100";
-
-      messageItem.appendChild(senderItem);
-      messageItem.appendChild(bodyItem);
-
-      chatMessages.appendChild(messageItem);
-      chatMessages.scrollTop = chatMessages.scrollHeight;
-    });
-
-    channel
-      .join()
-      .receive("ok", (resp) => {
-        console.log("Joined successfully", resp);
-        window.channel = channel;
-      })
-      .receive("error", (resp) => {
-        console.log("Unable to join", resp);
-      });
-  };
-
-  return { join };
-};
-
-const Chat = init();
-
-export default Chat;
diff --git a/lib/algora/chat.ex b/lib/algora/chat.ex
index c760d6b..7bf493d 100644
--- a/lib/algora/chat.ex
+++ b/lib/algora/chat.ex
@@ -8,7 +8,13 @@ defmodule Algora.Chat do
   alias Algora.Accounts.User
   alias Algora.{Repo, Accounts}
 
-  alias Algora.Chat.Message
+  alias Algora.Chat.{Message, Events}
+
+  @pubsub Algora.PubSub
+
+  def subscribe_to_room(%Video{} = video) do
+    Phoenix.PubSub.subscribe(@pubsub, topic(video.id))
+  end
 
   def list_messages(%Video{} = video) do
     # TODO: add limit
@@ -48,9 +54,11 @@ defmodule Algora.Chat do
     |> Repo.one!()
   end
 
-  def create_message(attrs \\ %{}) do
+  def create_message(%User{} = user, %Video{} = video, attrs \\ %{}) do
     %Message{}
     |> Message.changeset(attrs)
+    |> Message.put_user(user)
+    |> Message.put_video(video)
     |> Repo.insert()
   end
 
@@ -67,4 +75,18 @@ defmodule Algora.Chat do
   def change_message(%Message{} = message, attrs \\ %{}) do
     Message.changeset(message, attrs)
   end
+
+  defp topic(video_id) when is_integer(video_id), do: "room:#{video_id}"
+
+  defp broadcast!(topic, msg) do
+    Phoenix.PubSub.broadcast!(@pubsub, topic, {__MODULE__, msg})
+  end
+
+  def broadcast_message_deleted!(message) do
+    broadcast!(topic(message.video_id), %Events.MessageDeleted{message: message})
+  end
+
+  def broadcast_message_sent!(message) do
+    broadcast!(topic(message.video_id), %Events.MessageSent{message: message})
+  end
 end
diff --git a/lib/algora/chat/events.ex b/lib/algora/chat/events.ex
new file mode 100644
index 0000000..827f9e3
--- /dev/null
+++ b/lib/algora/chat/events.ex
@@ -0,0 +1,9 @@
+defmodule Algora.Chat.Events do
+  defmodule MessageSent do
+    defstruct message: nil
+  end
+
+  defmodule MessageDeleted do
+    defstruct message: nil
+  end
+end
diff --git a/lib/algora/chat/message.ex b/lib/algora/chat/message.ex
index b65f3ad..597b30e 100644
--- a/lib/algora/chat/message.ex
+++ b/lib/algora/chat/message.ex
@@ -1,15 +1,15 @@
 defmodule Algora.Chat.Message do
   use Ecto.Schema
-  alias Algora.Accounts
-  alias Algora.Library
+  alias Algora.Accounts.User
+  alias Algora.Library.Video
   import Ecto.Changeset
 
   schema "messages" do
     field :body, :string
     field :sender_handle, :string, virtual: true
     field :channel_id, :integer, virtual: true
-    belongs_to :user, Accounts.User
-    belongs_to :video, Library.Video
+    belongs_to :user, User
+    belongs_to :video, Video
 
     timestamps()
   end
@@ -20,4 +20,12 @@ defmodule Algora.Chat.Message do
     |> cast(attrs, [:body])
     |> validate_required([:body])
   end
+
+  def put_user(%Ecto.Changeset{} = changeset, %User{} = user) do
+    put_assoc(changeset, :user, user)
+  end
+
+  def put_video(%Ecto.Changeset{} = changeset, %Video{} = video) do
+    put_assoc(changeset, :video, video)
+  end
 end
diff --git a/lib/algora/library.ex b/lib/algora/library.ex
index dcbb548..0899a19 100644
--- a/lib/algora/library.ex
+++ b/lib/algora/library.ex
@@ -640,10 +640,6 @@ defmodule Algora.Library do
     Phoenix.PubSub.broadcast!(@pubsub, topic, {__MODULE__, msg})
   end
 
-  def broadcast_message_deleted!(%Channel{} = channel, message) do
-    broadcast!(topic(channel.user_id), %Events.MessageDeleted{message: message})
-  end
-
   def broadcast_processing_progressed!(stage, video, pct) do
     broadcast!(topic_studio(), %Events.ProcessingProgressed{video: video, stage: stage, pct: pct})
   end
diff --git a/lib/algora/library/events.ex b/lib/algora/library/events.ex
index 6d8f639..bf3b82f 100644
--- a/lib/algora/library/events.ex
+++ b/lib/algora/library/events.ex
@@ -26,8 +26,4 @@ defmodule Algora.Library.Events do
   defmodule ProcessingFailed do
     defstruct video: nil, attempt: nil, max_attempts: nil
   end
-
-  defmodule MessageDeleted do
-    defstruct message: nil
-  end
 end
diff --git a/lib/algora_web/channels/room_channel.ex b/lib/algora_web/channels/room_channel.ex
deleted file mode 100644
index 0ca13e6..0000000
--- a/lib/algora_web/channels/room_channel.ex
+++ /dev/null
@@ -1,32 +0,0 @@
-defmodule AlgoraWeb.RoomChannel do
-  alias Algora.Chat.Message
-  alias Algora.Repo
-
-  use Phoenix.Channel
-
-  def join("room:" <> _room_id, _params, socket) do
-    {:ok, socket}
-  end
-
-  def handle_in("new_msg", %{"body" => body}, socket) do
-    user = socket.assigns.user
-    "room:" <> video_id = socket.topic
-
-    if user do
-      message =
-        Repo.insert!(%Message{
-          body: body,
-          user_id: user.id,
-          video_id: String.to_integer(video_id)
-        })
-
-      broadcast!(socket, "new_msg", %{
-        user: %{id: user.id, handle: user.handle},
-        id: message.id,
-        body: body
-      })
-    end
-
-    {:noreply, socket}
-  end
-end
diff --git a/lib/algora_web/channels/user_socket.ex b/lib/algora_web/channels/user_socket.ex
deleted file mode 100644
index 9534a7e..0000000
--- a/lib/algora_web/channels/user_socket.ex
+++ /dev/null
@@ -1,35 +0,0 @@
-defmodule AlgoraWeb.UserSocket do
-  use Phoenix.Socket
-  alias Algora.Accounts
-
-  channel "room:*", AlgoraWeb.RoomChannel
-
-  @impl true
-  def connect(%{"token" => token}, socket, _connect_info) do
-    # max_age: 1209600 is equivalent to two weeks in seconds
-    case Phoenix.Token.verify(socket, "user socket", token, max_age: 1_209_600) do
-      {:ok, 0} ->
-        {:ok, socket}
-
-      {:ok, user_id} ->
-        user = Accounts.get_user(user_id)
-        {:ok, assign(socket, :user, user)}
-
-      {:error, _} = error ->
-        error
-    end
-  end
-
-  # Socket id's are topics that allow you to identify all sockets for a given user:
-  #
-  #     def id(socket), do: "user_socket:#{socket.assigns.user_id}"
-  #
-  # Would allow you to broadcast a "disconnect" event and terminate
-  # all active sockets and channels for a given user:
-  #
-  #     Elixir.AlgoraWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
-  #
-  # Returning `nil` makes this socket anonymous.
-  @impl true
-  def id(_socket), do: nil
-end
diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex
index b1a031d..22c89a7 100644
--- a/lib/algora_web/components/core_components.ex
+++ b/lib/algora_web/components/core_components.ex
@@ -810,7 +810,8 @@ defmodule AlgoraWeb.CoreComponents do
           "text-gray-50 focus:outline-none focus:ring-4 sm:text-sm sm:leading-6",
           "phx-no-feedback:border-gray-600 phx-no-feedback:focus:border-gray-500 phx-no-feedback:focus:ring-gray-100/5",
           "border-gray-600 focus:border-gray-500 focus:ring-gray-100/5",
-          @errors != [] && "border-red-500 focus:border-red-500 focus:ring-red-500/10"
+          @errors != [] &&
+            "border-red-500 focus:border-red-500 focus:ring-red-500/10 placeholder-red-300"
         ]}
         {@rest}
       />
diff --git a/lib/algora_web/components/layouts/root.html.heex b/lib/algora_web/components/layouts/root.html.heex
index 5a0199d..c06bd76 100644
--- a/lib/algora_web/components/layouts/root.html.heex
+++ b/lib/algora_web/components/layouts/root.html.heex
@@ -57,9 +57,6 @@
 
     <link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
     <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
-    <script>
-      window.userToken = "<%= assigns[:user_token] %>";
-    </script>
     <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
     </script>
     <script defer data-domain="tv.algora.io" src="https://plausible.io/js/script.js">
diff --git a/lib/algora_web/components/layouts/root_embed.html.heex b/lib/algora_web/components/layouts/root_embed.html.heex
index 1bd2837..e63bccf 100644
--- a/lib/algora_web/components/layouts/root_embed.html.heex
+++ b/lib/algora_web/components/layouts/root_embed.html.heex
@@ -40,9 +40,6 @@
 
     <link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
     <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
-    <script>
-      window.userToken = "<%= assigns[:user_token] %>";
-    </script>
     <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
     </script>
     <script defer data-domain="tv.algora.io" src="https://plausible.io/js/script.js">
diff --git a/lib/algora_web/controllers/user_auth.ex b/lib/algora_web/controllers/user_auth.ex
index 18a41c7..93b6dc2 100644
--- a/lib/algora_web/controllers/user_auth.ex
+++ b/lib/algora_web/controllers/user_auth.ex
@@ -91,11 +91,7 @@ defmodule AlgoraWeb.UserAuth do
   def fetch_current_user(conn, _opts) do
     user_id = get_session(conn, :user_id)
     user = user_id && Accounts.get_user(user_id)
-    token = Phoenix.Token.sign(conn, "user socket", user_id || 0)
-
-    conn
-    |> assign(:current_user, user)
-    |> assign(:user_token, token)
+    assign(conn, :current_user, user)
   end
 
   @doc """
diff --git a/lib/algora_web/embed/endpoint.ex b/lib/algora_web/embed/endpoint.ex
index 5478dc8..fa76d3c 100644
--- a/lib/algora_web/embed/endpoint.ex
+++ b/lib/algora_web/embed/endpoint.ex
@@ -1,10 +1,6 @@
 defmodule AlgoraWeb.Embed.Endpoint do
   use Phoenix.Endpoint, otp_app: :algora
 
-  socket "/socket", AlgoraWeb.UserSocket,
-    websocket: true,
-    longpoll: false
-
   socket "/live", Phoenix.LiveView.Socket
 
   # Serve at "/" the static files from "priv/static" directory.
diff --git a/lib/algora_web/endpoint.ex b/lib/algora_web/endpoint.ex
index e5b4e31..a597aeb 100644
--- a/lib/algora_web/endpoint.ex
+++ b/lib/algora_web/endpoint.ex
@@ -1,10 +1,6 @@
 defmodule AlgoraWeb.Endpoint do
   use Phoenix.Endpoint, otp_app: :algora
 
-  socket "/socket", AlgoraWeb.UserSocket,
-    websocket: true,
-    longpoll: false
-
   # The session will be stored in the cookie and signed,
   # this means its contents can be read but not tampered with.
   # Set :encryption_salt if you would also like to encrypt it.
diff --git a/lib/algora_web/live/chat_live.ex b/lib/algora_web/live/chat_live.ex
index be6db37..3569b30 100644
--- a/lib/algora_web/live/chat_live.ex
+++ b/lib/algora_web/live/chat_live.ex
@@ -20,10 +20,11 @@ defmodule AlgoraWeb.ChatLive do
             <div>
               <div
                 id="chat-messages"
-                phx-update="ignore"
+                phx-hook="Chat"
+                phx-update="stream"
                 class="text-sm break-words flex-1 m-1 scrollbar-thin overflow-y-auto inset-0 h-[400px] w-[400px] fixed overflow-hidden py-4 rounded ring-1 ring-purple-300"
               >
-                <div :for={message <- @messages} id={"message-#{message.id}"} class="px-4">
+                <div :for={{id, message} <- @streams.messages} id={id} class="px-4">
                   <span class={"font-semibold #{if(system_message?(message), do: "text-emerald-400", else: "text-indigo-400")}"}>
                     <%= message.sender_handle %>:
                   </span>
@@ -45,17 +46,18 @@ defmodule AlgoraWeb.ChatLive do
       Accounts.get_user_by!(handle: channel_handle)
       |> Library.get_channel!()
 
+    video = Library.get_video!(video_id)
+
     if connected?(socket) do
       Library.subscribe_to_livestreams()
       Library.subscribe_to_channel(channel)
+      Chat.subscribe_to_room(video)
 
       Presence.subscribe(channel_handle)
     end
 
     videos = Library.list_channel_videos(channel, 50)
 
-    video = Library.get_video!(video_id)
-
     subtitles = Library.list_subtitles(%Library.Video{id: video_id})
 
     data = %{}
@@ -78,11 +80,11 @@ defmodule AlgoraWeb.ChatLive do
         channel: channel,
         videos_count: Enum.count(videos),
         video: video,
-        subtitles: subtitles,
-        messages: Chat.list_messages(video)
+        subtitles: subtitles
       )
       |> assign_form(changeset)
       |> stream(:videos, videos)
+      |> stream(:messages, Chat.list_messages(video))
       |> stream(:presences, Presence.list_online_users(channel_handle))
 
     if connected?(socket), do: send(self(), {:play, video})
@@ -158,10 +160,14 @@ defmodule AlgoraWeb.ChatLive do
   end
 
   def handle_info(
-        {Library, %Library.Events.MessageDeleted{message: message}},
+        {Chat, %Chat.Events.MessageDeleted{message: message}},
         socket
       ) do
-    {:noreply, socket |> push_event("message_deleted", %{id: message.id})}
+    {:noreply, socket |> stream_delete(:messages, message)}
+  end
+
+  def handle_info({Chat, %Chat.Events.MessageSent{message: message}}, socket) do
+    {:noreply, socket |> stream_insert(:messages, message)}
   end
 
   def handle_info({Library, _}, socket), do: {:noreply, socket}
diff --git a/lib/algora_web/live/video_live.ex b/lib/algora_web/live/video_live.ex
index 7b53b22..9a02824 100644
--- a/lib/algora_web/live/video_live.ex
+++ b/lib/algora_web/live/video_live.ex
@@ -6,6 +6,7 @@ defmodule AlgoraWeb.VideoLive do
   alias AlgoraWeb.{LayoutComponent, Presence}
   alias AlgoraWeb.ChannelLive.{StreamFormComponent}
 
+  @impl true
   def render(assigns) do
     ~H"""
     <div class="lg:mr-[24rem]">
@@ -215,7 +216,7 @@ defmodule AlgoraWeb.VideoLive do
                   <div class={[
                     "overflow-y-auto text-sm break-words flex-1 scrollbar-thin",
                     if(@can_edit,
-                      do: "h-[calc(100vh-11rem)]",
+                      do: "h-[calc(100vh-12rem)]",
                       else: "h-[calc(100vh-8.75rem)]"
                     )
                   ]}>
@@ -241,13 +242,13 @@ defmodule AlgoraWeb.VideoLive do
                 <.simple_form
                   :if={@can_edit}
                   id="edit-transcript"
-                  for={@form}
+                  for={@transcript_form}
                   phx-submit="save"
                   phx-update="ignore"
                   class="hidden h-full px-4"
                 >
                   <.input
-                    field={@form[:subtitles]}
+                    field={@transcript_form[:subtitles]}
                     type="textarea"
                     label="Edit transcript"
                     class="font-mono h-[calc(100vh-14.75rem)]"
@@ -274,12 +275,13 @@ defmodule AlgoraWeb.VideoLive do
               <div :if={tab == :chat}>
                 <div
                   id="chat-messages"
-                  phx-update="ignore"
-                  class="text-sm break-words flex-1 scrollbar-thin overflow-y-auto h-[calc(100vh-11rem)]"
+                  phx-hook="Chat"
+                  phx-update="stream"
+                  class="text-sm break-words flex-1 scrollbar-thin overflow-y-auto h-[calc(100vh-12rem)]"
                 >
                   <div
-                    :for={message <- @messages}
-                    id={"message-#{message.id}"}
+                    :for={{id, message} <- @streams.messages}
+                    id={id}
                     class="group hover:bg-white/5 relative px-4"
                   >
                     <span class={"font-semibold #{if(system_message?(message), do: "text-emerald-400", else: "text-indigo-400")}"}>
@@ -301,13 +303,14 @@ defmodule AlgoraWeb.VideoLive do
                   </div>
                 </div>
                 <div class="px-4">
-                  <input
+                  <.simple_form
                     :if={@current_user}
-                    id="chat-input"
-                    placeholder="Send a message"
-                    disabled={@current_user == nil}
-                    class="mt-2 bg-gray-950 h-[30px] text-white focus:outline-none focus:ring-purple-400 block w-full min-w-0 rounded-md sm:text-sm ring-1 ring-gray-600 px-2"
-                  />
+                    for={@chat_form}
+                    phx-submit="send"
+                    phx-change="validate"
+                  >
+                    <.input field={@chat_form[:body]} placeholder="Send a message" autocomplete="off" />
+                  </.simple_form>
                   <a
                     :if={!@current_user}
                     href={Algora.Github.authorize_url()}
@@ -330,6 +333,7 @@ defmodule AlgoraWeb.VideoLive do
     """
   end
 
+  @impl true
   def mount(
         %{"channel_handle" => channel_handle, "video_id" => video_id} = params,
         _session,
@@ -341,9 +345,12 @@ defmodule AlgoraWeb.VideoLive do
       Accounts.get_user_by!(handle: channel_handle)
       |> Library.get_channel!()
 
+    video = Library.get_video!(video_id)
+
     if connected?(socket) do
       Library.subscribe_to_livestreams()
       Library.subscribe_to_channel(channel)
+      Chat.subscribe_to_room(video)
 
       Presence.track_user(channel_handle, %{
         id: if(current_user, do: current_user.handle, else: "")
@@ -354,8 +361,6 @@ defmodule AlgoraWeb.VideoLive do
 
     videos = Library.list_channel_videos(channel, 50)
 
-    video = Library.get_video!(video_id)
-
     subtitles = Library.list_subtitles(%Library.Video{id: video_id})
 
     data = %{}
@@ -367,7 +372,7 @@ defmodule AlgoraWeb.VideoLive do
 
     types = %{subtitles: :string}
 
-    changeset =
+    transcript_changeset =
       {data, types}
       |> Ecto.Changeset.cast(%{subtitles: encoded_subtitles}, Map.keys(types))
 
@@ -381,14 +386,15 @@ defmodule AlgoraWeb.VideoLive do
         videos_count: Enum.count(videos),
         video: video,
         subtitles: subtitles,
-        messages: Chat.list_messages(video),
         tabs: tabs,
         # TODO: reenable once fully implemented
         # associated segments need to be removed from db & vectorstore
-        can_edit: false
+        can_edit: false,
+        transcript_form: to_form(transcript_changeset, as: :data),
+        chat_form: to_form(Chat.change_message(%Chat.Message{}))
       )
-      |> assign_form(changeset)
       |> stream(:videos, videos)
+      |> stream(:messages, Chat.list_messages(video))
       |> stream(:presences, Presence.list_online_users(channel_handle))
 
     if connected?(socket), do: send(self(), {:play, {video, params["t"]}})
@@ -396,11 +402,13 @@ defmodule AlgoraWeb.VideoLive do
     {:ok, socket}
   end
 
+  @impl true
   def handle_params(params, _url, socket) do
     LayoutComponent.hide_modal()
     {:noreply, socket |> apply_action(socket.assigns.live_action, params)}
   end
 
+  @impl true
   def handle_info({:play, {video, t}}, socket) do
     socket =
       socket
@@ -476,10 +484,14 @@ defmodule AlgoraWeb.VideoLive do
   end
 
   def handle_info(
-        {Library, %Library.Events.MessageDeleted{message: message}},
+        {Chat, %Chat.Events.MessageDeleted{message: message}},
         socket
       ) do
-    {:noreply, socket |> push_event("message_deleted", %{id: message.id})}
+    {:noreply, socket |> stream_delete(:messages, message)}
+  end
+
+  def handle_info({Chat, %Chat.Events.MessageSent{message: message}}, socket) do
+    {:noreply, socket |> stream_insert(:messages, message)}
   end
 
   def handle_info({Library, _}, socket), do: {:noreply, socket}
@@ -496,6 +508,36 @@ defmodule AlgoraWeb.VideoLive do
     end
   end
 
+  @impl true
+  def handle_event("validate", %{"message" => %{"body" => ""}}, socket), do: {:noreply, socket}
+
+  def handle_event("validate", %{"message" => params}, socket) do
+    form =
+      %Chat.Message{}
+      |> Chat.change_message(params)
+      |> Map.put(:action, :insert)
+      |> to_form()
+
+    {:noreply, assign(socket, chat_form: form)}
+  end
+
+  def handle_event("send", %{"message" => %{"body" => ""}}, socket), do: {:noreply, socket}
+
+  def handle_event("send", %{"message" => params}, socket) do
+    %{current_user: current_user, video: video} = socket.assigns
+
+    case Chat.create_message(current_user, video, params) do
+      {:ok, message} ->
+        # HACK:
+        message = Chat.get_message!(message.id)
+        Chat.broadcast_message_sent!(message)
+        {:noreply, assign(socket, chat_form: to_form(Chat.change_message(%Chat.Message{})))}
+
+      {:error, %Ecto.Changeset{} = changeset} ->
+        {:noreply, assign(socket, chat_form: to_form(changeset))}
+    end
+  end
+
   def handle_event("save", %{"data" => %{"subtitles" => subtitles}, "save" => save_type}, socket) do
     {time, count} = :timer.tc(&save/2, [save_type, subtitles])
     msg = "Updated #{count} subtitles in #{fmt(round(time / 1000))} ms"
@@ -509,7 +551,7 @@ defmodule AlgoraWeb.VideoLive do
 
     if current_user && Chat.can_delete?(current_user, message) do
       {:ok, message} = Chat.delete_message(message)
-      Library.broadcast_message_deleted!(socket.assigns.channel, message)
+      Chat.broadcast_message_deleted!(message)
       {:noreply, socket}
     else
       {:noreply, socket |> put_flash(:error, "You can't do that")}
@@ -546,10 +588,6 @@ defmodule AlgoraWeb.VideoLive do
     if cond, do: list ++ [extra], else: list
   end
 
-  defp assign_form(socket, %Ecto.Changeset{} = changeset) do
-    assign(socket, :form, to_form(changeset, as: :data))
-  end
-
   defp apply_action(socket, :stream, _params) do
     if socket.assigns.owns_channel? do
       socket