Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions iznik-batch/app/Mail/Digest/UnifiedDigest.php
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,7 @@ protected function prepareCard(
// photos use the compact /e/d/i/{ref}/t/{attachId}/{preset}/{pos} form
// (preset 1 = 600x400 hero); external/placeholder keep the direct URL.
$heroImageUrl = $hasInternalImage
? $this->trackedResourceImageUrl('t', (int) $attachment->id, 1, "h{$index}", $this->getMessageImageUrl($message, 600, 400) ?? $placeholderUrl)
? $this->trackedResourceImageUrl('t', (int) $attachment->id, 1, "h{$index}", $this->getMessageImageUrl($message, 600, 400) ?? $placeholderUrl, $scrollPercent)
: ($this->getMessageImageUrl($message, 600, 400) ?? $placeholderUrl);

// Thumbnail image for multi-post (daily) cards: a fixed 4:3
Expand All @@ -948,7 +948,7 @@ protected function prepareCard(
// the card sits flush with the photo rather than dangling below.
// Compact form for our own photos (preset 0 = 240x240 card thumb).
$thumbImageUrl = $hasInternalImage
? $this->trackedResourceImageUrl('t', (int) $attachment->id, 0, "i{$index}", $this->getMessageImageUrl($message, 240, 240) ?? $placeholderUrl)
? $this->trackedResourceImageUrl('t', (int) $attachment->id, 0, "i{$index}", $this->getMessageImageUrl($message, 240, 240) ?? $placeholderUrl, $scrollPercent)
: ($this->getMessageImageUrl($message, 240, 240) ?? $placeholderUrl);

// Decode emoji sequences in message text.
Expand Down Expand Up @@ -1016,7 +1016,7 @@ protected function prepareCard(
// Compact form: type 'u' preset 2 (avatar) -> handler reconstructs the
// poster's avatar at 36px. Falls back to the direct avatar URL.
$posterAvatarUrl = ($posterUser && $posterUser->id)
? $this->trackedResourceImageUrl('u', (int) $posterUser->id, 2, "a{$index}", $this->resolveAvatarUrl($posterUser, 36))
? $this->trackedResourceImageUrl('u', (int) $posterUser->id, 2, "a{$index}", $this->resolveAvatarUrl($posterUser, 36), $scrollPercent)
: $this->resolveAvatarUrl($posterUser, 36);
$posterName = $posterUser?->displayname ?? $message->fromname ?? 'Freegler';

Expand Down
12 changes: 8 additions & 4 deletions iznik-batch/app/Mail/Traits/TrackableEmail.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,13 @@ public function trackedResourceUrl(string $type, int $id, string $position, stri

/**
* Compact tracked image for one of our own resources (message photo,
* avatar). Emits /e/d/i/{ref}/{type}/{idEnc}/{preset}/{pos}. Falls back
* to the direct image URL when tracking is disabled.
* avatar). Emits /e/d/i/{ref}/{type}/{idEnc}/{preset}/{pos}[?s={scrollPercent}].
* Falls back to the direct image URL when tracking is disabled.
*
* Pass $scrollPercent so the Go handler can update scroll_depth_percent via
* the same max-update logic as the long-form Image handler.
*/
public function trackedResourceImageUrl(string $type, int $id, int $preset, string $position, string $fallbackUrl): string
public function trackedResourceImageUrl(string $type, int $id, int $preset, string $position, string $fallbackUrl, ?int $scrollPercent = null): string
{
if (!$this->tracking) {
return $fallbackUrl;
Expand All @@ -161,7 +164,8 @@ public function trackedResourceImageUrl(string $type, int $id, int $preset, stri
$type,
\App\Models\EmailTracking::encodeId($id),
$preset,
$position
$position,
$scrollPercent
);
}

Expand Down
16 changes: 13 additions & 3 deletions iznik-batch/app/Models/EmailTracking.php
Original file line number Diff line number Diff line change
Expand Up @@ -247,16 +247,26 @@ public function getCompactLinkUrl(string $type, string $idEnc, string $position)

/**
* Compact tracked image for one of our own resources:
* /e/d/i/{ref}/{type}/{idEnc}/{preset}/{pos}
* /e/d/i/{ref}/{type}/{idEnc}/{preset}/{pos}[?s={scrollPercent}]
* {preset} indexes a shared dimension table (0=240x240 card thumb,
* 1=600x400 hero, 2=avatar) so width/height/fit aren't repeated in
* every URL — the handler reconstructs the delivery URL from the
* attachment/user id + preset.
*
* When $scrollPercent is provided, the Go handler updates
* email_tracking.scroll_depth_percent via the same max-update logic as
* the long-form Image handler, enabling scroll depth analytics for
* compact digest emails.
*/
public function getCompactImageUrl(string $type, string $idEnc, int $preset, string $position): string
public function getCompactImageUrl(string $type, string $idEnc, int $preset, string $position, ?int $scrollPercent = null): string
{
$baseUrl = config('freegle.api.base_url', 'https://api.ilovefreegle.org');
$url = "{$baseUrl}/e/d/i/{$this->compactRef()}/{$type}/{$idEnc}/{$preset}/{$position}";

return "{$baseUrl}/e/d/i/{$this->compactRef()}/{$type}/{$idEnc}/{$preset}/{$position}";
if ($scrollPercent !== null) {
$url .= "?s={$scrollPercent}";
}

return $url;
}
}
59 changes: 59 additions & 0 deletions iznik-batch/tests/Unit/Mail/UnifiedDigestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1286,4 +1286,63 @@ public function test_immediate_preheader_shows_post_subject(): void
'Immediate-mode preheader must contain the post subject location'
);
}

public function test_compact_image_urls_contain_scroll_percent(): void
{
// Guards that compact image URLs (heroImageUrl, thumbImageUrl, posterAvatarUrl)
// carry the ?s= scroll-percent query param so the Go handler can update
// email_tracking.scroll_depth_percent via the same max-update logic as the
// long-form trackedImageUrl path. Compact URLs without s= meant scroll depth
// was never populated for digest recipients (Fix 15).
$user = $this->createTestUser();
$group = $this->createTestGroup();
$this->createMembership($user, $group);

$poster = $this->createTestUser();
$this->createMembership($poster, $group);

$msg1 = $this->createTestMessage($poster, $group, ['subject' => 'OFFER: Bicycle (London)']);
$msg2 = $this->createTestMessage($poster, $group, ['subject' => 'OFFER: Table (London)']);

// Give msg1 an internal attachment (externaluid set, no externalurl) so
// prepareCard() takes the compact URL path instead of the fallback URL.
DB::table('messages_attachments')->insertGetId([
'msgid' => $msg1->id,
'externaluid' => 'freegletusd-' . str_repeat('c', 32),
'primary' => 1,
'archived' => 0,
]);

// Eager-load fromUser so prepareCard() resolves $posterUser and takes the
// compact 'u' avatar URL path rather than the fallback resolveAvatarUrl().
$msg1->load(['attachments', 'fromUser']);

$posts = collect([
['message' => $msg1, 'postedToGroups' => [$group->id]],
['message' => $msg2, 'postedToGroups' => [$group->id]],
]);

$mail = new UnifiedDigest($user, $posts, UnifiedDigestService::MODE_DAILY);

$ref = new \ReflectionProperty(UnifiedDigest::class, 'preparedPosts');
$ref->setAccessible(true);
$prepared = $ref->getValue($mail);

// Post 0 (msg1) has an internal attachment -> compact URL path is taken.
// scroll = round((0/2)*100) = 0, so s=0 must be present.
$card0 = $prepared->get(0);

$this->assertStringContainsString('s=0', $card0['thumbImageUrl'],
'thumbImageUrl for post 0 (index 0 of 2) must carry s=0');
$this->assertStringContainsString('s=0', $card0['heroImageUrl'],
'heroImageUrl for post 0 (index 0 of 2) must carry s=0');
$this->assertStringContainsString('s=0', $card0['posterAvatarUrl'],
'posterAvatarUrl for post 0 (index 0 of 2) must carry s=0');

// All three must be compact-format URLs (not the fallback direct URL).
foreach (['thumbImageUrl', 'heroImageUrl', 'posterAvatarUrl'] as $key) {
$this->assertStringContainsString('/e/d/i/', $card0[$key],
"{$key} must use the compact /e/d/i/ tracking route");
}
}
}
4 changes: 3 additions & 1 deletion iznik-nuxt3/api/AlertAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export default class AlertAPI extends BaseAPI {
}

record(data) {
return this.$postv2('/alert', data)
// Click-tracking for admin alerts. The Go handler lives under the modtools
// prefix; this posted to '/alert' (never registered) and silently 404ed.
return this.$postv2('/modtools/alert', data)
}
}
8 changes: 3 additions & 5 deletions iznik-nuxt3/api/ConfigAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@ export default class ConfigAPI extends BaseAPI {
return this.$getv2('/config/admin/' + key)
}

