diff --git a/Dockerfile.backup b/Dockerfile.backup index ea1ddf4..f1742c6 100644 --- a/Dockerfile.backup +++ b/Dockerfile.backup @@ -1,5 +1,7 @@ FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS restic +ARG X_TEXT_VERSION=v0.40.0 + ENV GOTOOLCHAIN=local WORKDIR /src @@ -7,7 +9,9 @@ WORKDIR /src RUN go mod init who-need-help/restic-build \ && go get github.com/restic/restic/cmd/restic@v0.19.1 \ && go get google.golang.org/grpc@v1.82.1 \ + && go get "golang.org/x/text@${X_TEXT_VERSION}" \ && test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "v1.82.1" \ + && test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "${X_TEXT_VERSION}" \ && CGO_ENABLED=0 go build \ -trimpath \ -ldflags="-s -w" \ diff --git a/Dockerfile.caddy b/Dockerfile.caddy index e1b1156..dd6b53a 100644 --- a/Dockerfile.caddy +++ b/Dockerfile.caddy @@ -5,15 +5,21 @@ FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff9 ENV CGO_ENABLED=0 ENV GOTOOLCHAIN=local +ARG CADDY_VERSION=v2.11.4 +ARG GRPC_GO_VERSION=v1.82.1 +ARG X_TEXT_VERSION=v0.40.0 + WORKDIR /src RUN go mod init who-need-help/caddy-build \ - && go get github.com/caddyserver/caddy/v2/cmd/caddy@v2.11.4 \ - && go get google.golang.org/grpc@v1.82.1 \ - && test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "v1.82.1" \ + && go get "github.com/caddyserver/caddy/v2/cmd/caddy@${CADDY_VERSION}" \ + && go get "google.golang.org/grpc@${GRPC_GO_VERSION}" \ + && go get "golang.org/x/text@${X_TEXT_VERSION}" \ + && test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "$GRPC_GO_VERSION" \ + && test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "$X_TEXT_VERSION" \ && go build \ -trimpath \ - -ldflags="-s -w -X github.com/caddyserver/caddy/v2.CustomVersion=v2.11.4-wnh-grpc1.82.1" \ + -ldflags="-s -w -X github.com/caddyserver/caddy/v2.CustomVersion=${CADDY_VERSION}-wnh-grpc1.82.1-xtext0.40.0" \ -o /out/caddy \ github.com/caddyserver/caddy/v2/cmd/caddy diff --git a/Dockerfile.minio b/Dockerfile.minio index abace4c..3e7ef7c 100644 --- a/Dockerfile.minio +++ b/Dockerfile.minio @@ -1,5 +1,9 @@ FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS minio_builder +ARG X_TEXT_VERSION=v0.40.0 +ARG X_CRYPTO_VERSION=v0.54.0 +ARG X_NET_VERSION=v0.57.0 + ADD --checksum=sha256:45521908307306e925c98d629e1c17d78c8b72b6ee242b1bfb1409f7d8ee5841 \ https://github.com/minio/minio/archive/9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a.tar.gz \ /tmp/minio.tar.gz @@ -12,10 +16,14 @@ RUN tar --extract --gzip --file /tmp/minio.tar.gz \ github.com/apache/thrift@v0.23.0 \ github.com/buger/jsonparser@v1.1.2 \ github.com/prometheus/prometheus@v0.311.3 \ - golang.org/x/crypto@v0.52.0 \ - golang.org/x/net@v0.55.0 \ + golang.org/x/crypto@${X_CRYPTO_VERSION} \ + golang.org/x/net@${X_NET_VERSION} \ + golang.org/x/text@${X_TEXT_VERSION} \ google.golang.org/grpc@v1.82.1 \ + && test "$(go list -m -f '{{.Version}}' golang.org/x/crypto)" = "${X_CRYPTO_VERSION}" \ + && test "$(go list -m -f '{{.Version}}' golang.org/x/net)" = "${X_NET_VERSION}" \ && test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "v1.82.1" \ + && test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "${X_TEXT_VERSION}" \ && CGO_ENABLED=0 go build \ -mod=mod \ -trimpath \ @@ -30,6 +38,10 @@ RUN tar --extract --gzip --file /tmp/minio.tar.gz \ FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS mc_builder +ARG X_TEXT_VERSION=v0.40.0 +ARG X_CRYPTO_VERSION=v0.54.0 +ARG X_NET_VERSION=v0.57.0 + ADD --checksum=sha256:95cd293c7119f16921a6dc515a1fb74a2227f19fd994b9c8b770a154e802ac44 \ https://github.com/minio/mc/archive/7394ce0dd2a80935aded936b09fa12cbb3cb8096.tar.gz \ /tmp/mc.tar.gz @@ -40,11 +52,15 @@ RUN tar --extract --gzip --file /tmp/mc.tar.gz \ --directory . --strip-components=1 \ && go get \ github.com/prometheus/prometheus@v0.311.3 \ - golang.org/x/crypto@v0.52.0 \ - golang.org/x/net@v0.55.0 \ + golang.org/x/crypto@${X_CRYPTO_VERSION} \ + golang.org/x/net@${X_NET_VERSION} \ + golang.org/x/text@${X_TEXT_VERSION} \ google.golang.org/grpc@v1.82.1 \ && go mod tidy \ + && test "$(go list -m -f '{{.Version}}' golang.org/x/crypto)" = "${X_CRYPTO_VERSION}" \ + && test "$(go list -m -f '{{.Version}}' golang.org/x/net)" = "${X_NET_VERSION}" \ && test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "v1.82.1" \ + && test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "${X_TEXT_VERSION}" \ && CGO_ENABLED=0 go build \ -trimpath \ -tags kqueue \ diff --git a/Dockerfile.traefik b/Dockerfile.traefik index edda63e..1ec0f4d 100644 --- a/Dockerfile.traefik +++ b/Dockerfile.traefik @@ -2,18 +2,18 @@ FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS builder -ARG TRAEFIK_VERSION=v3.7.8 -ARG TRAEFIK_SOURCE_SHA256=3a725c0ead27fa512756acd57056ec4652420a9daceaa6d9c170bfbb25bf51f9 -ARG TRAEFIK_BUILD_DATE=2026-07-15_12:51:10PM +ARG TRAEFIK_VERSION=v3.7.10 +ARG TRAEFIK_SOURCE_SHA256=31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6 +ARG TRAEFIK_BUILD_DATE=2026-07-31_12:49:21PM ARG GRPC_GO_VERSION=v1.82.1 -ADD --checksum=sha256:3a725c0ead27fa512756acd57056ec4652420a9daceaa6d9c170bfbb25bf51f9 \ - https://github.com/traefik/traefik/releases/download/v3.7.8/traefik-v3.7.8.src.tar.gz \ +ADD --checksum=sha256:31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6 \ + https://github.com/traefik/traefik/releases/download/v3.7.10/traefik-v3.7.10.src.tar.gz \ /tmp/traefik.tar.gz WORKDIR /src -RUN test "$TRAEFIK_SOURCE_SHA256" = "3a725c0ead27fa512756acd57056ec4652420a9daceaa6d9c170bfbb25bf51f9" \ +RUN test "$TRAEFIK_SOURCE_SHA256" = "31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6" \ && tar -xzf /tmp/traefik.tar.gz --strip-components=1 \ && go get "google.golang.org/grpc@${GRPC_GO_VERSION}" \ && test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "$GRPC_GO_VERSION" \ diff --git a/README.md b/README.md index cd07f10..d014007 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,10 @@ Compose starts Traefik, PostGIS, Mailpit, a migration runner, 2 web replicas, and 2 Oban worker replicas. It waits for readiness and verifies a PubSub message broadcast from a different BEAM node. Registration emails appear in Mailpit. The Traefik image is reproducibly built from the checksum-pinned upstream -`v3.7.8` source with `grpc-go 1.82.1`. The Caddy edge, backup image, and optional -MinIO images build upstream Caddy `v2.11.4`, restic `v0.19.1`, MinIO, and `mc` -with the same dependency pin. Those upstream dependency graphs still contain +`v3.7.10` source with `grpc-go 1.82.1`. The Caddy edge, backup image, and optional +MinIO images build upstream Caddy `v2.11.4` (with `grpc-go 1.82.1` and +`golang.org/x/text 0.40.0`), restic `v0.19.1`, MinIO, and `mc` with the same +dependency pin. Those upstream dependency graphs still contain older `grpc-go` versions affected by [GHSA-hrxh-6v49-42gf](https://github.com/advisories/GHSA-hrxh-6v49-42gf). diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/LaunchUrlResolver.java b/android/app/src/main/java/org/whoneedhelp/mobile/LaunchUrlResolver.java index be402f7..7697c99 100644 --- a/android/app/src/main/java/org/whoneedhelp/mobile/LaunchUrlResolver.java +++ b/android/app/src/main/java/org/whoneedhelp/mobile/LaunchUrlResolver.java @@ -42,4 +42,8 @@ final class LaunchUrlResolver { return null; } + + static boolean shouldLoadIncomingUrl(String currentUrl, String candidateUrl) { + return candidateUrl != null && !candidateUrl.equals(currentUrl); + } } diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java index 829fc6a..06b9a8b 100644 --- a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java +++ b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java @@ -259,7 +259,10 @@ public final class MainActivity extends ComponentActivity { BuildConfig.DEBUG ); - if (candidate != null && webView != null) { + if ( + webView != null && + LaunchUrlResolver.shouldLoadIncomingUrl(webView.getUrl(), candidate) + ) { webView.loadUrl(candidate); } } diff --git a/android/app/src/test/java/org/whoneedhelp/mobile/LaunchUrlResolverTest.java b/android/app/src/test/java/org/whoneedhelp/mobile/LaunchUrlResolverTest.java index a7402cc..7d74099 100644 --- a/android/app/src/test/java/org/whoneedhelp/mobile/LaunchUrlResolverTest.java +++ b/android/app/src/test/java/org/whoneedhelp/mobile/LaunchUrlResolverTest.java @@ -1,7 +1,9 @@ package org.whoneedhelp.mobile; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import org.junit.Test; @@ -76,4 +78,26 @@ public final class LaunchUrlResolverTest { ) ); } + + @Test + public void incomingIntentDoesNotReloadThePageAlreadyShown() { + assertFalse( + LaunchUrlResolver.shouldLoadIncomingUrl( + "https://help.example/requests/123", + "https://help.example/requests/123" + ) + ); + assertTrue( + LaunchUrlResolver.shouldLoadIncomingUrl( + "https://help.example/requests/123", + "https://help.example/activities/456" + ) + ); + assertFalse( + LaunchUrlResolver.shouldLoadIncomingUrl( + "https://help.example/requests/123", + null + ) + ); + } } diff --git a/android/play-store/data-safety.md b/android/play-store/data-safety.md index 52ba744..8ad7620 100644 --- a/android/play-store/data-safety.md +++ b/android/play-store/data-safety.md @@ -6,7 +6,9 @@ current Play form immediately before submission. ## Form-level answers -- Does the app collect or share required user data types? **Yes, collects.** +- Does the app collect or share required user data types? **Yes, collects and + shares.** The conservative sharing declaration is required by the current + direct OpenStreetMap tile integration described below. - Is all user data encrypted in transit? **Yes.** Production app traffic uses HTTPS; Firebase Cloud Messaging also uses encrypted transport. - Can users request deletion? **Yes.** @@ -61,13 +63,30 @@ The current implementation sends data to: participant, sending a message, or starting live sharing. Google Play excludes some service-provider transfers and user-initiated sharing -from the “shared” declaration. The production map-tile provider and its -contractual/service-provider status are not yet confirmed, so the exact -top-level “shared” answer is currently **unknown**. Do not submit the form until -the final provider, its terms/data-processing role, and the exact release -network trace have been reviewed against the definitions shown by the current -Play form. If no exemption applies, declare the applicable device/network data -as shared. +from the “shared” declaration. The current default production configuration +loads raster tiles directly from `tile.openstreetmap.org`; OSMF is an independent +third party and there is no verified service-provider agreement under which it +processes data solely on behalf of Who Need Help. OSMF's current privacy policy +says that requests to its services produce records including IP address, +browser/device type, operating system, referrer, time, and requested pages. +At detailed zoom levels, requested tile coordinates can also describe an area +smaller than 3 km². + +Until the release uses a separately verified provider relationship, answer the +top-level sharing question **Yes** and conservatively declare these current +direct tile transfers: + +- **Approximate location — shared, optional, app functionality.** +- **Precise location — shared, optional, app functionality.** This applies when + a user opens a detailed map around an exact or live point. +- **Device or other IDs — shared, required while maps are used, app + functionality.** This is the conservative classification for the network and + browser/application identifiers recorded by OSMF. + +This is a disclosure choice, not permission to send private request text, +messages, email, handover codes, or raw live-location API payloads to the tile +provider; the current tile requests must remain limited to standard tile +coordinates and ordinary HTTP request metadata. ## Source checks before every release @@ -76,7 +95,8 @@ as shared. 2. Confirm that Analytics, Crashlytics, ads, and delivery-metrics export remain absent or update the declaration. 3. Confirm the final map-tile provider, provider agreement, request metadata, - and Play sharing classification. + and Play sharing classification. If the direct OSMF integration remains, + keep the conservative sharing declarations above. 4. Compare with `/privacy`, `/account/delete`, Android manifest permissions, and the live-location prominent disclosure. 5. Confirm the external deletion URL loads without authentication and submits a @@ -84,9 +104,29 @@ as shared. 6. Update the worksheet if media uploads, avatars, payments, analytics, or any new SDK is introduced. +## 2026-07-31 release-candidate verification + +- `releaseRuntimeClasspath` contains Firebase Cloud Messaging 25.1.1 and its + Firebase Installations dependency. It does not contain the Firebase + Analytics, Crashlytics, Performance Monitoring, or advertising SDKs. +- The manifest keeps FCM auto-initialization and Firebase Analytics collection + disabled. Push registration is enabled only after the user requests it in the + product UI. +- No call enabling BigQuery message-delivery export was found in the Android + source. +- Firebase's current Android disclosure reference says FCM automatically + collects the app version and Firebase user agent, while Firebase + Installations generates and collects a per-installation FID. The device-ID + row above conservatively accounts for the installation identifier. +- The exact dependency report is generated locally during release validation + and intentionally is not treated as a permanent substitute for re-checking + the final AAB and current Google Play form. + ## Official references - https://support.google.com/googleplay/android-developer/answer/10787469 - https://support.google.com/googleplay/android-developer/answer/13327111 - https://firebase.google.com/docs/android/play-data-disclosure - https://firebase.google.com/support/privacy/ +- https://operations.osmfoundation.org/policies/tiles/ +- https://osmfoundation.org/wiki/Privacy_Policy diff --git a/android/play-store/release-checklist.md b/android/play-store/release-checklist.md index 8e0aa1a..002a30f 100644 --- a/android/play-store/release-checklist.md +++ b/android/play-store/release-checklist.md @@ -2,8 +2,8 @@ ## External account gate -- [ ] Google Play developer identity verification approved. -- [ ] Contact phone verification completed. +- [x] Google Play developer identity verification approved. +- [x] Contact phone verification completed. - [ ] Play Console enables **Create app**. ## App identity and signing @@ -22,12 +22,12 @@ ## Build -- [x] Build from the exact committed candidate with the validated Android +- [ ] Build from the exact committed candidate with the validated Android allow-list read from the production checkout’s single `.env`. -- [x] Run `scripts/android-release-build.sh`. -- [x] Verify source fingerprint, signing certificate, bundletool validation, +- [ ] Run `scripts/android-release-build.sh`. +- [ ] Verify source fingerprint, signing certificate, bundletool validation, lint, package name, version code/name, target SDK, and production origin. -- [x] Install the universal APK generated from the same AAB on the authorized +- [ ] Install the universal APK generated from the same AAB on the authorized physical phone and run the release smoke test. - [ ] Upload the source-bound AAB first to internal testing. diff --git a/android/store-assets/README.md b/android/store-assets/README.md index 8cc6049..fa93e65 100644 --- a/android/store-assets/README.md +++ b/android/store-assets/README.md @@ -17,16 +17,19 @@ Suggested alt text: ## Screenshots -The four files in `screenshots/phone/` were captured from the development -Android client on a physical 1220×2712 device and cropped without stretching to -1080×1920. They contain only public examples or synthetic E2E data: +The four files in `screenshots/phone/` were captured on 2026-07-31 from the +development Android client on the authorised physical 1220×2712 device. The +captures were cropped to a 9:16 frame and proportionally scaled to 1080×1920. +They are 24-bit RGB PNG files without alpha and contain only public example +content or an unsubmitted empty form: 1. `01-home.png` — public product explanation and example medicine request. -2. `02-discovery.png` — authenticated request discovery and filters. -3. `03-private-request.png` — completed synthetic request, double-blind review, - and approximate request area. -4. `04-activity.png` — approved synthetic group chat and exact meeting point - disclosed to an approved participant. +2. `02-request-form.png` — the first step of the urgent-help request flow and + its safety notice. +3. `03-location-privacy.png` — an unsubmitted approximate-area selection with + a draggable privacy circle and radius controls. +4. `04-category-proposals.png` — the community category and subcategory + proposal flow. The uncropped ADB source captures are intentionally kept outside Git. No real email, real user message, notification, medical detail, or exact real-user @@ -39,9 +42,9 @@ privacy, or map behavior change. Suggested English alt text: 1. `Who Need Help home screen explaining nearby voluntary assistance.` -2. `Nearby help request discovery with category, urgency, and area filters.` -3. `Completed synthetic request with double-blind review and approximate map area.` -4. `Approved synthetic activity group chat with its disclosed meeting point.` +2. `Urgent-help request form with clear steps and a non-emergency safety notice.` +3. `Approximate-area map with a movable privacy circle and selectable radius.` +4. `Community form for proposing a new help category or subcategory.` Official asset requirements: diff --git a/android/store-assets/screenshots/phone/01-home.png b/android/store-assets/screenshots/phone/01-home.png index 94ea7b1..017ae9e 100644 Binary files a/android/store-assets/screenshots/phone/01-home.png and b/android/store-assets/screenshots/phone/01-home.png differ diff --git a/android/store-assets/screenshots/phone/02-discovery.png b/android/store-assets/screenshots/phone/02-discovery.png deleted file mode 100644 index 2906dee..0000000 Binary files a/android/store-assets/screenshots/phone/02-discovery.png and /dev/null differ diff --git a/android/store-assets/screenshots/phone/02-request-form.png b/android/store-assets/screenshots/phone/02-request-form.png new file mode 100644 index 0000000..fd39f4c Binary files /dev/null and b/android/store-assets/screenshots/phone/02-request-form.png differ diff --git a/android/store-assets/screenshots/phone/03-location-privacy.png b/android/store-assets/screenshots/phone/03-location-privacy.png new file mode 100644 index 0000000..df01cfa Binary files /dev/null and b/android/store-assets/screenshots/phone/03-location-privacy.png differ diff --git a/android/store-assets/screenshots/phone/03-private-request.png b/android/store-assets/screenshots/phone/03-private-request.png deleted file mode 100644 index 3b9064c..0000000 Binary files a/android/store-assets/screenshots/phone/03-private-request.png and /dev/null differ diff --git a/android/store-assets/screenshots/phone/04-activity.png b/android/store-assets/screenshots/phone/04-activity.png deleted file mode 100644 index eb292fe..0000000 Binary files a/android/store-assets/screenshots/phone/04-activity.png and /dev/null differ diff --git a/android/store-assets/screenshots/phone/04-category-proposals.png b/android/store-assets/screenshots/phone/04-category-proposals.png new file mode 100644 index 0000000..6092827 Binary files /dev/null and b/android/store-assets/screenshots/phone/04-category-proposals.png differ diff --git a/assets/css/app.css b/assets/css/app.css index 046053c..b5486e7 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -344,42 +344,6 @@ html { white-space: nowrap; } -.request-map-cluster { - display: grid; - width: 2.75rem; - height: 2.75rem; - padding: 0.2rem; - place-items: center; - color: white; - font: inherit; - font-size: 0.78rem; - font-weight: 800; - border: 3px solid white; - border-radius: 9999px; - background: #16725b; - box-shadow: 0 3px 12px rgb(0 0 0 / 24%); - cursor: pointer; -} - -.request-map-cluster.is-lg { - width: 3.25rem; - height: 3.25rem; -} - -.request-map-cluster.is-xl { - width: 3.5rem; - height: 3.5rem; -} - -.request-map-cluster.is-xxl { - width: 3.75rem; - height: 3.75rem; -} - -.request-map-cluster.is-urgent { - background: #b63b21; -} - @media (max-width: 56rem) { .request-discovery-toolbar { align-items: stretch; @@ -411,11 +375,6 @@ html { } } -.request-map-cluster:hover, -.request-map-cluster:focus-visible { - transform: scale(1.08); -} - .maplibregl-marker.is-selected svg { filter: drop-shadow(0 0 5px #d6573b) drop-shadow(0 0 8px white); transform: scale(1.12); @@ -449,6 +408,12 @@ html { height: 2.75rem; } + .aid-map .maplibregl-ctrl-attrib-button { + background-position: center; + background-repeat: no-repeat; + background-size: 1.5rem 1.5rem; + } + .request-discovery-toolbar { align-items: stretch; flex-direction: column; @@ -472,6 +437,15 @@ html { .request-discovery[data-view-mode="split"] .request-discovery-map-panel { position: static; order: -1; + width: 100%; + align-self: stretch; + flex: 0 0 auto; + } + + .request-discovery[data-view-mode="split"] .request-results-list, + .request-discovery[data-view-mode="split"] .request-discovery-map-shell { + width: 100%; + min-width: 0; } .request-discovery-map, diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 20fcaf1..d93d912 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -1,7 +1,8 @@ import maplibregl from "maplibre-gl" import { - clusterCountLabel, clusterExpansionTarget, + clusterExpansionCenter, + clusterIconDescriptor, pointFeatureCollection, pointSourceOptions, serverClusterDescriptor @@ -117,6 +118,54 @@ const markerPoints = element => { } } +const clusterIconImage = descriptor => { + const pixelRatio = 2 + const shadowPadding = 7 + const logicalSize = descriptor.diameter + shadowPadding * 2 + const canvas = document.createElement("canvas") + canvas.width = logicalSize * pixelRatio + canvas.height = logicalSize * pixelRatio + + const context = canvas.getContext("2d") + context.scale(pixelRatio, pixelRatio) + const center = logicalSize / 2 + const radius = descriptor.diameter / 2 - 2 + + context.save() + context.shadowColor = "rgba(0, 0, 0, 0.24)" + context.shadowBlur = 6 + context.shadowOffsetY = 3 + context.beginPath() + context.arc(center, center, radius, 0, Math.PI * 2) + context.fillStyle = descriptor.urgent ? "#b63b21" : "#16725b" + context.fill() + context.restore() + + context.beginPath() + context.arc(center, center, radius, 0, Math.PI * 2) + context.strokeStyle = "#ffffff" + context.lineWidth = 3 + context.stroke() + + let fontSize = descriptor.label.length >= 4 ? 11 : 12 + context.font = `800 ${fontSize}px system-ui, sans-serif` + + while (context.measureText(descriptor.label).width > descriptor.diameter - 12 && fontSize > 8) { + fontSize -= 1 + context.font = `800 ${fontSize}px system-ui, sans-serif` + } + + context.fillStyle = "#ffffff" + context.textAlign = "center" + context.textBaseline = "middle" + context.fillText(descriptor.label, center, center + 0.5) + + return { + image: context.getImageData(0, 0, canvas.width, canvas.height), + pixelRatio + } +} + const emptyFeatureCollection = () => ({type: "FeatureCollection", features: []}) const areaPolygon = point => { @@ -288,13 +337,12 @@ const createAidMap = element => { element, map: null, active: true, - viewportTimer: null, + pendingViewportAction: null, resizeFrame: null, resizeObserver: null, renderedSize: null, - clusterMarkers: new Map(), - visibleClusterKeys: new Set(), - markerElements: new Map(), + clusterImageIds: new Set(), + clusterImageRemovalTimers: new Map(), markerDataSignature: null, points: [], selectedItemId: null, @@ -308,6 +356,7 @@ const createAidMap = element => { areaLineId: `${element.id || "aid-map"}-area-line`, areaSelectedId: `${element.id || "aid-map"}-area-selected`, clusterSourceId: `${element.id || "aid-map"}-clusters`, + clusterIconId: `${element.id || "aid-map"}-cluster-icon`, pointCircleId: `${element.id || "aid-map"}-point-circle`, pointSelectedId: `${element.id || "aid-map"}-point-selected`, statusBadge: element.parentElement?.querySelector("[data-map-status-badge]") || null @@ -323,14 +372,6 @@ const createAidMap = element => { state.element.dataset.mapReady = "true" } - state.queueViewportChange = viewport => { - window.clearTimeout(state.viewportTimer) - state.viewportTimer = window.setTimeout(() => { - state.viewportTimer = null - if (state.active) state.onViewportChange?.(viewport) - }, 300) - } - state.retry = () => { if (!state.active) return @@ -467,10 +508,6 @@ const createAidMap = element => { state.highlightRequest = id => { state.selectedItemId = id || null - state.markerElements.forEach((element, requestId) => { - element.classList.toggle("is-selected", requestId === id) - }) - if (state.map?.getLayer(state.areaSelectedId)) { state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""]) } @@ -575,95 +612,45 @@ const createAidMap = element => { state.clusterExpansionPending = true state.map.easeTo({ - center: [point.longitude, point.latitude], + center: clusterExpansionCenter(point), zoom: expansionZoom, duration: 300 }) } - state.syncClusterMarkers = () => { + state.syncClusterImages = points => { if (!state.map || state.element.dataset.discoveryMap !== "true") return - const visibleKeys = new Set() - - state.points.forEach(point => { - const descriptor = serverClusterDescriptor(point) - if (!descriptor) return - - const {key, count, urgentCount, longitude, latitude} = descriptor - visibleKeys.add(key) - - const formattedCount = new Intl.NumberFormat(uiLocale()).format(count) - const label = (state.element.dataset.clusterLabel || "%{count} items") - .replace("%{count}", formattedCount) - let entry = state.clusterMarkers.get(key) - - if (!entry) { - const button = document.createElement("button") - const marker = new maplibregl.Marker({element: button}) - .setSubpixelPositioning(true) - - button.type = "button" - button.className = "request-map-cluster" - button.addEventListener("click", event => { - event.stopPropagation() - state.expandServerCluster(entry.point) - }) - - entry = { - button, - marker, - point, - visible: false, - count: null, - urgentCount: null, - label: null, - longitude: null, - latitude: null - } - state.clusterMarkers.set(key, entry) - } - - entry.point = point - - if (entry.count !== count || entry.urgentCount !== urgentCount) { - entry.count = count - entry.urgentCount = urgentCount - entry.button.dataset.clusterCount = String(count) - entry.button.classList.toggle("is-lg", count >= 1_000 && count < 10_000) - entry.button.classList.toggle("is-xl", count >= 10_000 && count < 100_000) - entry.button.classList.toggle("is-xxl", count >= 100_000) - entry.button.classList.toggle("is-urgent", urgentCount > 0) - entry.button.textContent = clusterCountLabel(count, uiLocale()) - } - - if (entry.label !== label) { - entry.label = label - entry.button.setAttribute("aria-label", label) - entry.button.title = label - } - - if (entry.longitude !== longitude || entry.latitude !== latitude) { - entry.longitude = longitude - entry.latitude = latitude - entry.marker.setLngLat([longitude, latitude]) - } - - if (!entry.visible) { - entry.marker.addTo(state.map) - entry.visible = true - } + const descriptors = new Map() + points.forEach(point => { + if (!serverClusterDescriptor(point)) return + const descriptor = clusterIconDescriptor(point, uiLocale()) + descriptors.set(descriptor.id, descriptor) }) - state.visibleClusterKeys.forEach(key => { - if (visibleKeys.has(key)) return + descriptors.forEach((descriptor, imageId) => { + const removalTimer = state.clusterImageRemovalTimers.get(imageId) + if (removalTimer !== undefined) { + window.clearTimeout(removalTimer) + state.clusterImageRemovalTimers.delete(imageId) + } - const entry = state.clusterMarkers.get(key) - entry?.marker.remove() - state.clusterMarkers.delete(key) + if (state.map.hasImage(imageId)) return + const {image, pixelRatio} = clusterIconImage(descriptor) + state.map.addImage(imageId, image, {pixelRatio}) }) - state.visibleClusterKeys = visibleKeys + state.clusterImageIds.forEach(imageId => { + if (descriptors.has(imageId) || state.clusterImageRemovalTimers.has(imageId)) return + const timer = window.setTimeout(() => { + state.clusterImageRemovalTimers.delete(imageId) + if (!state.active || state.clusterImageIds.has(imageId)) return + if (state.map?.hasImage(imageId)) state.map.removeImage(imageId) + }, 100) + state.clusterImageRemovalTimers.set(imageId, timer) + }) + + state.clusterImageIds = new Set(descriptors.keys()) } state.syncVisibleAreas = () => { @@ -742,6 +729,7 @@ const createAidMap = element => { { const areaSource = existingAreaSource + state.syncClusterImages(points) if (areaSource) { areaSource.setData(areaFeatures) @@ -784,6 +772,18 @@ const createAidMap = element => { pointSource.setData(pointFeatures) } else { state.map.addSource(state.clusterSourceId, pointSourceOptions(pointFeatures)) + state.map.addLayer({ + id: state.clusterIconId, + type: "symbol", + source: state.clusterSourceId, + filter: ["==", ["get", "item_type"], "cluster"], + layout: { + "icon-image": ["get", "cluster_icon"], + "icon-anchor": "center", + "icon-allow-overlap": true, + "icon-ignore-placement": true + } + }) state.map.addLayer({ id: state.pointCircleId, type: "circle", @@ -819,7 +819,7 @@ const createAidMap = element => { } }) - const interactiveLayers = [state.pointCircleId] + const interactiveLayers = [state.clusterIconId, state.pointCircleId] interactiveLayers.forEach(layerId => { state.map.on("mouseenter", layerId, () => { @@ -830,6 +830,12 @@ const createAidMap = element => { }) }) + state.map.on("click", state.clusterIconId, event => { + const clusterId = event.features?.[0]?.properties?.server_cluster_id + const point = state.points.find(candidate => candidate.cluster_id === clusterId) + if (point) state.expandServerCluster(point) + }) + state.map.on("click", state.pointCircleId, event => { const feature = event.features?.[0] const properties = feature?.properties @@ -846,7 +852,6 @@ const createAidMap = element => { } state.markerDataSignature = markerDataSignature - state.syncClusterMarkers() if (state.element.dataset.autoFit !== "false") state.fitMarkers() } @@ -936,14 +941,11 @@ const createAidMap = element => { const viewport = mapViewport(state.map) if (!viewport) return - if (state.clusterExpansionPending) { - state.clusterExpansionPending = false - window.clearTimeout(state.viewportTimer) - state.viewportTimer = null - state.onClusterExpand?.(viewport) - } else { - state.queueViewportChange(viewport) + state.pendingViewportAction = { + viewport, + clusterExpansion: state.clusterExpansionPending } + state.clusterExpansionPending = false } state.onIdle = () => { @@ -955,6 +957,15 @@ const createAidMap = element => { } state.syncVisibleAreas() + + const action = state.pendingViewportAction + state.pendingViewportAction = null + + if (action?.clusterExpansion) { + state.onClusterExpand?.(action.viewport) + } else if (action) { + state.onViewportChange?.(action.viewport) + } } state.onRequestHighlight = event => state.highlightRequest(event.detail?.id) @@ -972,16 +983,14 @@ const createAidMap = element => { state.destroy = () => { state.active = false - window.clearTimeout(state.viewportTimer) + state.clusterImageRemovalTimers.forEach(timer => window.clearTimeout(timer)) + state.clusterImageRemovalTimers.clear() if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame) state.resizeObserver?.disconnect() state.map?.off("load", state.onLoad) state.map?.off("idle", state.onIdle) state.map?.off("moveend", state.onMoveEnd) window.removeEventListener("wnh:request-highlight", state.onRequestHighlight) - state.clusterMarkers.forEach(({marker}) => marker.remove()) - state.clusterMarkers.clear() - state.visibleClusterKeys.clear() state.map?.remove() state.map = null state.locationBounds = null diff --git a/assets/js/map_cluster_features.mjs b/assets/js/map_cluster_features.mjs index 60319c4..99e38d1 100644 --- a/assets/js/map_cluster_features.mjs +++ b/assets/js/map_cluster_features.mjs @@ -21,6 +21,33 @@ export const clusterCountLabel = (count, locale = "en") => { }).format(value) } +const clusterIconDiameter = count => { + if (count >= 100_000) return 60 + if (count >= 10_000) return 56 + if (count >= 1_000) return 52 + return 44 +} + +const imageKeyPart = value => + Array.from(value) + .map(character => character.codePointAt(0).toString(16)) + .join("-") + +export const clusterIconDescriptor = (point, locale = "en") => { + const count = finiteCount(point?.count, 1) + const urgent = finiteCount(point?.urgent_count, 0) > 0 + const label = clusterCountLabel(count, locale) + const diameter = clusterIconDiameter(count) + + return { + id: `wnh-cluster-${urgent ? "urgent" : "normal"}-${diameter}-${imageKeyPart(label)}`, + count, + urgent, + label, + diameter + } +} + export const clusterExpansionTarget = (currentZoom, requestedZoom, maximumZoom = 22) => { const current = finiteCount(currentZoom, 0) const requested = finiteCount(requestedZoom, current + 1) @@ -32,6 +59,29 @@ export const clusterExpansionTarget = (currentZoom, requestedZoom, maximumZoom = return Math.min(maximumZoom, Math.max(nextIntegerZoom, requested)) } +const normalizedLongitude = longitude => { + const wrapped = ((longitude + 180) % 360 + 360) % 360 - 180 + return wrapped === -180 && longitude > 0 ? 180 : wrapped +} + +export const clusterExpansionCenter = point => { + const fallback = [Number(point?.longitude), Number(point?.latitude)] + const bounds = point?.bounds + const west = Number(bounds?.west) + const south = Number(bounds?.south) + const east = Number(bounds?.east) + const north = Number(bounds?.north) + + if (![west, south, east, north].every(Number.isFinite)) return fallback + + const longitude = + west <= east + ? (west + east) / 2 + : normalizedLongitude((west + east + 360) / 2) + + return [longitude, (south + north) / 2] +} + export const serverClusterDescriptor = point => { if ( point?.type !== "cluster" || @@ -53,32 +103,35 @@ export const serverClusterDescriptor = point => { export const pointFeatureCollection = (points, locale = "en") => ({ type: "FeatureCollection", - features: points.map(point => ({ - type: "Feature", - id: point.id, - properties: { + features: points.map(point => { + const clusterIcon = + point.type === "cluster" ? clusterIconDescriptor(point, locale) : null + + return { + type: "Feature", id: point.id, - item_type: point.type || "request", - title: point.title || "", - location: point.location || "", - exact: point.exact === true, - radius_meters: Number.isFinite(point.radius_meters) ? point.radius_meters : 0, - weight: finiteCount(point.count, 1), - urgent_weight: finiteCount(point.urgent_count, 0), - server_cluster_id: point.cluster_id || "", - parent_cluster_id: point.parent_cluster_id || "", - hierarchy_level: finiteCount(point.hierarchy_level, 0), - expansion_zoom: finiteCount(point.expansion_zoom, 22), - count_label: - point.type === "cluster" - ? clusterCountLabel(finiteCount(point.count, 1), locale) - : "" - }, - geometry: { - type: "Point", - coordinates: [Number(point.longitude), Number(point.latitude)] + properties: { + id: point.id, + item_type: point.type || "request", + title: point.title || "", + location: point.location || "", + exact: point.exact === true, + radius_meters: Number.isFinite(point.radius_meters) ? point.radius_meters : 0, + weight: finiteCount(point.count, 1), + urgent_weight: finiteCount(point.urgent_count, 0), + server_cluster_id: point.cluster_id || "", + parent_cluster_id: point.parent_cluster_id || "", + hierarchy_level: finiteCount(point.hierarchy_level, 0), + expansion_zoom: finiteCount(point.expansion_zoom, 22), + count_label: clusterIcon?.label || "", + cluster_icon: clusterIcon?.id || "" + }, + geometry: { + type: "Point", + coordinates: [Number(point.longitude), Number(point.latitude)] + } } - })) + }) }) export const pointSourceOptions = data => ({ diff --git a/assets/js/map_cluster_features.test.mjs b/assets/js/map_cluster_features.test.mjs index ca6243a..e0429f6 100644 --- a/assets/js/map_cluster_features.test.mjs +++ b/assets/js/map_cluster_features.test.mjs @@ -3,13 +3,33 @@ import test from "node:test" import { clusterCountLabel, + clusterExpansionCenter, clusterExpansionTarget, + clusterIconDescriptor, clusterSizeClass, pointFeatureCollection, pointSourceOptions, serverClusterDescriptor } from "./map_cluster_features.mjs" +test("cluster icon descriptors are stable and encode visual state", () => { + assert.deepEqual(clusterIconDescriptor({count: 999, urgent_count: 0}, "en"), { + id: "wnh-cluster-normal-44-39-39-39", + count: 999, + urgent: false, + label: "999", + diameter: 44 + }) + + assert.deepEqual(clusterIconDescriptor({count: 12_500, urgent_count: 2}, "en"), { + id: "wnh-cluster-urgent-56-31-33-4b", + count: 12_500, + urgent: true, + label: "13K", + diameter: 56 + }) +}) + test("server hierarchy metadata survives GeoJSON conversion without becoming a native cluster", () => { const feature = pointFeatureCollection([ { @@ -31,6 +51,7 @@ test("server hierarchy metadata survives GeoJSON conversion without becoming a n assert.equal(feature.properties.expansion_zoom, 13) assert.equal(feature.properties.weight, 27) assert.equal(feature.properties.count_label, "27") + assert.equal(feature.properties.cluster_icon, "wnh-cluster-normal-44-32-37") assert.equal("point_count" in feature.properties, false) }) @@ -115,3 +136,30 @@ test("cluster expansion always advances at least one integer zoom level", () => assert.equal(clusterExpansionTarget(9.6, 13), 13) assert.equal(clusterExpansionTarget(21.9, 24), 22) }) + +test("cluster expansion centers on actual member bounds instead of the display cell", () => { + const [longitude, latitude] = clusterExpansionCenter({ + longitude: 30.4, + latitude: 50.6, + bounds: {west: 30.51, south: 50.44, east: 30.53, north: 50.46} + }) + + assert.ok(Math.abs(longitude - 30.52) <= Number.EPSILON * 32) + assert.ok(Math.abs(latitude - 50.45) <= Number.EPSILON * 32) + + assert.deepEqual( + clusterExpansionCenter({ + longitude: 179, + latitude: 10, + bounds: {west: 179.5, south: 9, east: -179.5, north: 11} + }), + [180, 10] + ) +}) + +test("cluster expansion falls back to the display point without valid bounds", () => { + assert.deepEqual( + clusterExpansionCenter({longitude: 30.52, latitude: 50.45}), + [30.52, 50.45] + ) +}) diff --git a/compose.yaml b/compose.yaml index cd9165e..5ca5151 100644 --- a/compose.yaml +++ b/compose.yaml @@ -92,7 +92,7 @@ services: restart: unless-stopped proxy: - image: who-need-help:traefik-v3.7.8-grpc1.82.1 + image: who-need-help:traefik-v3.7.10-grpc1.82.1 build: context: . dockerfile: Dockerfile.traefik diff --git a/docs/dependency-baseline.md b/docs/dependency-baseline.md index bb1f8bf..61e1e21 100644 --- a/docs/dependency-baseline.md +++ b/docs/dependency-baseline.md @@ -35,7 +35,7 @@ package checksums are in `mix.lock` and `assets/package-lock.json`. | --- | --- | | PostgreSQL | 18.4 | | PostGIS | 3.6.4 | -| Traefik | 3.7.8 | +| Traefik | 3.7.10 | | Mailpit | 1.30.4 | | k6 load generator | 2.1.0 | | Prometheus | 3.13.1 | @@ -55,8 +55,10 @@ release feeds reported those same versions as current during verification. `Dockerfile.minio` fetches checksum-pinned upstream source commits for MinIO server and client and records every dependency override used to rebuild them. -The current overrides move Apache Thrift, jsonparser, Prometheus, Go crypto/net, -and gRPC dependencies to the versions checked by the image security gate. +The current overrides move Apache Thrift, jsonparser, Prometheus, +`golang.org/x/crypto` 0.54.0, `golang.org/x/net` 0.57.0, +`golang.org/x/text` 0.40.0, and gRPC dependencies to the versions checked by +the image security gate. Both projects are AGPLv3; anyone distributing or operating modified builds must review and satisfy the applicable license obligations. This repository keeps the exact upstream source identifiers and the complete modification/build diff --git a/docs/google-play-release-candidate-2026-08-01.md b/docs/google-play-release-candidate-2026-08-01.md new file mode 100644 index 0000000..5ffaa63 --- /dev/null +++ b/docs/google-play-release-candidate-2026-08-01.md @@ -0,0 +1,75 @@ +# Google Play release candidate — 2026-08-01 + +This document identifies the exact locally validated artifact intended for the +first Google Play upload. It contains no credentials or private signing-key +material. + +## Upload artifact + +- File: `android/dist-release-20260801-final/who-need-help-release.aab` +- SHA-256: `55f05f4b7394be40f2740529eb8597279f9af25269b97c4f58fa4446b431bb14` +- Package: `org.whoneedhelp.mobile` +- Version code: `1` +- Version name: `0.1.0` +- Minimum SDK: `24` +- Target SDK: `37` +- Source fingerprint: + `8339812414061c6090b91f0abfe3562633926b4e72159885f57e7bd4000d2555` + +The source fingerprint stored next to the artifact matched a fresh local +fingerprint after the build. + +## Upload certificate + +- SHA-256: + `A5:74:2B:AE:70:C6:D0:34:E3:75:44:B6:2E:37:A3:75:C0:E0:05:64:74:50:F4:0F:29:B2:A9:84:F9:FD:B8:FB` +- SHA-1: + `8C:84:D5:CA:2F:B7:EA:2B:7E:08:2D:D1:CD:E8:AC:60:56:AA:1B:3C` + +This upload certificate is not the Google Play App Signing certificate. After +the first upload, record the Play-generated certificate separately and add its +fingerprints to production Google/Firebase configuration and the production +App Links association. + +## Validation evidence + +- `bundletool` validation passed. +- Release unit tests and Android lint passed. +- R8 release build completed successfully. +- APK and AAB signing verification passed. +- Universal APK generated from this AAB: + `android/dist-release-20260801-final/who-need-help-release-universal.apk` +- Universal APK SHA-256: + `cf8bf8001b02447fb63ee73b3d8dd980485c38113cc7e0f67705690afa64f79c` +- The universal APK was installed on the authorised physical Android 16 / API + 36 device and cold-started successfully. +- The production origin rendered correctly on the device. +- `https://whoneedhelp.com/safety` opened in the installed production app. +- No application crash or TLS/SSL/WebView load failure was observed in the + release smoke-test log. +- The complete repository quality run passed 414 tests plus compiler, format, + xref, Credo, Sobelow, Dialyzer, dependency audit, container, Compose, Helm, + migration, rollback, observability, and image-security gates. + +## First Play Console session + +1. Create **Who Need Help** as an app (not a game), free, default language + English (United States), support email `contact@whoneedhelp.com`. +2. Accept the policy, export-law, and Play App Signing declarations. +3. Complete the prepared store listing and App content sections using + `android/play-store/` and `android/store-assets/`. +4. Upload only the AAB identified above to an internal-testing release first. +5. Install the Play-delivered build from the internal-test opt-in link and + repeat the production-origin, sign-in, notification, location, and App Link + smoke tests. +6. Record the Play App Signing SHA-1 and SHA-256 before starting the closed + test. +7. Start a closed test with at least 12 continuously opted-in testers for at + least 14 days before requesting production access. + +Official references: + +- https://support.google.com/googleplay/android-developer/answer/9859152 +- https://support.google.com/googleplay/android-developer/answer/9842756 +- https://support.google.com/googleplay/android-developer/answer/9845334 +- https://support.google.com/googleplay/android-developer/answer/14151465 diff --git a/e2e/pwa/pwa.spec.ts b/e2e/pwa/pwa.spec.ts index b6ca686..356b7f6 100644 --- a/e2e/pwa/pwa.spec.ts +++ b/e2e/pwa/pwa.spec.ts @@ -172,7 +172,6 @@ test("public HTTPS PWA installs, updates its cache, and falls back safely offlin expect(await updatedPage.evaluate(() => navigator.onLine)).toBe(false); await context.setOffline(false); - await updatedPage.reload(); await expect(updatedPage.getByRole("heading", { name: "Log in", exact: true })).toBeVisible(); expect(consoleFailures).toEqual([]); }); diff --git a/lib/who_need_help/activities.ex b/lib/who_need_help/activities.ex index 72de757..3389e7a 100644 --- a/lib/who_need_help/activities.ex +++ b/lib/who_need_help/activities.ex @@ -67,8 +67,12 @@ defmodule WhoNeedHelp.Activities do points = user - |> open_activities_query(filters, viewport) + |> open_activities_query(filters, nil) |> where([activity], activity.location_visibility == :approximate_public) + |> filter_discovery_envelopes( + DiscoveryCluster.viewport_envelopes(viewport, level), + filters + ) |> select([activity], %{ id: activity.id, title: activity.title, @@ -857,8 +861,12 @@ defmodule WhoNeedHelp.Activities do defp filter_discovery_viewport(query, nil, _filters), do: query defp filter_discovery_viewport(query, %DiscoveryViewport{} = viewport, filters) do + filter_discovery_envelopes(query, DiscoveryViewport.envelopes(viewport), filters) + end + + defp filter_discovery_envelopes(query, envelopes, filters) when is_list(envelopes) do candidate_condition = - Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn + Enum.reduce(envelopes, dynamic(false), fn {west, south, east, north}, condition -> dynamic( [activity], @@ -879,7 +887,7 @@ defmodule WhoNeedHelp.Activities do end) exact_condition = - Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn + Enum.reduce(envelopes, dynamic(false), fn {west, south, east, north}, condition -> dynamic( [activity], @@ -1004,6 +1012,7 @@ defmodule WhoNeedHelp.Activities do defp map_discovery_item(row, level) do cluster_id = DiscoveryCluster.cluster_id(:activity, level, row.cell_x, row.cell_y) + {longitude, latitude} = DiscoveryCluster.cell_center(level, row.cell_x, row.cell_y) %{ type: "cluster", @@ -1021,8 +1030,8 @@ defmodule WhoNeedHelp.Activities do row.north_m ), count: row.count, - latitude: row.latitude, - longitude: row.longitude, + latitude: latitude, + longitude: longitude, bounds: %{ west: row.west, south: row.south, diff --git a/lib/who_need_help/discovery_query_cache.ex b/lib/who_need_help/discovery_query_cache.ex index 43c586f..66deb62 100644 --- a/lib/who_need_help/discovery_query_cache.ex +++ b/lib/who_need_help/discovery_query_cache.ex @@ -38,6 +38,7 @@ defmodule WhoNeedHelp.DiscoveryQueryCache do @impl true def handle_call({:fetch, key, function}, from, state) do now = System.monotonic_time(:millisecond) + state = update_in(state.entries, &prune_expired(&1, now)) case state.entries do %{^key => {expires_at, value}} when expires_at > now -> @@ -120,4 +121,8 @@ defmodule WhoNeedHelp.DiscoveryQueryCache do end defp maybe_store(state, _key, {:error, _reason}), do: state + + defp prune_expired(entries, now) do + Map.reject(entries, fn {_key, {expires_at, _value}} -> expires_at <= now end) + end end diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex index c16936b..3ffc5c5 100644 --- a/lib/who_need_help/help.ex +++ b/lib/who_need_help/help.ex @@ -83,7 +83,7 @@ defmodule WhoNeedHelp.Help do [request], not is_nil(request.location) and request.location_visibility != :hidden ) - |> filter_request_map_viewport(viewport) + |> filter_request_map_cluster_viewport(viewport, level) |> select([request], %{ id: request.id, title: request.title, @@ -202,13 +202,22 @@ defmodule WhoNeedHelp.Help do # Lists and map markers use the same displayed public center. A privacy # radius describes uncertainty around that center; it must not make a card # appear in a viewport where its public marker was not loaded. - defp filter_request_map_viewport(query, %DiscoveryViewport{} = viewport) do - where(query, ^request_map_viewport_condition(viewport)) + defp filter_request_map_cluster_viewport( + query, + %DiscoveryViewport{} = viewport, + level + ) do + envelopes = DiscoveryCluster.viewport_envelopes(viewport, level) + where(query, ^request_map_envelope_condition(envelopes)) end defp request_map_viewport_condition(%DiscoveryViewport{} = viewport) do + request_map_envelope_condition(DiscoveryViewport.envelopes(viewport)) + end + + defp request_map_envelope_condition(envelopes) when is_list(envelopes) do spatial_condition = - Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn + Enum.reduce(envelopes, dynamic(false), fn {west, south, east, north}, condition -> dynamic( [request], @@ -1271,6 +1280,7 @@ defmodule WhoNeedHelp.Help do defp map_discovery_item(row, level) do cluster_id = DiscoveryCluster.cluster_id(:request, level, row.cell_x, row.cell_y) + {longitude, latitude} = DiscoveryCluster.cell_center(level, row.cell_x, row.cell_y) %{ type: "cluster", @@ -1289,8 +1299,8 @@ defmodule WhoNeedHelp.Help do ), count: row.count, urgent_count: row.urgent_count, - latitude: row.latitude, - longitude: row.longitude, + latitude: latitude, + longitude: longitude, bounds: %{ west: row.west, south: row.south, diff --git a/lib/who_need_help/help/discovery_cluster.ex b/lib/who_need_help/help/discovery_cluster.ex index 8c4c0e0..9a1ff62 100644 --- a/lib/who_need_help/help/discovery_cluster.ex +++ b/lib/who_need_help/help/discovery_cluster.ex @@ -11,12 +11,15 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do MapLibre renders the resulting hierarchy nodes directly. They must not be passed through a second browser-side clustering pass, because that changes cluster identity and center between server responses. For each server node, - `expansion_zoom/5` identifies the first map zoom where it has more than one + `expansion_zoom` identifies the first map zoom where it has more than one child cell. """ + alias WhoNeedHelp.Help.DiscoveryViewport + @world_half_meters 20_037_508.342_789_244 @world_width_meters @world_half_meters * 2.0 + @max_mercator_latitude 85.051_128_78 @cluster_level_offset 2 @minimum_level 0 @maximum_map_zoom 22 @@ -26,7 +29,7 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do def level_for_zoom(zoom) when is_number(zoom) do zoom - |> floor() + |> round() |> Kernel.+(@cluster_level_offset) |> clamp(@minimum_level, @maximum_level) end @@ -37,12 +40,52 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do def world_half_meters, do: @world_half_meters + @doc """ + Expands a viewport to complete hierarchy cells at the selected level. + + With MapLibre's 512-pixel world tiles and the current level offset, a cell is + roughly 128 screen pixels wide at its integer map zoom. Returning + complete cells plus one neighboring cell gives edge clusters stable + membership and a viewport buffer comparable to the 60-pixel padding used by + Google's viewport Supercluster algorithm. Panning inside a cell therefore + cannot change that cell's count or centroid. + """ + def viewport_envelopes(%DiscoveryViewport{} = viewport, level) + when is_integer(level) and level in 0..@maximum_level do + viewport + |> DiscoveryViewport.envelopes() + |> Enum.map(&align_envelope_to_cells(&1, level)) + |> Enum.uniq() + end + def cluster_id(kind, level, cell_x, cell_y) when kind in [:request, :activity] and is_integer(level) and is_integer(cell_x) and is_integer(cell_y) do "#{kind}:#{level}:#{cell_x}:#{cell_y}" end + @doc """ + Returns the stable geographic center of one hierarchy cell. + + Cluster symbols use this center rather than the moving average of their + members. Adjacent cells therefore keep their symbols separated while the + map is panned and while a refreshed viewport returns the same hierarchy + level. Individual, unclustered points still use their real public location. + """ + def cell_center(level, cell_x, cell_y) + when is_integer(level) and level in 0..@maximum_level and is_integer(cell_x) and + is_integer(cell_y) do + maximum_index = Integer.pow(2, level) - 1 + cell_x = clamp(cell_x, 0, maximum_index) + cell_y = clamp(cell_y, 0, maximum_index) + half_cell = cell_size_meters(level) / 2.0 + + { + cell_x |> cell_start(level) |> Kernel.+(half_cell) |> mercator_x_to_longitude(), + cell_y |> cell_start(level) |> Kernel.+(half_cell) |> mercator_y_to_latitude() + } + end + def parent_id(_kind, 0, _cell_x, _cell_y), do: nil def parent_id(kind, level, cell_x, cell_y) @@ -88,6 +131,92 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do |> floor() end + defp align_envelope_to_cells({west, south, east, north}, level) do + maximum_index = Integer.pow(2, level) - 1 + + west_index = + west + |> longitude_to_mercator_x() + |> cell_index(level) + |> Kernel.-(1) + |> clamp(0, maximum_index) + + east_index = + east + |> longitude_to_mercator_x() + |> cell_index(level) + |> Kernel.+(1) + |> clamp(0, maximum_index) + + south_index = + south + |> latitude_to_mercator_y() + |> cell_index(level) + |> Kernel.-(1) + |> clamp(0, maximum_index) + + north_index = + north + |> latitude_to_mercator_y() + |> cell_index(level) + |> Kernel.+(1) + |> clamp(0, maximum_index) + + { + west_index |> cell_start(level) |> mercator_x_to_longitude(), + south_index |> cell_start(level) |> mercator_y_to_latitude(), + east_index |> cell_end(level) |> mercator_x_to_longitude(), + north_index |> cell_end(level) |> mercator_y_to_latitude() + } + end + + defp cell_start(index, level) do + -@world_half_meters + index * cell_size_meters(level) + end + + defp cell_end(index, level) do + min(@world_half_meters, cell_start(index + 1, level)) + end + + defp longitude_to_mercator_x(longitude) do + longitude + |> clamp(-180.0, 180.0) + |> Kernel.*(@world_half_meters / 180.0) + |> clamp_coordinate() + end + + defp latitude_to_mercator_y(latitude) do + latitude = + latitude + |> clamp(-@max_mercator_latitude, @max_mercator_latitude) + |> degrees_to_radians() + + @world_half_meters / :math.pi() * + :math.log(:math.tan(:math.pi() / 4.0 + latitude / 2.0)) + end + + defp mercator_x_to_longitude(x) do + x + |> clamp(-@world_half_meters, @world_half_meters) + |> Kernel.*(180.0 / @world_half_meters) + |> clamp(-180.0, 180.0) + end + + defp mercator_y_to_latitude(y) do + y + |> clamp(-@world_half_meters, @world_half_meters) + |> Kernel.*(:math.pi() / @world_half_meters) + |> :math.exp() + |> :math.atan() + |> Kernel.*(2.0) + |> Kernel.-(:math.pi() / 2.0) + |> radians_to_degrees() + |> clamp(-@max_mercator_latitude, @max_mercator_latitude) + end + + defp degrees_to_radians(value), do: value * :math.pi() / 180.0 + defp radians_to_degrees(value), do: value * 180.0 / :math.pi() + defp clamp_coordinate(value) do value |> Kernel.*(1.0) diff --git a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex index 5740ffb..9827021 100644 --- a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex +++ b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex @@ -32,7 +32,7 @@ {gettext("A Google account is connected. You can use it to sign in.")} <% else %> {gettext( - "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses." + "Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation." )} <% end %>
diff --git a/lib/who_need_help_web/live/activity_live/index.ex b/lib/who_need_help_web/live/activity_live/index.ex index 3107088..4813637 100644 --- a/lib/who_need_help_web/live/activity_live/index.ex +++ b/lib/who_need_help_web/live/activity_live/index.ex @@ -10,7 +10,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do @view_modes ~w(list split map) @map_update_modes ~w(manual auto) @filter_keys ~w(category_id area) - @map_update_delay_ms 450 @impl true def mount(_params, _session, socket) do @@ -26,7 +25,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do |> assign(:view_mode, "split") |> assign(:view_explicit, false) |> assign(:map_update_mode, "manual") - |> assign(:map_update_timer, nil) |> assign(:viewport, nil) |> assign(:pending_viewport, nil) |> assign(:activities, []) @@ -52,7 +50,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do {:noreply, socket - |> cancel_map_update_timer() |> assign(:filters, filters) |> assign(:filter_form, to_form(filters, as: :filters)) |> assign(:discovery_mode, discovery_mode) @@ -130,17 +127,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do {:noreply, socket |> assign(:refresh_timer, nil) |> load_if_connected()} end - def handle_info({:apply_map_viewport, %DiscoveryViewport{} = viewport}, socket) do - socket = assign(socket, :map_update_timer, nil) - - if socket.assigns.map_update_mode == "auto" and - DiscoveryViewport.same_area?(viewport, socket.assigns.pending_viewport) do - {:noreply, push_patch(socket, to: activity_path(socket, viewport: viewport))} - else - {:noreply, socket} - end - end - @impl true def handle_event("filter", %{"filters" => filters}, socket) do {:noreply, push_patch(socket, to: activity_path(socket, filters: normalize_filters(filters)))} @@ -183,13 +169,13 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do {:ok, viewport} -> cond do DiscoveryViewport.same_area?(viewport, socket.assigns.viewport) -> - {:noreply, - socket - |> cancel_map_update_timer() - |> assign(:pending_viewport, nil)} + {:noreply, assign(socket, :pending_viewport, nil)} socket.assigns.map_update_mode == "auto" -> - {:noreply, schedule_map_update(socket, viewport)} + {:noreply, + push_patch(socket, + to: activity_path(socket, viewport: viewport) + )} true -> {:noreply, assign(socket, :pending_viewport, viewport)} @@ -449,23 +435,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do ~p"/activities?#{params}" end - defp schedule_map_update(socket, viewport) do - socket = cancel_map_update_timer(socket) - timer = Process.send_after(self(), {:apply_map_viewport, viewport}, @map_update_delay_ms) - - socket - |> assign(:pending_viewport, viewport) - |> assign(:map_update_timer, timer) - end - - defp cancel_map_update_timer(socket) do - if socket.assigns.map_update_timer do - Process.cancel_timer(socket.assigns.map_update_timer) - end - - assign(socket, :map_update_timer, nil) - end - defp viewport_json(nil), do: "" defp viewport_json(%DiscoveryViewport{} = viewport) do diff --git a/lib/who_need_help_web/live/request_live/index.ex b/lib/who_need_help_web/live/request_live/index.ex index d152aa3..a45b0ed 100644 --- a/lib/who_need_help_web/live/request_live/index.ex +++ b/lib/who_need_help_web/live/request_live/index.ex @@ -10,7 +10,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do @view_modes ~w(list split map) @map_update_modes ~w(manual auto) @filter_keys ~w(category_id urgency area) - @map_update_delay_ms 450 @empty_reputation %{ completed: 0, unique_people: 0, @@ -33,7 +32,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do |> assign(:view_mode, "split") |> assign(:view_explicit, false) |> assign(:map_update_mode, "manual") - |> assign(:map_update_timer, nil) |> assign(:viewport, nil) |> assign(:pending_viewport, nil) |> assign(:discovery_topics, []) @@ -62,7 +60,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do socket = socket - |> cancel_map_update_timer() |> assign(:filters, filters) |> assign(:filter_form, to_form(filters, as: :filters)) |> assign(:discovery_mode, discovery_mode) @@ -129,13 +126,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do {:ok, viewport} -> cond do DiscoveryViewport.same_area?(viewport, socket.assigns.viewport) -> - {:noreply, - socket - |> cancel_map_update_timer() - |> assign(:pending_viewport, nil)} + {:noreply, assign(socket, :pending_viewport, nil)} socket.assigns.map_update_mode == "auto" -> - {:noreply, schedule_map_update(socket, viewport)} + {:noreply, + push_patch(socket, + to: request_path(socket, viewport: viewport) + )} true -> {:noreply, assign(socket, :pending_viewport, viewport)} @@ -224,17 +221,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do {:noreply, socket |> assign(:refresh_timer, nil) |> load_if_connected()} end - def handle_info({:apply_map_viewport, %DiscoveryViewport{} = viewport}, socket) do - socket = assign(socket, :map_update_timer, nil) - - if socket.assigns.map_update_mode == "auto" and - DiscoveryViewport.same_area?(viewport, socket.assigns.pending_viewport) do - {:noreply, push_patch(socket, to: request_path(socket, viewport: viewport))} - else - {:noreply, socket} - end - end - @impl true def handle_async(:load_discovery, {:ok, result}, socket) do {:noreply, assign(socket, result)} @@ -454,23 +440,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do ~p"/requests?#{params}" end - defp schedule_map_update(socket, viewport) do - socket = cancel_map_update_timer(socket) - timer = Process.send_after(self(), {:apply_map_viewport, viewport}, @map_update_delay_ms) - - socket - |> assign(:pending_viewport, viewport) - |> assign(:map_update_timer, timer) - end - - defp cancel_map_update_timer(socket) do - if socket.assigns.map_update_timer do - Process.cancel_timer(socket.assigns.map_update_timer) - end - - assign(socket, :map_update_timer, nil) - end - defp urgency_label(:now), do: gettext("Now") defp urgency_label("now"), do: gettext("Now") defp urgency_label(:today), do: gettext("Today") 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 e7eaafb..731dec0 100644 --- a/lib/who_need_help_web/live/request_live/show.ex +++ b/lib/who_need_help_web/live/request_live/show.ex @@ -302,8 +302,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do {:error, _reason} -> socket end - {:noreply, - put_action_flash(socket, :error, gettext("The browser could not share your location."))} + {:noreply, put_action_flash(socket, :error, gettext("Location sharing could not start."))} end def handle_event("stop-tracking", _, socket) do diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a385e4f..89da8ca 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2686,7 +2686,7 @@ msgstr "" #: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34 #, elixir-autogen, elixir-format -msgid "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses." +msgid "Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation." msgstr "" #: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99 diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index b5189f4..6c13f8b 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2686,8 +2686,8 @@ msgstr "Connect Google account" #: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34 #, elixir-autogen, elixir-format -msgid "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses." -msgstr "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses." +msgid "Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation." +msgstr "Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation." #: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99 #, elixir-autogen, elixir-format diff --git a/priv/gettext/ru/LC_MESSAGES/default.po b/priv/gettext/ru/LC_MESSAGES/default.po index 9242bda..12a7c77 100644 --- a/priv/gettext/ru/LC_MESSAGES/default.po +++ b/priv/gettext/ru/LC_MESSAGES/default.po @@ -2826,8 +2826,8 @@ msgstr "Подключить аккаунт Google" #: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34 #, elixir-autogen, elixir-format -msgid "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses." -msgstr "Подключайте Google только после входа здесь. Мы никогда не объединяем аккаунты автоматически по совпадению email." +msgid "Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation." +msgstr "Подключите Google для будущих входов. Аккаунт с совпадающим подтверждённым адресом Gmail или Google Workspace также может подключиться автоматически; для других почтовых доменов требуется подтверждение аккаунта." #: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99 #, elixir-autogen, elixir-format diff --git a/priv/gettext/uk/LC_MESSAGES/default.po b/priv/gettext/uk/LC_MESSAGES/default.po index 1aed17f..1de12b5 100644 --- a/priv/gettext/uk/LC_MESSAGES/default.po +++ b/priv/gettext/uk/LC_MESSAGES/default.po @@ -2820,8 +2820,8 @@ msgstr "Підключити обліковий запис Google" #: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34 #, elixir-autogen, elixir-format -msgid "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses." -msgstr "Підключайте Google лише після входу тут. Ми ніколи не об’єднуємо облікові записи автоматично за збігом email." +msgid "Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation." +msgstr "Підключіть Google для майбутніх входів. Обліковий запис із відповідною підтвердженою адресою Gmail або Google Workspace також може підключитися автоматично; для інших поштових доменів потрібне підтвердження облікового запису." #: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99 #, elixir-autogen, elixir-format diff --git a/priv/static/offline.html b/priv/static/offline.html index c38b456..5fd7714 100644 --- a/priv/static/offline.html +++ b/priv/static/offline.html @@ -68,6 +68,12 @@ font-size: 0.875rem; } + .connection-status { + min-height: 1.5rem; + margin-top: 1rem; + font-size: 0.875rem; + } + @media (prefers-color-scheme: dark) { :root { background: #151618; @@ -88,6 +94,14 @@ Reconnect to view current requests, messages, maps, and live locations. We do not save private pages on this device. ++ Waiting for a connection… +
@@ -95,5 +109,54 @@ Немає з’єднання · Нет соединения +