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

Add basic support for attachments (as per MSC2881) #6683

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/ContentMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
import { IUpload } from "./models/IUpload";
import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
import { BlurhashEncoder } from "./BlurhashEncoder";
import SettingsStore from "./settings/SettingsStore";

const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
Expand Down Expand Up @@ -66,6 +67,8 @@ interface IContent {
};
file?: string;
url?: string;
// eslint-disable-next-line camelcase
is_attachment?: boolean;
}

interface IThumbnail {
Expand Down Expand Up @@ -430,14 +433,17 @@ export default class ContentMessages {
}
}

async sendContentListToRoom(files: File[], roomId: string, matrixClient: MatrixClient) {
async sendContentListToRoom(
files: File[], roomId: string, matrixClient: MatrixClient,
attachToMessage?: (ISendEventResponse) => Promise<any>,
) {
Comment on lines +436 to +439
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to avoid the callback here? It would be more readable, imo

if (matrixClient.isGuest()) {
dis.dispatch({ action: 'require_registration' });
return;
}

const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
if (isQuoting) {
if (isQuoting && !SettingsStore.getValue("feature_message_attachments")) {
// FIXME: Using an import will result in Element crashing
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, {
Expand Down Expand Up @@ -506,8 +512,9 @@ export default class ContentMessages {
uploadAll = true;
}
}
promBefore = this.sendContentToRoom(file, roomId, matrixClient, promBefore);
promBefore = this.sendContentToRoom(file, roomId, matrixClient, promBefore, Boolean(attachToMessage));
}
promBefore.then(attachToMessage);
}

getCurrentUploads() {
Expand All @@ -529,7 +536,9 @@ export default class ContentMessages {
}
}

private sendContentToRoom(file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise<any>) {
private sendContentToRoom(
file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise<any>, attachment: boolean,
) {
const startTime = CountlyAnalytics.getTimestamp();
const content: IContent = {
body: file.name || 'Attachment',
Expand All @@ -539,6 +548,10 @@ export default class ContentMessages {
msgtype: "", // set later
};

if (attachment) {
content.is_attachment = true;
}

// if we have a mime type for the file, add it to the message metadata
if (file.type) {
content.info.mimetype = file.type;
Expand Down
26 changes: 23 additions & 3 deletions src/components/structures/RoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import MessageComposer from '../views/rooms/MessageComposer';
import JumpToBottomButton from "../views/rooms/JumpToBottomButton";
import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
import SpaceStore from "../../stores/SpaceStore";
import { ISendEventResponse } from 'matrix-js-sdk/src/@types/requests';

const DEBUG = false;
let debuglog = function(msg: string) {};
Expand Down Expand Up @@ -203,6 +204,7 @@ export default class RoomView extends React.Component<IProps, IState> {
private roomView = createRef<HTMLElement>();
private searchResultsPanel = createRef<ScrollPanel>();
private messagePanel: TimelinePanel;
private messageComposer: MessageComposer;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that createRef() should be sufficient in this case


static contextType = MatrixClientContext;

Expand Down Expand Up @@ -750,10 +752,17 @@ export default class RoomView extends React.Component<IProps, IState> {
payload.data.content.info,
payload.data.description || payload.data.name);
break;
case 'picture_snapshot':
case 'picture_snapshot': {
const promAfter = (SettingsStore.getValue("feature_message_attachments") && this.messageComposer
&& (!this.messageComposer.state.isComposerEmpty || this.messageComposer.props.replyToEvent)) ?
(event: ISendEventResponse) => {
return this.messageComposer.sendMessage(event.event_id);
} : null;
ContentMessages.sharedInstance().sendContentListToRoom(
[payload.file], this.state.room.roomId, this.context);
[payload.file], this.state.room.roomId, this.context, promAfter,
);
break;
}
case 'notifier_enabled':
case Action.UploadStarted:
case Action.UploadFinished:
Expand Down Expand Up @@ -1245,8 +1254,14 @@ export default class RoomView extends React.Component<IProps, IState> {
private onDrop = ev => {
ev.stopPropagation();
ev.preventDefault();
const promAfter = (SettingsStore.getValue("feature_message_attachments") && this.messageComposer
&& ev.dataTransfer.files.length === 1
&& (!this.messageComposer.state.isComposerEmpty || this.messageComposer.props.replyToEvent)) ?
(event: ISendEventResponse) => {
return this.messageComposer.sendMessage(event.event_id);
} : null;
ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, this.context,
ev.dataTransfer.files, this.state.room.roomId, this.context, promAfter,
);
dis.fire(Action.FocusSendMessageComposer);

Expand Down Expand Up @@ -1684,6 +1699,10 @@ export default class RoomView extends React.Component<IProps, IState> {
this.messagePanel = r;
};

private gatherMessageComposerRef = r => {
this.messageComposer = r;
};

private getOldRoom() {
const createEvent = this.state.room.currentState.getStateEvents("m.room.create", "");
if (!createEvent || !createEvent.getContent()['predecessor']) return null;
Expand Down Expand Up @@ -1945,6 +1964,7 @@ export default class RoomView extends React.Component<IProps, IState> {
if (canSpeak) {
messageComposer =
<MessageComposer
ref={this.gatherMessageComposerRef}
room={this.state.room}
e2eStatus={this.state.e2eStatus}
resizeNotifier={this.props.resizeNotifier}
Expand Down
38 changes: 36 additions & 2 deletions src/components/views/messages/MessageEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { ReactAnyComponent } from "../../../@types/common";
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
import { IBodyProps } from "./IBodyProps";
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import { Room } from 'matrix-js-sdk/src/models/room';

// onMessageAllowed is handled internally
interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
Expand All @@ -37,15 +39,20 @@ interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {

@replaceableComponent("views.messages.MessageEvent")
export default class MessageEvent extends React.Component<IProps> implements IMediaBody, IOperableEventTile {
public static contextType = MatrixClientContext;
private body: React.RefObject<React.Component | IOperableEventTile> = createRef();
private mediaHelper: MediaEventHelper;
private room: Room;

public constructor(props: IProps) {
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props);

if (MediaEventHelper.isEligible(this.props.mxEvent)) {
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
}

this.context = context;
this.room = this.context.getRoom(this.props.mxEvent.getRoomId());
}

public componentWillUnmount() {
Expand Down Expand Up @@ -128,8 +135,34 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
}
}

let attachment = null;
if (SettingsStore.getValue("feature_message_attachments")) {
if (this.props.mxEvent.isRelation("org.matrix.msc2881.m.attachment")) {
const relation = this.props.mxEvent.getRelation();
if (this.room && relation && relation.event_id) {
const event = this.room.findEventById(relation.event_id);
if (event) {
attachment = (
<MessageEvent
mxEvent={event}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
tileShape={this.props.tileShape}
maxImageHeight={this.props.maxImageHeight}
onHeightChanged={this.props.onHeightChanged}
overrideBodyTypes={this.props.overrideBodyTypes}
overrideEventTypes={this.props.overrideEventTypes}
permalinkCreator={this.props.permalinkCreator}
mediaEventHelper={this.props.mediaEventHelper}
/>
);
}
}
}
}

// @ts-ignore - this is a dynamic react component
return BodyType ? <BodyType
const body = BodyType ? <BodyType
ref={this.body}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
Expand All @@ -144,5 +177,6 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
permalinkCreator={this.props.permalinkCreator}
mediaEventHelper={this.mediaHelper}
/> : null;
return [body, attachment];
}
}
3 changes: 3 additions & 0 deletions src/components/views/rooms/EventTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import SettingsStore from "../../../settings/SettingsStore";

const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent',
Expand Down Expand Up @@ -1209,6 +1210,8 @@ export function haveTileForEvent(e: MatrixEvent, showHiddenEvents?: boolean) {
return hasText(e, showHiddenEvents);
} else if (handler === 'messages.RoomCreate') {
return Boolean(e.getContent()['predecessor']);
} else if (SettingsStore.getValue("feature_message_attachments") && e.getContent().is_attachment) {
return false;
} else {
return true;
}
Expand Down
15 changes: 12 additions & 3 deletions src/components/views/rooms/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { Action } from "../../../dispatcher/actions";
import EditorModel from "../../../editor/model";
import EmojiPicker from '../emojipicker/EmojiPicker';
import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar";
import { ISendEventResponse } from 'matrix-js-sdk/src/@types/requests';

interface IComposerAvatarProps {
me: object;
Expand Down Expand Up @@ -107,6 +108,7 @@ const EmojiButton = ({ addEmoji }) => {

interface IUploadButtonProps {
roomId: string;
composer?: MessageComposer;
}

class UploadButton extends React.Component<IUploadButtonProps> {
Expand Down Expand Up @@ -147,8 +149,15 @@ class UploadButton extends React.Component<IUploadButtonProps> {
tfiles.push(ev.target.files[i]);
}

const promAfter = (SettingsStore.getValue("feature_message_attachments") && this.props.composer
&& ev.target.files.length === 1
&& (!this.props.composer.state.isComposerEmpty || this.props.composer.props.replyToEvent)) ?
(event: ISendEventResponse) => {
return this.props.composer.sendMessage(event.event_id);
} : null;
Comment on lines +152 to +157
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would be much more readable if it were split into variables


ContentMessages.sharedInstance().sendContentListToRoom(
tfiles, this.props.roomId, MatrixClientPeg.get(),
tfiles, this.props.roomId, MatrixClientPeg.get(), promAfter,
);

// This is the onChange handler for a file form control, but we're
Expand Down Expand Up @@ -324,15 +333,15 @@ export default class MessageComposer extends React.Component<IProps, IState> {
});
}

private sendMessage = async () => {
public sendMessage = async (attachmentEventId?: string) => {
if (this.state.haveRecording && this.voiceRecordingButton) {
// There shouldn't be any text message to send when a voice recording is active, so
// just send out the voice recording.
await this.voiceRecordingButton.send();
return;
}

this.messageComposerInput.sendMessage();
this.messageComposerInput.sendMessage(attachmentEventId);
};

private onChange = (model: EditorModel) => {
Expand Down
50 changes: 44 additions & 6 deletions src/components/views/rooms/SendMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import { Room } from 'matrix-js-sdk/src/models/room';
import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads";
import { ISendEventResponse } from 'matrix-js-sdk/src/@types/requests';
import DocumentOffset from '../../../editor/offset';

function addReplyToMessageContent(
content: IContent,
Expand All @@ -74,11 +76,25 @@ function addReplyToMessageContent(
}
}

function addAttachmentToMessageContent(
content: IContent,
attachEventId: string,
): void {
const attachContent = {
'm.relates_to': {
'rel_type': 'org.matrix.msc2881.m.attachment',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throughout we generally prefer to use UnstableValue from the js-sdk for identifiers like this. It makes for transitioning a bit easier, particularly when/if the MSC becomes stable.

There should be some example usages in the code somewhere: look for new UnstableValue

'event_id': attachEventId,
},
};
Object.assign(content, attachContent);
}

// exported for tests
export function createMessageContent(
model: EditorModel,
permalinkCreator: RoomPermalinkCreator,
replyToEvent: MatrixEvent,
attachEventId: string,
): IContent {
const isEmote = containsEmote(model);
if (isEmote) {
Expand All @@ -95,15 +111,22 @@ export function createMessageContent(
body: body,
};
const formattedBody = htmlSerializeIfNeeded(model, { forceHTML: !!replyToEvent });
if (formattedBody) {
if (formattedBody || replyToEvent) {
content.format = "org.matrix.custom.html";
content.formatted_body = formattedBody;
content.formatted_body = formattedBody || body;
}

if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, permalinkCreator);
}

// TODO: Currently, an attachment will override a reply.
// This allows replying with images, but removes the reply relation from the message.
// When/if we get the ability to add multiple relations, this will be fixed.
if (attachEventId) {
addAttachmentToMessageContent(content, attachEventId);
}

return content;
}

Expand Down Expand Up @@ -342,9 +365,14 @@ export default class SendMessageComposer extends React.Component<IProps> {
}
}

public async sendMessage(): Promise<void> {
public async sendMessage(attachmentEventId?: string): Promise<void> {
if (this.model.isEmpty) {
return;
if (!attachmentEventId) {
return;
}
// If replying with just an attachment, add empty text to model so it has at least one part.
// Otherwise, various functions expecting at least one part will fail.
this.model.update(" ", "insertText", new DocumentOffset(1, true));
}

const replyToEvent = this.props.replyToEvent;
Expand All @@ -359,6 +387,9 @@ export default class SendMessageComposer extends React.Component<IProps> {
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator);
}
if (attachmentEventId) {
addAttachmentToMessageContent(content, attachmentEventId);
}
} else {
this.runSlashCommand(cmd, args);
shouldSend = false;
Expand Down Expand Up @@ -400,7 +431,9 @@ export default class SendMessageComposer extends React.Component<IProps> {
const startTime = CountlyAnalytics.getTimestamp();
const { roomId } = this.props.room;
if (!content) {
content = createMessageContent(this.model, this.props.permalinkCreator, replyToEvent);
content = createMessageContent(
this.model, this.props.permalinkCreator, replyToEvent, attachmentEventId,
);
}
// don't bother sending an empty message
if (!content.body.trim()) return;
Expand Down Expand Up @@ -519,8 +552,13 @@ export default class SendMessageComposer extends React.Component<IProps> {
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
// it puts the filename in as text/plain which we want to ignore.
if (clipboardData.files.length && !clipboardData.types.includes("text/rtf")) {
const promAfter = (SettingsStore.getValue("feature_message_attachments")
&& clipboardData.files.length === 1 && (!this.model.isEmpty || this.props.replyToEvent)) ?
(event: ISendEventResponse) => {
return this.sendMessage(event.event_id);
} : null;
Comment on lines +555 to +559
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would be more readable if it were split into variables

ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(clipboardData.files), this.props.room.roomId, this.context,
Array.from(clipboardData.files), this.props.room.roomId, this.context, promAfter,
);
return true; // to skip internal onPaste handler
}
Expand Down
Loading