Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Overlay virtual room call events into main timeline (#9626)
Browse files Browse the repository at this point in the history
* super WIP POC for merging virtual room events into main timeline

* remove some debugs

* c

* add some todos

* remove hardcoded fake virtual user

* insert overlay events into main timeline without resorting main tl events

* remove more debugs

* add extra tick to roomview tests

* RoomView test case for virtual room

* test case for merged timeline

* make overlay event filter generic

* remove TODOs from LegacyCallEventGrouper

* tidy comments

* remove some newlines

* test timelinepanel room timeline event handling

* use newState.roomId

* fix strict errors in RoomView

* fix strict errors in TimelinePanel

* add type

* pr tweaks

* strict errors

* more strict fix

* strict error whackamole

* update ROomView tests to use rtl
  • Loading branch information
Kerry authored Dec 8, 2022
1 parent 1b6d753 commit 6150b86
Show file tree
Hide file tree
Showing 7 changed files with 1,174 additions and 88 deletions.
2 changes: 1 addition & 1 deletion src/VoipUserMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export default class VoipUserMapper {
return findDMForUser(MatrixClientPeg.get(), virtualUser);
}

public nativeRoomForVirtualRoom(roomId: string): string {
public nativeRoomForVirtualRoom(roomId: string): string | null {
const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId);
if (cachedNativeRoomId) {
logger.log(
Expand Down
7 changes: 6 additions & 1 deletion src/components/structures/LegacyCallEventGrouper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,18 @@ export enum CustomCallState {
Missed = "missed",
}

const isCallEventType = (eventType: string): boolean =>
eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call.");

export const isCallEvent = (event: MatrixEvent): boolean => isCallEventType(event.getType());

export function buildLegacyCallEventGroupers(
callEventGroupers: Map<string, LegacyCallEventGrouper>,
events?: MatrixEvent[],
): Map<string, LegacyCallEventGrouper> {
const newCallEventGroupers = new Map();
events?.forEach(ev => {
if (!ev.getType().startsWith("m.call.") && !ev.getType().startsWith("org.matrix.call.")) {
if (!isCallEvent(ev)) {
return;
}

Expand Down
19 changes: 15 additions & 4 deletions src/components/structures/RoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ import { CallStore, CallStoreEvent } from "../../stores/CallStore";
import { Call } from "../../models/Call";
import { RoomSearchView } from './RoomSearchView';
import eventSearch from "../../Searching";
import VoipUserMapper from '../../VoipUserMapper';
import { isCallEvent } from './LegacyCallEventGrouper';

const DEBUG = false;
let debuglog = function(msg: string) {};
Expand Down Expand Up @@ -144,6 +146,7 @@ enum MainSplitContentType {
}
export interface IRoomState {
room?: Room;
virtualRoom?: Room;
roomId?: string;
roomAlias?: string;
roomLoading: boolean;
Expand Down Expand Up @@ -654,7 +657,11 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// NB: This does assume that the roomID will not change for the lifetime of
// the RoomView instance
if (initial) {
newState.room = this.context.client.getRoom(newState.roomId);
const virtualRoom = newState.roomId ?
await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(newState.roomId) : undefined;

newState.room = this.context.client!.getRoom(newState.roomId) || undefined;
newState.virtualRoom = virtualRoom || undefined;
if (newState.room) {
newState.showApps = this.shouldShowApps(newState.room);
this.onRoomLoaded(newState.room);
Expand Down Expand Up @@ -1264,7 +1271,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
});
}

private onRoom = (room: Room) => {
private onRoom = async (room: Room) => {
if (!room || room.roomId !== this.state.roomId) {
return;
}
Expand All @@ -1277,16 +1284,18 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
);
}

const virtualRoom = await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(room.roomId);
this.setState({
room: room,
virtualRoom: virtualRoom || undefined,
}, () => {
this.onRoomLoaded(room);
});
};

private onDeviceVerificationChanged = (userId: string) => {
const room = this.state.room;
if (!room.currentState.getMember(userId)) {
if (!room?.currentState.getMember(userId)) {
return;
}
this.updateE2EStatus(room);
Expand Down Expand Up @@ -2093,7 +2102,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
hideMessagePanel = true;
}

let highlightedEventId = null;
let highlightedEventId: string | undefined;
if (this.state.isInitialEventHighlighted) {
highlightedEventId = this.state.initialEventId;
}
Expand All @@ -2102,6 +2111,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
<TimelinePanel
ref={this.gatherTimelinePanelRef}
timelineSet={this.state.room.getUnfilteredTimelineSet()}
overlayTimelineSet={this.state.virtualRoom?.getUnfilteredTimelineSet()}
overlayTimelineSetFilter={isCallEvent}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={!this.state.isPeeking}
sendReadReceiptOnLoad={!this.state.wasContextSwitch}
Expand Down
130 changes: 88 additions & 42 deletions src/components/structures/TimelinePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ interface IProps {
// a timeline representing. If it has a room, we maintain RRs etc for
// that room.
timelineSet: EventTimelineSet;
// overlay events from a second timelineset on the main timeline
// added to support virtual rooms
// events from the overlay timeline set will be added by localTimestamp
// into the main timeline
// back paging not yet supported
overlayTimelineSet?: EventTimelineSet;
// filter events from overlay timeline
overlayTimelineSetFilter?: (event: MatrixEvent) => boolean;
showReadReceipts?: boolean;
// Enable managing RRs and RMs. These require the timelineSet to have a room.
manageReadReceipts?: boolean;
Expand Down Expand Up @@ -236,14 +244,15 @@ class TimelinePanel extends React.Component<IProps, IState> {
private readonly messagePanel = createRef<MessagePanel>();
private readonly dispatcherRef: string;
private timelineWindow?: TimelineWindow;
private overlayTimelineWindow?: TimelineWindow;
private unmounted = false;
private readReceiptActivityTimer: Timer;
private readMarkerActivityTimer: Timer;
private readReceiptActivityTimer: Timer | null = null;
private readMarkerActivityTimer: Timer | null = null;

// A map of <callId, LegacyCallEventGrouper>
private callEventGroupers = new Map<string, LegacyCallEventGrouper>();

constructor(props, context) {
constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.context = context;

Expand Down Expand Up @@ -642,7 +651,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
data: IRoomTimelineData,
): void => {
// ignore events for other timeline sets
if (data.timeline.getTimelineSet() !== this.props.timelineSet) return;
if (
data.timeline.getTimelineSet() !== this.props.timelineSet
&& data.timeline.getTimelineSet() !== this.props.overlayTimelineSet
) {
return;
}

if (!Thread.hasServerSideSupport && this.context.timelineRenderingType === TimelineRenderingType.Thread) {
if (toStartOfTimeline && !this.state.canBackPaginate) {
Expand Down Expand Up @@ -680,21 +694,27 @@ class TimelinePanel extends React.Component<IProps, IState> {
// timeline window.
//
// see https://github.com/vector-im/vector-web/issues/1035
this.timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => {
if (this.unmounted) { return; }

const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildLegacyCallEventGroupers(events);
const lastLiveEvent = liveEvents[liveEvents.length - 1];

const updatedState: Partial<IState> = {
events,
liveEvents,
firstVisibleEventIndex,
};
this.timelineWindow!.paginate(EventTimeline.FORWARDS, 1, false)
.then(() => {
if (this.overlayTimelineWindow) {
return this.overlayTimelineWindow.paginate(EventTimeline.FORWARDS, 1, false);
}
})
.then(() => {
if (this.unmounted) { return; }

const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildLegacyCallEventGroupers(events);
const lastLiveEvent = liveEvents[liveEvents.length - 1];

const updatedState: Partial<IState> = {
events,
liveEvents,
firstVisibleEventIndex,
};

let callRMUpdated;
if (this.props.manageReadMarkers) {
let callRMUpdated = false;
if (this.props.manageReadMarkers) {
// when a new event arrives when the user is not watching the
// window, but the window is in its auto-scroll mode, make sure the
// read marker is visible.
Expand All @@ -703,28 +723,28 @@ class TimelinePanel extends React.Component<IProps, IState> {
// read-marker when a remote echo of an event we have just sent takes
// more than the timeout on userActiveRecently.
//
const myUserId = MatrixClientPeg.get().credentials.userId;
callRMUpdated = false;
if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
updatedState.readMarkerVisible = true;
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
const myUserId = MatrixClientPeg.get().credentials.userId;
callRMUpdated = false;
if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
updatedState.readMarkerVisible = true;
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle

this.setReadMarker(lastLiveEvent.getId(), lastLiveEvent.getTs(), true);
updatedState.readMarkerVisible = false;
updatedState.readMarkerEventId = lastLiveEvent.getId();
callRMUpdated = true;
this.setReadMarker(lastLiveEvent.getId() ?? null, lastLiveEvent.getTs(), true);
updatedState.readMarkerVisible = false;
updatedState.readMarkerEventId = lastLiveEvent.getId();
callRMUpdated = true;
}
}
}

this.setState<null>(updatedState, () => {
this.messagePanel.current?.updateTimelineMinHeight();
if (callRMUpdated) {
this.props.onReadMarkerUpdated?.();
}
this.setState(updatedState as IState, () => {
this.messagePanel.current?.updateTimelineMinHeight();
if (callRMUpdated) {
this.props.onReadMarkerUpdated?.();
}
});
});
});
};

private onRoomTimelineReset = (room: Room, timelineSet: EventTimelineSet): void => {
Expand All @@ -735,7 +755,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
};

public canResetTimeline = () => this.messagePanel?.current.isAtBottom();
public canResetTimeline = () => this.messagePanel?.current?.isAtBottom();

private onRoomRedaction = (ev: MatrixEvent, room: Room): void => {
if (this.unmounted) return;
Expand Down Expand Up @@ -1337,6 +1357,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
private loadTimeline(eventId?: string, pixelOffset?: number, offsetBase?: number, scrollIntoView = true): void {
const cli = MatrixClientPeg.get();
this.timelineWindow = new TimelineWindow(cli, this.props.timelineSet, { windowLimit: this.props.timelineCap });
this.overlayTimelineWindow = this.props.overlayTimelineSet
? new TimelineWindow(cli, this.props.overlayTimelineSet, { windowLimit: this.props.timelineCap })
: undefined;

const onLoaded = () => {
if (this.unmounted) return;
Expand All @@ -1351,8 +1374,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.advanceReadMarkerPastMyEvents();

this.setState({
canBackPaginate: this.timelineWindow.canPaginate(EventTimeline.BACKWARDS),
canForwardPaginate: this.timelineWindow.canPaginate(EventTimeline.FORWARDS),
canBackPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS),
canForwardPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.FORWARDS),
timelineLoading: false,
}, () => {
// initialise the scroll state of the message panel
Expand Down Expand Up @@ -1433,12 +1456,19 @@ class TimelinePanel extends React.Component<IProps, IState> {
// if we've got an eventId, and the timeline exists, we can skip
// the promise tick.
this.timelineWindow.load(eventId, INITIAL_SIZE);
this.overlayTimelineWindow?.load(undefined, INITIAL_SIZE);
// in this branch this method will happen in sync time
onLoaded();
return;
}

const prom = this.timelineWindow.load(eventId, INITIAL_SIZE);
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE).then(async () => {
if (this.overlayTimelineWindow) {
// @TODO(kerrya) use timestampToEvent to load the overlay timeline
// with more correct position when main TL eventId is truthy
await this.overlayTimelineWindow.load(undefined, INITIAL_SIZE);
}
});
this.buildLegacyCallEventGroupers();
this.setState({
events: [],
Expand Down Expand Up @@ -1471,7 +1501,23 @@ class TimelinePanel extends React.Component<IProps, IState> {

// get the list of events from the timeline window and the pending event list
private getEvents(): Pick<IState, "events" | "liveEvents" | "firstVisibleEventIndex"> {
const events: MatrixEvent[] = this.timelineWindow.getEvents();
const mainEvents: MatrixEvent[] = this.timelineWindow?.getEvents() || [];
const eventFilter = this.props.overlayTimelineSetFilter || Boolean;
const overlayEvents = this.overlayTimelineWindow?.getEvents().filter(eventFilter) || [];

// maintain the main timeline event order as returned from the HS
// merge overlay events at approximately the right position based on local timestamp
const events = overlayEvents.reduce((acc: MatrixEvent[], overlayEvent: MatrixEvent) => {
// find the first main tl event with a later timestamp
const index = acc.findIndex(event => event.localTimestamp > overlayEvent.localTimestamp);
// insert overlay event into timeline at approximately the right place
if (index > -1) {
acc.splice(index, 0, overlayEvent);
} else {
acc.push(overlayEvent);
}
return acc;
}, [...mainEvents]);

// `arrayFastClone` performs a shallow copy of the array
// we want the last event to be decrypted first but displayed last
Expand All @@ -1483,20 +1529,20 @@ class TimelinePanel extends React.Component<IProps, IState> {
client.decryptEventIfNeeded(event);
});

const firstVisibleEventIndex = this.checkForPreJoinUISI(events);
const firstVisibleEventIndex = this.checkForPreJoinUISI(mainEvents);

// Hold onto the live events separately. The read receipt and read marker
// should use this list, so that they don't advance into pending events.
const liveEvents = [...events];

// if we're at the end of the live timeline, append the pending events
if (!this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
if (!this.timelineWindow?.canPaginate(EventTimeline.FORWARDS)) {
const pendingEvents = this.props.timelineSet.getPendingEvents();
events.push(...pendingEvents.filter(event => {
const {
shouldLiveInRoom,
threadId,
} = this.props.timelineSet.room.eventShouldLiveIn(event, pendingEvents);
} = this.props.timelineSet.room!.eventShouldLiveIn(event, pendingEvents);

if (this.context.timelineRenderingType === TimelineRenderingType.Thread) {
return threadId === this.context.threadId;
Expand Down
Loading

0 comments on commit 6150b86

Please sign in to comment.