diff --git a/assets/js/hooks.js b/assets/js/hooks.js index a4f79a9..9cacc1f 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -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 = () => { diff --git a/lib/who_need_help/activities/activity.ex b/lib/who_need_help/activities/activity.ex index bba1847..4eb5693 100644 --- a/lib/who_need_help/activities/activity.ex +++ b/lib/who_need_help/activities/activity.ex @@ -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 diff --git a/lib/who_need_help/messaging.ex b/lib/who_need_help/messaging.ex index 42af3b2..7407325 100644 --- a/lib/who_need_help/messaging.ex +++ b/lib/who_need_help/messaging.ex @@ -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)) diff --git a/lib/who_need_help_web/components/core_components.ex b/lib/who_need_help_web/components/core_components.ex index 234cb72..56623eb 100644 --- a/lib/who_need_help_web/components/core_components.ex +++ b/lib/who_need_help_web/components/core_components.ex @@ -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""" +
+ {@label} +
+ +
+ + +
+
+ +
+

+ {gettext("Your time zone:")} +

+
+ <.error :for={msg <- @errors}>{msg} +
+ """ + end + # Helper used by inputs to generate form errors defp error(assigns) do ~H""" diff --git a/lib/who_need_help_web/live/activity_live/new.ex b/lib/who_need_help_web/live/activity_live/new.ex index 0040f6d..182c141 100644 --- a/lib/who_need_help_web/live/activity_live/new.ex +++ b/lib/who_need_help_web/live/activity_live/new.ex @@ -233,15 +233,13 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do } />
- <.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")} />
<.input diff --git a/lib/who_need_help_web/live/activity_live/show.ex b/lib/who_need_help_web/live/activity_live/show.ex index c897765..b0c7eb0 100644 --- a/lib/who_need_help_web/live/activity_live/show.ex +++ b/lib/who_need_help_web/live/activity_live/show.ex @@ -442,7 +442,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do "Only the organizer and approved participants can read or send these messages." )}

-
+
<.input diff --git a/lib/who_need_help_web/live/request_live/show.ex b/lib/who_need_help_web/live/request_live/show.ex index d980f44..cd0904b 100644 --- a/lib/who_need_help_web/live/request_live/show.ex +++ b/lib/who_need_help_web/live/request_live/show.ex @@ -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
+ 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" >