Improve chat and date-time editing

This commit is contained in:
SimpleTest 2026-07-22 05:13:04 +03:00
parent e5e6b501ea
commit 0880b25e73
11 changed files with 508 additions and 30 deletions

View File

@ -204,6 +204,198 @@ export const mountStaticAidMaps = root => {
} }
export const Hooks = { export const Hooks = {
DateTimePicker: {
mounted() {
this.hiddenInput = this.el.querySelector("[data-datetime-value]")
this.dateInput = this.el.querySelector("[data-datetime-date]")
this.timeInput = this.el.querySelector("[data-datetime-time]")
this.timezoneLabel = this.el.querySelector("[data-datetime-timezone]")
this.quickButtons = [...this.el.querySelectorAll("[data-datetime-offset]")]
const pad = value => String(value).padStart(2, "0")
const localParts = date => ({
date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
time: `${pad(date.getHours())}:${pad(date.getMinutes())}`
})
const utcValue = date =>
`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` +
`T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`
this.parseUtc = value => {
const match = String(value || "").match(
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?/
)
if (!match) return null
const [, year, month, day, hour, minute] = match
const date = new Date(Date.UTC(year, Number(month) - 1, day, hour, minute))
return Number.isNaN(date.getTime()) ? null : date
}
this.applyLocalDate = date => {
const parts = localParts(date)
this.dateInput.value = parts.date
this.timeInput.value = parts.time
this.updateMinimum()
}
this.updateMinimum = () => {
const minimum = this.parseUtc(this.el.dataset.minValue)
if (!minimum) return
const parts = localParts(minimum)
this.dateInput.min = parts.date
this.timeInput.min = this.dateInput.value === parts.date ? parts.time : ""
}
this.syncHidden = () => {
this.updateMinimum()
let value = ""
if (this.dateInput.value && this.timeInput.value) {
const [year, month, day] = this.dateInput.value.split("-").map(Number)
const [hour, minute] = this.timeInput.value.split(":").map(Number)
const date = new Date(year, month - 1, day, hour, minute)
if (!Number.isNaN(date.getTime())) value = utcValue(date)
}
if (this.hiddenInput.value === value) return
this.hiddenInput.value = value
this.hiddenInput.dispatchEvent(new Event("input", {bubbles: true}))
}
this.setQuickTime = event => {
const minutes = Number(event.currentTarget.dataset.datetimeOffset)
if (!Number.isFinite(minutes)) return
const date = new Date(Date.now() + minutes * 60_000)
date.setSeconds(0, 0)
this.applyLocalDate(date)
this.syncHidden()
}
this.dateInput.addEventListener("input", this.syncHidden)
this.timeInput.addEventListener("input", this.syncHidden)
this.quickButtons.forEach(button => button.addEventListener("click", this.setQuickTime))
const initial = this.parseUtc(this.hiddenInput.value)
if (initial) this.applyLocalDate(initial)
this.updateMinimum()
if (this.timezoneLabel) {
this.timezoneLabel.textContent =
Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
}
},
destroyed() {
this.dateInput?.removeEventListener("input", this.syncHidden)
this.timeInput?.removeEventListener("input", this.syncHidden)
this.quickButtons?.forEach(button => button.removeEventListener("click", this.setQuickTime))
}
},
LocalTime: {
mounted() {
const value = this.el.dateTime || this.el.dataset.datetime
const date = new Date(value)
if (Number.isNaN(date.getTime())) return
this.el.textContent = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(date)
this.el.title = date.toISOString()
}
},
ChatMessages: {
mounted() {
this.wasNearBottom = true
this.el.scrollTop = this.el.scrollHeight
this.isIntersecting = false
this.markingRead = false
this.recordPosition = () => {
this.wasNearBottom =
this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight < 80
}
this.markRead = () => {
if (
this.el.dataset.readReceipts !== "true" ||
this.el.dataset.hasUnread !== "true" ||
document.visibilityState !== "visible" ||
!this.isIntersecting ||
this.markingRead
) return
this.markingRead = true
this.pushEvent("mark-messages-read", {}, () => {
this.markingRead = false
})
}
this.visibilityChanged = () => this.markRead()
this.el.addEventListener("scroll", this.recordPosition, {passive: true})
document.addEventListener("visibilitychange", this.visibilityChanged)
if (this.el.dataset.readReceipts === "true" && "IntersectionObserver" in window) {
this.visibilityObserver = new IntersectionObserver(entries => {
this.isIntersecting = entries.some(entry => entry.isIntersecting)
this.markRead()
}, {threshold: 0.1})
this.visibilityObserver.observe(this.el)
} else {
this.isIntersecting = true
this.markRead()
}
},
beforeUpdate() {
this.recordPosition()
const firstMessage = this.el.querySelector("[data-chat-message]")
this.anchorId = firstMessage?.id
this.anchorOffset = firstMessage?.offsetTop
},
updated() {
if (this.wasNearBottom) {
this.el.scrollTop = this.el.scrollHeight
} else if (this.anchorId) {
const anchor = document.getElementById(this.anchorId)
if (anchor && Number.isFinite(this.anchorOffset)) {
this.el.scrollTop += anchor.offsetTop - this.anchorOffset
}
}
this.markRead()
},
destroyed() {
this.el.removeEventListener("scroll", this.recordPosition)
document.removeEventListener("visibilitychange", this.visibilityChanged)
this.visibilityObserver?.disconnect()
}
},
ChatComposer: {
mounted() {
this.resize = () => {
this.el.style.height = "auto"
this.el.style.height = `${Math.min(this.el.scrollHeight, 160)}px`
}
this.submitOnEnter = event => {
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return
event.preventDefault()
this.el.form?.requestSubmit()
}
this.reset = () => window.requestAnimationFrame(this.resize)
this.el.addEventListener("input", this.resize)
this.el.addEventListener("keydown", this.submitOnEnter)
this.el.form?.addEventListener("reset", this.reset)
this.resize()
},
destroyed() {
this.el.removeEventListener("input", this.resize)
this.el.removeEventListener("keydown", this.submitOnEnter)
this.el.form?.removeEventListener("reset", this.reset)
}
},
CopyToClipboard: { CopyToClipboard: {
mounted() { mounted() {
this.resetLabel = () => { this.resetLabel = () => {

View File

@ -125,9 +125,11 @@ defmodule WhoNeedHelp.Activities.Activity do
changeset changeset
|> then(fn changeset -> |> then(fn changeset ->
if starts_at && DateTime.after?(starts_at, now), cond do
do: changeset, is_nil(starts_at) -> changeset
else: add_error(changeset, :starts_at, "must be in the future") DateTime.after?(starts_at, now) -> changeset
true -> add_error(changeset, :starts_at, "must be in the future")
end
end) end)
|> then(fn changeset -> |> then(fn changeset ->
cond do cond do

View File

@ -99,6 +99,34 @@ defmodule WhoNeedHelp.Messaging do
end end
end end
def mark_messages_read(%Scope{user: user} = scope, %Assignment{} = assignment) do
assignment = Repo.preload(assignment, :request)
if Trust.eligible?(scope) and Help.participant?(scope, assignment) and
not blocked_assignment?(scope, assignment) do
now = DateTime.utc_now(:second)
{count, _messages} =
Message
|> where([message], message.assignment_id == ^assignment.id)
|> where([message], message.sender_id != ^user.id)
|> where([message], is_nil(message.read_at))
|> Repo.update_all(set: [read_at: now, updated_at: now])
if count > 0 do
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"messages:#{assignment.id}",
{:messages_read, user.id, now}
)
end
{:ok, %{count: count, read_at: now}}
else
{:error, :forbidden}
end
end
defp blocked_assignment?(%Scope{user: user}, assignment) do defp blocked_assignment?(%Scope{user: user}, assignment) do
request = Repo.preload(assignment, :request).request request = Repo.preload(assignment, :request).request
Trust.blocked_between?(user.id, counterpart_id(user.id, assignment, request)) Trust.blocked_between?(user.id, counterpart_id(user.id, assignment, request))

View File

@ -373,6 +373,96 @@ defmodule WhoNeedHelpWeb.CoreComponents do
defp safe_form_value?(value) when is_list(value), do: Enum.all?(value, &safe_form_value?/1) defp safe_form_value?(value) when is_list(value), do: Enum.all?(value, &safe_form_value?/1)
defp safe_form_value?(value), do: not is_nil(Phoenix.HTML.Safe.impl_for(value)) defp safe_form_value?(value), do: not is_nil(Phoenix.HTML.Safe.impl_for(value))
@doc """
Renders a browser-local date and time editor backed by one UTC form field.
Splitting the controls lets a person correct only the time without the native
`datetime-local` control clearing the whole value. The hook converts the
visible browser-local parts to the UTC value expected by the changeset.
"""
attr :field, Phoenix.HTML.FormField, required: true
attr :label, :string, required: true
attr :min, :string, default: nil
attr :required, :boolean, default: true
attr :quick_options, :list, default: []
def datetime_picker(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns =
assigns
|> assign(:id, field.id)
|> assign(:name, field.name)
|> assign(:value, normalized_input_value("datetime-local", safe_form_value(field.value)))
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
~H"""
<div class="fieldset mb-2">
<span class="label mb-1">{@label}</span>
<div
id={"#{@id}-picker"}
phx-hook="DateTimePicker"
phx-update="ignore"
data-min-value={@min}
class={[
"rounded-2xl border bg-base-100 p-3",
@errors == [] && "border-base-300",
@errors != [] && "border-error"
]}
>
<input
type="hidden"
id={@id}
name={@name}
value={@value}
data-datetime-value
/>
<div class="grid gap-3 sm:grid-cols-2">
<label>
<span class="label mb-1 text-xs">{gettext("Date")}</span>
<input
id={"#{@id}-date"}
type="date"
data-datetime-date
required={@required}
class="input w-full"
/>
</label>
<label>
<span class="label mb-1 text-xs">{gettext("Time")}</span>
<input
id={"#{@id}-time"}
type="time"
data-datetime-time
required={@required}
step="60"
class="input w-full"
/>
</label>
</div>
<div
:if={@quick_options != []}
class="mt-3 flex flex-wrap gap-2"
aria-label={gettext("Quick time choices")}
>
<button
:for={{label, offset_minutes} <- @quick_options}
type="button"
data-datetime-offset={offset_minutes}
class="btn btn-ghost btn-xs"
>
{label}
</button>
</div>
<p class="mt-2 text-xs text-base-content/55">
{gettext("Your time zone:")} <span data-datetime-timezone></span>
</p>
</div>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors # Helper used by inputs to generate form errors
defp error(assigns) do defp error(assigns) do
~H""" ~H"""

View File

@ -233,15 +233,13 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
} }
/> />
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<.input <.datetime_picker
field={@form[:starts_at]} field={@form[:starts_at]}
type="datetime-local" label={gettext("Starts at")}
label={gettext("Starts at (UTC)")}
/> />
<.input <.datetime_picker
field={@form[:join_deadline]} field={@form[:join_deadline]}
type="datetime-local" label={gettext("Join requests close")}
label={gettext("Join requests close (UTC)")}
/> />
</div> </div>
<.input <.input

View File

@ -442,7 +442,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
"Only the organizer and approved participants can read or send these messages." "Only the organizer and approved participants can read or send these messages."
)} )}
</p> </p>
<div id="activity-messages" class="mt-5 max-h-96 space-y-3 overflow-y-auto"> <div
id="activity-messages"
phx-hook="ChatMessages"
role="log"
aria-live="polite"
aria-relevant="additions text"
class="mt-5 max-h-96 space-y-3 overflow-y-auto"
>
<button <button
:if={@messages_cursor} :if={@messages_cursor}
type="button" type="button"
@ -456,12 +463,22 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
</p> </p>
<article <article
:for={message <- ordered_messages(@activity)} :for={message <- ordered_messages(@activity)}
id={"activity-message-#{message.id}"}
data-chat-message
class="rounded-2xl bg-base-100 p-4" class="rounded-2xl bg-base-100 p-4"
> >
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="text-xs font-semibold text-info"> <div class="text-xs font-semibold text-info">
{message.sender.display_name || gettext("Participant")} {message.sender.display_name || gettext("Participant")}
</div> </div>
<time
id={"activity-message-time-#{message.id}"}
phx-hook="LocalTime"
datetime={DateTime.to_iso8601(message.inserted_at)}
class="ml-auto text-xs text-base-content/50"
>
{Calendar.strftime(message.inserted_at, "%d.%m.%Y, %H:%M UTC")}
</time>
<button <button
:if={message.sender_id != @current_scope.user.id} :if={message.sender_id != @current_scope.user.id}
type="button" type="button"
@ -480,14 +497,23 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
for={@message_form} for={@message_form}
id="activity-message-form" id="activity-message-form"
phx-submit="send-message" phx-submit="send-message"
class="mt-4 flex gap-2" class="mt-4 flex items-end gap-2"
> >
<div class="min-w-0 flex-1">
<.input <.input
id="activity-message-body"
field={@message_form[:body]} field={@message_form[:body]}
type="textarea"
rows="1"
maxlength="2000"
phx-hook="ChatComposer"
placeholder={gettext("Message the approved group")} placeholder={gettext("Message the approved group")}
class="flex-1" class="min-h-11 w-full resize-none"
/> />
<.button class="btn btn-info self-end">{gettext("Send")}</.button> </div>
<.button class="btn btn-info mb-2" phx-disable-with={gettext("Sending…")}>
{gettext("Send")}
</.button>
</.form> </.form>
</section> </section>
</main> </main>

View File

@ -331,11 +331,15 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
{gettext("Scheduled"), "scheduled"} {gettext("Scheduled"), "scheduled"}
]} ]}
/> />
<.input <.datetime_picker
field={@form[:expires_at]} field={@form[:expires_at]}
type="datetime-local" label={gettext("Request expires")}
label={gettext("Request expires (UTC)")}
min={minimum_expiry_value()} min={minimum_expiry_value()}
quick_options={[
{gettext("In 1 hour"), 60},
{gettext("In 3 hours"), 180},
{gettext("Tomorrow"), 1_440}
]}
/> />
</div> </div>
<.input <.input

View File

@ -80,6 +80,23 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{:noreply, put_realtime_message(socket, message)} {:noreply, put_realtime_message(socket, message)}
end end
def handle_info({:messages_read, reader_id, read_at}, socket) do
if reader_id == socket.assigns.current_scope.user.id do
{:noreply, socket}
else
messages =
Enum.map(socket.assigns.messages, fn message ->
if message.sender_id != reader_id and is_nil(message.read_at) do
%{message | read_at: read_at}
else
message
end
end)
{:noreply, assign(socket, :messages, messages)}
end
end
def handle_info({:position_updated, user_id, position, evidence}, socket) do def handle_info({:position_updated, user_id, position, evidence}, socket) do
positions = Map.put(socket.assigns.positions, user_id, position) positions = Map.put(socket.assigns.positions, user_id, position)
@ -164,6 +181,34 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
end end
end end
def handle_event("mark-messages-read", _params, socket) do
result =
if socket.assigns.participant && !socket.assigns.interaction_blocked do
Messaging.mark_messages_read(socket.assigns.current_scope, socket.assigns.assignment)
else
{:error, :forbidden}
end
case result do
{:ok, %{count: count, read_at: read_at}} when count > 0 ->
current_user_id = socket.assigns.current_scope.user.id
messages =
Enum.map(socket.assigns.messages, fn message ->
if message.sender_id != current_user_id and is_nil(message.read_at) do
%{message | read_at: read_at}
else
message
end
end)
{:noreply, assign(socket, :messages, messages)}
_other ->
{:noreply, socket}
end
end
def handle_event("load-earlier-messages", _params, socket) do def handle_event("load-earlier-messages", _params, socket) do
page = page =
Messaging.paginate_messages( Messaging.paginate_messages(
@ -775,6 +820,18 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
</div> </div>
<div <div
id="messages" id="messages"
phx-hook="ChatMessages"
data-read-receipts="true"
data-has-unread={
if Enum.any?(@messages, fn message ->
message.sender_id != @current_scope.user.id && is_nil(message.read_at)
end),
do: "true",
else: "false"
}
role="log"
aria-live="polite"
aria-relevant="additions text"
class="mt-5 max-h-80 space-y-3 overflow-y-auto rounded-2xl bg-base-200 p-4" class="mt-5 max-h-80 space-y-3 overflow-y-auto rounded-2xl bg-base-200 p-4"
> >
<button <button
@ -799,14 +856,31 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
</p> </p>
<div <div
:for={message <- @messages} :for={message <- @messages}
id={"message-#{message.id}"}
data-chat-message
class={[ class={[
"chat", "chat",
message.sender_id == @current_scope.user.id && "chat-end", message.sender_id == @current_scope.user.id && "chat-end",
message.sender_id != @current_scope.user.id && "chat-start" message.sender_id != @current_scope.user.id && "chat-start"
]} ]}
> >
<div class="chat-header text-xs">{message.sender.display_name}</div> <div class="chat-header flex items-center gap-2 text-xs">
<div class="chat-bubble">{message.body}</div> <span>{message.sender.display_name}</span>
<time
id={"message-time-#{message.id}"}
phx-hook="LocalTime"
datetime={DateTime.to_iso8601(message.inserted_at)}
>
{Calendar.strftime(message.inserted_at, "%d.%m.%Y, %H:%M UTC")}
</time>
</div>
<div class="chat-bubble whitespace-pre-wrap break-words">{message.body}</div>
<div
:if={message.sender_id == @current_scope.user.id}
class="chat-footer text-xs text-base-content/50"
>
{if message.read_at, do: gettext("Read"), else: gettext("Sent")}
</div>
<button <button
:if={message.sender_id != @current_scope.user.id} :if={message.sender_id != @current_scope.user.id}
phx-click="select-report-message" phx-click="select-report-message"
@ -822,15 +896,25 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
id="message-form" id="message-form"
for={@message_form} for={@message_form}
phx-submit="send-message" phx-submit="send-message"
class="mt-4 flex gap-2" class="mt-4 flex items-end gap-2"
> >
<div class="min-w-0 flex-1">
<.input <.input
id="message-body" id="message-body"
field={@message_form[:body]} field={@message_form[:body]}
type="textarea"
rows="1"
maxlength="2000"
phx-hook="ChatComposer"
placeholder={gettext("Write a safe coordination message…")} placeholder={gettext("Write a safe coordination message…")}
class="grow" class="min-h-11 w-full resize-none"
/> />
<.button id="send-message-button" class="btn btn-primary self-end"> </div>
<.button
id="send-message-button"
class="btn btn-primary mb-2"
phx-disable-with={gettext("Sending…")}
>
{gettext("Send")} {gettext("Send")}
</.button> </.button>
</.form> </.form>

View File

@ -45,6 +45,14 @@ defmodule WhoNeedHelp.ActivitiesTest do
attrs: attrs} attrs: attrs}
end end
test "an incomplete start time does not get a misleading future error", context do
changeset =
Activity.create_changeset(%Activity{}, Map.put(context.attrs, "starts_at", ""))
assert "can't be blank" in errors_on(changeset).starts_at
refute "must be in the future" in errors_on(changeset).starts_at
end
test "organizer approves a participant and the approved group can chat", context do test "organizer approves a participant and the approved group can chat", context do
assert {:ok, activity} = assert {:ok, activity} =
Activities.create_activity(context.organizer_scope, context.attrs) Activities.create_activity(context.organizer_scope, context.attrs)

View File

@ -167,6 +167,36 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
Messaging.send_message(outsider_scope, assignment, %{"body" => "not allowed"}) Messaging.send_message(outsider_scope, assignment, %{"body" => "not allowed"})
end end
test "participants can mark only counterpart messages as read", context do
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
:ok = Messaging.subscribe(assignment.id)
{:ok, helper_message} =
Messaging.send_message(context.helper_scope, assignment, %{"body" => "On my way"})
helper_message_id = helper_message.id
assert_receive {:new_message, %{id: ^helper_message_id}}
assert {:ok, %{count: 1, read_at: result_read_at}} =
Messaging.mark_messages_read(context.requester_scope, assignment)
assert_receive {:messages_read, requester_id, %DateTime{} = broadcast_read_at}
assert requester_id == context.requester.id
assert broadcast_read_at == result_read_at
[stored_message] = Messaging.list_messages(context.requester_scope, assignment)
assert stored_message.read_at == result_read_at
assert {:ok, %{count: 0}} =
Messaging.mark_messages_read(context.requester_scope, assignment)
refute_receive {:messages_read, ^requester_id, _read_at}
assert {:error, :forbidden} = Messaging.mark_messages_read(outsider_scope, assignment)
end
test "non-participants cannot reopen matched request details by UUID", context do test "non-participants cannot reopen matched request details by UUID", context do
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture() outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)

View File

@ -267,6 +267,15 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
) )
end end
test "new request form uses separate local date and time controls", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/requests/new")
assert has_element?(view, "#help_request_expires_at[type='hidden']")
assert has_element?(view, "#help_request_expires_at-date[type='date']")
assert has_element?(view, "#help_request_expires_at-time[type='time']")
assert has_element?(view, "#help_request_expires_at-picker[phx-hook='DateTimePicker']")
end
test "new request form explains location and expiry validation failures", %{conn: conn} do test "new request form explains location and expiry validation failures", %{conn: conn} do
category = Catalog.seed_defaults() category = Catalog.seed_defaults()
{:ok, view, _html} = live(conn, ~p"/requests/new") {:ok, view, _html} = live(conn, ~p"/requests/new")
@ -487,6 +496,13 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "I am on my way to the pharmacy." assert html =~ "I am on my way to the pharmacy."
assert_push_event(helper_view, "reset-message-form", %{id: "message-form"}) assert_push_event(helper_view, "reset-message-form", %{id: "message-form"})
assert render(requester_view) =~ "I am on my way to the pharmacy." assert render(requester_view) =~ "I am on my way to the pharmacy."
assert has_element?(requester_view, "#messages[data-has-unread='true']")
render_hook(requester_view, "mark-messages-read", %{})
assert has_element?(requester_view, "#messages[data-has-unread='false']")
assert render(helper_view) =~ "Read"
assert has_element?(helper_view, "#message-body[phx-hook='ChatComposer']")
assert has_element?(helper_view, "#messages[phx-hook='ChatMessages']")
assert has_element?(helper_view, "time[phx-hook='LocalTime']")
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
assignment_id = Repo.get_by!(WhoNeedHelp.Help.Assignment, request_id: request.id).id assignment_id = Repo.get_by!(WhoNeedHelp.Help.Assignment, request_id: request.id).id