Loading threads with server-side assistance (#9356)
* Fix bug with message context menu * fix bug where ThreadSummary failed if no last reply is available * Fix relations direction API * Use same API for threads as for any other timeline * Determine if event belongs to thread on jumping to event * properly listen to thread deletion * Add thread redaction tests * Add fetchInitialEvent tests * Paginate using default TimelinePanel behaviour * Remove unused threads deleted code Co-authored-by: Germain <germain@souquet.com> Co-authored-by: Germain <germains@element.io>
This commit is contained in:
parent
750ca78e98
commit
d92fdc1f5b
11 changed files with 205 additions and 82 deletions
|
@ -18,9 +18,6 @@ import React, { createRef, KeyboardEvent } from 'react';
|
||||||
import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
|
import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
|
||||||
import { Room, RoomEvent } from 'matrix-js-sdk/src/models/room';
|
import { Room, RoomEvent } from 'matrix-js-sdk/src/models/room';
|
||||||
import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||||
import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window';
|
|
||||||
import { Direction } from 'matrix-js-sdk/src/models/event-timeline';
|
|
||||||
import { IRelationsRequestOpts } from 'matrix-js-sdk/src/@types/requests';
|
|
||||||
import { logger } from 'matrix-js-sdk/src/logger';
|
import { logger } from 'matrix-js-sdk/src/logger';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
@ -236,10 +233,8 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
thread_id: thread.id,
|
thread_id: thread.id,
|
||||||
});
|
});
|
||||||
thread.emit(ThreadEvent.ViewThread);
|
thread.emit(ThreadEvent.ViewThread);
|
||||||
await thread.fetchInitialEvents();
|
|
||||||
this.updateThreadRelation();
|
this.updateThreadRelation();
|
||||||
this.nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward);
|
this.timelinePanel.current?.refreshTimeline(this.props.initialEvent?.getId());
|
||||||
this.timelinePanel.current?.refreshTimeline();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupThreadListeners(thread?: Thread | undefined, oldThread?: Thread | undefined): void {
|
private setupThreadListeners(thread?: Thread | undefined, oldThread?: Thread | undefined): void {
|
||||||
|
@ -293,40 +288,6 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private nextBatch: string | undefined | null = null;
|
|
||||||
|
|
||||||
private onPaginationRequest = async (
|
|
||||||
timelineWindow: TimelineWindow | null,
|
|
||||||
direction = Direction.Backward,
|
|
||||||
limit = 20,
|
|
||||||
): Promise<boolean> => {
|
|
||||||
if (!Thread.hasServerSideSupport && timelineWindow) {
|
|
||||||
timelineWindow.extend(direction, limit);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const opts: IRelationsRequestOpts = {
|
|
||||||
limit,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.nextBatch) {
|
|
||||||
opts.from = this.nextBatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextBatch: string | null | undefined = null;
|
|
||||||
if (this.state.thread) {
|
|
||||||
const response = await this.state.thread.fetchEvents(opts);
|
|
||||||
nextBatch = response.nextBatch;
|
|
||||||
this.nextBatch = nextBatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advances the marker on the TimelineWindow to define the correct
|
|
||||||
// window of events to display on screen
|
|
||||||
timelineWindow?.extend(direction, limit);
|
|
||||||
|
|
||||||
return !!nextBatch;
|
|
||||||
};
|
|
||||||
|
|
||||||
private onFileDrop = (dataTransfer: DataTransfer) => {
|
private onFileDrop = (dataTransfer: DataTransfer) => {
|
||||||
const roomId = this.props.mxEvent.getRoomId();
|
const roomId = this.props.mxEvent.getRoomId();
|
||||||
if (roomId) {
|
if (roomId) {
|
||||||
|
@ -409,7 +370,6 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||||
highlightedEventId={highlightedEventId}
|
highlightedEventId={highlightedEventId}
|
||||||
eventScrollIntoView={this.props.initialEventScrollIntoView}
|
eventScrollIntoView={this.props.initialEventScrollIntoView}
|
||||||
onEventScrolledIntoView={this.resetJumpToEvent}
|
onEventScrolledIntoView={this.resetJumpToEvent}
|
||||||
onPaginationRequest={this.onPaginationRequest}
|
|
||||||
/>
|
/>
|
||||||
</>;
|
</>;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1409,13 +1409,18 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||||
// quite slow. So we detect that situation and shortcut straight to
|
// quite slow. So we detect that situation and shortcut straight to
|
||||||
// calling _reloadEvents and updating the state.
|
// calling _reloadEvents and updating the state.
|
||||||
|
|
||||||
const timeline = this.props.timelineSet.getTimelineForEvent(eventId);
|
|
||||||
if (timeline) {
|
|
||||||
// This is a hot-path optimization by skipping a promise tick
|
// This is a hot-path optimization by skipping a promise tick
|
||||||
// by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline
|
// by repeating a no-op sync branch in
|
||||||
this.timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time
|
// TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline
|
||||||
|
if (this.props.timelineSet.getTimelineForEvent(eventId)) {
|
||||||
|
// if we've got an eventId, and the timeline exists, we can skip
|
||||||
|
// the promise tick.
|
||||||
|
this.timelineWindow.load(eventId, INITIAL_SIZE);
|
||||||
|
// in this branch this method will happen in sync time
|
||||||
onLoaded();
|
onLoaded();
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE);
|
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE);
|
||||||
this.buildLegacyCallEventGroupers();
|
this.buildLegacyCallEventGroupers();
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -1427,7 +1432,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||||
});
|
});
|
||||||
prom.then(onLoaded, onError);
|
prom.then(onLoaded, onError);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// handle the completion of a timeline load or localEchoUpdate, by
|
// handle the completion of a timeline load or localEchoUpdate, by
|
||||||
// reloading the events from the timelinewindow and pending event list into
|
// reloading the events from the timelinewindow and pending event list into
|
||||||
|
@ -1443,8 +1447,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force refresh the timeline before threads support pending events
|
// Force refresh the timeline before threads support pending events
|
||||||
public refreshTimeline(): void {
|
public refreshTimeline(eventId?: string): void {
|
||||||
this.loadTimeline();
|
this.loadTimeline(eventId, undefined, undefined, false);
|
||||||
this.reloadEvents();
|
this.reloadEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -382,7 +382,13 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||||
public render(): JSX.Element {
|
public render(): JSX.Element {
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
const me = cli.getUserId();
|
const me = cli.getUserId();
|
||||||
const { mxEvent, rightClick, link, eventTileOps, reactions, collapseReplyChain } = this.props;
|
const {
|
||||||
|
mxEvent, rightClick, link, eventTileOps, reactions, collapseReplyChain,
|
||||||
|
...other
|
||||||
|
} = this.props;
|
||||||
|
delete other.getRelationsForEvent;
|
||||||
|
delete other.permalinkCreator;
|
||||||
|
|
||||||
const eventStatus = mxEvent.status;
|
const eventStatus = mxEvent.status;
|
||||||
const unsentReactionsCount = this.getUnsentReactions().length;
|
const unsentReactionsCount = this.getUnsentReactions().length;
|
||||||
const contentActionable = isContentActionable(mxEvent);
|
const contentActionable = isContentActionable(mxEvent);
|
||||||
|
@ -747,7 +753,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<IconizedContextMenu
|
<IconizedContextMenu
|
||||||
{...this.props}
|
{...other}
|
||||||
className="mx_MessageContextMenu"
|
className="mx_MessageContextMenu"
|
||||||
compact={true}
|
compact={true}
|
||||||
data-testid="mx_MessageContextMenu"
|
data-testid="mx_MessageContextMenu"
|
||||||
|
|
|
@ -540,7 +540,11 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||||
|
|
||||||
private renderThreadInfo(): React.ReactNode {
|
private renderThreadInfo(): React.ReactNode {
|
||||||
if (this.state.thread?.id === this.props.mxEvent.getId()) {
|
if (this.state.thread?.id === this.props.mxEvent.getId()) {
|
||||||
return <ThreadSummary mxEvent={this.props.mxEvent} thread={this.state.thread} />;
|
return <ThreadSummary
|
||||||
|
mxEvent={this.props.mxEvent}
|
||||||
|
thread={this.state.thread}
|
||||||
|
data-testid="thread-summary"
|
||||||
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.context.timelineRenderingType === TimelineRenderingType.Search && this.props.mxEvent.threadRootId) {
|
if (this.context.timelineRenderingType === TimelineRenderingType.Search && this.props.mxEvent.threadRootId) {
|
||||||
|
@ -1528,9 +1532,11 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||||
|
|
||||||
// Wrap all event tiles with the tile error boundary so that any throws even during construction are captured
|
// Wrap all event tiles with the tile error boundary so that any throws even during construction are captured
|
||||||
const SafeEventTile = forwardRef((props: EventTileProps, ref: RefObject<UnwrappedEventTile>) => {
|
const SafeEventTile = forwardRef((props: EventTileProps, ref: RefObject<UnwrappedEventTile>) => {
|
||||||
return <TileErrorBoundary mxEvent={props.mxEvent} layout={props.layout}>
|
return <>
|
||||||
|
<TileErrorBoundary mxEvent={props.mxEvent} layout={props.layout}>
|
||||||
<UnwrappedEventTile ref={ref} {...props} />
|
<UnwrappedEventTile ref={ref} {...props} />
|
||||||
</TileErrorBoundary>;
|
</TileErrorBoundary>
|
||||||
|
</>;
|
||||||
});
|
});
|
||||||
export default SafeEventTile;
|
export default SafeEventTile;
|
||||||
|
|
||||||
|
|
|
@ -37,7 +37,7 @@ interface IProps {
|
||||||
thread: Thread;
|
thread: Thread;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThreadSummary = ({ mxEvent, thread }: IProps) => {
|
const ThreadSummary = ({ mxEvent, thread, ...props }: IProps) => {
|
||||||
const roomContext = useContext(RoomContext);
|
const roomContext = useContext(RoomContext);
|
||||||
const cardContext = useContext(CardContext);
|
const cardContext = useContext(CardContext);
|
||||||
const count = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.length);
|
const count = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.length);
|
||||||
|
@ -50,6 +50,7 @@ const ThreadSummary = ({ mxEvent, thread }: IProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
|
{...props}
|
||||||
className="mx_ThreadSummary"
|
className="mx_ThreadSummary"
|
||||||
onClick={(ev: ButtonEvent) => {
|
onClick={(ev: ButtonEvent) => {
|
||||||
defaultDispatcher.dispatch<ShowThreadPayload>({
|
defaultDispatcher.dispatch<ShowThreadPayload>({
|
||||||
|
@ -94,7 +95,9 @@ export const ThreadMessagePreview = ({ thread, showDisplayname = false }: IPrevi
|
||||||
await cli.decryptEventIfNeeded(lastReply);
|
await cli.decryptEventIfNeeded(lastReply);
|
||||||
return MessagePreviewStore.instance.generatePreviewForEvent(lastReply);
|
return MessagePreviewStore.instance.generatePreviewForEvent(lastReply);
|
||||||
}, [lastReply, content]);
|
}, [lastReply, content]);
|
||||||
if (!preview) return null;
|
if (!preview || !lastReply) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<MemberAvatar
|
<MemberAvatar
|
||||||
|
|
|
@ -451,12 +451,8 @@ export class StopGapWidgetDriver extends WidgetDriver {
|
||||||
eventId,
|
eventId,
|
||||||
relationType ?? null,
|
relationType ?? null,
|
||||||
eventType ?? null,
|
eventType ?? null,
|
||||||
{
|
{ from, to, limit, dir },
|
||||||
from,
|
);
|
||||||
to,
|
|
||||||
limit,
|
|
||||||
dir,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chunk: events.map(e => e.getEffectiveEvent() as IRoomEvent),
|
chunk: events.map(e => e.getEffectiveEvent() as IRoomEvent),
|
||||||
|
|
|
@ -238,8 +238,11 @@ export async function fetchInitialEvent(
|
||||||
) {
|
) {
|
||||||
const threadId = initialEvent.threadRootId;
|
const threadId = initialEvent.threadRootId;
|
||||||
const room = client.getRoom(roomId);
|
const room = client.getRoom(roomId);
|
||||||
|
const mapper = client.getEventMapper();
|
||||||
|
const rootEvent = room.findEventById(threadId)
|
||||||
|
?? mapper(await client.fetchRoomEvent(roomId, threadId));
|
||||||
try {
|
try {
|
||||||
room.createThread(threadId, room.findEventById(threadId), [initialEvent], true);
|
room.createThread(threadId, rootEvent, [initialEvent], true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("Could not find root event: " + threadId);
|
logger.warn("Could not find root event: " + threadId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,16 +14,19 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { act, render } from "@testing-library/react";
|
import React from "react";
|
||||||
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||||
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
|
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
|
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile";
|
import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile";
|
||||||
|
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
|
||||||
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
|
||||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||||
import { getRoomContext, mkMessage, stubClient } from "../../../test-utils";
|
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||||
|
import { getRoomContext, mkEvent, mkMessage, stubClient } from "../../../test-utils";
|
||||||
import { mkThread } from "../../../test-utils/threads";
|
import { mkThread } from "../../../test-utils/threads";
|
||||||
|
|
||||||
describe("EventTile", () => {
|
describe("EventTile", () => {
|
||||||
|
@ -52,9 +55,11 @@ describe("EventTile", () => {
|
||||||
timelineRenderingType: renderingType,
|
timelineRenderingType: renderingType,
|
||||||
});
|
});
|
||||||
return render(
|
return render(
|
||||||
|
<MatrixClientContext.Provider value={client}>
|
||||||
<RoomContext.Provider value={context}>
|
<RoomContext.Provider value={context}>
|
||||||
<TestEventTile {...overrides} />
|
<TestEventTile {...overrides} />
|
||||||
</RoomContext.Provider>,
|
</RoomContext.Provider>,
|
||||||
|
</MatrixClientContext.Provider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,6 +74,8 @@ describe("EventTile", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
||||||
|
jest.spyOn(client, "decryptEventIfNeeded").mockResolvedValue();
|
||||||
|
jest.spyOn(SettingsStore, "getValue").mockImplementation(name => name === "feature_thread");
|
||||||
|
|
||||||
mxEvent = mkMessage({
|
mxEvent = mkMessage({
|
||||||
room: room.roomId,
|
room: room.roomId,
|
||||||
|
@ -78,6 +85,40 @@ describe("EventTile", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("EventTile thread summary", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the thread summary when thread is deleted", async () => {
|
||||||
|
const { rootEvent, events: [, reply] } = mkThread({
|
||||||
|
room,
|
||||||
|
client,
|
||||||
|
authorId: "@alice:example.org",
|
||||||
|
participantUserIds: ["@alice:example.org"],
|
||||||
|
length: 2, // root + 1 answer
|
||||||
|
});
|
||||||
|
getComponent({
|
||||||
|
mxEvent: rootEvent,
|
||||||
|
}, TimelineRenderingType.Room);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("thread-summary")).not.toBeNull());
|
||||||
|
|
||||||
|
const redaction = mkEvent({
|
||||||
|
event: true,
|
||||||
|
type: EventType.RoomRedaction,
|
||||||
|
user: "@alice:example.org",
|
||||||
|
room: room.roomId,
|
||||||
|
redacts: reply.getId(),
|
||||||
|
content: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => room.processThreadedEvents([redaction], false));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("thread-summary")).toBeNull());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("EventTile renderingType: ThreadsList", () => {
|
describe("EventTile renderingType: ThreadsList", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const { rootEvent } = mkThread({
|
const { rootEvent } = mkThread({
|
||||||
|
|
|
@ -212,6 +212,7 @@ type MakeEventPassThruProps = {
|
||||||
};
|
};
|
||||||
type MakeEventProps = MakeEventPassThruProps & {
|
type MakeEventProps = MakeEventPassThruProps & {
|
||||||
type: string;
|
type: string;
|
||||||
|
redacts?: string;
|
||||||
content: IContent;
|
content: IContent;
|
||||||
room?: Room["roomId"]; // to-device messages are roomless
|
room?: Room["roomId"]; // to-device messages are roomless
|
||||||
// eslint-disable-next-line camelcase
|
// eslint-disable-next-line camelcase
|
||||||
|
@ -245,6 +246,7 @@ export function mkEvent(opts: MakeEventProps): MatrixEvent {
|
||||||
event_id: "$" + Math.random() + "-" + Math.random(),
|
event_id: "$" + Math.random() + "-" + Math.random(),
|
||||||
origin_server_ts: opts.ts ?? 0,
|
origin_server_ts: opts.ts ?? 0,
|
||||||
unsigned: opts.unsigned,
|
unsigned: opts.unsigned,
|
||||||
|
redacts: opts.redacts,
|
||||||
};
|
};
|
||||||
if (opts.skey !== undefined) {
|
if (opts.skey !== undefined) {
|
||||||
event.state_key = opts.skey;
|
event.state_key = opts.skey;
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MatrixClient, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix";
|
import { MatrixClient, MatrixEvent, MatrixEventEvent, RelationType, Room } from "matrix-js-sdk/src/matrix";
|
||||||
import { Thread } from "matrix-js-sdk/src/models/thread";
|
import { Thread } from "matrix-js-sdk/src/models/thread";
|
||||||
|
|
||||||
import { mkMessage, MessageEventProps } from "./test-utils";
|
import { mkMessage, MessageEventProps } from "./test-utils";
|
||||||
|
@ -115,10 +115,18 @@ export const mkThread = ({
|
||||||
ts,
|
ts,
|
||||||
currentUserId: client.getUserId(),
|
currentUserId: client.getUserId(),
|
||||||
});
|
});
|
||||||
|
expect(rootEvent).toBeTruthy();
|
||||||
|
|
||||||
|
for (const evt of events) {
|
||||||
|
room?.reEmitter.reEmit(evt, [
|
||||||
|
MatrixEventEvent.BeforeRedaction,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const thread = room.createThread(rootEvent.getId(), rootEvent, events, true);
|
const thread = room.createThread(rootEvent.getId(), rootEvent, events, true);
|
||||||
// So that we do not have to mock the thread loading
|
// So that we do not have to mock the thread loading
|
||||||
thread.initialEventsFetched = true;
|
thread.initialEventsFetched = true;
|
||||||
|
thread.addEvents(events, true);
|
||||||
|
|
||||||
return { thread, rootEvent, events };
|
return { thread, rootEvent, events };
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,21 +18,27 @@ import { M_LOCATION } from "matrix-js-sdk/src/@types/location";
|
||||||
import {
|
import {
|
||||||
EventStatus,
|
EventStatus,
|
||||||
EventType,
|
EventType,
|
||||||
|
IEvent,
|
||||||
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
MsgType,
|
MsgType,
|
||||||
|
PendingEventOrdering,
|
||||||
RelationType,
|
RelationType,
|
||||||
|
Room,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
import { Thread } from "matrix-js-sdk/src/models/thread";
|
||||||
|
|
||||||
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
||||||
import {
|
import {
|
||||||
canCancel,
|
canCancel,
|
||||||
canEditContent,
|
canEditContent,
|
||||||
canEditOwnEvent,
|
canEditOwnEvent,
|
||||||
|
fetchInitialEvent,
|
||||||
isContentActionable,
|
isContentActionable,
|
||||||
isLocationEvent,
|
isLocationEvent,
|
||||||
isVoiceMessage,
|
isVoiceMessage,
|
||||||
} from "../../src/utils/EventUtils";
|
} from "../../src/utils/EventUtils";
|
||||||
import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent } from "../test-utils";
|
import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent, stubClient } from "../test-utils";
|
||||||
|
|
||||||
describe('EventUtils', () => {
|
describe('EventUtils', () => {
|
||||||
const userId = '@user:server';
|
const userId = '@user:server';
|
||||||
|
@ -336,4 +342,92 @@ describe('EventUtils', () => {
|
||||||
expect(canCancel(status)).toBe(false);
|
expect(canCancel(status)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fetchInitialEvent", () => {
|
||||||
|
const ROOM_ID = "!roomId:example.org";
|
||||||
|
let room: Room;
|
||||||
|
let client: MatrixClient;
|
||||||
|
|
||||||
|
const NORMAL_EVENT = "$normalEvent";
|
||||||
|
const THREAD_ROOT = "$threadRoot";
|
||||||
|
const THREAD_REPLY = "$threadReply";
|
||||||
|
|
||||||
|
const events: Record<string, Partial<IEvent>> = {
|
||||||
|
[NORMAL_EVENT]: {
|
||||||
|
event_id: NORMAL_EVENT,
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
content: {
|
||||||
|
"body": "Classic event",
|
||||||
|
"msgtype": MsgType.Text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[THREAD_ROOT]: {
|
||||||
|
event_id: THREAD_ROOT,
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
content: {
|
||||||
|
"body": "Thread root",
|
||||||
|
"msgtype": "m.text",
|
||||||
|
},
|
||||||
|
unsigned: {
|
||||||
|
"m.relations": {
|
||||||
|
[RelationType.Thread]: {
|
||||||
|
latest_event: {
|
||||||
|
event_id: THREAD_REPLY,
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
content: {
|
||||||
|
"body": "Thread reply",
|
||||||
|
"msgtype": MsgType.Text,
|
||||||
|
"m.relates_to": {
|
||||||
|
event_id: "$threadRoot",
|
||||||
|
rel_type: RelationType.Thread,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
count: 1,
|
||||||
|
current_user_participated: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[THREAD_REPLY]: {
|
||||||
|
event_id: THREAD_REPLY,
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
content: {
|
||||||
|
"body": "Thread reply",
|
||||||
|
"msgtype": MsgType.Text,
|
||||||
|
"m.relates_to": {
|
||||||
|
event_id: THREAD_ROOT,
|
||||||
|
rel_type: RelationType.Thread,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
stubClient();
|
||||||
|
client = MatrixClientPeg.get();
|
||||||
|
|
||||||
|
room = new Room(ROOM_ID, client, client.getUserId(), {
|
||||||
|
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
|
||||||
|
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
||||||
|
jest.spyOn(client, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => {
|
||||||
|
return events[eventId] ?? Promise.reject();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown events", async () => {
|
||||||
|
expect(await fetchInitialEvent(client, room.roomId, "$UNKNOWN")).toBeNull();
|
||||||
|
expect(await fetchInitialEvent(client, room.roomId, NORMAL_EVENT)).toBeInstanceOf(MatrixEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a thread when needed", async () => {
|
||||||
|
await fetchInitialEvent(client, room.roomId, THREAD_REPLY);
|
||||||
|
expect(room.getThread(THREAD_ROOT)).toBeInstanceOf(Thread);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue