diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index 14a0c1ed517..3d9b29ac154 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -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; @@ -66,6 +67,8 @@ interface IContent { }; file?: string; url?: string; + // eslint-disable-next-line camelcase + is_attachment?: boolean; } interface IThumbnail { @@ -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, + ) { 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, { @@ -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() { @@ -529,7 +536,9 @@ export default class ContentMessages { } } - private sendContentToRoom(file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise) { + private sendContentToRoom( + file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise, attachment: boolean, + ) { const startTime = CountlyAnalytics.getTimestamp(); const content: IContent = { body: file.name || 'Attachment', @@ -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; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 474b99262da..7ca9f8c09d3 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -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) {}; @@ -203,6 +204,7 @@ export default class RoomView extends React.Component { private roomView = createRef(); private searchResultsPanel = createRef(); private messagePanel: TimelinePanel; + private messageComposer: MessageComposer; static contextType = MatrixClientContext; @@ -750,10 +752,17 @@ export default class RoomView extends React.Component { 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: @@ -1245,8 +1254,14 @@ export default class RoomView extends React.Component { 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); @@ -1684,6 +1699,10 @@ export default class RoomView extends React.Component { 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; @@ -1945,6 +1964,7 @@ export default class RoomView extends React.Component { if (canSpeak) { messageComposer = { @@ -37,15 +39,20 @@ interface IProps extends Omit { @replaceableComponent("views.messages.MessageEvent") export default class MessageEvent extends React.Component implements IMediaBody, IOperableEventTile { + public static contextType = MatrixClientContext; private body: React.RefObject = createRef(); private mediaHelper: MediaEventHelper; + private room: Room; - public constructor(props: IProps) { + public constructor(props: IProps, context: React.ContextType) { 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() { @@ -128,8 +135,34 @@ export default class MessageEvent extends React.Component 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 = ( + + ); + } + } + } + } + // @ts-ignore - this is a dynamic react component - return BodyType ? implements IMe permalinkCreator={this.props.permalinkCreator} mediaEventHelper={this.mediaHelper} /> : null; + return [body, attachment]; } } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index dd954e46ce6..051876ce073 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -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', @@ -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; } diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 8455e9aa11c..b57afc5c434 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -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; @@ -107,6 +108,7 @@ const EmojiButton = ({ addEmoji }) => { interface IUploadButtonProps { roomId: string; + composer?: MessageComposer; } class UploadButton extends React.Component { @@ -147,8 +149,15 @@ class UploadButton extends React.Component { 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; + 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 @@ -324,7 +333,7 @@ export default class MessageComposer extends React.Component { }); } - 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. @@ -332,7 +341,7 @@ export default class MessageComposer extends React.Component { return; } - this.messageComposerInput.sendMessage(); + this.messageComposerInput.sendMessage(attachmentEventId); }; private onChange = (model: EditorModel) => { diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 205320fb686..90830fba913 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -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, @@ -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', + '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) { @@ -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; } @@ -342,9 +365,14 @@ export default class SendMessageComposer extends React.Component { } } - public async sendMessage(): Promise { + public async sendMessage(attachmentEventId?: string): Promise { 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; @@ -359,6 +387,9 @@ export default class SendMessageComposer extends React.Component { if (replyToEvent) { addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator); } + if (attachmentEventId) { + addAttachmentToMessageContent(content, attachmentEventId); + } } else { this.runSlashCommand(cmd, args); shouldSend = false; @@ -400,7 +431,9 @@ export default class SendMessageComposer extends React.Component { 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; @@ -519,8 +552,13 @@ export default class SendMessageComposer extends React.Component { // 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; 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 } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 21859fb1aaa..2a0ffd81552 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -819,6 +819,7 @@ "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", "Send pseudonymous analytics data": "Send pseudonymous analytics data", + "Message attachments": "Message attachments", "Show info about bridges in room settings": "Show info about bridges in room settings", "New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)", "Don't send read receipts": "Don't send read receipts", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 28c5b1353fc..1a297b35204 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -276,6 +276,12 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: false, controller: new PseudonymousAnalyticsController(), }, + "feature_message_attachments": { + isFeature: true, + supportedLevels: LEVELS_FEATURE, + displayName: _td('Message attachments'), + default: false, + }, "doNotDisturb": { supportedLevels: [SettingLevel.DEVICE], default: false,