Files
marco/app/utils/activity.js
T
raucao 35aa3c950f Auto-load older activities
Both initially (if not enough recent ones are available), and when
scrolling to the end of an activity timeline
2026-09-02 18:36:58 -06:00

256 lines
7.5 KiB
JavaScript

/**
* Utilities for parsing social activity events into timeline entries.
*
* An `ActivityEntry` represents a single social interaction related to the
* user's content (e.g. a zap received on one of their photos). Entries are
* ordered newest-first.
*/
import { tracked } from '@glimmer/tracking';
import {
getZapSender,
getZapRecipient,
getZapPayment,
getZapEventPointer,
getZapRequest,
} from 'applesauce-common/helpers';
import { parsePhotoFromEvent, applyDeletions } from './contributions';
/**
* A single activity timeline entry.
*
* `senderName`, `senderAvatar`, `senderProfileLoading`, `placeName`,
* and `placeNameLoading` are tracked so the row re-renders when the sender's
* profile or place name is resolved asynchronously.
*/
export class ActivityEntry {
type = 'zap';
photoEventId;
photo;
photos = [];
placeIdentifier;
osmType;
osmId;
senderPubkey;
amountSats;
message;
createdAt;
@tracked senderName = null;
@tracked senderAvatar = null;
@tracked senderProfileLoading = true;
@tracked placeName = null;
@tracked placeNameLoading = true;
constructor({
photoEventId,
photo,
photos,
placeIdentifier,
osmType,
osmId,
senderPubkey,
amountSats,
message,
createdAt,
}) {
this.photoEventId = photoEventId;
this.photo = photo;
this.photos = photos ?? [];
this.placeIdentifier = placeIdentifier;
this.osmType = osmType;
this.osmId = osmId;
this.senderPubkey = senderPubkey;
this.amountSats = amountSats;
this.message = message;
this.createdAt = createdAt;
}
}
// TODO: Place-name resolution. Add a shared place-name-resolver util co-used by
// services/contributions.js and this service. Each ActivityEntry should get
// @tracked placeName / placeNameLoading, resolved via the existing cascade
// (bookmarks → localForage cache → osm cache → batch OSM fetch). Extract from
// contributions.js rather than duplicating.
/**
* Parses a kind 9735 (Zap Receipt) event into an `ActivityEntry`, given the
* user's pubkey (to confirm they are the recipient).
*
* Returns `null` if the receipt is malformed, not directed at the user, or
* does not reference a zapped event (kind 360 photo).
*
* @param {object} receipt A NIP-57 zap receipt event (kind 9735)
* @param {string} userPubkey The logged-in user's pubkey
* @returns {ActivityEntry|null}
*/
export function parseZapReceipt(receipt, userPubkey) {
if (!receipt || receipt.kind !== 9735) return null;
const recipient = getZapRecipient(receipt);
if (!recipient || recipient !== userPubkey) return null;
const sender = getZapSender(receipt);
if (!sender) return null;
const payment = getZapPayment(receipt);
if (!payment || !payment.amount) return null;
const amountSats = Math.round(payment.amount / 1000);
const eventPointer = getZapEventPointer(receipt);
if (!eventPointer || !eventPointer.id) return null;
const zapRequest = getZapRequest(receipt);
const message = zapRequest?.content || null;
return new ActivityEntry({
photoEventId: eventPointer.id,
photo: null,
placeIdentifier: null,
senderPubkey: sender,
amountSats,
message,
createdAt: receipt.created_at,
});
}
/**
* Enriches an `ActivityEntry` with photo and place data from the zapped kind
* 360 event, if it is available in the store.
*
* Returns `true` if the entry was enriched (i.e. the zapped event was found
* and is a kind 360 authored by the user), or `false` if the entry should be
* discarded (the zapped event is not one of the user's photos).
*
* @param {ActivityEntry} entry
* @param {object} store The applesauce EventStore to look up the zapped event
* @param {string} userPubkey The logged-in user's pubkey
* @returns {boolean}
*/
export function enrichWithPhoto(entry, store, userPubkey) {
const event = store.getEvent?.(entry.photoEventId);
if (!event || event.kind !== 360 || event.pubkey !== userPubkey) {
return false;
}
const photo = parsePhotoFromEvent(event);
if (!photo) return false;
entry.photo = photo;
entry.placeIdentifier = photo.placeIdentifier || null;
// Parse osmType and osmId from placeIdentifier (e.g. "osm:node:123" → "node", "123")
if (entry.placeIdentifier) {
const [, osmType, osmId] = entry.placeIdentifier.split(':');
entry.osmType = osmType;
entry.osmId = osmId;
}
return true;
}
const HOUR_IN_SECONDS = 60 * 60;
/**
* Builds a single `ActivityEntry` (type 'photo') from a group of kind 360
* events by the same author for the same OSM entity.
*
* @param {Array} events Kind 360 events in this sub-group (same author + place)
* @returns {ActivityEntry}
*/
function buildSocialEntry(events) {
const photos = events
.map(parsePhotoFromEvent)
.filter(Boolean)
.sort((a, b) => a.createdAt - b.createdAt);
const createdAt = events.reduce(
(max, e) => (e.created_at > max ? e.created_at : max),
0
);
const placeIdentifier =
events[0].tags?.find((t) => t[0] === 'i')?.[1] || null;
let osmType;
let osmId;
if (placeIdentifier) {
const parts = placeIdentifier.split(':');
osmType = parts[1];
osmId = parts[2];
}
const entry = new ActivityEntry({
photo: photos[0] || null,
photos,
placeIdentifier,
osmType,
osmId,
senderPubkey: events[0].pubkey,
createdAt,
});
entry.type = 'photo';
return entry;
}
/**
* Groups kind 360 (Place Photo) events from followed contacts into activity
* entries, keyed by (author + OSM entity + time proximity).
*
* This mirrors the time-proximity grouping logic from `groupPhotoContributions`
* in `utils/contributions.js` but adds author as a grouping dimension so that
* "Alice shared 3 photos to Central Park Café" is one entry.
*
* @param {Array} events Mixed kind 360 / kind 5 events from followed contacts
* @param {number} [thresholdHours=3] Max gap in hours within a sub-group
* @returns {Array} Sorted `ActivityEntry` entries (newest first)
*/
export function groupSocialPhotos(events, thresholdHours = 3) {
if (!events || events.length === 0) return [];
const photoEvents = applyDeletions(events);
if (photoEvents.length === 0) return [];
// Group by (author pubkey + OSM entity identifier)
const byAuthorEntity = new Map();
for (const event of photoEvents) {
const entityTag = (event.tags || []).find((t) => t[0] === 'i');
const entityId = entityTag?.[1];
if (!entityId) continue;
const key = `${event.pubkey}:${entityId}`;
if (!byAuthorEntity.has(key)) byAuthorEntity.set(key, []);
byAuthorEntity.get(key).push(event);
}
const thresholdSeconds = thresholdHours * HOUR_IN_SECONDS;
const entries = [];
for (const [, groupEvents] of byAuthorEntity) {
// Sort newest-first within the group
const sorted = [...groupEvents].sort((a, b) => b.created_at - a.created_at);
// Walk newest -> oldest, starting a new sub-group when the gap to the
// previous event exceeds the threshold.
let currentGroup = [];
let prevTime = null;
for (const event of sorted) {
if (prevTime !== null && prevTime - event.created_at > thresholdSeconds) {
entries.push(buildSocialEntry(currentGroup));
currentGroup = [];
}
currentGroup.push(event);
prevTime = event.created_at;
}
if (currentGroup.length > 0) {
entries.push(buildSocialEntry(currentGroup));
}
}
// Sort entries by their newest event's created_at, descending. Entries with
// no usable photos (e.g. malformed imeta) are filtered out.
return entries
.filter((e) => e.photos.length > 0)
.sort((a, b) => b.createdAt - a.createdAt);
}