Prepare Android launch candidate and harden discovery
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -42,4 +42,8 @@ final class LaunchUrlResolver {
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
static boolean shouldLoadIncomingUrl(String currentUrl, String candidateUrl) {
|
||||
return candidateUrl != null && !candidateUrl.equals(currentUrl);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 229 KiB |
BIN
android/store-assets/screenshots/phone/02-request-form.png
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
android/store-assets/screenshots/phone/03-location-privacy.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
android/store-assets/screenshots/phone/04-category-proposals.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
75
docs/google-play-release-candidate-2026-08-01.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
</p>
|
||||
<p
|
||||
id="connection-status"
|
||||
class="connection-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Waiting for a connection…
|
||||
</p>
|
||||
<form method="get">
|
||||
<button type="submit">Try again</button>
|
||||
</form>
|
||||
|
|
@ -95,5 +109,54 @@
|
|||
Немає з’єднання · Нет соединения
|
||||
</p>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const status = document.getElementById("connection-status")
|
||||
let retryTimer
|
||||
let checking = false
|
||||
|
||||
const scheduleRetry = delay => {
|
||||
window.clearTimeout(retryTimer)
|
||||
retryTimer = window.setTimeout(checkConnection, delay)
|
||||
}
|
||||
|
||||
const setStatus = message => {
|
||||
if (status) status.textContent = message
|
||||
}
|
||||
|
||||
const checkConnection = async () => {
|
||||
if (checking) return
|
||||
|
||||
checking = true
|
||||
setStatus("Checking the connection…")
|
||||
|
||||
try {
|
||||
const response = await fetch("/healthz/ready", {
|
||||
cache: "no-store",
|
||||
headers: {accept: "application/json"}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setStatus("Connection restored. Reloading…")
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
} catch (_error) {
|
||||
// The next scheduled attempt handles temporary network failures.
|
||||
}
|
||||
|
||||
checking = false
|
||||
setStatus("Still offline. We will keep trying.")
|
||||
scheduleRetry(3_000)
|
||||
}
|
||||
|
||||
window.addEventListener("online", checkConnection)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") checkConnection()
|
||||
})
|
||||
|
||||
scheduleRetry(navigator.onLine ? 0 : 3_000)
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const CACHE_PREFIX = "who-need-help-static-"
|
||||
const CACHE = `${CACHE_PREFIX}v5`
|
||||
const CACHE = `${CACHE_PREFIX}v6`
|
||||
const OFFLINE_URL = "/offline.html"
|
||||
const SHELL = [
|
||||
OFFLINE_URL,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ set -euo pipefail
|
|||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
|
||||
mapfile -d '' -t source_files < <(
|
||||
mapfile -d '' -t candidate_files < <(
|
||||
git -C "$ROOT" ls-files \
|
||||
--cached \
|
||||
--others \
|
||||
|
|
@ -13,6 +13,21 @@ mapfile -d '' -t source_files < <(
|
|||
LC_ALL=C sort -z
|
||||
)
|
||||
|
||||
source_files=()
|
||||
for relative_path in "${candidate_files[@]}"; do
|
||||
absolute_path="$ROOT/$relative_path"
|
||||
|
||||
# `git ls-files --cached` also reports tracked paths deleted in the working
|
||||
# tree. They are not inputs to the artifact being built, so exclude them
|
||||
# from the working-tree fingerprint instead of treating a valid deletion as
|
||||
# a broken source tree.
|
||||
if [[ ! -e "$absolute_path" && ! -L "$absolute_path" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
source_files+=("$relative_path")
|
||||
done
|
||||
|
||||
if [[ "${#source_files[@]}" -eq 0 ]]; then
|
||||
echo "No Android source files were found." >&2
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -55,6 +55,30 @@ cleanup() {
|
|||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
scan_image() {
|
||||
image=$1
|
||||
scan_image_sequence=$((scan_image_sequence + 1))
|
||||
image_archive="$scan_dir/trivy-image-$scan_image_sequence.tar"
|
||||
|
||||
if ! docker image inspect "$image" >/dev/null 2>&1; then
|
||||
docker pull "$image" >/dev/null
|
||||
fi
|
||||
|
||||
docker image save --output "$image_archive" "$image"
|
||||
docker run --rm \
|
||||
--volume "$scan_dir:/scan:ro" \
|
||||
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
||||
"$TRIVY_IMAGE" image \
|
||||
--input "/scan/$(basename "$image_archive")" \
|
||||
--scanners vuln \
|
||||
--severity HIGH,CRITICAL \
|
||||
--ignore-unfixed \
|
||||
--exit-code 1
|
||||
rm -f "$image_archive"
|
||||
}
|
||||
|
||||
scan_image_sequence=0
|
||||
|
||||
echo "Checking shell scripts with ShellCheck 0.11.0"
|
||||
# Word splitting is intentional: find emits repository-controlled paths and
|
||||
# ShellCheck expects each file as a separate argument.
|
||||
|
|
@ -1271,8 +1295,9 @@ docker build --tag "$traefik_image" --file Dockerfile.traefik .
|
|||
test "$(docker image inspect --format '{{.Config.User}}' "$socket_proxy_image")" = "haproxy"
|
||||
test "$(docker image inspect --format '{{.Config.User}}' "$postgis_image")" = "postgres"
|
||||
test "$(docker image inspect --format '{{.Config.User}}' "$caddy_image")" = "1000:1000"
|
||||
docker run --rm "$caddy_image" version | grep -F 'v2.11.4' >/dev/null
|
||||
docker run --rm "$traefik_image" version | grep -F 'v3.7.8-wnh-grpc1.82.1' >/dev/null
|
||||
docker run --rm "$caddy_image" version |
|
||||
grep -F 'v2.11.4-wnh-grpc1.82.1-xtext0.40.0' >/dev/null
|
||||
docker run --rm "$traefik_image" version | grep -F 'v3.7.10-wnh-grpc1.82.1' >/dev/null
|
||||
docker run --rm --entrypoint sh "$postgis_image" -euc '
|
||||
test ! -e /usr/local/bin/gosu
|
||||
test "$(id -u)" = 70
|
||||
|
|
@ -1329,15 +1354,7 @@ for image in \
|
|||
"$traefik_image" \
|
||||
"$caddy_image" \
|
||||
"axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6"; do
|
||||
docker run --rm \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
||||
"$TRIVY_IMAGE" image \
|
||||
--scanners vuln \
|
||||
--severity HIGH,CRITICAL \
|
||||
--ignore-unfixed \
|
||||
--exit-code 1 \
|
||||
"$image"
|
||||
scan_image "$image"
|
||||
done
|
||||
|
||||
echo "Building the pinned quality image and cached Dialyzer PLTs"
|
||||
|
|
@ -1490,15 +1507,7 @@ printf '%s\n' "$backup_versions" |
|
|||
grep -F 'restic 0.19.1 compiled with go1.26.5' >/dev/null
|
||||
printf '%s\n' "$backup_versions" |
|
||||
grep -F 'pg_dump (PostgreSQL) 18.4' >/dev/null
|
||||
docker run --rm \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
||||
"$TRIVY_IMAGE" image \
|
||||
--scanners vuln \
|
||||
--severity HIGH,CRITICAL \
|
||||
--ignore-unfixed \
|
||||
--exit-code 1 \
|
||||
"$backup_image"
|
||||
scan_image "$backup_image"
|
||||
|
||||
echo "Building and scanning the pinned non-root MinIO server and client images"
|
||||
docker build --target server --tag "$minio_image" --file Dockerfile.minio .
|
||||
|
|
@ -1534,15 +1543,7 @@ printf '%s\n' "$mc_version" |
|
|||
printf '%s\n' "$mc_version" |
|
||||
grep -F 'Runtime: go1.26.5 linux/' >/dev/null
|
||||
for image in "$minio_image" "$mc_image"; do
|
||||
docker run --rm \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
||||
"$TRIVY_IMAGE" image \
|
||||
--scanners vuln \
|
||||
--severity HIGH,CRITICAL \
|
||||
--ignore-unfixed \
|
||||
--exit-code 1 \
|
||||
"$image"
|
||||
scan_image "$image"
|
||||
done
|
||||
|
||||
echo "Building and scanning the pinned non-root external-boundary mock image"
|
||||
|
|
@ -1552,26 +1553,10 @@ docker build \
|
|||
ops/external-boundaries
|
||||
test "$(docker image inspect --format '{{.Config.User}}' "$boundary_mock_image")" = \
|
||||
"10001:10001"
|
||||
docker run --rm \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
||||
"$TRIVY_IMAGE" image \
|
||||
--scanners vuln \
|
||||
--severity HIGH,CRITICAL \
|
||||
--ignore-unfixed \
|
||||
--exit-code 1 \
|
||||
"$boundary_mock_image"
|
||||
scan_image "$boundary_mock_image"
|
||||
|
||||
echo "Building and scanning the production release image"
|
||||
docker build --target release --tag "$release_image" .
|
||||
docker run --rm \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
||||
"$TRIVY_IMAGE" image \
|
||||
--scanners vuln \
|
||||
--severity HIGH,CRITICAL \
|
||||
--ignore-unfixed \
|
||||
--exit-code 1 \
|
||||
"$release_image"
|
||||
scan_image "$release_image"
|
||||
|
||||
echo "All isolated quality and security gates passed."
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
|
||||
alias WhoNeedHelp.{Activities, Catalog, Trust}
|
||||
alias WhoNeedHelp.Activities.Activity
|
||||
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||
alias WhoNeedHelp.Help.{DiscoveryCluster, DiscoveryViewport}
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelp.Trust.AuditEvent
|
||||
|
||||
|
|
@ -336,7 +336,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
|
||||
expected_ids =
|
||||
activities
|
||||
|> Enum.sort_by(&{&1.starts_at, &1.id})
|
||||
|> Enum.sort_by(&{DateTime.to_unix(&1.starts_at, :microsecond), &1.id})
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
actual_ids = Enum.map(first.entries ++ second.entries, & &1.id)
|
||||
|
|
@ -464,8 +464,14 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
assert String.starts_with?(cluster.cluster_id, "activity:11:")
|
||||
assert String.starts_with?(cluster.parent_cluster_id, "activity:10:")
|
||||
assert cluster.expansion_zoom > 9
|
||||
assert_in_delta cluster.latitude, 50.45, 0.000_001
|
||||
assert_in_delta cluster.longitude, 30.52, 0.000_001
|
||||
|
||||
["activity", "11", cell_x, cell_y] = String.split(cluster.cluster_id, ":")
|
||||
|
||||
{expected_longitude, expected_latitude} =
|
||||
DiscoveryCluster.cell_center(11, String.to_integer(cell_x), String.to_integer(cell_y))
|
||||
|
||||
assert_in_delta cluster.latitude, expected_latitude, 0.000_001
|
||||
assert_in_delta cluster.longitude, expected_longitude, 0.000_001
|
||||
assert_in_delta cluster.bounds.west, 30.52, 0.000_001
|
||||
assert_in_delta cluster.bounds.east, 30.52, 0.000_001
|
||||
assert_in_delta cluster.bounds.south, 50.45, 0.000_001
|
||||
|
|
@ -484,6 +490,28 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
false
|
||||
end)
|
||||
|
||||
left_edge_cluster =
|
||||
context.outsider_scope
|
||||
|> Activities.map_discovery_items(
|
||||
%{},
|
||||
viewport!(30.519, 50.449, 30.521, 50.451, 15)
|
||||
)
|
||||
|> Enum.find(&match?(%{type: "cluster", count: 3}, &1))
|
||||
|
||||
right_edge_cluster =
|
||||
context.outsider_scope
|
||||
|> Activities.map_discovery_items(
|
||||
%{},
|
||||
viewport!(30.521, 50.449, 30.523, 50.451, 15)
|
||||
)
|
||||
|> Enum.find(&match?(%{type: "cluster", count: 3}, &1))
|
||||
|
||||
assert left_edge_cluster
|
||||
assert right_edge_cluster
|
||||
assert left_edge_cluster.cluster_id == right_edge_cluster.cluster_id
|
||||
assert left_edge_cluster.latitude == right_edge_cluster.latitude
|
||||
assert left_edge_cluster.longitude == right_edge_cluster.longitude
|
||||
|
||||
world_items =
|
||||
Activities.map_discovery_items(
|
||||
context.outsider_scope,
|
||||
|
|
|
|||
|
|
@ -33,4 +33,28 @@ defmodule WhoNeedHelp.DiscoveryQueryCacheTest do
|
|||
|
||||
assert DiscoveryQueryCache.fetch(key, fn -> :fresh_result end) == :fresh_result
|
||||
end
|
||||
|
||||
test "prunes expired entries created by other viewport keys" do
|
||||
expired_key = {:expired, make_ref()}
|
||||
live_key = {:live, make_ref()}
|
||||
now = System.monotonic_time(:millisecond)
|
||||
|
||||
:sys.replace_state(DiscoveryQueryCache, fn state ->
|
||||
put_in(
|
||||
state.entries,
|
||||
Map.merge(state.entries, %{
|
||||
expired_key => {now - 1, :expired},
|
||||
live_key => {now + 60_000, :live}
|
||||
})
|
||||
)
|
||||
end)
|
||||
|
||||
assert DiscoveryQueryCache.fetch({:new, make_ref()}, fn -> :new end) == :new
|
||||
|
||||
%{entries: entries} = :sys.get_state(DiscoveryQueryCache)
|
||||
refute Map.has_key?(entries, expired_key)
|
||||
assert Map.has_key?(entries, live_key)
|
||||
|
||||
assert :ok = DiscoveryQueryCache.invalidate(live_key)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
defmodule WhoNeedHelp.Help.DiscoveryClusterTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias WhoNeedHelp.Help.DiscoveryCluster
|
||||
alias WhoNeedHelp.Help.{DiscoveryCluster, DiscoveryViewport}
|
||||
|
||||
test "map zooms select stable hierarchy levels" do
|
||||
assert DiscoveryCluster.level_for_zoom(0) == 2
|
||||
assert DiscoveryCluster.level_for_zoom(5.49) == 7
|
||||
assert DiscoveryCluster.level_for_zoom(5.99) == 7
|
||||
assert DiscoveryCluster.level_for_zoom(5.5) == 8
|
||||
assert DiscoveryCluster.level_for_zoom(5.99) == 8
|
||||
assert DiscoveryCluster.level_for_zoom(6) == 8
|
||||
assert DiscoveryCluster.level_for_zoom(22) == 24
|
||||
|
||||
|
|
@ -23,6 +24,41 @@ defmodule WhoNeedHelp.Help.DiscoveryClusterTest do
|
|||
assert DiscoveryCluster.parent_id(:activity, 0, 0, 0) == nil
|
||||
end
|
||||
|
||||
test "cell centers are stable geographic points separated from adjacent cells" do
|
||||
{longitude, latitude} = DiscoveryCluster.cell_center(2, 2, 2)
|
||||
{east_longitude, east_latitude} = DiscoveryCluster.cell_center(2, 3, 2)
|
||||
|
||||
assert_in_delta longitude, 45.0, 1.0e-9
|
||||
assert_in_delta latitude, 40.979_898, 1.0e-6
|
||||
assert_in_delta east_longitude, 135.0, 1.0e-9
|
||||
assert_in_delta east_latitude, latitude, 1.0e-9
|
||||
end
|
||||
|
||||
test "viewport envelopes cover complete stable hierarchy cells" do
|
||||
viewport = %DiscoveryViewport{
|
||||
west: 30.50,
|
||||
south: 50.40,
|
||||
east: 30.60,
|
||||
north: 50.50,
|
||||
zoom: 10.2,
|
||||
width: 900,
|
||||
height: 700
|
||||
}
|
||||
|
||||
level = DiscoveryCluster.level_for_zoom(viewport.zoom)
|
||||
[{west, south, east, north}] = DiscoveryCluster.viewport_envelopes(viewport, level)
|
||||
|
||||
assert west <= viewport.west
|
||||
assert south <= viewport.south
|
||||
assert east >= viewport.east
|
||||
assert north >= viewport.north
|
||||
|
||||
shifted = %{viewport | west: 30.5001, east: 30.5999, south: 50.4001, north: 50.4999}
|
||||
|
||||
assert DiscoveryCluster.viewport_envelopes(shifted, level) ==
|
||||
DiscoveryCluster.viewport_envelopes(viewport, level)
|
||||
end
|
||||
|
||||
test "expansion zoom is the first child level that separates member bounds" do
|
||||
level = 7
|
||||
cell = DiscoveryCluster.cell_size_meters(level)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
import WhoNeedHelp.AccountsFixtures
|
||||
|
||||
alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust}
|
||||
alias WhoNeedHelp.Help.Assignment
|
||||
alias WhoNeedHelp.Help.{Assignment, DiscoveryCluster}
|
||||
alias WhoNeedHelp.Notifications.Notification
|
||||
alias WhoNeedHelp.Push.NearbyMatchWorker
|
||||
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||
|
|
@ -440,7 +440,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
|
||||
expected_ids =
|
||||
requests
|
||||
|> Enum.sort_by(&{&1.expires_at, &1.id})
|
||||
|> Enum.sort_by(&{DateTime.to_unix(&1.expires_at, :microsecond), &1.id})
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
actual_ids = Enum.map(first.entries ++ second.entries ++ third.entries, & &1.id)
|
||||
|
|
@ -502,7 +502,8 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
_request, total -> total + 1
|
||||
end)
|
||||
|
||||
assert map_count == length(ids)
|
||||
assert map_count >= length(ids)
|
||||
assert map_count < 3
|
||||
end
|
||||
|
||||
test "hidden coordinates require an explicit area-label search and never become map points",
|
||||
|
|
@ -580,8 +581,14 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
assert String.starts_with?(cluster.cluster_id, "request:11:")
|
||||
assert String.starts_with?(cluster.parent_cluster_id, "request:10:")
|
||||
assert cluster.expansion_zoom > viewport.zoom
|
||||
assert_in_delta cluster.latitude, 50.4501, 0.000_001
|
||||
assert_in_delta cluster.longitude, 30.52345, 0.000_001
|
||||
|
||||
["request", "11", cell_x, cell_y] = String.split(cluster.cluster_id, ":")
|
||||
|
||||
{expected_longitude, expected_latitude} =
|
||||
DiscoveryCluster.cell_center(11, String.to_integer(cell_x), String.to_integer(cell_y))
|
||||
|
||||
assert_in_delta cluster.latitude, expected_latitude, 0.000_001
|
||||
assert_in_delta cluster.longitude, expected_longitude, 0.000_001
|
||||
assert_in_delta cluster.bounds.west, 30.5234, 0.000_001
|
||||
assert_in_delta cluster.bounds.east, 30.5235, 0.000_001
|
||||
assert_in_delta cluster.bounds.south, 50.4501, 0.000_001
|
||||
|
|
@ -608,6 +615,28 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
_item -> false
|
||||
end)
|
||||
|
||||
left_edge_cluster =
|
||||
context.helper_scope
|
||||
|> Help.map_discovery_items(
|
||||
%{},
|
||||
viewport!(30.52339, 50.45005, 30.52344, 50.45015, 15)
|
||||
)
|
||||
|> Enum.find(&match?(%{type: "cluster", count: 3}, &1))
|
||||
|
||||
right_edge_cluster =
|
||||
context.helper_scope
|
||||
|> Help.map_discovery_items(
|
||||
%{},
|
||||
viewport!(30.52346, 50.45005, 30.52351, 50.45015, 15)
|
||||
)
|
||||
|> Enum.find(&match?(%{type: "cluster", count: 3}, &1))
|
||||
|
||||
assert left_edge_cluster
|
||||
assert right_edge_cluster
|
||||
assert left_edge_cluster.cluster_id == right_edge_cluster.cluster_id
|
||||
assert left_edge_cluster.latitude == right_edge_cluster.latitude
|
||||
assert left_edge_cluster.longitude == right_edge_cluster.longitude
|
||||
|
||||
world_items =
|
||||
Help.map_discovery_items(
|
||||
context.helper_scope,
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
|
|||
body = response(conn, 200)
|
||||
|
||||
assert body =~ ~s(const CACHE_PREFIX = "who-need-help-static-")
|
||||
assert body =~ ~s(const CACHE = `${CACHE_PREFIX}v5`)
|
||||
assert body =~ ~s(const CACHE = `${CACHE_PREFIX}v6`)
|
||||
assert body =~ ~s(const OFFLINE_URL = "/offline.html")
|
||||
assert body =~ ~s(event.request.mode === "navigate")
|
||||
assert body =~ ~S|key.startsWith(CACHE_PREFIX) && key !== CACHE|
|
||||
|
|
@ -258,6 +258,8 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
|
|||
assert Enum.count(LazyHTML.query(document, "img[src='/images/logo.svg'][alt='']")) == 1
|
||||
assert html =~ "You're offline"
|
||||
assert html =~ "Try again"
|
||||
assert html =~ ~s(fetch("/healthz/ready")
|
||||
assert html =~ "Connection restored. Reloading"
|
||||
end
|
||||
|
||||
test "brand raster fallbacks are served", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
assert response =~ ~s(autocomplete="username")
|
||||
assert response =~ "Google sign-in"
|
||||
assert response =~ "Connect Google account"
|
||||
assert response =~ "matching verified Gmail or Google Workspace address"
|
||||
refute response =~ "We never merge accounts automatically"
|
||||
end
|
||||
|
||||
test "redirects if user is not logged in" do
|
||||
|
|
|
|||
|
|
@ -229,7 +229,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
|
||||
test "authenticated LiveView honors the locale stored by the browser pipeline", %{conn: conn} do
|
||||
conn = get(conn, ~p"/?locale=uk")
|
||||
{:ok, _view, html} = live(conn, ~p"/requests")
|
||||
{:ok, view, html} = live(conn, ~p"/requests")
|
||||
render_async(view)
|
||||
|
||||
assert html =~ "Хто потребує допомоги?"
|
||||
assert html =~ "Створити термінову заявку"
|
||||
|
|
@ -377,6 +378,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
view,
|
||||
~p"/requests?#{Map.merge(expanded, %{"east" => "30.6", "view" => "split", "west" => "30.4"})}"
|
||||
)
|
||||
|
||||
render_async(view)
|
||||
end
|
||||
|
||||
test "request discovery keeps the previous list until a moved map area is applied", %{
|
||||
|
|
@ -440,7 +443,6 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
}
|
||||
|
||||
render_hook(view, "map-viewport-change", moved)
|
||||
assert render(view) =~ "Updating results for the visible map area"
|
||||
refute has_element?(view, "#search-map-area")
|
||||
|
||||
assert_patch(
|
||||
|
|
@ -471,6 +473,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
view,
|
||||
~p"/activities?#{Map.merge(expanded, %{"east" => "30.6", "view" => "split", "west" => "30.4"})}"
|
||||
)
|
||||
|
||||
render_async(view)
|
||||
end
|
||||
|
||||
test "profile social-link creation uses the shared action limiter", %{conn: conn} do
|
||||
|
|
@ -1468,7 +1472,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
|
||||
html = render_hook(helper_view, "location-error", %{})
|
||||
|
||||
assert html =~ "The browser could not share your location."
|
||||
assert html =~ "Location sharing could not start."
|
||||
assert has_element?(helper_view, "button[phx-click='start-tracking']")
|
||||
refute has_element?(helper_view, "button[phx-click='stop-tracking']")
|
||||
|
||||
|
|
|
|||