@towns-protocol/sdk
Version:
For more details, visit the following resources:
651 lines • 26 kB
JavaScript
import { isMessageTipEvent, RiverTimelineEvent, getRedactsId, getEditsId, } from '../models/timelineTypes';
import { dlogger } from '@towns-protocol/dlog';
import { getFallbackContent } from '../models/timelineEvent';
const logger = dlogger('csb:timelineInterface');
export function makeTimelinesViewInterface(setState) {
const initializeStream = (userId, streamId) => {
setState((state) => _initializeStream(state, streamId));
};
const _initializeStream = (state, streamId) => {
const aggregated = {
threadStats: {},
threads: {},
reactions: {},
tips: {},
};
return {
timelines: { ...state.timelines, [streamId]: [] },
replacedEvents: state.replacedEvents,
pendingReplacedEvents: state.pendingReplacedEvents,
threadsStats: {
...state.threadsStats,
[streamId]: aggregated.threadStats,
},
threads: {
...state.threads,
[streamId]: aggregated.threads,
},
reactions: {
...state.reactions,
[streamId]: aggregated.reactions,
},
tips: {
...state.tips,
[streamId]: aggregated.tips,
},
lastestEventByUser: state.lastestEventByUser,
};
};
const reset = (streamIds) => {
setState((prev) => {
for (const streamId of streamIds) {
delete prev.timelines[streamId];
delete prev.replacedEvents[streamId];
delete prev.pendingReplacedEvents[streamId];
delete prev.threadsStats[streamId];
delete prev.threads[streamId];
delete prev.reactions[streamId];
delete prev.tips[streamId];
}
return prev;
});
};
const removeEvent = (state, streamId, eventId) => {
const eventIndex = state.timelines[streamId]?.findIndex((e) => e.eventId == eventId);
if ((eventIndex ?? -1) < 0) {
return state;
}
const event = state.timelines[streamId][eventIndex];
return {
timelines: removeTimelineEvent(streamId, eventIndex, state.timelines),
replacedEvents: state.replacedEvents,
pendingReplacedEvents: state.pendingReplacedEvents,
threadsStats: removeThreadStat(streamId, event, state.threadsStats),
threads: removeThreadEvent(streamId, event, state.threads),
reactions: removeReaction(streamId, event, state.reactions),
tips: removeTip(streamId, event, state.tips),
lastestEventByUser: state.lastestEventByUser,
};
};
const appendEvent = (state, userId, streamId, timelineEvent) => {
return {
timelines: appendTimelineEvent(streamId, timelineEvent, state.timelines),
replacedEvents: state.replacedEvents,
pendingReplacedEvents: state.pendingReplacedEvents,
threadsStats: addThreadStats(streamId, timelineEvent, state.threadsStats, state.timelines[streamId], userId),
threads: insertThreadEvent(streamId, timelineEvent, state.threads),
reactions: addReactions(streamId, timelineEvent, state.reactions),
tips: addTips(streamId, timelineEvent, state.tips, 'append'),
lastestEventByUser: state.lastestEventByUser,
};
};
const prependEvent = (state, userId, streamId, inTimelineEvent) => {
const timelineEvent = state.pendingReplacedEvents[streamId]?.[inTimelineEvent.eventId]
? toReplacedMessageEvent(inTimelineEvent, state.pendingReplacedEvents[streamId][inTimelineEvent.eventId])
: inTimelineEvent;
return {
timelines: prependTimelineEvent(streamId, timelineEvent, state.timelines),
replacedEvents: state.replacedEvents,
pendingReplacedEvents: state.pendingReplacedEvents,
threadsStats: addThreadStats(streamId, timelineEvent, state.threadsStats, state.timelines[streamId], userId),
threads: insertThreadEvent(streamId, timelineEvent, state.threads),
reactions: addReactions(streamId, timelineEvent, state.reactions),
tips: addTips(streamId, timelineEvent, state.tips, 'prepend'),
lastestEventByUser: state.lastestEventByUser,
};
};
const replaceEvent = (state, userId, streamId, replacedMsgId, timelineEvent) => {
const timeline = state.timelines[streamId] ?? [];
const eventIndex = timeline.findIndex((e) => e.eventId === replacedMsgId ||
(e.localEventId && e.localEventId === timelineEvent.localEventId));
if (eventIndex === -1) {
// if we didn't find an event to replace...
if (state.pendingReplacedEvents[streamId]?.[replacedMsgId] &&
state.pendingReplacedEvents[streamId][replacedMsgId].latestEventNum >
timelineEvent.latestEventNum) {
// if we already have a replacement here, leave it, because we sync backwards, we assume the first one is the correct one
return state;
}
else {
// otherwise add it to the pending list
return {
...state,
pendingReplacedEvents: {
...state.pendingReplacedEvents,
[streamId]: {
...state.pendingReplacedEvents[streamId],
[replacedMsgId]: timelineEvent,
},
},
};
}
}
const oldEvent = timeline[eventIndex];
if (timelineEvent.latestEventNum < oldEvent.latestEventNum) {
return state;
}
const newEvent = toReplacedMessageEvent(oldEvent, timelineEvent);
const threadParentId = newEvent.threadParentId;
const threadTimeline = threadParentId
? state.threads[streamId]?.[threadParentId]
: undefined;
const threadEventIndex = threadTimeline?.findIndex((e) => e.eventId === replacedMsgId ||
(e.localEventId && e.localEventId === timelineEvent.localEventId)) ?? -1;
return {
timelines: replaceTimelineEvent(streamId, newEvent, eventIndex, timeline, state.timelines),
replacedEvents: {
...state.replacedEvents,
[streamId]: [...(state.replacedEvents[streamId] ?? []), { oldEvent, newEvent }],
},
pendingReplacedEvents: state.pendingReplacedEvents,
threadsStats: addThreadStats(streamId, newEvent, removeThreadStat(streamId, oldEvent, state.threadsStats), state.timelines[streamId], userId),
threads: threadParentId && threadTimeline && threadEventIndex >= 0
? {
...state.threads,
[streamId]: replaceTimelineEvent(threadParentId, newEvent, threadEventIndex, threadTimeline, state.threads[streamId]),
}
: threadParentId
? insertThreadEvent(streamId, newEvent, state.threads)
: state.threads,
reactions: addReactions(streamId, newEvent, removeReaction(streamId, oldEvent, state.reactions)),
tips: addTips(streamId, newEvent, removeTip(streamId, oldEvent, state.tips), 'append'), // not sure one will ever replace a tip
lastestEventByUser: state.lastestEventByUser,
};
};
function confirmEvent(state, streamId, confirmation) {
// very very similar to replaceEvent, but we only swap out the confirmedInBlockNum and confirmedEventNum
const timeline = state.timelines[streamId] ?? [];
const eventIndex = timeline.findIndex((e) => e.eventId === confirmation.eventId);
if (eventIndex === -1) {
return state;
}
const oldEvent = timeline[eventIndex];
const newEvent = {
...oldEvent,
confirmedEventNum: confirmation.confirmedEventNum,
confirmedInBlockNum: confirmation.confirmedInBlockNum,
};
const threadParentId = newEvent.threadParentId;
const threadTimeline = threadParentId
? state.threads[streamId]?.[threadParentId]
: undefined;
const threadEventIndex = threadTimeline?.findIndex((e) => e.eventId === confirmation.eventId) ?? -1;
return {
timelines: replaceTimelineEvent(streamId, newEvent, eventIndex, timeline, state.timelines),
replacedEvents: {
...state.replacedEvents,
[streamId]: [...(state.replacedEvents[streamId] ?? []), { oldEvent, newEvent }],
},
pendingReplacedEvents: state.pendingReplacedEvents,
threadsStats: state.threadsStats,
threads: threadParentId && threadTimeline && threadEventIndex >= 0
? {
...state.threads,
[streamId]: replaceTimelineEvent(threadParentId, newEvent, threadEventIndex, threadTimeline, state.threads[streamId]),
}
: state.threads,
reactions: state.reactions,
tips: state.tips,
lastestEventByUser: state.lastestEventByUser,
};
}
function processEvent(state, event, userId, streamId, updatingEventId) {
const editsEventId = getEditsId(event.content);
const redactsEventId = getRedactsId(event.content);
if (redactsEventId) {
const redactedEvent = makeRedactionEvent(event);
state = replaceEvent(state, userId, streamId, redactsEventId, redactedEvent);
if (updatingEventId) {
// replace the formerly encrypted event
state = replaceEvent(state, userId, streamId, updatingEventId, event);
}
else {
state = appendEvent(state, userId, streamId, event);
}
}
else if (editsEventId) {
if (updatingEventId) {
// remove the formerly encrypted event
state = removeEvent(state, streamId, updatingEventId);
}
state = replaceEvent(state, userId, streamId, editsEventId, event);
}
else {
if (updatingEventId) {
// replace the formerly encrypted event
state = replaceEvent(state, userId, streamId, updatingEventId, event);
}
else {
state = appendEvent(state, userId, streamId, event);
}
}
const prevLatestEvent = state.lastestEventByUser[event.sender.id];
if ((prevLatestEvent?.createdAtEpochMs ?? 0) < event.createdAtEpochMs) {
state = {
...state,
lastestEventByUser: {
...state.lastestEventByUser,
[event.sender.id]: event,
},
};
}
return state;
}
function appendEvents(events, userId, streamId, specialFunction) {
setState((state) => {
if (specialFunction === 'initializeStream') {
state = _initializeStream(state, streamId);
}
for (const event of events) {
state = processEvent(state, event, userId, streamId, undefined);
}
return state;
});
}
function prependEvents(events, userId, streamId) {
setState((state) => {
for (const event of [...events].reverse()) {
const editsEventId = getEditsId(event.content);
const redactsEventId = getRedactsId(event.content);
if (redactsEventId) {
const redactedEvent = makeRedactionEvent(event);
state = prependEvent(state, userId, streamId, event);
state = replaceEvent(state, userId, streamId, redactsEventId, redactedEvent);
}
else if (editsEventId) {
state = replaceEvent(state, userId, streamId, editsEventId, event);
}
else {
state = prependEvent(state, userId, streamId, event);
}
}
return state;
});
}
function updateEvents(events, userId, streamId) {
setState((state) => {
for (const event of events) {
state = processEvent(state, event, userId, streamId, event.eventId);
}
return state;
});
}
function updateEvent(event, userId, streamId, replacingEventId) {
setState((state) => {
return processEvent(state, event, userId, streamId, replacingEventId);
});
}
function confirmEvents(confirmations, streamId) {
setState((state) => {
confirmations.forEach((confirmation) => {
state = confirmEvent(state, streamId, confirmation);
});
return state;
});
}
return {
initializeStream,
reset,
appendEvents,
prependEvents,
updateEvents,
updateEvent,
confirmEvents,
};
}
function canReplaceEvent(prev, next) {
if (next.content?.kind === RiverTimelineEvent.RedactedEvent && next.content.isAdminRedaction) {
return true;
}
if (next.sender.id === prev.sender.id) {
return true;
}
logger.info('cannot replace event', { prev, next });
return false;
}
function toReplacedMessageEvent(prev, next) {
const isLocalId = prev.eventId.startsWith('~');
if (!canReplaceEvent(prev, next)) {
return prev;
}
else if (next.content?.kind === RiverTimelineEvent.ChannelMessage &&
prev.content?.kind === RiverTimelineEvent.ChannelMessage) {
// when we replace an event, we copy the content up to the root event
// so we keep the prev id, but use the next content
const eventId = !isLocalId ? prev.eventId : next.eventId;
return {
...next,
eventId: eventId,
eventNum: prev.eventNum,
latestEventId: next.eventId,
latestEventNum: next.eventNum,
confirmedEventNum: prev.confirmedEventNum ?? next.confirmedEventNum,
confirmedInBlockNum: prev.confirmedInBlockNum ?? next.confirmedInBlockNum,
createdAtEpochMs: prev.createdAtEpochMs,
updatedAtEpochMs: next.createdAtEpochMs,
content: {
...next.content,
threadId: prev.content.threadId,
},
threadParentId: prev.threadParentId,
reactionParentId: prev.reactionParentId,
sender: prev.sender,
};
}
else if (next.content?.kind === RiverTimelineEvent.RedactedEvent) {
// for redacted events, carry over previous pointers to content
// we don't want to lose thread info
return {
...next,
eventId: prev.eventId,
eventNum: prev.eventNum,
latestEventId: next.eventId,
latestEventNum: next.eventNum,
confirmedEventNum: prev.confirmedEventNum ?? next.confirmedEventNum,
confirmedInBlockNum: prev.confirmedInBlockNum ?? next.confirmedInBlockNum,
createdAtEpochMs: prev.createdAtEpochMs,
updatedAtEpochMs: next.createdAtEpochMs,
threadParentId: prev.threadParentId,
reactionParentId: prev.reactionParentId,
};
}
else if (prev.content?.kind === RiverTimelineEvent.RedactedEvent) {
// replacing a redacted event should maintain the redacted state
return {
...prev,
latestEventId: next.eventId,
latestEventNum: next.eventNum,
confirmedEventNum: prev.confirmedEventNum ?? next.confirmedEventNum,
confirmedInBlockNum: prev.confirmedInBlockNum ?? next.confirmedInBlockNum,
};
}
else {
// make sure we carry the createdAtEpochMs of the previous event
// so we don't end up with a timeline that has events out of order.
const eventId = isLocalId ? next.eventId : prev.eventId;
return {
...next,
eventId: eventId,
eventNum: prev.eventNum,
latestEventId: next.eventId,
latestEventNum: next.eventNum,
confirmedEventNum: prev.confirmedEventNum ?? next.confirmedEventNum,
confirmedInBlockNum: prev.confirmedInBlockNum ?? next.confirmedInBlockNum,
createdAtEpochMs: prev.createdAtEpochMs,
updatedAtEpochMs: next.createdAtEpochMs,
};
}
}
function makeRedactionEvent(redactionAction) {
if (redactionAction.content?.kind !== RiverTimelineEvent.RedactionActionEvent) {
throw new Error('makeRedactionEvent called with non-redaction action event');
}
const newContent = {
kind: RiverTimelineEvent.RedactedEvent,
isAdminRedaction: redactionAction.content.adminRedaction,
};
return {
...redactionAction,
content: newContent,
fallbackContent: getFallbackContent('', newContent),
isRedacted: true,
};
}
function addThreadStats(streamId, timelineEvent, threadsStats, timeline, userId) {
const parentId = timelineEvent.threadParentId;
// if we have a parent...
if (parentId) {
return {
...threadsStats,
[streamId]: {
...threadsStats[streamId],
[parentId]: addThreadStat(timelineEvent, parentId, threadsStats[streamId]?.[parentId], timeline, userId),
},
};
}
// if we are a parent...
if (threadsStats[streamId]?.[timelineEvent.eventId]) {
// update ourself in the map
return {
...threadsStats,
[streamId]: {
...threadsStats[streamId],
[timelineEvent.eventId]: {
...threadsStats[streamId][timelineEvent.eventId],
parentEvent: timelineEvent,
parentMessageContent: getChannelMessageContent(timelineEvent),
isParticipating: threadsStats[streamId][timelineEvent.eventId].isParticipating ||
(timelineEvent.content?.kind !== RiverTimelineEvent.RedactedEvent &&
threadsStats[streamId][timelineEvent.eventId].replyEventIds.size > 0 &&
(timelineEvent.sender.id === userId || timelineEvent.isMentioned)),
},
},
};
}
// otherwise noop
return threadsStats;
}
function makeNewThreadStats(event, parentId, timeline) {
const parent = timeline?.find((t) => t.eventId === parentId); // one time lookup of the parent message for the first reply
return {
replyEventIds: new Set(),
userIds: new Set(),
latestTs: event.createdAtEpochMs,
parentId,
parentEvent: parent,
parentMessageContent: getChannelMessageContent(parent),
isParticipating: false,
};
}
function addThreadStat(event, parentId, entry, timeline, userId) {
const updated = entry ? { ...entry } : makeNewThreadStats(event, parentId, timeline);
if (event.content?.kind === RiverTimelineEvent.RedactedEvent) {
return updated;
}
updated.replyEventIds.add(event.eventId);
updated.latestTs = Math.max(updated.latestTs, event.createdAtEpochMs);
const senderId = getMessageSenderId(event);
if (senderId) {
updated.userIds.add(senderId);
}
updated.isParticipating =
updated.isParticipating ||
updated.userIds.has(userId) ||
updated.parentEvent?.sender.id === userId ||
event.isMentioned;
return updated;
}
function removeThreadStat(streamId, timelineEvent, threadsStats) {
const parentId = timelineEvent.threadParentId;
if (!parentId) {
return threadsStats;
}
if (!threadsStats[streamId]?.[parentId]) {
return threadsStats;
}
const updated = { ...threadsStats[streamId] };
const entry = updated[parentId];
if (entry) {
entry.replyEventIds.delete(timelineEvent.eventId);
if (entry.replyEventIds.size === 0) {
delete updated[parentId];
}
else {
const senderId = getMessageSenderId(timelineEvent);
if (senderId) {
entry.userIds.delete(senderId);
}
}
}
return { ...threadsStats, [streamId]: updated };
}
function addTips(streamId, event, tips, direction) {
if (!isMessageTipEvent(event)) {
return tips;
}
// note to future self, if anyone starts uploading the same transaction multiple times,
// store the tips in a Record keyed by transactionHash instead of eventId
return {
...tips,
[streamId]: addTip(event, tips[streamId], direction),
};
}
function addTip(event, tips, direction) {
const refEventId = event.content.refEventId;
if (!tips) {
return {
[refEventId]: [event],
};
}
if (direction === 'append') {
return {
...tips,
[refEventId]: [...(tips[refEventId] ?? []), event],
};
}
else {
return {
...tips,
[refEventId]: [event, ...(tips[refEventId] ?? [])],
};
}
}
function removeTip(streamId, event, tips) {
if (!isMessageTipEvent(event)) {
return tips;
}
const refEventId = event.content.refEventId;
if (!tips[streamId]?.[refEventId]) {
return tips;
}
return {
...tips,
[streamId]: {
...tips[streamId],
[refEventId]: tips[streamId][refEventId].filter((t) => t.eventId !== event.eventId),
},
};
}
function addReactions(streamId, event, reactions) {
const parentId = event.reactionParentId;
if (!parentId) {
return reactions;
}
return {
...reactions,
[streamId]: {
...reactions[streamId],
[parentId]: addReaction(event, reactions[streamId]?.[parentId]),
},
};
}
function addReaction(event, entry) {
const content = event.content?.kind === RiverTimelineEvent.Reaction ? event.content : undefined;
if (!content) {
return entry ?? {};
}
const reactionName = content.reaction;
const senderId = event.sender.id;
return {
...entry,
[reactionName]: {
...entry?.[reactionName],
[senderId]: { eventId: event.eventId },
},
};
}
function removeReaction(streamId, event, reactions) {
const parentId = event.reactionParentId;
if (!parentId) {
return reactions;
}
if (!reactions[streamId]?.[parentId]) {
return reactions;
}
const content = event.content?.kind === RiverTimelineEvent.Reaction ? event.content : undefined;
if (!content) {
return reactions;
}
const reactionName = content.reaction;
const senderId = event.sender.id;
const updated = { ...reactions[streamId] };
const entry = updated[parentId];
if (entry) {
const reactions = entry[reactionName];
if (reactions) {
delete reactions[senderId];
}
if (Object.keys(reactions).length === 0) {
delete entry[reactionName];
}
}
return { ...reactions, [streamId]: updated };
}
function removeThreadEvent(streamId, event, threads) {
const parentId = event.threadParentId;
if (!parentId) {
return threads;
}
const threadEventIndex = threads[streamId]?.[parentId]?.findIndex((e) => e.eventId === event.eventId) ?? -1;
if (threadEventIndex === -1) {
return threads;
}
return {
...threads,
[streamId]: removeTimelineEvent(parentId, threadEventIndex, threads[streamId]),
};
}
function insertThreadEvent(streamId, timelineEvent, threads) {
if (!timelineEvent.threadParentId) {
return threads;
}
return {
...threads,
[streamId]: insertTimelineEvent(timelineEvent.threadParentId, timelineEvent, threads[streamId] ?? {}),
};
}
function removeTimelineEvent(streamId, eventIndex, timelines) {
return {
...timelines,
[streamId]: [
...timelines[streamId].slice(0, eventIndex),
...timelines[streamId].slice(eventIndex + 1),
],
};
}
function insertTimelineEvent(streamId, timelineEvent, timelines) {
// thread items are decrypted in an unpredictable order, so we need to insert them in the correct order
return {
...timelines,
[streamId]: [timelineEvent, ...(timelines[streamId] ?? [])].sort((a, b) => a.eventNum > b.eventNum ? 1 : -1),
};
}
function appendTimelineEvent(streamId, timelineEvent, timelines) {
return {
...timelines,
[streamId]: [...(timelines[streamId] ?? []), timelineEvent],
};
}
function prependTimelineEvent(streamId, timelineEvent, timelines) {
return {
...timelines,
[streamId]: [timelineEvent, ...(timelines[streamId] ?? [])],
};
}
function replaceTimelineEvent(streamId, newEvent, eventIndex, timeline, timelines) {
return {
...timelines,
[streamId]: [...timeline.slice(0, eventIndex), newEvent, ...timeline.slice(eventIndex + 1)],
};
}
function getChannelMessageContent(event) {
return event?.content?.kind === RiverTimelineEvent.ChannelMessage ? event.content : undefined;
}
function getMessageSenderId(event) {
if (event.content?.kind === RiverTimelineEvent.ChannelMessage ||
event.content?.kind === RiverTimelineEvent.TokenTransfer) {
return event.sender.id;
}
return undefined;
}
//# sourceMappingURL=timelinesModel.js.map