patchAdminv2(data) {
return this.$patchv2('/config/admin', data)
}

// Individual worry word management
addWorrywordv2(data) {
return this.$postv2('/config/admin/worry_words', data)
Expand All @@ -35,7 +31,9 @@ export default class ConfigAPI extends BaseAPI {
// Concern keywords management
fetchConcernKeywordsv2(params = {}) {
const query = new URLSearchParams(params).toString()
return this.$getv2('/config/admin/concern_keywords' + (query ? '?' + query : ''))
return this.$getv2(
'/config/admin/concern_keywords' + (query ? '?' + query : '')
)
}

addConcernKeywordv2(data) {
Expand Down
15 changes: 7 additions & 8 deletions iznik-nuxt3/api/MessageAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,6 @@ export default class MessageAPI extends BaseAPI {
return this.$postv2('/message', params, logErrorFn)
}

del(id) {
return this.$delv2('/message/' + id)
}

put(data) {
return this.$putv2('/message', data)
}
Expand Down Expand Up @@ -321,9 +317,12 @@ export default class MessageAPI extends BaseAPI {

// Update one item's availability and/or count from the logged-out page.
async updateBulkEditItem(token, itemid, changes) {
return await this.$postv2('/bulkoffer/update/' + encodeURIComponent(token), {
itemid,
...changes,
})
return await this.$postv2(
'/bulkoffer/update/' + encodeURIComponent(token),
{
itemid,
...changes,
}
)
}
}
4 changes: 0 additions & 4 deletions iznik-nuxt3/api/NoticeboardAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,4 @@ export default class NoticeboardAPI extends BaseAPI {
action(data) {
return this.$postv2('/noticeboard', data)
}

del(id) {
return this.$delv2('/noticeboard', { id })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1318,7 +1318,7 @@ export async function setupRipplingExplorer({
if (fly) map.flyTo([lat, lng], Math.max(map.getZoom(), 13))
if (inbound) {
// Inbound mode: don't recompute outbound isochrones; just refresh
// the posts-for-member dots. Also wipe any freegler dots that may
// the digest-simulator post dots. Also wipe any freegler dots that may
// still be on the map from the outbound view.
clearOutboundLayers()
fetchInbox()
Expand Down
1 change: 0 additions & 1 deletion iznik-routing-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ All endpoints are available on both ports. On the external port (8196) every `/v
| `GET /v1/groups/nearby?lat=&lng=` | Freegle group boundaries near a point (GeoJSON FeatureCollection) |
| `GET /v1/ripple-schedule?lat=&lng=&mode=&ticks=&max_minutes=` | The "rippling out" schedule: the expanding sequence of reachable areas over time used to introduce a post gradually. Steps are smaller in dense regions and larger across empty voids. Powers the Rippling Explorer |
| `POST /v1/ripple-eval` | Evaluate a rippling scenario for a post (JSON body) — who would be reached at each tick |
| `GET /v1/posts-for-member?lat=&lng=&date=&max_minutes=` | The posts a member at this location would be shown (`date` defaults to today, `max_minutes` to 30) |
| `GET /v1/digest-simulator?lat=&lng=&max_minutes=&w_closeness=&w_freshness=&w_budget=&w_anchor=&cap=&group_by_poster=` | Simulate a member's ranked digest with tunable scoring weights and a result cap |
| `GET /swagger` | Browsable OpenAPI reference (Redoc). Raw spec at `/swagger/swagger.json` |

Expand Down
Loading