Improve chat and date-time editing
This commit is contained in:
parent
e5e6b501ea
commit
0880b25e73
|
|
@ -204,6 +204,198 @@ export const mountStaticAidMaps = root => {
|
|||
}
|
||||
|
||||
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: {
|
||||
mounted() {
|
||||
this.resetLabel = () => {
|
||||
|
|
|
|||
|
|
@ -125,9 +125,11 @@ defmodule WhoNeedHelp.Activities.Activity do
|
|||
|
||||
changeset
|
||||
|> then(fn changeset ->
|
||||
if starts_at && DateTime.after?(starts_at, now),
|
||||
do: changeset,
|
||||
else: add_error(changeset, :starts_at, "must be in the future")
|
||||
cond do
|
||||
is_nil(starts_at) -> changeset
|
||||
DateTime.after?(starts_at, now) -> changeset
|
||||
true -> add_error(changeset, :starts_at, "must be in the future")
|
||||
end
|
||||
end)
|
||||
|> then(fn changeset ->
|
||||
cond do
|
||||
|
|
|
|||
|
|
@ -99,6 +99,34 @@ defmodule WhoNeedHelp.Messaging do
|
|||
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
|
||||
request = Repo.preload(assignment, :request).request
|
||||
Trust.blocked_between?(user.id, counterpart_id(user.id, assignment, request))
|
||||
|
|
|
|||
|
|
@ -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), 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
|
||||
defp error(assigns) do
|
||||
~H"""
|
||||
|
|
|
|||
|
|
@ -233,15 +233,13 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
}
|
||||
/>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<.input
|
||||
<.datetime_picker
|
||||
field={@form[:starts_at]}
|
||||
type="datetime-local"
|
||||
label={gettext("Starts at (UTC)")}
|
||||
label={gettext("Starts at")}
|
||||
/>
|
||||
<.input
|
||||
<.datetime_picker
|
||||
field={@form[:join_deadline]}
|
||||
type="datetime-local"
|
||||
label={gettext("Join requests close (UTC)")}
|
||||
label={gettext("Join requests close")}
|
||||
/>
|
||||
</div>
|
||||
<.input
|
||||
|
|
|
|||
|
|
@ -442,7 +442,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
"Only the organizer and approved participants can read or send these messages."
|
||||
)}
|
||||
</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
|
||||
:if={@messages_cursor}
|
||||
type="button"
|
||||
|
|
@ -456,12 +463,22 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
</p>
|
||||
<article
|
||||
:for={message <- ordered_messages(@activity)}
|
||||
id={"activity-message-#{message.id}"}
|
||||
data-chat-message
|
||||
class="rounded-2xl bg-base-100 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-xs font-semibold text-info">
|
||||
{message.sender.display_name || gettext("Participant")}
|
||||
</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
|
||||
:if={message.sender_id != @current_scope.user.id}
|
||||
type="button"
|
||||
|
|
@ -480,14 +497,23 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
for={@message_form}
|
||||
id="activity-message-form"
|
||||
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
|
||||
id="activity-message-body"
|
||||
field={@message_form[:body]}
|
||||
type="textarea"
|
||||
rows="1"
|
||||
maxlength="2000"
|
||||
phx-hook="ChatComposer"
|
||||
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>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -331,11 +331,15 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
{gettext("Scheduled"), "scheduled"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
<.datetime_picker
|
||||
field={@form[:expires_at]}
|
||||
type="datetime-local"
|
||||
label={gettext("Request expires (UTC)")}
|
||||
label={gettext("Request expires")}
|
||||
min={minimum_expiry_value()}
|
||||
quick_options={[
|
||||
{gettext("In 1 hour"), 60},
|
||||
{gettext("In 3 hours"), 180},
|
||||
{gettext("Tomorrow"), 1_440}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<.input
|
||||
|
|
|
|||
|
|
@ -80,6 +80,23 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
{:noreply, put_realtime_message(socket, message)}
|
||||
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
|
||||
positions = Map.put(socket.assigns.positions, user_id, position)
|
||||
|
||||
|
|
@ -164,6 +181,34 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
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
|
||||
page =
|
||||
Messaging.paginate_messages(
|
||||
|
|
@ -775,6 +820,18 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
</div>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<button
|
||||
|
|
@ -799,14 +856,31 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
</p>
|
||||
<div
|
||||
:for={message <- @messages}
|
||||
id={"message-#{message.id}"}
|
||||
data-chat-message
|
||||
class={[
|
||||
"chat",
|
||||
message.sender_id == @current_scope.user.id && "chat-end",
|
||||
message.sender_id != @current_scope.user.id && "chat-start"
|
||||
]}
|
||||
>
|
||||
<div class="chat-header text-xs">{message.sender.display_name}</div>
|
||||
<div class="chat-bubble">{message.body}</div>
|
||||
<div class="chat-header flex items-center gap-2 text-xs">
|
||||
<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
|
||||
:if={message.sender_id != @current_scope.user.id}
|
||||
phx-click="select-report-message"
|
||||
|
|
@ -822,15 +896,25 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
id="message-form"
|
||||
for={@message_form}
|
||||
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
|
||||
id="message-body"
|
||||
field={@message_form[:body]}
|
||||
type="textarea"
|
||||
rows="1"
|
||||
maxlength="2000"
|
||||
phx-hook="ChatComposer"
|
||||
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")}
|
||||
</.button>
|
||||
</.form>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
attrs: attrs}
|
||||
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
|
||||
assert {:ok, activity} =
|
||||
Activities.create_activity(context.organizer_scope, context.attrs)
|
||||
|
|
|
|||
|
|
@ -167,6 +167,36 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
Messaging.send_message(outsider_scope, assignment, %{"body" => "not allowed"})
|
||||
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
|
||||
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
|
|
|
|||
|
|
@ -267,6 +267,15 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
)
|
||||
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
|
||||
category = Catalog.seed_defaults()
|
||||
{: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_push_event(helper_view, "reset-message-form", %{id: "message-form"})
|
||||
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)
|
||||
assignment_id = Repo.get_by!(WhoNeedHelp.Help.Assignment, request_id: request.id).id
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user