From 8a1d1b7661c618a1e08fd34f4fe82789d19faddc Mon Sep 17 00:00:00 2001 From: Keno Dressel Date: Fri, 19 Jan 2024 13:48:23 +0100 Subject: [PATCH] feat: adds info on minimum token required and sends user to buy some --- components.json | 1 + config.sample.json | 3 +- src/atoms.ts | 15 + .../views/elements/DisabledMessageField.tsx | 45 ++ .../views/elements/MessageButton.tsx | 43 ++ src/components/views/right_panel/UserInfo.tsx | 38 +- .../views/rooms/MessageComposer.tsx | 667 ++++++++++++++++++ src/context/SuperheroProvider.tsx | 51 +- 8 files changed, 822 insertions(+), 41 deletions(-) create mode 100644 src/components/views/elements/DisabledMessageField.tsx create mode 100644 src/components/views/elements/MessageButton.tsx create mode 100644 src/components/views/rooms/MessageComposer.tsx diff --git a/components.json b/components.json index 8290b6ac67..bbf8fea8a9 100644 --- a/components.json +++ b/components.json @@ -4,6 +4,7 @@ "src/components/views/auth/AuthPage.tsx": "src/components/views/auth/VectorAuthPage.tsx", "src/components/views/rooms/RoomTile.tsx": "src/components/views/rooms/RoomTile.tsx", "src/components/views/rooms/NewRoomIntro.tsx": "src/components/views/rooms/NewRoomIntro.tsx", + "src/components/views/rooms/MessageComposer.tsx": "src/components/views/rooms/MessageComposer.tsx", "src/components/views/elements/RoomName.tsx": "src/components/views/elements/RoomName.tsx", "src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx": "src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx", "src/components/views/avatars/BaseAvatar.tsx": "src/components/views/avatars/BaseAvatar.tsx", diff --git a/config.sample.json b/config.sample.json index 5f302345c9..7974608974 100644 --- a/config.sample.json +++ b/config.sample.json @@ -46,5 +46,6 @@ "participant_limit": 8, "brand": "Element Call" }, - "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx" + "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx", + "community_bot_user_id": "@communitybot:superhero.com" } diff --git a/src/atoms.ts b/src/atoms.ts index 69b907e800..97a0aae3a1 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -1,3 +1,18 @@ import { atomWithStorage } from "jotai/utils"; +type TokenThreshold = { + threshold: string; + symbol: string; +} + +export type BareUser = { + userId: string; + rawDisplayName: string; +} + export const verifiedAccountsAtom = atomWithStorage>("VERIFIED_ACCOUNTS", {}); +export const minimumTokenThresholdAtom = atomWithStorage>("TOKEN_THRESHOLD", {}); +export const communityBotAtom = atomWithStorage("COMMUNITY_BOT", { + userId: "", + rawDisplayName: "", +}); diff --git a/src/components/views/elements/DisabledMessageField.tsx b/src/components/views/elements/DisabledMessageField.tsx new file mode 100644 index 0000000000..73ea565472 --- /dev/null +++ b/src/components/views/elements/DisabledMessageField.tsx @@ -0,0 +1,45 @@ +import { useAtom } from "jotai"; +import { communityBotAtom, minimumTokenThresholdAtom } from "../../../atoms"; +import { _t } from "../../../languageHandler"; +import React from "react"; +import { useVerifiedRoom } from "../../../hooks/useVerifiedRoom"; +import { Room } from "matrix-js-sdk/src/matrix"; +import { MessageButton } from "./MessageButton"; + +export function DisabledMessageField({ room }: { room: Room }): JSX.Element { + const [allTokens] = useAtom(minimumTokenThresholdAtom) + const [communityBot] = useAtom(communityBotAtom) + const { isTokenGatedRoom, isCommunityRoom, } = useVerifiedRoom(room); + + let tokenThreshold = allTokens[room.name]; + if(!tokenThreshold) { + const tokenName = room.name.match(/\[TG] (.*) \(ct_.*\)/)?.[1]; + if(isTokenGatedRoom && tokenName) { + tokenThreshold = { + threshold: "1", + symbol: tokenName, + } + } + } + + + if (tokenThreshold) { + return ( +
+ You need at least {tokenThreshold.threshold} {tokenThreshold.symbol} to join this + community.{ isCommunityRoom ? ( + <> + + + + ) : null } +
+ ); + } else { + return ( +
+ {_t("composer|no_perms_notice")} +
+ ); + } +} diff --git a/src/components/views/elements/MessageButton.tsx b/src/components/views/elements/MessageButton.tsx new file mode 100644 index 0000000000..15ba43997b --- /dev/null +++ b/src/components/views/elements/MessageButton.tsx @@ -0,0 +1,43 @@ +import React, { useContext, useState } from "react"; +import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext"; +import AccessibleButton from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import { Member } from "../right_panel/UserInfo"; +import { Icon as SendMessage } from "../../../../res/themes/superhero/img/icons/send.svg"; +import { MatrixClient, RoomMember, User } from "matrix-js-sdk/src/matrix"; +import { DirectoryMember, startDmOnFirstMessage } from "matrix-react-sdk/src/utils/direct-messages"; + +import { BareUser } from "../../../atoms"; +/** + * Converts the member to a DirectoryMember and starts a DM with them. + */ +async function openDmForUser(matrixClient: MatrixClient, user: Member | BareUser): Promise { + const avatarUrl = user instanceof User ? user.avatarUrl : user instanceof RoomMember ? user.getMxcAvatarUrl() : ''; + const startDmUser = new DirectoryMember({ + user_id: user.userId, + display_name: user.rawDisplayName, + avatar_url: avatarUrl, + }); + await startDmOnFirstMessage(matrixClient, [startDmUser]); +} + +export const MessageButton = ({ member, text = 'Send Message' }: { member: Member | BareUser, text?: string }): JSX.Element => { + const cli = useContext(MatrixClientContext); + const [busy, setBusy] = useState(false); + + return ( + => { + if (busy) return; + setBusy(true); + await openDmForUser(cli, member); + setBusy(false); + }} + className="mx_UserInfo_field" + disabled={busy} + > + + {text} + + ); +}; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index a45214d0b5..4a8e269c79 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -78,13 +78,12 @@ import { IRightPanelCardState } from "matrix-react-sdk/src/stores/right-panel/Ri import UserIdentifierCustomisations from "matrix-react-sdk/src/customisations/UserIdentifier"; import PosthogTrackers from "matrix-react-sdk/src/PosthogTrackers"; import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload"; -import { DirectoryMember, startDmOnFirstMessage } from "matrix-react-sdk/src/utils/direct-messages"; import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext"; import { asyncSome } from "matrix-react-sdk/src/utils/arrays"; import UIStore from "matrix-react-sdk/src/stores/UIStore"; import { UserVerifiedBadge } from "../elements/UserVerifiedBadge"; -import { Icon as SendMessage } from "../../../../res/themes/superhero/img/icons/send.svg"; +import { MessageButton } from "../elements/MessageButton"; export interface IDevice extends Device { ambiguous?: boolean; @@ -133,19 +132,6 @@ export const getE2EStatus = async ( return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified; }; -/** - * Converts the member to a DirectoryMember and starts a DM with them. - */ -async function openDmForUser(matrixClient: MatrixClient, user: Member): Promise { - const avatarUrl = user instanceof User ? user.avatarUrl : user.getMxcAvatarUrl(); - const startDmUser = new DirectoryMember({ - user_id: user.userId, - display_name: user.rawDisplayName, - avatar_url: avatarUrl, - }); - await startDmOnFirstMessage(matrixClient, [startDmUser]); -} - type SetUpdating = (updating: boolean) => void; function useHasCrossSigningKeys( @@ -359,28 +345,6 @@ function DevicesSection({ ); } -const MessageButton = ({ member }: { member: Member }): JSX.Element => { - const cli = useContext(MatrixClientContext); - const [busy, setBusy] = useState(false); - - return ( - => { - if (busy) return; - setBusy(true); - await openDmForUser(cli, member); - setBusy(false); - }} - className="mx_UserInfo_field" - disabled={busy} - > - - Send Message - - ); -}; - export const UserOptionsSection: React.FC<{ member: Member; isIgnored: boolean; diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx new file mode 100644 index 0000000000..a651dc22cf --- /dev/null +++ b/src/components/views/rooms/MessageComposer.tsx @@ -0,0 +1,667 @@ +/* +Copyright 2015 - 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { createRef, ReactNode } from "react"; +import classNames from "classnames"; +import { + IEventRelation, + MatrixEvent, + Room, + RoomMember, + EventType, + THREAD_RELATION_TYPE, +} from "matrix-js-sdk/src/matrix"; +import { Optional } from "matrix-events-sdk"; + +import { _t } from "../../../languageHandler"; +import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg"; +import { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton"; +import { MatrixClientProps, withMatrixClientHOC } from "matrix-react-sdk/src/contexts/MatrixClientContext"; +import ResizeNotifier from "matrix-react-sdk/src/utils/ResizeNotifier"; +import { E2EStatus } from "matrix-react-sdk/src/utils/ShieldUtils"; +import { makeRoomPermalink, RoomPermalinkCreator } from "matrix-react-sdk/src/utils/permalinks/Permalinks"; +import VoiceRecordComposerTile from "matrix-react-sdk/src/components/views/rooms/VoiceRecordComposerTile"; +import { VoiceMessageRecording } from "matrix-react-sdk/src/audio/VoiceMessageRecording"; +import RoomContext from "matrix-react-sdk/src/contexts/RoomContext"; +import { UPDATE_EVENT } from "matrix-react-sdk/src/stores/AsyncStore"; +import { VoiceRecordingStore } from "matrix-react-sdk/src/stores/VoiceRecordingStore"; +import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore"; +import { Features } from "matrix-react-sdk/src/settings/Settings"; +import { RecordingState } from "matrix-react-sdk/src/audio/VoiceRecording"; +import dis from "matrix-react-sdk/src/dispatcher/dispatcher"; +import UIStore, { UI_EVENTS } from "matrix-react-sdk/src/stores/UIStore"; +import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads"; +import { Action } from "matrix-react-sdk/src/dispatcher/actions"; +import { SettingUpdatedPayload } from "matrix-react-sdk/src/dispatcher/payloads/SettingUpdatedPayload"; +import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload"; +import { ComposerInsertPayload } from "matrix-react-sdk/src/dispatcher/payloads/ComposerInsertPayload"; +import { getConversionFunctions, sendMessage, SendWysiwygComposer } from "matrix-react-sdk/src/components/views/rooms/wysiwyg_composer"; +import EditorModel from "matrix-react-sdk/src/editor/model"; +import { isLocalRoom } from "matrix-react-sdk/src/utils/localRoom/isLocalRoom"; +import { aboveLeftOf, MenuProps } from "matrix-react-sdk/src/components/structures/ContextMenu"; +import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext"; +import { VoiceBroadcastInfoState } from "matrix-react-sdk/src/voice-broadcast"; +import { createCantStartVoiceMessageBroadcastDialog } from "matrix-react-sdk/src/components/views/dialogs/CantStartVoiceMessageBroadcastDialog"; +import E2EIcon from "matrix-react-sdk/src/components/views/rooms/E2EIcon"; +import SendMessageComposer, { SendMessageComposer as SendMessageComposerClass } from "matrix-react-sdk/src/components/views/rooms/SendMessageComposer"; +import Tooltip, { Alignment } from "matrix-react-sdk/src/components/views/elements/Tooltip"; +import { formatTimeLeft } from "matrix-react-sdk/src/DateUtils"; +import Stickerpicker from "matrix-react-sdk/src/components/views/rooms/Stickerpicker"; +import ReplyPreview from "matrix-react-sdk/src/components/views/rooms/ReplyPreview"; +import MessageComposerButtons from "matrix-react-sdk/src/components/views/rooms/MessageComposerButtons"; +import { UIFeature } from "matrix-react-sdk/src/settings/UIFeature"; +import { setUpVoiceBroadcastPreRecording } from "matrix-react-sdk/src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording"; +import { DisabledMessageField } from "../elements/DisabledMessageField"; + +let instanceCount = 0; + +interface ISendButtonProps { + onClick: (ev: ButtonEvent) => void; + title?: string; // defaults to something generic +} + +function SendButton(props: ISendButtonProps): JSX.Element { + return ( + + ); +} + +interface IProps extends MatrixClientProps { + room: Room; + resizeNotifier: ResizeNotifier; + permalinkCreator?: RoomPermalinkCreator; + replyToEvent?: MatrixEvent; + relation?: IEventRelation; + e2eStatus?: E2EStatus; + compact?: boolean; +} + +interface IState { + composerContent: string; + isComposerEmpty: boolean; + haveRecording: boolean; + recordingTimeLeftSeconds?: number; + me?: RoomMember; + isMenuOpen: boolean; + isStickerPickerOpen: boolean; + showStickersButton: boolean; + showPollsButton: boolean; + showVoiceBroadcastButton: boolean; + isWysiwygLabEnabled: boolean; + isRichTextEnabled: boolean; + initialComposerContent: string; +} + +export class MessageComposer extends React.Component { + private tooltipId = `mx_MessageComposer_${Math.random()}`; + private dispatcherRef?: string; + private messageComposerInput = createRef(); + private voiceRecordingButton = createRef(); + private ref: React.RefObject = createRef(); + private instanceId: number; + + private _voiceRecording: Optional; + + public static contextType = RoomContext; + public context!: React.ContextType; + + public static defaultProps = { + compact: false, + showVoiceBroadcastButton: false, + isRichTextEnabled: true, + }; + + public constructor(props: IProps) { + super(props); + VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate); + + this.state = { + isComposerEmpty: true, + composerContent: "", + haveRecording: false, + recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast + isMenuOpen: false, + isStickerPickerOpen: false, + showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"), + showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"), + showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast), + isWysiwygLabEnabled: SettingsStore.getValue("feature_wysiwyg_composer"), + isRichTextEnabled: true, + initialComposerContent: "", + }; + + this.instanceId = instanceCount++; + + SettingsStore.monitorSetting("MessageComposerInput.showStickersButton", null); + SettingsStore.monitorSetting("MessageComposerInput.showPollsButton", null); + SettingsStore.monitorSetting(Features.VoiceBroadcast, null); + SettingsStore.monitorSetting("feature_wysiwyg_composer", null); + } + + private get voiceRecording(): Optional { + return this._voiceRecording; + } + + private set voiceRecording(rec: Optional) { + if (this._voiceRecording) { + this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted); + this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon); + } + + this._voiceRecording = rec; + + if (rec) { + // Delay saying we have a recording until it is started, as we might not yet + // have A/V permissions + rec.on(RecordingState.Started, this.onRecordingStarted); + + // We show a little heads up that the recording is about to automatically end soon. The 3s + // display time is completely arbitrary. + rec.on(RecordingState.EndingSoon, this.onRecordingEndingSoon); + } + } + + public componentDidMount(): void { + this.dispatcherRef = dis.register(this.onAction); + this.waitForOwnMember(); + UIStore.instance.trackElementDimensions(`MessageComposer${this.instanceId}`, this.ref.current!); + UIStore.instance.on(`MessageComposer${this.instanceId}`, this.onResize); + this.updateRecordingState(); // grab any cached recordings + } + + private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry): void => { + if (type === UI_EVENTS.Resize) { + const { narrow } = this.context; + this.setState({ + isMenuOpen: !narrow ? false : this.state.isMenuOpen, + isStickerPickerOpen: false, + }); + } + }; + + private onAction = (payload: ActionPayload): void => { + switch (payload.action) { + case "reply_to_event": + if (payload.context === this.context.timelineRenderingType) { + // add a timeout for the reply preview to be rendered, so + // that the ScrollPanel listening to the resizeNotifier can + // correctly measure it's new height and scroll down to keep + // at the bottom if it already is + window.setTimeout(() => { + this.props.resizeNotifier.notifyTimelineHeightChanged(); + }, 100); + } + break; + + case Action.SettingUpdated: { + const settingUpdatedPayload = payload as SettingUpdatedPayload; + switch (settingUpdatedPayload.settingName) { + case "MessageComposerInput.showStickersButton": { + const showStickersButton = SettingsStore.getValue("MessageComposerInput.showStickersButton"); + if (this.state.showStickersButton !== showStickersButton) { + this.setState({ showStickersButton }); + } + break; + } + case "MessageComposerInput.showPollsButton": { + const showPollsButton = SettingsStore.getValue("MessageComposerInput.showPollsButton"); + if (this.state.showPollsButton !== showPollsButton) { + this.setState({ showPollsButton }); + } + break; + } + case Features.VoiceBroadcast: { + if (this.state.showVoiceBroadcastButton !== settingUpdatedPayload.newValue) { + this.setState({ showVoiceBroadcastButton: !!settingUpdatedPayload.newValue }); + } + break; + } + case "feature_wysiwyg_composer": { + if (this.state.isWysiwygLabEnabled !== settingUpdatedPayload.newValue) { + this.setState({ isWysiwygLabEnabled: Boolean(settingUpdatedPayload.newValue) }); + } + break; + } + } + } + } + }; + + private waitForOwnMember(): void { + // If we have the member already, do that + const me = this.props.room.getMember(MatrixClientPeg.safeGet().getUserId()!); + if (me) { + this.setState({ me }); + return; + } + // Otherwise, wait for member loading to finish and then update the member for the avatar. + // The members should already be loading, and loadMembersIfNeeded + // will return the promise for the existing operation + this.props.room.loadMembersIfNeeded().then(() => { + const me = this.props.room.getMember(MatrixClientPeg.safeGet().getSafeUserId()) ?? undefined; + this.setState({ me }); + }); + } + + public componentWillUnmount(): void { + VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate); + if (this.dispatcherRef) dis.unregister(this.dispatcherRef); + UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`); + UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize); + + // clean up our listeners by setting our cached recording to falsy (see internal setter) + this.voiceRecording = null; + } + + private onTombstoneClick = (ev: ButtonEvent): void => { + ev.preventDefault(); + + const replacementRoomId = this.context.tombstone?.getContent()["replacement_room"]; + const replacementRoom = MatrixClientPeg.safeGet().getRoom(replacementRoomId); + let createEventId: string | undefined; + if (replacementRoom) { + const createEvent = replacementRoom.currentState.getStateEvents(EventType.RoomCreate, ""); + if (createEvent?.getId()) createEventId = createEvent.getId(); + } + + const sender = this.context.tombstone?.getSender(); + const viaServers = sender ? [sender.split(":").slice(1).join(":")] : undefined; + + dis.dispatch({ + action: Action.ViewRoom, + highlighted: true, + event_id: createEventId, + room_id: replacementRoomId, + auto_join: true, + // Try to join via the server that sent the event. This converts @something:example.org + // into a server domain by splitting on colons and ignoring the first entry ("@something"). + via_servers: viaServers, + metricsTrigger: "Tombstone", + metricsViaKeyboard: ev.type !== "click", + }); + }; + + private renderPlaceholderText = (): string => { + if (this.props.replyToEvent) { + const replyingToThread = this.props.relation?.rel_type === THREAD_RELATION_TYPE.name; + if (replyingToThread && this.props.e2eStatus) { + return _t("composer|placeholder_thread_encrypted"); + } else if (replyingToThread) { + return _t("composer|placeholder_thread"); + } else if (this.props.e2eStatus) { + return _t("composer|placeholder_reply_encrypted"); + } else { + return _t("composer|placeholder_reply"); + } + } else { + if (this.props.e2eStatus) { + return _t("composer|placeholder_encrypted"); + } else { + return _t("composer|placeholder"); + } + } + }; + + private addEmoji = (emoji: string): boolean => { + dis.dispatch({ + action: Action.ComposerInsert, + text: emoji, + timelineRenderingType: this.context.timelineRenderingType, + }); + return true; + }; + + private sendMessage = async (): Promise => { + if (this.state.haveRecording && this.voiceRecordingButton.current) { + // 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.current?.send(); + return; + } + + this.messageComposerInput.current?.sendMessage(); + + if (this.state.isWysiwygLabEnabled) { + const { permalinkCreator, relation, replyToEvent } = this.props; + const composerContent = this.state.composerContent; + this.setState({ composerContent: "", initialComposerContent: "" }); + dis.dispatch({ + action: Action.ClearAndFocusSendMessageComposer, + timelineRenderingType: this.context.timelineRenderingType, + }); + await sendMessage(composerContent, this.state.isRichTextEnabled, { + mxClient: this.props.mxClient, + roomContext: this.context, + permalinkCreator, + relation, + replyToEvent, + }); + } + }; + + private onChange = (model: EditorModel): void => { + this.setState({ + isComposerEmpty: model.isEmpty, + }); + }; + + private onWysiwygChange = (content: string): void => { + this.setState({ + composerContent: content, + isComposerEmpty: content?.length === 0, + }); + }; + + private onRichTextToggle = async (): Promise => { + const { richToPlain, plainToRich } = await getConversionFunctions(); + + const { isRichTextEnabled, composerContent } = this.state; + const convertedContent = isRichTextEnabled + ? await richToPlain(composerContent, false) + : await plainToRich(composerContent, false); + + this.setState({ + isRichTextEnabled: !isRichTextEnabled, + composerContent: convertedContent, + initialComposerContent: convertedContent, + }); + }; + + private onVoiceStoreUpdate = (): void => { + this.updateRecordingState(); + }; + + private updateRecordingState(): void { + const voiceRecordingId = VoiceRecordingStore.getVoiceRecordingId(this.props.room, this.props.relation); + this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(voiceRecordingId); + if (this.voiceRecording) { + // If the recording has already started, it's probably a cached one. + if (this.voiceRecording.hasRecording && !this.voiceRecording.isRecording) { + this.setState({ haveRecording: true }); + } + + // Note: Listeners for recording states are set by the `this.voiceRecording` setter. + } else { + this.setState({ haveRecording: false }); + } + } + + private onRecordingStarted = (): void => { + // update the recording instance, just in case + const voiceRecordingId = VoiceRecordingStore.getVoiceRecordingId(this.props.room, this.props.relation); + this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(voiceRecordingId); + this.setState({ + haveRecording: !!this.voiceRecording, + }); + }; + + private onRecordingEndingSoon = ({ secondsLeft }: { secondsLeft: number }): void => { + this.setState({ recordingTimeLeftSeconds: secondsLeft }); + window.setTimeout(() => this.setState({ recordingTimeLeftSeconds: undefined }), 3000); + }; + + private setStickerPickerOpen = (isStickerPickerOpen: boolean): void => { + this.setState({ + isStickerPickerOpen, + isMenuOpen: false, + }); + }; + + private toggleStickerPickerOpen = (): void => { + this.setStickerPickerOpen(!this.state.isStickerPickerOpen); + }; + + private toggleButtonMenu = (): void => { + this.setState({ + isMenuOpen: !this.state.isMenuOpen, + }); + }; + + private get showStickersButton(): boolean { + return this.state.showStickersButton && !isLocalRoom(this.props.room); + } + + private getMenuPosition(): MenuProps | undefined { + if (this.ref.current) { + const hasFormattingButtons = this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled; + const contentRect = this.ref.current.getBoundingClientRect(); + // Here we need to remove the all the extra space above the editor + // Instead of doing a querySelector or pass a ref to find the compute the height formatting buttons + // We are using an arbitrary value, the formatting buttons height doesn't change during the lifecycle of the component + // It's easier to just use a constant here instead of an over-engineering way to find the height + const heightToRemove = hasFormattingButtons ? 36 : 0; + const fixedRect = new DOMRect( + contentRect.x, + contentRect.y + heightToRemove, + contentRect.width, + contentRect.height - heightToRemove, + ); + return aboveLeftOf(fixedRect); + } + } + + private onRecordStartEndClick = (): void => { + const currentBroadcastRecording = SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent(); + + if (currentBroadcastRecording && currentBroadcastRecording.getState() !== VoiceBroadcastInfoState.Stopped) { + createCantStartVoiceMessageBroadcastDialog(); + } else { + this.voiceRecordingButton.current?.onRecordStartEndClick(); + } + + if (this.context.narrow) { + this.toggleButtonMenu(); + } + }; + + public render(): React.ReactNode { + const hasE2EIcon = Boolean(!this.state.isWysiwygLabEnabled && this.props.e2eStatus); + const e2eIcon = hasE2EIcon && ( + + ); + + const controls: ReactNode[] = []; + const menuPosition = this.getMenuPosition(); + + const canSendMessages = this.context.canSendMessages && !this.context.tombstone; + let composer: ReactNode; + if (canSendMessages) { + if (this.state.isWysiwygLabEnabled && menuPosition) { + composer = ( + + ); + } else { + composer = ( + + ); + } + + controls.push( + , + ); + } else if (this.context.tombstone) { + const replacementRoomId = this.context.tombstone.getContent()["replacement_room"]; + + const continuesLink = replacementRoomId ? ( + + {_t("composer|room_upgraded_link")} + + ) : ( + "" + ); + + controls.push( +
+
+ + + {_t("composer|room_upgraded_notice")} + +
+ {continuesLink} +
+
, + ); + } else { + controls.push( + , + ); + } + + let recordingTooltip: JSX.Element | undefined; + if (this.state.recordingTimeLeftSeconds) { + const secondsLeft = Math.round(this.state.recordingTimeLeftSeconds); + recordingTooltip = ( + + ); + } + + const threadId = + this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : null; + + controls.push( + , + ); + + const showSendButton = canSendMessages && (!this.state.isComposerEmpty || this.state.haveRecording); + + const classes = classNames({ + "mx_MessageComposer": true, + "mx_MessageComposer--compact": this.props.compact, + "mx_MessageComposer_e2eStatus": hasE2EIcon, + "mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled, + }); + + return ( +
+ {recordingTooltip} +
+ +
+ {e2eIcon} + {composer} +
+ {controls} + {canSendMessages && ( + { + setUpVoiceBroadcastPreRecording( + this.props.room, + MatrixClientPeg.safeGet(), + SdkContextClass.instance.voiceBroadcastPlaybacksStore, + SdkContextClass.instance.voiceBroadcastRecordingsStore, + SdkContextClass.instance.voiceBroadcastPreRecordingStore, + ); + this.toggleButtonMenu(); + }} + /> + )} + {showSendButton && ( + + )} +
+
+
+
+ ); + } +} + +const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer); +export default MessageComposerWithMatrixClient; diff --git a/src/context/SuperheroProvider.tsx b/src/context/SuperheroProvider.tsx index 60ac890893..da0f7faf7e 100644 --- a/src/context/SuperheroProvider.tsx +++ b/src/context/SuperheroProvider.tsx @@ -1,19 +1,61 @@ import { useAtom } from "jotai"; -import React, { useEffect } from "react"; +import React, { useCallback, useEffect } from "react"; + +import { communityBotAtom, minimumTokenThresholdAtom, verifiedAccountsAtom } from "../atoms"; + +const useMinimumTokenThreshold = (config: any) => { + const [_, setMinimumTokenThreshold] = useAtom(minimumTokenThresholdAtom); + const [isLoading, setIsLoading] = React.useState(false); + + const loadMinimumTokenThreshold = useCallback(() => { + if (config.bots_backend_url && !isLoading) { + setIsLoading(true); + fetch(`${config.bots_backend_url}/ui/minimum-token-threshold`, { + method: "GET", + }) + .then((res) => res.json()) + .then(setMinimumTokenThreshold) + .catch((e) => { + console.error('Error loading minimum token threshold', e); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, [config.bots_backend_url, setMinimumTokenThreshold]); + + useEffect(() => { + loadMinimumTokenThreshold(); + + const interval = setInterval(() => { + loadMinimumTokenThreshold(); + }, 10000); + + return () => clearInterval(interval); + }, [loadMinimumTokenThreshold]); +} -import { verifiedAccountsAtom } from "../atoms"; /** * Provides the superhero context to its children components. * @param children The child components to be wrapped by the provider. + * @param config The SDK config * @returns The superhero provider component. */ export const SuperheroProvider = ({ children, config }: any): any => { const [verifiedAccounts, setVerifiedAccounts] = useAtom(verifiedAccountsAtom); + const [_, setCommunityBot] = useAtom(communityBotAtom); + + useEffect(() => { + setCommunityBot({ + userId: config.community_bot_user_id, + rawDisplayName: 'Community DAO Room Bot', + }); + }, []); function loadVerifiedAccounts(): void { if (config.bots_backend_url) { - fetch(`${config.bots_backend_url}/ae-wallet-bot/get-verified-accounts`, { + fetch(`${config.bots_backend_url}/ui/get-verified-accounts`, { method: "POST", }) .then((res) => res.json()) @@ -37,5 +79,8 @@ export const SuperheroProvider = ({ children, config }: any): any => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Load minimum token threshold + useMinimumTokenThreshold(config); + return <>{children}; };