Wire up drag-drop file uploads for the thread view (#7860)

This commit is contained in:
Michael Telatynski 2022-02-22 11:14:56 +00:00 committed by GitHub
parent 42e9ea4540
commit 8fccef86d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 616 additions and 482 deletions

View file

@ -43,6 +43,7 @@ import TagOrderActions from "../../../actions/TagOrderActions";
import { inviteUsersToRoom } from "../../../RoomInvite";
import ProgressBar from "../elements/ProgressBar";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { CreateEventField, IGroupRoom, IGroupSummary } from "../../../@types/groups";
interface IProps {
matrixClient: MatrixClient;
@ -50,50 +51,6 @@ interface IProps {
onFinished(spaceId?: string): void;
}
export const CreateEventField = "io.element.migrated_from_community";
interface IGroupRoom {
displayname: string;
name?: string;
roomId: string;
canonicalAlias?: string;
avatarUrl?: string;
topic?: string;
numJoinedMembers?: number;
worldReadable?: boolean;
guestCanJoin?: boolean;
isPublic?: boolean;
}
/* eslint-disable camelcase */
export interface IGroupSummary {
profile: {
avatar_url?: string;
is_openly_joinable?: boolean;
is_public?: boolean;
long_description: string;
name: string;
short_description: string;
};
rooms_section: {
rooms: unknown[];
categories: Record<string, unknown>;
total_room_count_estimate: number;
};
user: {
is_privileged: boolean;
is_public: boolean;
is_publicised: boolean;
membership: string;
};
users_section: {
users: unknown[];
roles: Record<string, unknown>;
total_user_count_estimate: number;
};
}
/* eslint-enable camelcase */
enum Progress {
NotStarted,
ValidatingInputs,

View file

@ -17,11 +17,8 @@ limitations under the License.
import React from 'react';
import classNames from 'classnames';
import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import escapeHtml from "escape-html";
import sanitizeHtml from "sanitize-html";
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { RelationType } from 'matrix-js-sdk/src/@types/event';
import { Relations } from 'matrix-js-sdk/src/models/relations';
import { _t } from '../../../languageHandler';
@ -32,12 +29,12 @@ import { Layout } from "../../../settings/enums/Layout";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import { Action } from "../../../dispatcher/actions";
import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';
import ReplyTile from "../rooms/ReplyTile";
import Pill from './Pill';
import { ButtonEvent } from './AccessibleButton';
import { getParentEventId } from '../../../utils/Reply';
/**
* This number is based on the previous behavior - if we have message of height
@ -97,174 +94,6 @@ export default class ReplyChain extends React.Component<IProps, IState> {
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
}
public static getParentEventId(ev: MatrixEvent): string | undefined {
if (!ev || ev.isRedacted()) return;
if (ev.replyEventId) {
return ev.replyEventId;
}
}
// Part of Replies fallback support
public static stripPlainReply(body: string): string {
// Removes lines beginning with `> ` until you reach one that doesn't.
const lines = body.split('\n');
while (lines.length && lines[0].startsWith('> ')) lines.shift();
// Reply fallback has a blank line after it, so remove it to prevent leading newline
if (lines[0] === '') lines.shift();
return lines.join('\n');
}
// Part of Replies fallback support
public static stripHTMLReply(html: string): string {
// Sanitize the original HTML for inclusion in <mx-reply>. We allow
// any HTML, since the original sender could use special tags that we
// don't recognize, but want to pass along to any recipients who do
// recognize them -- recipients should be sanitizing before displaying
// anyways. However, we sanitize to 1) remove any mx-reply, so that we
// don't generate a nested mx-reply, and 2) make sure that the HTML is
// properly formatted (e.g. tags are closed where necessary)
return sanitizeHtml(
html,
{
allowedTags: false, // false means allow everything
allowedAttributes: false,
// we somehow can't allow all schemes, so we allow all that we
// know of and mxc (for img tags)
allowedSchemes: [...PERMITTED_URL_SCHEMES, 'mxc'],
exclusiveFilter: (frame) => frame.tag === "mx-reply",
},
);
}
// Part of Replies fallback support
public static getNestedReplyText(
ev: MatrixEvent,
permalinkCreator: RoomPermalinkCreator,
): { body: string, html: string } | null {
if (!ev) return null;
let { body, formatted_body: html } = ev.getContent();
if (this.getParentEventId(ev)) {
if (body) body = this.stripPlainReply(body);
}
if (!body) body = ""; // Always ensure we have a body, for reasons.
if (html) {
// sanitize the HTML before we put it in an <mx-reply>
html = this.stripHTMLReply(html);
} else {
// Escape the body to use as HTML below.
// We also run a nl2br over the result to fix the fallback representation. We do this
// after converting the text to safe HTML to avoid user-provided BR's from being converted.
html = escapeHtml(body).replace(/\n/g, '<br/>');
}
// dev note: do not rely on `body` being safe for HTML usage below.
const evLink = permalinkCreator.forEvent(ev.getId());
const userLink = makeUserPermalink(ev.getSender());
const mxid = ev.getSender();
// This fallback contains text that is explicitly EN.
switch (ev.getContent().msgtype) {
case 'm.text':
case 'm.notice': {
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>${html}</blockquote></mx-reply>`;
const lines = body.trim().split('\n');
if (lines.length > 0) {
lines[0] = `<${mxid}> ${lines[0]}`;
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
}
break;
}
case 'm.image':
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent an image.</blockquote></mx-reply>`;
body = `> <${mxid}> sent an image.\n\n`;
break;
case 'm.video':
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent a video.</blockquote></mx-reply>`;
body = `> <${mxid}> sent a video.\n\n`;
break;
case 'm.audio':
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent an audio file.</blockquote></mx-reply>`;
body = `> <${mxid}> sent an audio file.\n\n`;
break;
case 'm.file':
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent a file.</blockquote></mx-reply>`;
body = `> <${mxid}> sent a file.\n\n`;
break;
case 'm.emote': {
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> * `
+ `<a href="${userLink}">${mxid}</a><br>${html}</blockquote></mx-reply>`;
const lines = body.trim().split('\n');
if (lines.length > 0) {
lines[0] = `* <${mxid}> ${lines[0]}`;
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
}
break;
}
default:
return null;
}
return { body, html };
}
public static makeReplyMixIn(ev: MatrixEvent, renderIn?: string[]) {
if (!ev) return {};
const mixin: any = {
'm.relates_to': {
'm.in_reply_to': {
'event_id': ev.getId(),
},
},
};
if (renderIn) {
mixin['m.relates_to']['m.in_reply_to']['m.render_in'] = renderIn;
}
/**
* If the event replied is part of a thread
* Add the `m.thread` relation so that clients
* that know how to handle that relation will
* be able to render them more accurately
*/
if (ev.isThreadRelation) {
mixin['m.relates_to'] = {
...mixin['m.relates_to'],
rel_type: RelationType.Thread,
event_id: ev.threadRootId,
};
}
return mixin;
}
public static shouldDisplayReply(event: MatrixEvent, renderTarget?: string): boolean {
const parentExist = Boolean(ReplyChain.getParentEventId(event));
const relations = event.getRelation();
const renderIn = relations?.["m.in_reply_to"]?.["m.render_in"] ?? [];
const shouldRenderInTarget = !renderTarget || (renderIn.includes(renderTarget));
return parentExist && shouldRenderInTarget;
}
public static getRenderInMixin(relation?: IEventRelation): string[] | undefined {
if (relation?.rel_type === RelationType.Thread) {
return [RelationType.Thread];
}
}
componentDidMount() {
this.initialize();
this.trySetExpandableQuotes();
@ -296,7 +125,7 @@ export default class ReplyChain extends React.Component<IProps, IState> {
private async initialize(): Promise<void> {
const { parentEv } = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId
const ev = await this.getEvent(ReplyChain.getParentEventId(parentEv));
const ev = await this.getEvent(getParentEventId(parentEv));
if (this.unmounted) return;
@ -314,7 +143,7 @@ export default class ReplyChain extends React.Component<IProps, IState> {
private async getNextEvent(ev: MatrixEvent): Promise<MatrixEvent> {
try {
const inReplyToEventId = ReplyChain.getParentEventId(ev);
const inReplyToEventId = getParentEventId(ev);
return await this.getEvent(inReplyToEventId);
} catch (e) {
return null;
@ -399,7 +228,7 @@ export default class ReplyChain extends React.Component<IProps, IState> {
}
</blockquote>;
} else if (this.props.forExport) {
const eventId = ReplyChain.getParentEventId(this.props.parentEv);
const eventId = getParentEventId(this.props.parentEv);
header = <p className="mx_ReplyChain_Export">
{ _t("In reply to <a>this message</a>",
{},

View file

@ -42,6 +42,7 @@ import ReplyChain from '../elements/ReplyChain';
import ReactionPicker from "../emojipicker/ReactionPicker";
import { CardContext } from '../right_panel/BaseCard';
import { showThread } from "../../../dispatcher/dispatch-actions/threads";
import { shouldDisplayReply } from '../../../utils/Reply';
interface IOptionsButtonProps {
mxEvent: MatrixEvent;
@ -375,7 +376,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
toolbarOpts.push(cancelSendingButton);
}
if (this.props.isQuoteExpanded !== undefined && ReplyChain.shouldDisplayReply(this.props.mxEvent)) {
if (this.props.isQuoteExpanded !== undefined && shouldDisplayReply(this.props.mxEvent)) {
const expandClassName = classNames({
'mx_MessageActionBar_maskButton': true,
'mx_MessageActionBar_expandMessageButton': !this.props.isQuoteExpanded,

View file

@ -28,7 +28,6 @@ import { _t } from '../../../languageHandler';
import * as ContextMenu from '../../structures/ContextMenu';
import { ChevronFace, toRightOf } from '../../structures/ContextMenu';
import SettingsStore from "../../../settings/SettingsStore";
import ReplyChain from "../elements/ReplyChain";
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
@ -48,6 +47,7 @@ import { IBodyProps } from "./IBodyProps";
import RoomContext from "../../../contexts/RoomContext";
import AccessibleButton from '../elements/AccessibleButton';
import { options as linkifyOpts } from "../../../linkify-matrix";
import { getParentEventId } from '../../../utils/Reply';
const MAX_HIGHLIGHT_LENGTH = 4096;
@ -557,7 +557,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
let isEmote = false;
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
const stripReply = !mxEvent.replacingEvent() && !!ReplyChain.getParentEventId(mxEvent);
const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent);
let body;
if (SettingsStore.isEnabled("feature_extensible_events")) {
const extev = this.props.mxEvent.unstableExtensibleEvent as MessageEvent;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from 'react';
import React, { forwardRef, ReactNode, KeyboardEvent, Ref } from 'react';
import classNames from 'classnames';
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
@ -32,7 +32,9 @@ interface IProps {
closeLabel?: string;
onClose?(ev: ButtonEvent): void;
onBack?(ev: ButtonEvent): void;
onKeyDown?(ev: KeyboardEvent): void;
cardState?: any;
ref?: Ref<HTMLDivElement>;
}
interface IGroupProps {
@ -47,7 +49,7 @@ export const Group: React.FC<IGroupProps> = ({ className, title, children }) =>
</div>;
};
const BaseCard: React.FC<IProps> = ({
const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(({
closeLabel,
onClose,
onBack,
@ -56,7 +58,8 @@ const BaseCard: React.FC<IProps> = ({
footer,
withoutScrollContainer,
children,
}) => {
onKeyDown,
}, ref) => {
let backButton;
const cardHistory = RightPanelStore.instance.roomPhaseHistory;
if (cardHistory.length > 1) {
@ -87,7 +90,7 @@ const BaseCard: React.FC<IProps> = ({
return (
<CardContext.Provider value={{ isCard: true }}>
<div className={classNames("mx_BaseCard", className)}>
<div className={classNames("mx_BaseCard", className)} ref={ref} onKeyDown={onKeyDown}>
<div className="mx_BaseCard_header">
{ backButton }
{ closeButton }
@ -98,6 +101,6 @@ const BaseCard: React.FC<IProps> = ({
</div>
</CardContext.Provider>
);
};
});
export default BaseCard;

View file

@ -78,6 +78,7 @@ import { copyPlaintext } from '../../../utils/strings';
import { DecryptionFailureTracker } from '../../../DecryptionFailureTracker';
import RedactedBody from '../messages/RedactedBody';
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { shouldDisplayReply } from '../../../utils/Reply';
export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations;
@ -1390,8 +1391,7 @@ export default class EventTile extends React.Component<IProps, IState> {
? RelationType.Thread
: undefined;
const replyChain = haveTileForEvent(this.props.mxEvent)
&& ReplyChain.shouldDisplayReply(this.props.mxEvent, renderTarget)
const replyChain = haveTileForEvent(this.props.mxEvent) && shouldDisplayReply(this.props.mxEvent, renderTarget)
? <ReplyChain
parentEv={this.props.mxEvent}
onHeightChanged={this.props.onHeightChanged}

View file

@ -17,7 +17,7 @@ limitations under the License.
import classNames from 'classnames';
import { IEventRelation } from "matrix-js-sdk/src/models/event";
import { M_POLL_START } from "matrix-events-sdk";
import React, { createContext, ReactElement, useContext } from 'react';
import React, { createContext, ReactElement, useContext, useRef } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { RelationType } from 'matrix-js-sdk/src/@types/event';
@ -33,10 +33,10 @@ import LocationButton from '../location/LocationButton';
import Modal from "../../../Modal";
import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { ActionPayload } from '../../../dispatcher/payloads';
import ContentMessages from '../../../ContentMessages';
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import RoomContext from '../../../contexts/RoomContext';
import { useDispatcher } from "../../../hooks/useDispatcher";
interface IProps {
addEmoji: (emoji: string) => boolean;
@ -189,50 +189,37 @@ interface IUploadButtonProps {
relation?: IEventRelation | null;
}
class UploadButton extends React.Component<IUploadButtonProps> {
private uploadInput = React.createRef<HTMLInputElement>();
private dispatcherRef: string;
const UploadButton = ({ roomId, relation }: IUploadButtonProps) => {
const cli = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext);
const overflowMenuCloser = useContext(OverflowMenuContext);
const uploadInput = useRef<HTMLInputElement>();
constructor(props: IUploadButtonProps) {
super(props);
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
}
private onAction = (payload: ActionPayload) => {
if (payload.action === "upload_file") {
this.onUploadClick();
}
};
private onUploadClick = () => {
if (MatrixClientPeg.get().isGuest()) {
const onUploadClick = () => {
if (cli.isGuest()) {
dis.dispatch({ action: 'require_registration' });
return;
}
this.uploadInput.current?.click();
uploadInput.current?.click();
overflowMenuCloser?.(); // close overflow menu
};
private onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
useDispatcher(dis, payload => {
if (roomContext.timelineRenderingType === payload.context && payload.action === "upload_file") {
onUploadClick();
}
});
const onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
if (ev.target.files.length === 0) return;
// take a copy so we can safely reset the value of the form control
// (Note it is a FileList: we can't use slice or sensible iteration).
const tfiles = [];
for (let i = 0; i < ev.target.files.length; ++i) {
tfiles.push(ev.target.files[i]);
}
// Take a copy, so we can safely reset the value of the form control
ContentMessages.sharedInstance().sendContentListToRoom(
tfiles,
this.props.roomId,
this.props.relation,
MatrixClientPeg.get(),
this.context.timelineRenderingType,
Array.from(ev.target.files),
roomId,
relation,
cli,
roomContext.timelineRenderingType,
);
// This is the onChange handler for a file form control, but we're
@ -242,24 +229,22 @@ class UploadButton extends React.Component<IUploadButtonProps> {
ev.target.value = '';
};
render() {
const uploadInputStyle = { display: 'none' };
return <>
<CollapsibleButton
className="mx_MessageComposer_button mx_MessageComposer_upload"
onClick={this.onUploadClick}
title={_t('Attachment')}
/>
<input
ref={this.uploadInput}
type="file"
style={uploadInputStyle}
multiple
onChange={this.onUploadFileInputChange}
/>
</>;
}
}
const uploadInputStyle = { display: 'none' };
return <>
<CollapsibleButton
className="mx_MessageComposer_button mx_MessageComposer_upload"
onClick={onUploadClick}
title={_t('Attachment')}
/>
<input
ref={uploadInput}
type="file"
style={uploadInputStyle}
multiple
onChange={onUploadFileInputChange}
/>
</>;
};
function showStickersButton(props: IProps): ReactElement {
return (

View file

@ -36,7 +36,6 @@ import {
} from '../../../editor/serialize';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
import ReplyChain from "../elements/ReplyChain";
import { findEditableEvent } from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager";
import { CommandCategories } from '../../../SlashCommands';
@ -58,6 +57,7 @@ import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { PosthogAnalytics } from "../../../PosthogAnalytics";
import { getNestedReplyText, getRenderInMixin, makeReplyMixIn } from '../../../utils/Reply';
interface IAddReplyOpts {
permalinkCreator?: RoomPermalinkCreator;
@ -72,13 +72,13 @@ function addReplyToMessageContent(
includeLegacyFallback: true,
},
): void {
const replyContent = ReplyChain.makeReplyMixIn(replyToEvent, opts.renderIn);
const replyContent = makeReplyMixIn(replyToEvent, opts.renderIn);
Object.assign(content, replyContent);
if (opts.includeLegacyFallback) {
// Part of Replies fallback support - prepend the text we're sending
// with the text we're replying to
const nestedReply = ReplyChain.getNestedReplyText(replyToEvent, opts.permalinkCreator);
const nestedReply = getNestedReplyText(replyToEvent, opts.permalinkCreator);
if (nestedReply) {
if (content.formatted_body) {
content.formatted_body = nestedReply.html + content.formatted_body;
@ -132,7 +132,7 @@ export function createMessageContent(
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator,
includeLegacyFallback: true,
renderIn: ReplyChain.getRenderInMixin(relation),
renderIn: getRenderInMixin(relation),
});
}
@ -384,7 +384,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator: this.props.permalinkCreator,
includeLegacyFallback: true,
renderIn: ReplyChain.getRenderInMixin(this.props.relation),
renderIn: getRenderInMixin(this.props.relation),
});
}
} else {

View file

@ -32,13 +32,13 @@ import dis from "../../../../../dispatcher/dispatcher";
import GroupActions from "../../../../../actions/GroupActions";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import { useDispatcher } from "../../../../../hooks/useDispatcher";
import { CreateEventField, IGroupSummary } from "../../../dialogs/CreateSpaceFromCommunityDialog";
import { createSpaceFromCommunity } from "../../../../../utils/space";
import Spinner from "../../../elements/Spinner";
import { UserTab } from "../../../dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../../../../dispatcher/payloads/OpenToTabPayload";
import { Action } from "../../../../../dispatcher/actions";
import { ViewRoomPayload } from "../../../../../dispatcher/payloads/ViewRoomPayload";
import { CreateEventField, IGroupSummary } from '../../../../../@types/groups';
interface IProps {
closeSettingsFn(success: boolean): void;