@towns-protocol/sdk
Version:
For more details, visit the following resources:
246 lines • 11.4 kB
JavaScript
import { isChannelStreamId, isDMChannelStreamId, isGDMChannelStreamId } from '../../id';
import { RiverTimelineEvent } from '../models/timelineTypes';
import { check, dlogger } from '@towns-protocol/dlog';
const console = dlogger('csb:unreadMarkersTransform');
export function unreadMarkersTransform(value, prev, state) {
console.log('unreadMarkersTransform', value, prev, state);
state = state ?? { markers: {} };
const { userId, myRemoteFullyReadMarkers, timelinesView } = value;
for (const streamId of Object.keys(myRemoteFullyReadMarkers)) {
const fullyReadMarkers = myRemoteFullyReadMarkers[streamId];
if (fullyReadMarkers !== prev.myRemoteFullyReadMarkers[streamId]) {
state = updateFullyReadMarkersFromRemote({ [streamId]: fullyReadMarkers }, state);
}
}
if (timelinesView !== prev.timelinesView) {
state = diffTimeline(timelinesView, prev.timelinesView, userId, state);
}
return state;
}
/// when we get an update from the server, update our local state
function updateFullyReadMarkersFromRemote(fullyReadMarkersMap, state) {
let markersUpdated = 0;
const updated = { ...state.markers };
for (const fullyReadMarkers of Object.values(fullyReadMarkersMap)) {
for (const [key, marker] of Object.entries(fullyReadMarkers)) {
// if we don't have a marker, or if the remote marker has been marked as read more recently than our local marker, update
if (!updated[key] || updated[key].beginUnreadWindow < marker.beginUnreadWindow) {
updated[key] = marker;
markersUpdated++;
}
}
}
if (markersUpdated > 0) {
console.log('$ onRoomAccountDataEvent: set markers for ', { markersUpdated });
return { markers: updated };
}
else {
return state;
}
}
function diffTimeline(timelineState, prev, userId, state) {
if (Object.keys(prev.timelines).length === 0 || timelineState.timelines === prev.timelines) {
// noop
return state;
}
const channelIds = Object.keys(timelineState.timelines).filter(isDiffableStreamTimeline);
for (const channelId of channelIds) {
const prevEvents = prev.timelines[channelId] ?? [];
const events = timelineState.timelines[channelId];
if (prevEvents !== events) {
const updated = { ...state.markers };
const resultReplaced = diffReplaced(channelId, userId, events, timelineState.replacedEvents[channelId] ?? [], prev.replacedEvents[channelId] ?? [], updated);
const resultAdded = diffAdded(channelId, userId, events, prevEvents, updated);
if (resultReplaced.didUpdate || resultAdded.didUpdate) {
state = { markers: updated };
}
}
}
return state;
}
function diffReplaced(channelId, userId, events, replacedEvents, prevReplacedEvents, updated) {
// if a replaced event is in the event index window, we need to update mentions and unread
// replaced is the primary way things get decrypted, so we need to adjust the first unread event
// and the unread windows for threads
if (replacedEvents.length === prevReplacedEvents.length) {
return { didUpdate: false };
}
// get the marker for this channel
const channelMarker = updated[channelId];
// if we don't have a channel marker, then all changes should get picked up in the added diff
if (!channelMarker) {
return { didUpdate: false };
}
let didUpdate = false;
for (let i = prevReplacedEvents.length; i < replacedEvents.length; i++) {
const event = replacedEvents[i];
// compare the event num against the channel window, not the thread id intentionally
// because we didn't know the thread id until after the event was decrypted and replaced
if (event.newEvent.eventNum > channelMarker.endUnreadWindow) {
// we'll pick this up in the added diff
continue;
}
const markerId = event.newEvent.threadParentId ?? channelId;
const marker = updated[markerId];
if (!marker) {
didUpdate = true;
// if a marker doesn't exist it usually means we are in a thread and this
// is the first event in that thread that's been decrypted
updated[markerId] = {
channelId: channelId,
threadParentId: event.newEvent.threadParentId,
eventId: event.newEvent.eventId,
eventNum: event.newEvent.eventNum,
beginUnreadWindow: channelMarker.beginUnreadWindow,
endUnreadWindow: channelMarker.endUnreadWindow,
isUnread: isCountedAsUnread(event.newEvent, userId),
markedReadAtTs: 0n,
mentions: event.newEvent.isMentioned ? 1 : 0,
};
continue;
}
else {
// if we're before the unread window, we don't need to update anything
if (event.newEvent.eventNum < marker.beginUnreadWindow) {
continue;
}
didUpdate = true;
// if the event is in the unread window,
const wasMentionedBefore = event.oldEvent.isMentioned;
const wasMentionedAfter = event.newEvent.isMentioned;
const mentions = wasMentionedBefore === wasMentionedAfter
? marker.mentions
: wasMentionedBefore
? marker.mentions - 1
: marker.mentions + 1;
const endUnreadWindow = maxBigint(event.newEvent.eventNum, marker.endUnreadWindow);
if (event.newEvent.isRedacted &&
!event.oldEvent.isRedacted &&
event.newEvent.eventNum === marker.eventNum) {
// if the event was redacted, we need to find the first unread event
const firstUnread = firstUnreadEvent(events, userId, channelId, markerId, marker.beginUnreadWindow, endUnreadWindow);
const lastEvent = events[events.length - 1];
updated[markerId] = {
...marker,
eventId: firstUnread?.eventId ?? lastEvent.eventId,
eventNum: firstUnread?.eventNum ?? lastEvent.eventNum,
endUnreadWindow: endUnreadWindow,
isUnread: firstUnread !== undefined,
mentions: mentions,
};
}
else {
// if this is not a redaction it should never move from countsAsUnread to !countsAsUnread
// meaning we just need to check to see if this is the new first unread event
const isNewFirstUnread = isCountedAsUnread(event.newEvent, userId) &&
(!marker.isUnread || event.newEvent.eventNum <= marker.eventNum);
const newEventId = isNewFirstUnread ? event.newEvent.eventId : marker.eventId;
const newEventNum = isNewFirstUnread ? event.newEvent.eventNum : marker.eventNum;
updated[markerId] = {
...marker,
eventId: newEventId,
eventNum: newEventNum,
endUnreadWindow: endUnreadWindow,
isUnread: marker.isUnread || isNewFirstUnread,
mentions: mentions,
};
}
}
}
return { didUpdate };
}
function diffAdded(channelId, userId, events, _prevEvents, updated) {
// we we always find the first event
// we count the mentions in the new events
// we update the unread window and isUnread
let didUpdate = false;
const eventsMap = {};
const prevEndUnreadWindow = updated[channelId]?.endUnreadWindow ?? -1n;
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i];
if (event.eventNum > prevEndUnreadWindow) {
const parentId = event.threadParentId ?? channelId;
eventsMap[parentId] = [event, ...(eventsMap[parentId] ?? [])];
}
}
Object.entries(eventsMap).forEach(([markerId, eventSegment]) => {
didUpdate = true;
const isThread = markerId !== channelId;
const prevMarker = updated[markerId];
const mentions = prevMarker?.mentions ?? 0 + eventSegment.filter((e) => e.isMentioned).length;
const beginUnreadWindow = prevMarker?.beginUnreadWindow ?? eventSegment[0].eventNum;
const endUnreadWindow = eventSegment[eventSegment.length - 1].eventNum;
if (beginUnreadWindow > endUnreadWindow) {
console.log('$ beginUnreadWindow must be <= endUnreadWindow');
return;
}
const firstUnread = firstUnreadEvent(events, userId, channelId, markerId, beginUnreadWindow, endUnreadWindow);
updated[markerId] = {
channelId: channelId,
threadParentId: isThread ? markerId : undefined,
eventId: firstUnread?.eventId ?? eventSegment[eventSegment.length - 1].eventId,
eventNum: firstUnread?.eventNum ?? eventSegment[eventSegment.length - 1].eventNum,
beginUnreadWindow: beginUnreadWindow,
endUnreadWindow: endUnreadWindow,
isUnread: firstUnread !== undefined,
markedReadAtTs: prevMarker?.markedReadAtTs ?? 0n,
mentions: mentions,
};
});
return { didUpdate };
}
function isCountedAsUnread(event, myUserId) {
switch (event.content?.kind) {
case RiverTimelineEvent.ChannelMessage:
return event.sender.id !== myUserId;
case RiverTimelineEvent.ChannelMessageEncrypted:
return event.sender.id !== myUserId;
case RiverTimelineEvent.ChannelMessageEncryptedWithRef:
return false;
default:
return false;
}
}
function firstUnreadEvent(events, userId, channelId, markerId, beginWindow, endWindow) {
check(beginWindow <= endWindow, 'beginWindow must be <= endWindow');
const startIndex = indexOfFirstEventNumEqualToOrGreaterThan(events, beginWindow);
for (let i = startIndex; i < events.length; i++) {
const event = events[i];
const eventMarkerId = event.threadParentId ?? channelId;
if (event.eventNum > endWindow) {
break;
}
if (eventMarkerId === markerId && isCountedAsUnread(event, userId)) {
return event;
}
}
return undefined;
}
function indexOfFirstEventNumEqualToOrGreaterThan(events, eventNum) {
let low = 0;
let high = events.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (events[mid].eventNum < eventNum) {
low = mid + 1;
}
else if (events[mid].eventNum >= eventNum) {
// Check if the previous eventNum is also >= to ensure we get the first occurrence
if (mid === 0 || events[mid - 1].eventNum < eventNum) {
return mid;
}
high = mid - 1;
}
}
return events.length;
}
// Math.max only supports numbers
function maxBigint(x, y) {
return x > y ? x : y;
}
function isDiffableStreamTimeline(streamId) {
return (isChannelStreamId(streamId) ||
isDMChannelStreamId(streamId) ||
isGDMChannelStreamId(streamId));
}
//# sourceMappingURL=unreadMarkersTransform.js.map