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("Date")}
+
+
+
+ {gettext("Time")}
+
+
+
+
+
+ {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."
)}
-
+
{message.sender.display_name || gettext("Participant")}
+
+ {Calendar.strftime(message.inserted_at, "%d.%m.%Y, %H:%M UTC")}
+
- <.input
- field={@message_form[:body]}
- placeholder={gettext("Message the approved group")}
- class="flex-1"
- />
- <.button class="btn btn-info self-end">{gettext("Send")}
+
+ <.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="min-h-11 w-full resize-none"
+ />
+
+ <.button class="btn btn-info mb-2" phx-disable-with={gettext("Sending…")}>
+ {gettext("Send")}
+
diff --git a/lib/who_need_help_web/live/request_live/new.ex b/lib/who_need_help_web/live/request_live/new.ex
index 300ef40..5a162ea 100644
--- a/lib/who_need_help_web/live/request_live/new.ex
+++ b/lib/who_need_help_web/live/request_live/new.ex
@@ -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}
+ ]}
/>
<.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"
>
-
-
{message.body}
+
+
{message.body}
+
- <.input
- id="message-body"
- field={@message_form[:body]}
- placeholder={gettext("Write a safe coordination message…")}
- class="grow"
- />
- <.button id="send-message-button" class="btn btn-primary self-end">
+
+ <.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="min-h-11 w-full resize-none"
+ />
+
+ <.button
+ id="send-message-button"
+ class="btn btn-primary mb-2"
+ phx-disable-with={gettext("Sending…")}
+ >
{gettext("Send")}
diff --git a/test/who_need_help/activities_test.exs b/test/who_need_help/activities_test.exs
index 1789432..b29a5d4 100644
--- a/test/who_need_help/activities_test.exs
+++ b/test/who_need_help/activities_test.exs
@@ -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)
diff --git a/test/who_need_help/mutual_aid_flow_test.exs b/test/who_need_help/mutual_aid_flow_test.exs
index eef8741..8961682 100644
--- a/test/who_need_help/mutual_aid_flow_test.exs
+++ b/test/who_need_help/mutual_aid_flow_test.exs
@@ -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)
diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs
index 8da5775..af43b06 100644
--- a/test/who_need_help_web/live/mutual_aid_live_test.exs
+++ b/test/who_need_help_web/live/mutual_aid_live_test.exs
@@ -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