fix: align realtime feedback and map privacy

This commit is contained in:
SimpleTest 2026-07-23 03:00:51 +03:00
parent f0512fab84
commit 7ce052d0a1
6 changed files with 326 additions and 84 deletions

View File

@ -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()
}
},

View File

@ -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

View File

@ -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")}
<span
data-enable-push-label
data-enable-label={gettext("Enable push on this device")}
data-enabled-label={gettext("Push enabled on this device")}
>
{gettext("Enable push on this device")}
</span>
</button>
<p data-push-status role="status" aria-live="polite" class="mt-2 text-xs">
<%= if @web_push_public_key do %>

View File

@ -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
</div>
<aside class="space-y-5">
<div
id="show-map"
phx-hook="AidMap"
phx-update="ignore"
data-markers={@markers}
data-map-unavailable-label={
gettext("The map is unavailable in this browser. Request details remain usable.")
}
data-map-retry-label={gettext("Try map again")}
class="aid-map"
/>
<div class="space-y-2">
<div
id="show-map"
phx-hook="AidMap"
phx-update="ignore"
data-markers={@markers}
data-map-unavailable-label={
gettext("The map is unavailable in this browser. Request details remain usable.")
}
data-map-retry-label={gettext("Try map again")}
class="aid-map"
/>
<div
:if={
@request_coordinates || map_size(@positions) > 0
}
class="flex flex-wrap gap-x-4 gap-y-1 px-1 text-xs text-base-content/65"
aria-label={gettext("Map legend")}
>
<span
:if={@request_coordinates}
class="inline-flex items-center gap-1.5"
>
<span
class={[
"size-2.5 rounded-full",
@request.location_visibility == :approximate_public && "bg-success",
@request.location_visibility != :approximate_public && "bg-error"
]}
aria-hidden="true"
>
</span>
<span>
{if @request.location_visibility == :approximate_public,
do: gettext("Request area"),
else: gettext("Request point")}
</span>
</span>
<span :if={map_size(@positions) > 0} class="inline-flex items-center gap-1.5">
<span class="size-2.5 rounded-full bg-blue-600" aria-hidden="true"></span>
<span>{gettext("Live shared location")}</span>
</span>
</div>
</div>
<div class="rounded-3xl bg-neutral p-6 text-neutral-content">
<h2 class="text-lg font-bold">{gettext("Action center")}</h2>

View File

@ -416,6 +416,13 @@ defmodule WhoNeedHelp.TrustSafetyTest do
radius_meters: 500
} = WhoNeedHelp.Help.HelpRequest.public_coordinates(request)
assert %{
latitude: 50.4501,
longitude: 30.5234,
exact: false,
radius_meters: 500
} = Help.request_coordinates(context.requester_scope, request)
invalid_attrs = Map.put(context.attrs, "location_radius_meters", "5000")
assert {:error, changeset} = Help.create_request(context.requester_scope, invalid_attrs)
assert "is invalid" in errors_on(changeset).location_radius_meters

View File

@ -31,10 +31,24 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
idempotency_key: "live-notification:#{Ecto.UUID.generate()}"
})
{:ok, push_device} =
Notifications.register_device(scope, %{
"platform" => "web",
"provider" => "web_push",
"token" => "https://push.example/subscriptions/#{Ecto.UUID.generate()}",
"installation_id" => Ecto.UUID.generate(),
"p256dh" => "test-public-key",
"auth_secret" => "test-auth-secret",
"device_label" => "Test browser"
})
{:ok, view, html} = live(conn, ~p"/notifications")
assert html =~ "Notifications"
assert html =~ "Support request updated"
assert html =~ "1 unread"
assert html =~ ~s(data-enable-label="Enable push on this device")
assert html =~ ~s(data-enabled-label="Push enabled on this device")
assert has_element?(view, "[data-disable-device='#{push_device.id}']")
view |> element("button[phx-click='mark-all-read']") |> render_click()
assert render(view) =~ "0 unread"
@ -704,6 +718,12 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> element("button[phx-click='accept']")
|> render_click()
assert render(helper_view) =~
"You accepted this request. Use private chat to coordinate safely."
assert render(requester_view) =~
"A volunteer accepted this request. Use private chat to coordinate safely."
assert render(requester_view) =~ "Private match chat"
assert render(helper_view) =~ "Private match chat"
assert has_element?(helper_view, "#message-form")
@ -765,10 +785,15 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
}}
)
assert render(requester_view) =~ "50.452"
assert render(requester_view) =~ "30.526"
assert render(requester_view) =~ "helper movement supported"
refute render(requester_view) =~ "no movement evidence"
requester_html = render(requester_view)
assert requester_html =~ "50.452"
assert requester_html =~ "30.526"
assert requester_html =~ "helper movement supported"
assert requester_html =~ "Live shared location"
refute requester_html =~ "no movement evidence"
assert has_element?(requester_view, "#show-map[data-markers*='live']")
assert has_element?(requester_view, "#show-map[data-markers*='Helper']")
send(requester_view.pid, {:tracking_stopped, helper.id})
@ -1069,11 +1094,18 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
code = Help.handover_code(request.id)
pasted_code = String.slice(code, 0, 3) <> " " <> String.slice(code, 3, 3)
view
|> form("#handover-form", handover: %{code: "000 000"})
|> render_submit()
assert render(view) =~ "That handover code is not correct."
view
|> form("#handover-form", handover: %{code: pasted_code})
|> render_submit()
assert render(view) =~ "Handover code verified."
refute render(view) =~ "That handover code is not correct."
refute has_element?(view, "#handover-form")
assert Help.get_request!(request.id).assignment.handover_verified_at
end