Both initially (if not enough recent ones are available), and when scrolling to the end of an activity timeline
256 lines
7.5 KiB
JavaScript
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);
|
|
}
|