diff --git a/assets/css/app.css b/assets/css/app.css index 0372f4b..d78438d 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1181,11 +1181,43 @@ html { display: flex; } +.activity-location-workspace .request-location-modes { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.activity-location-workspace .request-location-radius, +.activity-location-workspace [data-hidden-guidance] { + display: none; +} + +.activity-location-workspace[data-location-mode="hidden"] .request-location-grid { + grid-template-columns: minmax(0, 1fr) minmax(14rem, 17rem); +} + +.activity-location-workspace[data-location-mode="hidden"] .request-location-map, +.activity-location-workspace[data-location-mode="hidden"] .request-location-controls, +.activity-location-workspace[data-location-mode="hidden"] .request-location-manual { + display: block; +} + +.activity-location-workspace[data-location-mode="hidden"] .request-location-hidden-state, +.activity-location-workspace[data-location-mode="hidden"] [data-approximate-guidance] { + display: none; +} + +.activity-location-workspace[data-location-mode="hidden"] [data-hidden-guidance] { + display: flex; +} + @media (max-width: 63.999rem) { .request-location-grid { grid-template-columns: 1fr; } + .activity-location-workspace[data-location-mode="hidden"] .request-location-grid { + grid-template-columns: 1fr; + } + .request-location-map { min-height: 26rem; } @@ -1239,6 +1271,10 @@ html { grid-template-columns: 1fr; } + .activity-location-workspace .request-location-modes { + grid-template-columns: 1fr; + } + .request-location-mode { min-height: 3.75rem; justify-content: flex-start; diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 6aeead8..384cfbc 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -1784,6 +1784,7 @@ export const Hooks = { this.markerMode = null this.mapLoaded = false this.mapUnavailable = false + this.preserveHiddenCoordinates = this.el.dataset.preserveHiddenCoordinates === "true" this.areaSourceId = `${this.el.id}-selection-area` this.areaFillId = `${this.el.id}-selection-fill` this.areaLineId = `${this.el.id}-selection-line` @@ -1874,11 +1875,14 @@ export const Hooks = { return } - this.setStatus( - this.mode() === "approximate_public" + const mode = this.mode() + const message = mode === "hidden" && this.preserveHiddenCoordinates + ? this.el.dataset.privateSelectedLabel + : mode === "approximate_public" ? this.el.dataset.areaSelectedLabel : this.el.dataset.pointSelectedLabel - ) + + this.setStatus(message) } this.focusSelection = () => { @@ -1887,7 +1891,7 @@ export const Hooks = { this.map.flyTo({ center: [coordinates.longitude, coordinates.latitude], - zoom: this.mode() === "exact_public" ? 15 : 13 + zoom: this.mode() === "approximate_public" ? 13 : 15 }) } @@ -1903,7 +1907,10 @@ export const Hooks = { this.renderSelection() if (center && this.map) { - this.map.flyTo({center: [longitude, latitude], zoom: this.mode() === "exact_public" ? 15 : 13}) + this.map.flyTo({ + center: [longitude, latitude], + zoom: this.mode() === "approximate_public" ? 13 : 15 + }) } if (notify) this.notifyCoordinatesChanged() @@ -1956,7 +1963,11 @@ export const Hooks = { if (!this.marker) { this.markerMode = mode this.marker = new maplibregl.Marker({ - color: mode === "approximate_public" ? "#16725b" : "#d6573b", + color: mode === "approximate_public" + ? "#16725b" + : mode === "exact_private" + ? "#6152a3" + : "#d6573b", draggable: true }) .setLngLat([coordinates.longitude, coordinates.latitude]) @@ -1967,12 +1978,16 @@ export const Hooks = { "aria-label", mode === "approximate_public" ? this.el.dataset.areaMarkerLabel + : mode === "exact_private" + ? this.el.dataset.privateMarkerLabel : this.el.dataset.pointMarkerLabel ) markerElement.setAttribute( "title", mode === "approximate_public" ? this.el.dataset.areaMarkerLabel + : mode === "exact_private" + ? this.el.dataset.privateMarkerLabel : this.el.dataset.pointMarkerLabel ) @@ -2009,7 +2024,7 @@ export const Hooks = { if (!this.mapLoaded) return - if (mode === "hidden") { + if (mode === "hidden" && !this.preserveHiddenCoordinates) { this.removeMarker() this.setAreaData(emptyFeatureCollection()) return @@ -2032,14 +2047,17 @@ export const Hooks = { } this.setAreaData(emptyFeatureCollection()) - this.ensureSelectionMarker(coordinates, mode) + this.ensureSelectionMarker( + coordinates, + mode === "hidden" ? "exact_private" : mode + ) } this.syncMode = () => { const mode = this.mode() this.el.dataset.locationMode = mode - if (mode === "hidden") { + if (mode === "hidden" && !this.preserveHiddenCoordinates) { this.clearCoordinates() this.setStatus(this.el.dataset.hiddenLabel) } else { @@ -2093,11 +2111,7 @@ export const Hooks = { : raw this.setCoordinates(selected, {center: true}) - this.setStatus( - this.mode() === "approximate_public" - ? this.el.dataset.areaSelectedLabel - : this.el.dataset.pointSelectedLabel - ) + this.setSelectionStatus() button.disabled = false }, () => { @@ -2169,17 +2183,13 @@ export const Hooks = { }) this.map.on("click", event => { - if (this.mode() === "hidden") return + if (this.mode() === "hidden" && !this.preserveHiddenCoordinates) return this.setCoordinates( {latitude: event.lngLat.lat, longitude: event.lngLat.lng}, {center: true} ) - this.setStatus( - this.mode() === "approximate_public" - ? this.el.dataset.areaSelectedLabel - : this.el.dataset.pointSelectedLabel - ) + this.setSelectionStatus() }) this.syncMode() diff --git a/e2e/tests/activity-moderation.spec.ts b/e2e/tests/activity-moderation.spec.ts index e1e37d4..b72ad8d 100644 --- a/e2e/tests/activity-moderation.spec.ts +++ b/e2e/tests/activity-moderation.spec.ts @@ -75,11 +75,14 @@ test("activity approval, privacy controls, reporting, and moderation work end to await joinDeadlinePicker.getByLabel("Time").fill(joinDeadline.slice(11, 16)); await organizer.page.getByLabel("Total group capacity").fill("3"); await organizer.page - .getByLabel("Approximate public meeting area") + .getByLabel("Public meeting area or landmark") .fill("E2E central public café"); - await organizer.page.getByLabel("Public map visibility").selectOption("approximate_public"); - await organizer.page.getByLabel("Latitude").fill("50.4501"); - await organizer.page.getByLabel("Longitude").fill("30.5234"); + await organizer.page + .getByRole("radio", { name: /Approximate area/ }) + .check(); + await organizer.page.locator("#activity-location-manual summary").click(); + await organizer.page.locator("#activity-latitude").fill("50.4501"); + await organizer.page.locator("#activity-longitude").fill("30.5234"); await organizer.page.locator("#activity-form input[type=checkbox]").check(); await organizer.page.getByRole("button", { name: "Publish activity" }).click(); await expect(organizer.page).toHaveURL(/\/activities\/[0-9a-f-]+$/); diff --git a/lib/mix/tasks/wnh.staging_full_e2e.ex b/lib/mix/tasks/wnh.staging_full_e2e.ex index 446cb9f..876c0d3 100644 --- a/lib/mix/tasks/wnh.staging_full_e2e.ex +++ b/lib/mix/tasks/wnh.staging_full_e2e.ex @@ -152,7 +152,16 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do message_ids = ids(Message, :assignment_id, assignment_ids) tracking_session_ids = ids(TrackingSession, :assignment_id, assignment_ids) - notification_ids = ids(Notification, :user_id, user_ids) + + notification_ids = + Notification + |> where( + [notification], + notification.user_id in ^user_ids or + fragment("?->>'request_id'", notification.data) in ^request_ids + ) + |> select([notification], notification.id) + |> Repo.all() activity_ids = ids(Activity, :creator_id, user_ids) validate_activity_membership!(activity_ids, user_ids) @@ -311,10 +320,11 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do defp validate_assignments!(assignment_ids, request_ids, user_ids) do unexpected? = Repo.exists?( - from assignment in Assignment, + from(assignment in Assignment, where: assignment.id in ^assignment_ids and (assignment.request_id not in ^request_ids or assignment.helper_id not in ^user_ids) + ) ) if unexpected?, @@ -324,14 +334,16 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do defp validate_activity_membership!(activity_ids, user_ids) do unexpected_participant? = Repo.exists?( - from participant in Participant, + from(participant in Participant, where: participant.user_id in ^user_ids and participant.activity_id not in ^activity_ids + ) ) unexpected_message? = Repo.exists?( - from message in ActivityMessage, + from(message in ActivityMessage, where: message.sender_id in ^user_ids and message.activity_id not in ^activity_ids + ) ) if unexpected_participant? or unexpected_message? do @@ -342,8 +354,9 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do defp validate_category_votes!(proposal_ids, user_ids) do unexpected? = Repo.exists?( - from vote in CategoryVote, + from(vote in CategoryVote, where: vote.user_id in ^user_ids and vote.proposal_id not in ^proposal_ids + ) ) if unexpected?, do: Mix.raise("a full staging fixture vote targets a non-fixture proposal") @@ -352,20 +365,23 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do defp validate_reviewed_records!(user_ids, report_ids, proposal_ids) do unexpected_report? = Repo.exists?( - from report in Report, + from(report in Report, where: report.reviewed_by_id in ^user_ids and report.id not in ^report_ids + ) ) unexpected_proposal? = Repo.exists?( - from proposal in CategoryProposal, + from(proposal in CategoryProposal, where: proposal.reviewed_by_id in ^user_ids and proposal.id not in ^proposal_ids + ) ) unexpected_signal? = Repo.exists?( - from signal in AbuseSignal, + from(signal in AbuseSignal, where: signal.reviewed_by_id in ^user_ids and signal.subject_id not in ^user_ids + ) ) if unexpected_report? or unexpected_proposal? or unexpected_signal? do @@ -380,18 +396,20 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do ) do unexpected_support_request? = Repo.exists?( - from request in SupportRequest, + from(request in SupportRequest, where: request.reviewed_by_id in ^user_ids and request.id not in ^support_request_ids + ) ) unexpected_content_removal_notice? = Repo.exists?( - from notice in Notice, + from(notice in Notice, where: notice.reviewed_by_id in ^user_ids and notice.id not in ^content_removal_notice_ids + ) ) if unexpected_support_request? or unexpected_content_removal_notice? do @@ -445,7 +463,7 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do end defp assert_prefix_unused!(context) do - if Repo.exists?(from user in User, where: like(user.email, ^"#{prefix(context.run_id)}%")) do + if Repo.exists?(from(user in User, where: like(user.email, ^"#{prefix(context.run_id)}%"))) do Mix.raise("staging E2E users still exist for #{inspect(context.run_id)}") end end diff --git a/lib/who_need_help/activities.ex b/lib/who_need_help/activities.ex index 20577e3..518b574 100644 --- a/lib/who_need_help/activities.ex +++ b/lib/who_need_help/activities.ex @@ -824,7 +824,19 @@ defmodule WhoNeedHelp.Activities do [activity], ^condition or fragment( - "ST_Intersects(?, ST_MakeEnvelope(?, ?, ?, ?, 4326))", + """ + ST_Intersects( + ST_SetSRID( + ST_MakePoint( + round(ST_X(?)::numeric, 2)::double precision, + round(ST_Y(?)::numeric, 2)::double precision + ), + 4326 + ), + ST_MakeEnvelope(?, ?, ?, ?, 4326) + ) + """, + activity.location, activity.location, ^west, ^south, @@ -894,7 +906,7 @@ defmodule WhoNeedHelp.Activities do do: is_binary(area) and String.trim(area) != "" defp viewport_matches?(activity, %DiscoveryViewport{} = viewport, _area) do - %{latitude: latitude, longitude: longitude} = Activity.exact_coordinates(activity) + %{latitude: latitude, longitude: longitude} = Activity.public_coordinates(activity) latitude >= viewport.south and latitude <= viewport.north and longitude_in_viewport?(longitude, viewport) diff --git a/lib/who_need_help/tracking_presence_cleanup.ex b/lib/who_need_help/tracking_presence_cleanup.ex index 38cc54d..95fe9f7 100644 --- a/lib/who_need_help/tracking_presence_cleanup.ex +++ b/lib/who_need_help/tracking_presence_cleanup.ex @@ -28,7 +28,7 @@ defmodule WhoNeedHelp.TrackingPresenceCleanup do ":tracking_presence_cleanup_grace_ms must be a non-negative integer" end - {:ok, %{grace_ms: grace_ms}} + {:ok, %{grace_ms: grace_ms, pending: %{}}} end @impl GenServer @@ -36,24 +36,41 @@ defmodule WhoNeedHelp.TrackingPresenceCleanup do {:maybe_stop, key, assignment_id, user_id, tracking_session_id}, state ) do - Process.send_after( - self(), - {:maybe_stop_after_grace, key, assignment_id, user_id, tracking_session_id}, - state.grace_ms - ) + cleanup_key = {key, assignment_id, user_id, tracking_session_id} - {:noreply, state} + case Map.get(state.pending, cleanup_key) do + %{timer_ref: timer_ref} -> Process.cancel_timer(timer_ref) + nil -> :ok + end + + token = make_ref() + + timer_ref = + Process.send_after( + self(), + {:maybe_stop_after_grace, cleanup_key, token}, + state.grace_ms + ) + + pending = Map.put(state.pending, cleanup_key, %{timer_ref: timer_ref, token: token}) + + {:noreply, %{state | pending: pending}} end @impl GenServer - def handle_info( - {:maybe_stop_after_grace, key, assignment_id, user_id, tracking_session_id}, - state - ) do - if Presence.get_by_key(Presence.tracking_topic(), key) == [] do - Tracking.stop_browser_session(assignment_id, user_id, tracking_session_id) - end + def handle_info({:maybe_stop_after_grace, cleanup_key, token}, state) do + case Map.get(state.pending, cleanup_key) do + %{token: ^token} -> + {key, assignment_id, user_id, tracking_session_id} = cleanup_key - {:noreply, state} + if Presence.get_by_key(Presence.tracking_topic(), key) == [] do + Tracking.stop_browser_session(assignment_id, user_id, tracking_session_id) + end + + {:noreply, %{state | pending: Map.delete(state.pending, cleanup_key)}} + + _stale_or_cancelled -> + {:noreply, state} + end end end 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 c93a0e7..1506ac9 100644 --- a/lib/who_need_help_web/live/activity_live/new.ex +++ b/lib/who_need_help_web/live/activity_live/new.ex @@ -15,6 +15,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do |> assign(:structured_fields, []) |> assign(:structured_touched_keys, []) |> assign(:structured_submit_attempted, false) + |> assign(:submission_errors, []) |> assign(:form, to_form(Activities.change_activity(%Activity{})))} end @@ -37,6 +38,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do socket |> assign(:form, to_form(changeset)) |> assign(:structured_touched_keys, touched_keys) + |> maybe_refresh_submission_errors(changeset) |> assign_category(params)} end @@ -55,6 +57,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do socket |> assign(:form, to_form(changeset)) |> assign(:structured_submit_attempted, true) + |> assign(:submission_errors, submission_errors(changeset)) |> assign_category(params)} {:error, reason} -> @@ -82,6 +85,24 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do end) end + defp maybe_refresh_submission_errors(%{assigns: %{submission_errors: []}} = socket, _changeset), + do: socket + + defp maybe_refresh_submission_errors(socket, changeset), + do: assign(socket, :submission_errors, submission_errors(changeset)) + + defp submission_errors(changeset) do + changeset.errors + |> Enum.map(fn + {:location, _error} -> + {:location, gettext("Choose the meeting point on the map or use your current location.")} + + {field, error} -> + {field, "#{Phoenix.Naming.humanize(field)}: #{translate_error(error)}"} + end) + |> Enum.uniq() + end + defp assign_category(socket, params) do category = Enum.find(socket.assigns.categories, &(&1.id == params["category_id"])) @@ -139,6 +160,15 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do defp error_message(_reason), do: gettext("Could not publish the activity. Please try again.") + defp location_mode(form) do + case to_string(form[:location_visibility].value || "approximate_public") do + "hidden" -> "hidden" + _approximate_or_missing -> "approximate_public" + end + end + + defp coordinate_value(form, key), do: Map.get(form.params || %{}, key, "") + @impl true def render(assigns) do ~H""" @@ -292,54 +322,206 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do min="2" label={gettext("Total group capacity")} /> - <.input - field={@form[:location_label]} - label={gettext("Approximate public meeting area")} - placeholder={gettext("City centre, near the main entrance")} - /> - <.input - field={@form[:location_visibility]} - type="select" - label={gettext("Public map visibility")} - options={[ - {gettext("Approximate area"), "approximate_public"}, - {gettext("Hidden"), "hidden"} - ]} - /> -
- -

- {gettext("Exact coordinates are shown only to participants you approve.")} -

-
+
+ +
+ + {gettext("What should people see before approval?")} + +
+ + +
+
+ +
+
+
+
+ <.icon name="hero-hand-raised" class="size-4 shrink-0" /> + + {gettext("Click the map or drag the pin to choose the meeting point")} + + + {gettext("Click the map or drag the pin to choose the meeting point")} + +
+
+ + +
+ +

+ {gettext("Choose the public meeting point on the map or use your current location.")} +

+ +
+ {gettext("Enter coordinates manually")} +

+ {gettext( + "These coordinates are the exact meeting point. They are revealed only after approval." + )} +

+
+ + +
+
+ +

+ <.icon name="hero-exclamation-circle" class="size-5" /> + {message} +

+
<.input field={@form[:safety_confirmed]} 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 e970294..5c78ed5 100644 --- a/lib/who_need_help_web/live/activity_live/show.ex +++ b/lib/who_need_help_web/live/activity_live/show.ex @@ -535,6 +535,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do