From 7ce052d0a1946949d624ceede017cddad796eb29 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Thu, 23 Jul 2026 03:00:51 +0300 Subject: [PATCH] fix: align realtime feedback and map privacy --- assets/js/hooks.js | 89 ++++++- lib/who_need_help/help.ex | 17 +- .../live/notification_live.ex | 8 +- .../live/request_live/show.ex | 249 +++++++++++++----- test/who_need_help/trust_safety_test.exs | 7 + .../live/mutual_aid_live_test.exs | 40 ++- 6 files changed, 326 insertions(+), 84 deletions(-) diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 30df5c0..ae0ee27 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -393,15 +393,22 @@ const createAidMap = element => { exactPoints.forEach(point => { const popup = new maplibregl.Popup({offset: 18}).setDOMContent(state.popupContent(point)) - const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"}) + const markerColor = point.type === "live" ? "#2563eb" : point.exact ? "#d6573b" : "#278467" + const marker = new maplibregl.Marker({color: markerColor}) .setLngLat([point.longitude, point.latitude]) .setPopup(popup) .addTo(state.map) const markerElement = marker.getElement() - markerElement.dataset.requestId = point.id - markerElement.addEventListener("click", () => state.selectRequest(point.id)) - state.markerElements.set(point.id, markerElement) + markerElement.dataset.markerType = point.type || "request" + markerElement.setAttribute("aria-label", point.title || "Map marker") + + if (point.id) { + markerElement.dataset.requestId = point.id + markerElement.addEventListener("click", () => state.selectRequest(point.id)) + state.markerElements.set(point.id, markerElement) + } + state.markers.push(marker) }) @@ -591,6 +598,7 @@ export const Hooks = { PushNotifications: { mounted() { this.button = this.el.querySelector("[data-enable-push]") + this.buttonLabel = this.el.querySelector("[data-enable-push-label]") this.status = this.el.querySelector("[data-push-status]") this.native = typeof window.WhoNeedHelpAndroid?.postMessage === "function" @@ -600,6 +608,13 @@ export const Hooks = { if (this.status) this.status.textContent = message } + this.setButtonState = enabled => { + if (!this.buttonLabel) return + this.buttonLabel.textContent = enabled + ? this.buttonLabel.dataset.enabledLabel + : this.buttonLabel.dataset.enableLabel + } + this.decodeApplicationServerKey = value => { const padding = "=".repeat((4 - value.length % 4) % 4) const base64 = (value + padding).replace(/-/g, "+").replace(/_/g, "/") @@ -701,6 +716,9 @@ export const Hooks = { await subscription?.unsubscribe() } catch (error) { console.warn("Browser push unsubscribe failed", error) + } finally { + this.setButtonState(false) + this.setStatus("Push notifications are disabled on this device.") } } @@ -746,6 +764,7 @@ export const Hooks = { device_label: `${navigator.platform || "Browser"} ยท Web Push`.slice(0, 120), user_agent: navigator.userAgent.slice(0, 500) }) + this.setButtonState(true) this.setStatus("Push notifications are enabled on this device.") } catch (error) { console.warn("Push notification registration failed", error) @@ -755,11 +774,48 @@ export const Hooks = { } } + this.restoreWebState = async () => { + if (this.native) return + + const publicKey = String(this.el.dataset.vapidPublicKey || "") + if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) { + this.setStatus("Push notifications are unavailable in this browser.") + return + } + + if (Notification.permission === "denied") { + this.setStatus("Notification permission was not granted.") + return + } + + try { + const registration = await navigator.serviceWorker.ready + const subscription = await registration.pushManager.getSubscription() + const deviceId = window.localStorage.getItem("wnh.push.server-device-id") + const activeServerDevice = deviceId && + [...this.el.querySelectorAll("[data-disable-device]")] + .some(element => element.dataset.disableDevice === deviceId) + + if (subscription && activeServerDevice) { + this.setButtonState(true) + this.setStatus("Push notifications are enabled on this device.") + } else if (subscription || activeServerDevice) { + this.setStatus("This browser has a push subscription. Enable it to reconnect this device.") + } + } catch (error) { + console.warn("Push notification state could not be restored", error) + } + } + window.addEventListener("wnh:native-push-token", this.nativeToken) window.addEventListener("wnh:native-push-error", this.nativeError) this.el.addEventListener("click", this.disableCurrentDevice) this.button?.addEventListener("click", this.enable) - if (this.native) this.postNative({action: "push_token_request"}) + if (this.native) { + this.postNative({action: "push_token_request"}) + } else { + this.restoreWebState() + } }, destroyed() { window.removeEventListener("wnh:native-push-token", this.nativeToken) @@ -867,15 +923,22 @@ export const Hooks = { LocalTime: { mounted() { - const value = this.el.dateTime || this.el.dataset.datetime - const date = new Date(value) - if (Number.isNaN(date.getTime())) return + this.format = () => { + 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() + this.el.textContent = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short" + }).format(date) + this.el.title = date.toISOString() + } + + this.format() + }, + updated() { + this.format() } }, diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex index dff9adb..63caa73 100644 --- a/lib/who_need_help/help.ex +++ b/lib/who_need_help/help.ex @@ -620,11 +620,18 @@ defmodule WhoNeedHelp.Help do request.assignment.status in [:accepted, :in_progress] and not Trust.blocked_between?(scope.user.id, request.requester_id) - if owner or - (matched_participant and request.location_visibility == :exact_for_active_match) do - exact_request_coordinates(request) - else - HelpRequest.public_coordinates(request) + cond do + owner and request.location_visibility == :approximate_public -> + HelpRequest.public_coordinates(request) + + owner -> + exact_request_coordinates(request) + + matched_participant and request.location_visibility == :exact_for_active_match -> + exact_request_coordinates(request) + + true -> + HelpRequest.public_coordinates(request) end end diff --git a/lib/who_need_help_web/live/notification_live.ex b/lib/who_need_help_web/live/notification_live.ex index 036d5d5..4b54b4a 100644 --- a/lib/who_need_help_web/live/notification_live.ex +++ b/lib/who_need_help_web/live/notification_live.ex @@ -302,7 +302,13 @@ defmodule WhoNeedHelpWeb.NotificationLive do class="btn btn-success w-full" > <.icon name="hero-bell-alert" class="size-5" /> - {gettext("Enable push on this device")} + + {gettext("Enable push on this device")} +

<%= if @web_push_public_key do %> 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 785f513..5d3262b 100644 --- a/lib/who_need_help_web/live/request_live/show.ex +++ b/lib/who_need_help_web/live/request_live/show.ex @@ -74,7 +74,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do |> maybe_put_status_transition_flash( previous_status, request.status, - previous_assignment + previous_assignment, + request.assignment ) {:noreply, sync_tracking_after_request_update(socket, request)} @@ -142,10 +143,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do case Help.accept_request(socket.assigns.current_scope, socket.assigns.request.id) do {:ok, _} -> {:noreply, - put_flash(socket, :info, gettext("You are matched. Use chat to coordinate safely."))} + put_action_flash( + socket, + :info, + gettext("You accepted this request. Use private chat to coordinate safely.") + )} {:error, reason} -> - {:noreply, put_flash(socket, :error, message(reason))} + {:noreply, put_action_flash(socket, :error, message(reason))} end end @@ -154,10 +159,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id, attrs) do {:ok, _request} -> - {:noreply, put_flash(socket, :info, gettext("Request cancelled."))} + {:noreply, put_action_flash(socket, :info, gettext("Request cancelled."))} {:error, reason} -> - {:noreply, put_flash(socket, :error, message(reason))} + {:noreply, put_action_flash(socket, :error, message(reason))} end end @@ -185,24 +190,24 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do ) do {:ok, _assignment} -> {:noreply, - put_flash( + put_action_flash( socket, :info, gettext("You left this match. The request is open for another helper if time remains.") )} {:error, reason} -> - {:noreply, put_flash(socket, :error, message(reason))} + {:noreply, put_action_flash(socket, :error, message(reason))} end end def handle_event("verify-code", %{"handover" => %{"code" => code}}, socket) do case Help.verify_handover(socket.assigns.current_scope, socket.assigns.assignment.id, code) do {:ok, _} -> - {:noreply, put_flash(socket, :info, gettext("Handover code verified."))} + {:noreply, put_action_flash(socket, :info, gettext("Handover code verified."))} {:error, reason} -> - {:noreply, put_flash(socket, :error, message(reason))} + {:noreply, put_action_flash(socket, :error, message(reason))} end end @@ -215,7 +220,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do |> push_event("reset-message-form", %{id: "message-form"})} {:error, reason} -> - {:noreply, put_flash(socket, :error, message(reason))} + {:noreply, put_action_flash(socket, :error, message(reason))} end end @@ -276,7 +281,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do |> maybe_start_native_tracking()} {:error, reason} -> - {:noreply, put_flash(socket, :error, message(reason))} + {:noreply, put_action_flash(socket, :error, message(reason))} end end @@ -294,7 +299,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do {:error, _reason} -> socket end - {:noreply, put_flash(socket, :error, gettext("The browser could not share your location."))} + {:noreply, + put_action_flash(socket, :error, gettext("The browser could not share your location."))} end def handle_event("stop-tracking", _, socket) do @@ -303,7 +309,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do {:noreply, stopped_socket} {:error, reason} -> - {:noreply, put_flash(socket, :error, message(reason))} + {:noreply, put_action_flash(socket, :error, message(reason))} end end @@ -311,14 +317,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do case Trust.submit_review(socket.assigns.current_scope, socket.assigns.assignment, params) do {:ok, _} -> {:noreply, - put_flash( + put_action_flash( socket, :info, gettext("Review saved. It appears after both participants review.") )} {:error, reason} -> - {:noreply, put_flash(socket, :error, message(reason))} + {:noreply, put_action_flash(socket, :error, message(reason))} end end @@ -342,11 +348,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do socket |> assign(:report_form, report_form()) |> assign(:report_message_id, nil) - |> put_flash(:info, gettext("Report sent to moderators.")) + |> put_action_flash(:info, gettext("Report sent to moderators.")) |> push_event("reset-message-form", %{id: "request-report-form"})} {:error, reason} -> - {:noreply, put_flash(socket, :error, message(reason))} + {:noreply, put_action_flash(socket, :error, message(reason))} end end @@ -360,7 +366,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do {:noreply, assign(socket, :report_message_id, message.id)} else {:noreply, - put_flash(socket, :error, gettext("That message cannot be reported from this view."))} + put_action_flash( + socket, + :error, + gettext("That message cannot be reported from this view.") + )} end end @@ -384,17 +394,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do |> assign(:positions, %{}) |> assign(:tracking_active, false) |> assign(:tracking_session_id, nil) - |> assign( - :markers, - Jason.encode!(request_markers(socket.assigns.current_scope, socket.assigns.request)) - ) - |> put_flash( + |> refresh_request_coordinates() + |> put_action_flash( :info, gettext("User blocked. Their requests and new messages are hidden.") )} {:error, reason} -> - {:noreply, put_flash(socket, :error, message(reason))} + {:noreply, put_action_flash(socket, :error, message(reason))} end end @@ -412,13 +419,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do socket |> assign(:blocked_by_current, false) |> assign(:interaction_blocked, interaction_blocked) + |> refresh_request_coordinates() |> restore_private_interaction(interaction_blocked) - |> put_flash(:info, gettext("User unblocked.")) + |> put_action_flash(:info, gettext("User unblocked.")) {:noreply, socket} {:error, reason} -> - {:noreply, put_flash(socket, :error, message(reason))} + {:noreply, put_action_flash(socket, :error, message(reason))} end end @@ -488,57 +496,121 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do defp transition(socket, fun, success) do case fun.(socket.assigns.current_scope, socket.assigns.assignment.id) do - {:ok, _} -> {:noreply, put_flash(socket, :info, success)} - {:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))} + {:ok, _} -> {:noreply, put_action_flash(socket, :info, success)} + {:error, reason} -> {:noreply, put_action_flash(socket, :error, message(reason))} end end - defp maybe_put_status_transition_flash(socket, status, status, _previous_assignment), do: socket + defp put_action_flash(socket, kind, message) do + socket + |> clear_flash() + |> put_flash(kind, message) + end + + defp maybe_put_status_transition_flash( + socket, + status, + status, + _previous_assignment, + _assignment + ), + do: socket defp maybe_put_status_transition_flash( %{assigns: %{current_scope: %{user: %{id: user_id}}}} = socket, previous, :open, - %{helper_id: user_id} + %{helper_id: user_id}, + _assignment ) when previous in [:matched, :in_progress] do - put_flash( + put_action_flash( socket, :info, gettext("You left this match. The request is open for another helper if time remains.") ) end - defp maybe_put_status_transition_flash(socket, previous, :open, _previous_assignment) + defp maybe_put_status_transition_flash( + socket, + previous, + :open, + _previous_assignment, + _assignment + ) when previous in [:matched, :in_progress] do - put_flash( + put_action_flash( socket, :info, gettext("The previous helper left. This request is open for a new helper again.") ) end - defp maybe_put_status_transition_flash(socket, _previous, :matched, _previous_assignment) do - put_flash( + defp maybe_put_status_transition_flash( + %{assigns: %{current_scope: %{user: %{id: user_id}}}} = socket, + _previous, + :matched, + _previous_assignment, + %{helper_id: user_id} + ) do + put_action_flash( + socket, + :info, + gettext("You accepted this request. Use private chat to coordinate safely.") + ) + end + + defp maybe_put_status_transition_flash( + socket, + _previous, + :matched, + _previous_assignment, + _assignment + ) do + put_action_flash( socket, :info, gettext("A volunteer accepted this request. Use private chat to coordinate safely.") ) end - defp maybe_put_status_transition_flash(socket, _previous, :in_progress, _previous_assignment) do - put_flash(socket, :info, gettext("Help is now in progress.")) + defp maybe_put_status_transition_flash( + socket, + _previous, + :in_progress, + _previous_assignment, + _assignment + ) do + put_action_flash(socket, :info, gettext("Help is now in progress.")) end - defp maybe_put_status_transition_flash(socket, _previous, :completed, _previous_assignment) do - put_flash(socket, :info, gettext("Help is complete.")) + defp maybe_put_status_transition_flash( + socket, + _previous, + :completed, + _previous_assignment, + _assignment + ) do + put_action_flash(socket, :info, gettext("Help is complete.")) end - defp maybe_put_status_transition_flash(socket, _previous, :cancelled, _previous_assignment) do - put_flash(socket, :info, gettext("This request was cancelled.")) + defp maybe_put_status_transition_flash( + socket, + _previous, + :cancelled, + _previous_assignment, + _assignment + ) do + put_action_flash(socket, :info, gettext("This request was cancelled.")) end - defp maybe_put_status_transition_flash(socket, _previous, _status, _previous_assignment), + defp maybe_put_status_transition_flash( + socket, + _previous, + _status, + _previous_assignment, + _assignment + ), do: socket defp restore_private_interaction(socket, true), do: socket @@ -593,6 +665,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do |> Enum.reject(&is_nil/1) |> Trust.reputations() + request_coordinates = Help.request_coordinates(socket.assigns.current_scope, request) + socket |> assign(:page_title, request.title) |> assign(:request, request) @@ -625,6 +699,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do |> assign(:message_window_limit, max(length(messages_page.entries), @initial_message_window)) |> assign(:report_message_id, nil) |> assign(:positions, positions) + |> assign(:request_coordinates, request_coordinates) |> assign(:tracking_active, tracking_active) |> assign(:message_form, to_form(%{"body" => ""}, as: :message)) |> assign(:handover_form, to_form(%{"code" => ""}, as: :handover)) @@ -647,7 +722,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do |> assign( :markers, Jason.encode!( - request_markers(socket.assigns.current_scope, request) ++ position_markers(positions) + request_markers(request, request_coordinates) ++ + position_markers(positions, request, assignment) ) ) end @@ -711,14 +787,23 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do defp assign_positions(socket, positions) do markers = - request_markers(socket.assigns.current_scope, socket.assigns.request) ++ - position_markers(positions) + request_markers(socket.assigns.request, socket.assigns.request_coordinates) ++ + position_markers(positions, socket.assigns.request, socket.assigns.assignment) socket |> assign(:positions, positions) |> assign(:markers, Jason.encode!(markers)) end + defp refresh_request_coordinates(socket) do + request_coordinates = + Help.request_coordinates(socket.assigns.current_scope, socket.assigns.request) + + socket + |> assign(:request_coordinates, request_coordinates) + |> assign_positions(socket.assigns.positions) + end + defp assign_assignment_evidence(%{assigns: %{assignment: nil}} = socket, _evidence), do: socket @@ -775,8 +860,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do defp localized(_labels, _locale), do: nil - defp request_markers(scope, request) do - case Help.request_coordinates(scope, request) do + defp request_markers(request, request_coordinates) do + case request_coordinates do nil -> [] @@ -790,13 +875,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do end end - defp position_markers(positions) do - Enum.map(positions, fn {_user_id, point} -> + defp position_markers(positions, request, assignment) do + Enum.map(positions, fn {user_id, point} -> %{ latitude: point.latitude, longitude: point.longitude, exact: true, - title: gettext("Shared location"), + type: "live", + title: live_location_title(user_id, request, assignment), location: gettext( "Last update: %{time}", @@ -806,6 +892,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do end) end + defp live_location_title(user_id, request, assignment) do + cond do + user_id == request.requester_id -> gettext("Requester's live location") + assignment && user_id == assignment.helper_id -> gettext("Helper's live location") + true -> gettext("Shared live location") + end + end + defp message(:own_request), do: gettext("You cannot accept your own request.") defp message(:not_open), do: gettext("Another helper already accepted this request.") defp message(:invalid_code), do: gettext("That handover code is not correct.") @@ -1154,17 +1248,50 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do