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 = {
|
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 = () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
<.input
|
<div class="min-w-0 flex-1">
|
||||||
field={@message_form[:body]}
|
<.input
|
||||||
placeholder={gettext("Message the approved group")}
|
id="activity-message-body"
|
||||||
class="flex-1"
|
field={@message_form[:body]}
|
||||||
/>
|
type="textarea"
|
||||||
<.button class="btn btn-info self-end">{gettext("Send")}</.button>
|
rows="1"
|
||||||
|
maxlength="2000"
|
||||||
|
phx-hook="ChatComposer"
|
||||||
|
placeholder={gettext("Message the approved group")}
|
||||||
|
class="min-h-11 w-full resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<.button class="btn btn-info mb-2" phx-disable-with={gettext("Sending…")}>
|
||||||
|
{gettext("Send")}
|
||||||
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
<.input
|
<div class="min-w-0 flex-1">
|
||||||
id="message-body"
|
<.input
|
||||||
field={@message_form[:body]}
|
id="message-body"
|
||||||
placeholder={gettext("Write a safe coordination message…")}
|
field={@message_form[:body]}
|
||||||
class="grow"
|
type="textarea"
|
||||||
/>
|
rows="1"
|
||||||
<.button id="send-message-button" class="btn btn-primary self-end">
|
maxlength="2000"
|
||||||
|
phx-hook="ChatComposer"
|
||||||
|
placeholder={gettext("Write a safe coordination message…")}
|
||||||
|
class="min-h-11 w-full resize-none"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user