mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-16 18:06:17 +02:00
Park changes
This commit is contained in:
parent
62ff6d0fb0
commit
9e9dbe6239
@ -439,7 +439,7 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
|
||||
this.editorRef.current?.insertQuotedMessage(payload.event);
|
||||
} else if (payload.text) {
|
||||
this.editorRef.current?.insertPlaintext(payload.text);
|
||||
}
|
||||
} // File inserts explcitly unsupported in the edit composer.
|
||||
} else if (payload.action === Action.FocusEditMessageComposer) {
|
||||
this.editorRef.current.focus();
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@ -14,38 +15,32 @@ import {
|
||||
THREAD_RELATION_TYPE,
|
||||
M_POLL_START,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext, useRef } from "react";
|
||||
import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext } from "react";
|
||||
import {
|
||||
AttachmentIcon,
|
||||
MicOnIcon,
|
||||
OverflowHorizontalIcon,
|
||||
PollsIcon,
|
||||
StickerIcon,
|
||||
TextFormattingIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { ComposerApiFileUploadLocal } from "@element-hq/element-web-module-api";
|
||||
import { MultiOptionButton } from "@element-hq/web-shared-components";
|
||||
import { UploadButton, useViewModel } from "@element-hq/web-shared-components";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { CollapsibleButton } from "./CollapsibleButton";
|
||||
import { type MenuProps } from "../../structures/ContextMenu";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import { LocationButton } from "../location";
|
||||
import Modal from "../../../Modal";
|
||||
import PollCreateDialog from "../elements/PollCreateDialog";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import ContentMessages from "../../../ContentMessages";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
|
||||
import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
|
||||
import { EmojiButton } from "./EmojiButton";
|
||||
import { filterBoolean } from "../../../utils/arrays";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
||||
import { ModuleApi } from "../../../modules/Api.ts";
|
||||
import { useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
interface IProps {
|
||||
addEmoji: (emoji: string) => boolean;
|
||||
@ -69,6 +64,8 @@ export const OverflowMenuContext = createContext<OverflowMenuCloser | null>(null
|
||||
|
||||
const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
||||
const matrixClient = useContext(MatrixClientContext);
|
||||
const roomUploadVM = useRoomUploadViewModel();
|
||||
const { extraUploadOptions } = useViewModel(roomUploadVM);
|
||||
const { room, narrow } = useScopedRoomContext("room", "narrow");
|
||||
|
||||
const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer");
|
||||
@ -92,7 +89,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
||||
),
|
||||
];
|
||||
moreButtons = [
|
||||
<UploadButton key="uploads" />, // props passed via UploadButtonContext
|
||||
<UploadButton key="upload" options={extraUploadOptions} onSelect={roomUploadVM.onUploadOptionSelected} />,
|
||||
showStickersButton(props),
|
||||
voiceRecordingButton(props, narrow),
|
||||
props.showPollsButton ? pollButton(room, props.relation) : null,
|
||||
@ -109,7 +106,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
||||
) : (
|
||||
emojiButton(props)
|
||||
),
|
||||
<UploadButton key="uploads" />, // props passed via UploadButtonContext
|
||||
<UploadButton key="upload" options={extraUploadOptions} onSelect={roomUploadVM.onUploadOptionSelected} />,
|
||||
];
|
||||
moreButtons = [
|
||||
showStickersButton(props),
|
||||
@ -129,7 +126,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<UploadButtonContextProvider roomId={room.roomId} relation={props.relation}>
|
||||
<>
|
||||
{mainButtons}
|
||||
{moreButtons.length > 0 && (
|
||||
<AccessibleButton
|
||||
@ -152,7 +149,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
||||
</OverflowMenuContext.Provider>
|
||||
</IconizedContextMenu>
|
||||
)}
|
||||
</UploadButtonContextProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -167,110 +164,6 @@ function emojiButton(props: IProps): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
const UploadButton: React.FC = () => {
|
||||
const overflowMenuCloser = useContext(OverflowMenuContext);
|
||||
const onLocalUploadClick = useContext(UploadButtonContext);
|
||||
const { room } = useScopedRoomContext("room");
|
||||
|
||||
const onLocalClick = (): void => {
|
||||
onLocalUploadClick?.();
|
||||
overflowMenuCloser?.(); // close overflow menu
|
||||
};
|
||||
|
||||
const options = [...ModuleApi.instance.composer.fileUploadOptions.values()].map((uploadOption) => {
|
||||
if (uploadOption.type === ComposerApiFileUploadLocal) {
|
||||
return {
|
||||
icon: AttachmentIcon,
|
||||
label: _t("common|attachment"),
|
||||
onSelect: onLocalClick,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
icon: uploadOption.icon,
|
||||
label: uploadOption.label,
|
||||
onSelect: () => {
|
||||
uploadOption.onSelected(room!.roomId, (res) => {
|
||||
console.log("Do something with the result", res);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<MultiOptionButton
|
||||
options={options}
|
||||
multipleOptionsButton={{ label: _t("action|upload_file"), icon: AttachmentIcon }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type UploadButtonFn = () => void;
|
||||
export const UploadButtonContext = createContext<UploadButtonFn | null>(null);
|
||||
|
||||
interface IUploadButtonProps {
|
||||
roomId: string;
|
||||
relation?: IEventRelation;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes.
|
||||
const UploadButtonContextProvider: React.FC<IUploadButtonProps> = ({ roomId, relation, children }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const roomContext = useScopedRoomContext("timelineRenderingType", "replyToEvent");
|
||||
const uploadInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onUploadClick = (): void => {
|
||||
if (cli?.isGuest()) {
|
||||
dis.dispatch({ action: "require_registration" });
|
||||
return;
|
||||
}
|
||||
uploadInput.current?.click();
|
||||
};
|
||||
|
||||
useDispatcher(dis, (payload) => {
|
||||
if (roomContext.timelineRenderingType === payload.context && payload.action === "upload_file") {
|
||||
onUploadClick();
|
||||
}
|
||||
});
|
||||
|
||||
const onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
if (ev.target.files?.length === 0) return;
|
||||
|
||||
// Take a copy, so we can safely reset the value of the form control
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(ev.target.files!),
|
||||
roomId,
|
||||
relation,
|
||||
roomContext.replyToEvent,
|
||||
cli,
|
||||
roomContext.timelineRenderingType,
|
||||
);
|
||||
|
||||
// This is the onChange handler for a file form control, but we're
|
||||
// not keeping any state, so reset the value of the form control
|
||||
// to empty.
|
||||
// NB. we need to set 'value': the 'files' property is immutable.
|
||||
ev.target.value = "";
|
||||
};
|
||||
|
||||
const uploadInputStyle = { display: "none" };
|
||||
return (
|
||||
<UploadButtonContext.Provider value={onUploadClick}>
|
||||
{children}
|
||||
|
||||
<input
|
||||
ref={uploadInput}
|
||||
type="file"
|
||||
style={uploadInputStyle}
|
||||
multiple
|
||||
onClick={chromeFileInputFix}
|
||||
onChange={onUploadFileInputChange}
|
||||
/>
|
||||
</UploadButtonContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
function showStickersButton(props: IProps): ReactElement | null {
|
||||
return props.showStickersButton ? (
|
||||
<CollapsibleButton
|
||||
|
||||
@ -48,7 +48,7 @@ import { type ActionPayload } from "../../../dispatcher/payloads";
|
||||
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import DocumentPosition from "../../../editor/position";
|
||||
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import { ComposerInsertPayload, ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
|
||||
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
||||
import { PosthogAnalytics } from "../../../PosthogAnalytics";
|
||||
@ -541,18 +541,22 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
this.editorRef.current?.focus();
|
||||
}
|
||||
break;
|
||||
case Action.ComposerInsert:
|
||||
if (payload.timelineRenderingType !== this.context.timelineRenderingType) break;
|
||||
if (payload.composerType !== ComposerType.Send) break;
|
||||
case Action.ComposerInsert: {
|
||||
const insertPayload = payload as ComposerInsertPayload;
|
||||
if (insertPayload.timelineRenderingType !== this.context.timelineRenderingType) break;
|
||||
if (insertPayload.composerType !== ComposerType.Send) break;
|
||||
|
||||
if (payload.userId) {
|
||||
this.editorRef.current?.insertMention(payload.userId);
|
||||
} else if (payload.event) {
|
||||
this.editorRef.current?.insertQuotedMessage(payload.event);
|
||||
} else if (payload.text) {
|
||||
this.editorRef.current?.insertPlaintext(payload.text);
|
||||
if (insertPayload.userId) {
|
||||
this.editorRef.current?.insertMention(insertPayload.userId);
|
||||
} else if (insertPayload.event) {
|
||||
this.editorRef.current?.insertQuotedMessage(insertPayload.event);
|
||||
} else if (insertPayload.text) {
|
||||
this.editorRef.current?.insertPlaintext(insertPayload.text);
|
||||
} else if (insertPayload.files) {
|
||||
this.props.uploadVm.initiateViaInputFiles(insertPayload.files);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -15,10 +15,11 @@ import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
|
||||
import { useDispatcher } from "../../../../../hooks/useDispatcher";
|
||||
import { focusComposer } from "./utils";
|
||||
import { type ComposerFunctions } from "../types";
|
||||
import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import { ComposerInsertPayload, ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import { useComposerContext } from "../ComposerContext";
|
||||
import { setSelection } from "../utils/selection";
|
||||
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
|
||||
import { useRoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
export function useWysiwygSendActionHandler(
|
||||
disabled: boolean,
|
||||
@ -27,6 +28,7 @@ export function useWysiwygSendActionHandler(
|
||||
): void {
|
||||
const roomContext = useScopedRoomContext("timelineRenderingType");
|
||||
const composerContext = useComposerContext();
|
||||
const uploadVm = useRoomUploadViewModel();
|
||||
const timeoutId = useRef<number | null>(null);
|
||||
|
||||
const handler = useCallback(
|
||||
@ -50,21 +52,25 @@ export function useWysiwygSendActionHandler(
|
||||
composerFunctions.clear();
|
||||
focusComposer(composerElement, context, roomContext, timeoutId);
|
||||
break;
|
||||
case Action.ComposerInsert:
|
||||
if (payload.timelineRenderingType !== roomContext.timelineRenderingType) break;
|
||||
if (payload.composerType !== ComposerType.Send) break;
|
||||
case Action.ComposerInsert: {
|
||||
const insertPayload = payload as ComposerInsertPayload;
|
||||
if (insertPayload.timelineRenderingType !== roomContext.timelineRenderingType) break;
|
||||
if (insertPayload.composerType !== ComposerType.Send) break;
|
||||
|
||||
if (payload.userId) {
|
||||
if (insertPayload.userId) {
|
||||
// TODO insert mention - see SendMessageComposer
|
||||
} else if (payload.event) {
|
||||
} else if (insertPayload.event) {
|
||||
// TODO insert quote message - see SendMessageComposer
|
||||
} else if (payload.text) {
|
||||
} else if (insertPayload.text) {
|
||||
setSelection(composerContext.selection).then(() => composerFunctions.insertText(payload.text));
|
||||
} else if (insertPayload.files) {
|
||||
uploadVm.initiateViaInputFiles(insertPayload.files);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, composerElement, roomContext, composerFunctions, composerContext],
|
||||
[disabled, composerElement, roomContext, composerFunctions, composerContext, uploadVm],
|
||||
);
|
||||
|
||||
useDispatcher(defaultDispatcher, handler);
|
||||
|
||||
@ -29,4 +29,11 @@ interface IComposerInsertPlaintextPayload extends IBaseComposerInsertPayload {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type ComposerInsertPayload = IComposerInsertMentionPayload | IComposerInsertPlaintextPayload;
|
||||
interface IComposerInsertFilesPayload extends IBaseComposerInsertPayload {
|
||||
files: File[];
|
||||
}
|
||||
|
||||
export type ComposerInsertPayload =
|
||||
| IComposerInsertMentionPayload
|
||||
| IComposerInsertPlaintextPayload
|
||||
| IComposerInsertFilesPayload;
|
||||
|
||||
@ -5,27 +5,68 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ComposerApi as ModuleComposerApi } from "@element-hq/element-web-module-api";
|
||||
import {
|
||||
type ComposerApi as ModuleComposerApi,
|
||||
type ComposerApiFileUploadOption,
|
||||
} from "@element-hq/element-web-module-api";
|
||||
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type { MatrixDispatcher } from "../dispatcher/dispatcher";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
import type { ComposerInsertPayload } from "../dispatcher/payloads/ComposerInsertPayload";
|
||||
|
||||
export class ComposerApi implements ModuleComposerApi {
|
||||
export enum ModuleComposerApiEvents {
|
||||
UploaderOptionsChanged = "uploaderOptionsChanged",
|
||||
}
|
||||
|
||||
interface ModuleComposerApiEventsMap {
|
||||
[ModuleComposerApiEvents.UploaderOptionsChanged]: (option: ComposerApiFileUploadOption) => void;
|
||||
}
|
||||
|
||||
export class ComposerApi
|
||||
extends TypedEventEmitter<ModuleComposerApiEvents, ModuleComposerApiEventsMap>
|
||||
implements ModuleComposerApi
|
||||
{
|
||||
private allowLocalFileUploads = true;
|
||||
private readonly configuredFileUploadOptions = new Map<string, ComposerApiFileUploadOption>();
|
||||
|
||||
public constructor(private readonly dispatcher: MatrixDispatcher) {}
|
||||
public constructor(private readonly dispatcher: MatrixDispatcher) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the user permitted to upload local files in any way.
|
||||
*/
|
||||
public get localFileUploadsAllowed(): boolean {
|
||||
return this.allowLocalFileUploads;
|
||||
}
|
||||
|
||||
public addFileUploadOption(option): void {}
|
||||
/**
|
||||
* List of possible file upload options.
|
||||
*/
|
||||
public get fileUploadOptions(): ComposerApiFileUploadOption[] {
|
||||
return [...this.configuredFileUploadOptions.values()];
|
||||
}
|
||||
|
||||
public disableLocalFileUploads(): void {
|
||||
this.allowLocalFileUploads = false;
|
||||
}
|
||||
|
||||
public addFileUploadOption(option: ComposerApiFileUploadOption): void {
|
||||
if (this.configuredFileUploadOptions.has(option.type)) {
|
||||
throw new Error(`Option "${option.type}" already exists `);
|
||||
}
|
||||
this.configuredFileUploadOptions.set(option.type, option);
|
||||
this.emit(ModuleComposerApiEvents.UploaderOptionsChanged, option);
|
||||
}
|
||||
|
||||
public openFileUploadConfirmation(files: File[]): void {
|
||||
this.dispatcher.dispatch({
|
||||
action: Action.ComposerInsert,
|
||||
files,
|
||||
} satisfies ComposerInsertPayload);
|
||||
}
|
||||
|
||||
public insertPlaintextIntoComposer(plaintext: string): void {
|
||||
this.dispatcher.dispatch({
|
||||
action: Action.ComposerInsert,
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { BaseViewModel, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
|
||||
import type { ComposerApiFileUploadOption } from "@element-hq/element-web-module-api";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||
import React, {
|
||||
type ChangeEventHandler,
|
||||
@ -32,23 +33,15 @@ import { chromeFileInputFix } from "../../utils/BrowserWorkarounds";
|
||||
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { ModuleApi } from "../../modules/Api";
|
||||
import { ModuleComposerApiEvents } from "../../modules/ComposerApi";
|
||||
|
||||
const logger = rootLogger.getChild("RoomUploadViewModel");
|
||||
|
||||
export interface RoomUploadViewSnapshot {
|
||||
mayUpload: boolean;
|
||||
}
|
||||
|
||||
export interface RoomUploadViewActions {
|
||||
initiateViaInputFiles(files: FileList | null): Promise<void>;
|
||||
initiateViaDataTransfer(dataTransfer: DataTransfer): Promise<void>;
|
||||
openUploadDialog(): void;
|
||||
}
|
||||
|
||||
export class RoomUploadViewModel
|
||||
extends BaseViewModel<RoomUploadViewSnapshot, Record<string, never>>
|
||||
implements RoomUploadViewActions
|
||||
{
|
||||
private readonly extraUploadSelectFns = new Map<string, ComposerApiFileUploadOption["onSelected"]>();
|
||||
public constructor(
|
||||
private readonly room: Room,
|
||||
private readonly client: MatrixClient,
|
||||
@ -57,16 +50,27 @@ export class RoomUploadViewModel
|
||||
private replyToEvent: MatrixEvent | undefined,
|
||||
private threadRelation: IEventRelation | undefined,
|
||||
public readonly openUploadDialog: () => void,
|
||||
private readonly moduleComposerApi = ModuleApi.instance.composer,
|
||||
) {
|
||||
super(
|
||||
{},
|
||||
{
|
||||
mayUpload: ModuleApi.instance.composer.localFileUploadsAllowed ?? room.maySendMessage(),
|
||||
extraUploadOptions: moduleComposerApi.fileUploadOptions.map((option) => ({
|
||||
type: option.type,
|
||||
label: option.label,
|
||||
icon: option.icon,
|
||||
})),
|
||||
},
|
||||
);
|
||||
for (const option of moduleComposerApi.fileUploadOptions) {
|
||||
this.extraUploadSelectFns.set(option.type, option.onSelected);
|
||||
}
|
||||
room.on(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
|
||||
moduleComposerApi.on(ModuleComposerApiEvents.UploaderOptionsChanged, this.onUploaderOptionsChanged);
|
||||
this.disposables.track(() => {
|
||||
room.off(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
|
||||
moduleComposerApi.off(ModuleComposerApiEvents.UploaderOptionsChanged, this.onUploaderOptionsChanged);
|
||||
});
|
||||
}
|
||||
|
||||
@ -76,6 +80,20 @@ export class RoomUploadViewModel
|
||||
});
|
||||
};
|
||||
|
||||
private onUploaderOptionsChanged = (option: ComposerApiFileUploadOption): void => {
|
||||
this.extraUploadSelectFns.set(option.type, option.onSelected);
|
||||
this.snapshot.merge({
|
||||
extraUploadOptions: [
|
||||
...this.snapshot.current.extraUploadOptions,
|
||||
{
|
||||
type: option.type,
|
||||
label: option.label,
|
||||
icon: option.icon,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
public setReplyToEvent = (replyToEvent?: MatrixEvent): void => {
|
||||
this.replyToEvent = replyToEvent;
|
||||
};
|
||||
@ -128,6 +146,17 @@ export class RoomUploadViewModel
|
||||
}
|
||||
};
|
||||
|
||||
public onUploadOptionSelected = (type: ComposerApiFileUploadOption["type"]): void => {
|
||||
const fn = this.extraUploadSelectFns.get(type);
|
||||
if (!fn) {
|
||||
throw Error("Unexpectedly called onUploadOptionSelected with an unknown type");
|
||||
}
|
||||
fn(this.room.roomId, {
|
||||
inReplyToEventId: this.replyToEvent?.getId(),
|
||||
relType: this.threadRelation?.rel_type,
|
||||
});
|
||||
};
|
||||
|
||||
private checkCanUpload(): boolean {
|
||||
if (this.client.isGuest()) {
|
||||
this.dispatcher.dispatch({ action: "require_registration" });
|
||||
|
||||
@ -105,7 +105,7 @@ export interface ComposerApi {
|
||||
addFileUploadOption(option: ComposerApiFileUploadOption): void;
|
||||
disableLocalFileUploads(): void;
|
||||
insertPlaintextIntoComposer(plaintext: string): void;
|
||||
openFileUploadConfirmation(file: File | DataTransfer): void;
|
||||
openFileUploadConfirmation(file: File[]): void;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
@ -113,13 +113,10 @@ export type ComposerApiFileUploadOption = {
|
||||
type: string;
|
||||
label: string;
|
||||
icon?: ComponentType<SVGAttributes<SVGElement>>;
|
||||
onSelected: (roomId: string, relation?: ComposerApiFileUploadRelation) => Promise<void> | void;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ComposerApiFileUploadRelation = {
|
||||
inReplyToEventId?: string;
|
||||
relType?: "m.thread" | "m";
|
||||
onSelected: (roomId: string, relation?: {
|
||||
inReplyToEventId?: string;
|
||||
relType?: string;
|
||||
}) => Promise<void> | void;
|
||||
};
|
||||
|
||||
// @public
|
||||
@ -241,15 +238,6 @@ export interface ExtrasApi {
|
||||
setSpacePanelItem(spaceKey: string, props: SpacePanelItemProps): void;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export type FileUploadResult = {
|
||||
mxc: string;
|
||||
} | {
|
||||
file: File;
|
||||
} | {
|
||||
blob: Blob;
|
||||
} | null;
|
||||
|
||||
// @public
|
||||
export interface I18nApi {
|
||||
humanizeTime(this: void, timeMillis: number): string;
|
||||
|
||||
@ -7,11 +7,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { ComponentType, SVGAttributes } from "react";
|
||||
|
||||
export type ComposerApiFileUploadRelation = {
|
||||
inReplyToEventId?: string;
|
||||
relType?: "m.thread" | "m";
|
||||
};
|
||||
|
||||
/**
|
||||
* An option presented to the user for uploading a file.
|
||||
* @alpha Unlikely to change
|
||||
@ -31,21 +26,20 @@ export type ComposerApiFileUploadOption = {
|
||||
*/
|
||||
icon?: ComponentType<SVGAttributes<SVGElement>>;
|
||||
/**
|
||||
* Function called when the option is selected. The room ID
|
||||
* @param roomId
|
||||
* @param relation
|
||||
* @param onFileSelected
|
||||
* Function called when the option is selected.
|
||||
* @param roomId - The room ID of the room in focus.
|
||||
* @param relation - Whether or not a thread and/or reply is in focus.
|
||||
* @returns
|
||||
*/
|
||||
onSelected: (roomId: string, relation?: ComposerApiFileUploadRelation) => Promise<void> | void;
|
||||
onSelected: (
|
||||
roomId: string,
|
||||
relation?: {
|
||||
inReplyToEventId?: string;
|
||||
relType?: string;
|
||||
},
|
||||
) => Promise<void> | void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result from a file upload.
|
||||
* @alpha Unlikely to change
|
||||
*/
|
||||
export type FileUploadResult = { mxc: string } | { file: File } | { blob: Blob } | null;
|
||||
|
||||
/**
|
||||
* API to interact with the message composer.
|
||||
* @alpha Likely to change
|
||||
@ -53,7 +47,6 @@ export type FileUploadResult = { mxc: string } | { file: File } | { blob: Blob }
|
||||
export interface ComposerApi {
|
||||
/**
|
||||
* Add a new file upload option for the user.
|
||||
* Use {@link ComposerApiFileUploadLocal} to alter the local file upload logic.
|
||||
* @throws If another option is already using the same `type`.
|
||||
* @alpha Likely to change
|
||||
*/
|
||||
@ -66,7 +59,7 @@ export interface ComposerApi {
|
||||
* Open the file upload confirmation dialog. This may be used in conjunction
|
||||
* with `addFileUploadOption` to support an alternative file upload kind.
|
||||
*/
|
||||
openFileUploadConfirmation(file: File | DataTransfer): void;
|
||||
openFileUploadConfirmation(file: File[]): void;
|
||||
/**
|
||||
* Insert plaintext into the current composer.
|
||||
* @param plaintext - The plain text to insert
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@ -238,6 +238,9 @@
|
||||
"view_image": "View image"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
"attachment_button_label": "Open attachment menu"
|
||||
},
|
||||
"widget": {
|
||||
"context_menu": {
|
||||
"move_left": "Move left",
|
||||
|
||||
@ -9,21 +9,25 @@
|
||||
export * from "./audio/Clock";
|
||||
export * from "./audio/PlayPauseButton";
|
||||
export * from "./audio/SeekBar";
|
||||
export * from "./core/avatar/AvatarWithDetails";
|
||||
export * from "./composer/Banner";
|
||||
export * from "./core/AvatarWithDetails";
|
||||
export * from "./core/roving";
|
||||
export * from "./room/composer/Banner";
|
||||
export * from "./room/composer/UploadButton";
|
||||
export * from "./crypto/SasEmoji";
|
||||
export * from "./room/timeline/ReadMarker";
|
||||
export * from "./room/timeline/event-tile/body/EventContentBodyView";
|
||||
export * from "./room/timeline/event-tile/body/RedactedBodyView";
|
||||
export * from "./room/timeline/event-tile/body/MFileBodyView";
|
||||
export * from "./room/timeline/event-tile/body/MImageBodyView";
|
||||
export * from "./room/timeline/event-tile/body/MVideoBodyView";
|
||||
export * from "./room/timeline/event-tile/body/TextualBodyView";
|
||||
export * from "./room/timeline/event-tile/EventTileView/TileErrorView";
|
||||
export * from "./core/pill-input/Pill";
|
||||
export * from "./core/pill-input/PillInput";
|
||||
export * from "./core/MultiOptionButton";
|
||||
export * from "./room/RoomStatusBar";
|
||||
export * from "./room/WidgetPip";
|
||||
export * from "./room/HistoryVisibilityBadge";
|
||||
export * from "./room/right-panel/WidgetContextMenuView";
|
||||
export * from "./room/timeline/DateSeparatorView";
|
||||
export * from "./room/timeline/TimelineSeparator";
|
||||
export * from "./room/timeline/event-tile/actions/ActionBarView";
|
||||
@ -44,16 +48,15 @@ export * from "./core/rich-list/RichItem";
|
||||
export * from "./core/rich-list/RichList";
|
||||
export * from "./room-list/RoomListHeaderView";
|
||||
export * from "./room-list/RoomListSearchView";
|
||||
export * from "./room-list/RoomListSectionHeaderView";
|
||||
export * from "./room-list/RoomListView";
|
||||
export * from "./room-list/RoomListItemView";
|
||||
export * from "./room-list/RoomListItemAccessibilityWrapper";
|
||||
export * from "./room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView";
|
||||
export * from "./room-list/RoomListPrimaryFilters";
|
||||
export * from "./room-list/VirtualizedRoomListView";
|
||||
export * from "./room-list/VirtualizedRoomListView/RoomListSectionHeaderView";
|
||||
export * from "./room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper";
|
||||
export * from "./core/utils/Box";
|
||||
export * from "./core/utils/Flex";
|
||||
export * from "./core/utils/LinkedText";
|
||||
export * from "./right-panel/WidgetContextMenu";
|
||||
export * from "./core/VirtualizedList";
|
||||
export * from "./resize";
|
||||
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { type Meta, type StoryFn } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { AttachmentIcon, ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { UploadButton, type UploadButtonViewActions, type UploadButtonViewSnapshot } from "./UploadButton";
|
||||
import { useMockedViewModel } from "../../../core/viewmodel";
|
||||
import { withViewDocs } from "../../../../.storybook/withViewDocs";
|
||||
|
||||
const UploadButtonWrapperImpl = ({
|
||||
onUploadOptionSelected,
|
||||
...rest
|
||||
}: UploadButtonViewSnapshot & UploadButtonViewActions): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
onUploadOptionSelected,
|
||||
});
|
||||
return <UploadButton vm={vm} />;
|
||||
};
|
||||
|
||||
const UploadButtonWrapper = withViewDocs(UploadButtonWrapperImpl, UploadButton);
|
||||
|
||||
export default {
|
||||
title: "Room/UploadButton",
|
||||
component: UploadButtonWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
onUploadOptionSelected: fn(),
|
||||
options: [
|
||||
{
|
||||
type: "local",
|
||||
label: "Attachment",
|
||||
icon: AttachmentIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Meta<typeof UploadButtonWrapper>;
|
||||
|
||||
const Template: StoryFn<typeof UploadButtonWrapper> = (args) => <UploadButtonWrapper {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
options: [
|
||||
{
|
||||
type: "local",
|
||||
label: "Attachment",
|
||||
icon: AttachmentIcon,
|
||||
},
|
||||
{
|
||||
label: "Fun Button",
|
||||
icon: ReactionIcon,
|
||||
type: "fun",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithOneOption = Template.bind({});
|
||||
|
||||
WithOneOption.args = {
|
||||
options: [
|
||||
{
|
||||
type: "local",
|
||||
label: "Attachment",
|
||||
icon: AttachmentIcon,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render } from "@test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { userEvent } from "vitest/browser";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import * as stories from "./UploadButton.stories.tsx";
|
||||
|
||||
const { Default } = composeStories(stories);
|
||||
|
||||
describe("UploadButton", () => {
|
||||
it("renders a default button", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
type ReactElement,
|
||||
type PropsWithChildren,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type SVGAttributes,
|
||||
type ComponentType,
|
||||
useCallback,
|
||||
type MouseEventHandler,
|
||||
} from "react";
|
||||
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
|
||||
import { AttachmentIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { useI18n } from "../../../core/i18n/i18nContext";
|
||||
import { useViewModel, type ViewModel } from "../../../core/viewmodel";
|
||||
|
||||
export interface UploadButtonViewSnapshot {
|
||||
mayUpload: boolean;
|
||||
options: { type: string; label: string; icon: ComponentType<SVGAttributes<SVGElement>> }[];
|
||||
}
|
||||
|
||||
export interface UploadButtonViewActions {
|
||||
onUploadOptionSelected(type: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A button that may have one or more options that the user can select.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <UploadButton vm={} />
|
||||
* ```
|
||||
*/
|
||||
export function UploadButton({
|
||||
vm,
|
||||
...rootButtonProps
|
||||
}: PropsWithChildren<
|
||||
{ vm: ViewModel<UploadButtonViewSnapshot, UploadButtonViewActions> } & ComponentProps<typeof IconButton>
|
||||
>): ReactElement {
|
||||
const i18n = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { options } = useViewModel(vm);
|
||||
// Shift click is a shortcut to selecting the first item.
|
||||
const onMenuClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||
(ev) => {
|
||||
if (!ev.shiftKey) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
vm.onUploadOptionSelected(options[0].type);
|
||||
},
|
||||
[options, vm],
|
||||
);
|
||||
if (options.length === 1) {
|
||||
const { label, icon: Icon } = options[0];
|
||||
return (
|
||||
<IconButton
|
||||
tooltip={label}
|
||||
{...rootButtonProps}
|
||||
title={label}
|
||||
onClick={() => vm.onUploadOptionSelected(options[0].type)}
|
||||
>
|
||||
<Icon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
const trigger = (
|
||||
<IconButton
|
||||
tooltip={i18n.translate("common|attachment")}
|
||||
onClick={onMenuClick}
|
||||
{...rootButtonProps}
|
||||
title={i18n.translate("common|attachment")}
|
||||
>
|
||||
<AttachmentIcon />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
side="top"
|
||||
title={i18n.translate("composer|attachment_button_label")}
|
||||
trigger={trigger}
|
||||
open={open}
|
||||
onOpenChange={(o) => setOpen(o)}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<MenuItem
|
||||
key={o.label}
|
||||
label={o.label}
|
||||
Icon={o.icon}
|
||||
onSelect={() => vm.onUploadOptionSelected(o.type)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export * from "./UploadButton";
|
||||
Loading…
x
Reference in New Issue
Block a user