// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners"
import Client from "@/api/client.ts"
import { RoomStateStore, usePreference, useRoomEvent } from "@/api/statestore"
import type {
EventID,
MediaMessageEventContent,
MemDBEvent,
Mentions,
MessageEventContent,
RelatesTo,
RoomID,
} from "@/api/types"
import { PartialEmoji, emojiToMarkdown } from "@/util/emoji"
import { isMobileDevice } from "@/util/ismobile.ts"
import { escapeMarkdown } from "@/util/markdown.ts"
import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
import GIFPicker from "../emojipicker/GIFPicker.tsx"
import { keyToString } from "../keybindings.ts"
import { LeafletPicker } from "../maps/async.tsx"
import { ModalContext } from "../modal/Modal.tsx"
import { useRoomContext } from "../roomview/roomcontext.ts"
import { ReplyBody } from "../timeline/ReplyBody.tsx"
import { useMediaContent } from "../timeline/content/useMediaContent.tsx"
import type { AutocompleteQuery } from "./Autocompleter.tsx"
import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./getAutocompleter.ts"
import AttachIcon from "@/icons/attach.svg?react"
import CloseIcon from "@/icons/close.svg?react"
import EmojiIcon from "@/icons/emoji-categories/smileys-emotion.svg?react"
import GIFIcon from "@/icons/gif.svg?react"
import LocationIcon from "@/icons/location.svg?react"
import SendIcon from "@/icons/send.svg?react"
import "./MessageComposer.css"
export interface ComposerLocationValue {
lat: number
long: number
prec?: number
}
export interface ComposerState {
text: string
media: MediaMessageEventContent | null
location: ComposerLocationValue | null
replyTo: EventID | null
uninited?: boolean
}
const MAX_TEXTAREA_ROWS = 10
const emptyComposer: ComposerState = { text: "", media: null, replyTo: null, location: null }
const uninitedComposer: ComposerState = { ...emptyComposer, uninited: true }
const composerReducer = (state: ComposerState, action: Partial) =>
({ ...state, ...action, uninited: undefined })
const draftStore = {
get: (roomID: RoomID): ComposerState | null => {
const data = localStorage.getItem(`draft-${roomID}`)
if (!data) {
return null
}
try {
return JSON.parse(data)
} catch {
return null
}
},
set: (roomID: RoomID, data: ComposerState) => localStorage.setItem(`draft-${roomID}`, JSON.stringify(data)),
clear: (roomID: RoomID) => localStorage.removeItem(`draft-${roomID}`),
}
type CaretEvent = React.MouseEvent | React.KeyboardEvent | React.ChangeEvent
const MessageComposer = () => {
const roomCtx = useRoomContext()
const room = roomCtx.store
const client = use(ClientContext)!
const openModal = use(ModalContext)
const [autocomplete, setAutocomplete] = useState(null)
const [state, setState] = useReducer(composerReducer, uninitedComposer)
const [editing, rawSetEditing] = useState(null)
const [loadingMedia, setLoadingMedia] = useState(false)
const fileInput = useRef(null)
const textInput = useRef(null)
const composerRef = useRef(null)
const textRows = useRef(1)
const typingSentAt = useRef(0)
const replyToEvt = useRoomEvent(room, state.replyTo)
roomCtx.insertText = useCallback((text: string) => {
textInput.current?.focus()
document.execCommand("insertText", false, text)
}, [])
roomCtx.setReplyTo = useCallback((evt: EventID | null) => {
setState({ replyTo: evt })
textInput.current?.focus()
}, [])
roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => {
if (evt === null) {
rawSetEditing(null)
setState(draftStore.get(room.roomID) ?? emptyComposer)
return
}
const evtContent = evt.content as MessageEventContent
const mediaMsgTypes = ["m.image", "m.audio", "m.video", "m.file"]
const isMedia = mediaMsgTypes.includes(evtContent.msgtype)
&& Boolean(evt.content?.url || evt.content?.file?.url)
rawSetEditing(evt)
setState({
media: isMedia ? evtContent as MediaMessageEventContent : null,
text: (!evt.content.filename || evt.content.filename !== evt.content.body)
? (evt.local_content?.edit_source ?? evtContent.body ?? "")
: "",
replyTo: null,
})
textInput.current?.focus()
}, [room.roomID])
const sendMessage = useEvent((evt: React.FormEvent) => {
evt.preventDefault()
if (state.text === "" && !state.media && !state.location) {
return
}
if (editing) {
setState(draftStore.get(room.roomID) ?? emptyComposer)
} else {
setState(emptyComposer)
}
rawSetEditing(null)
setAutocomplete(null)
const mentions: Mentions = {
user_ids: [],
room: false,
}
let relates_to: RelatesTo | undefined = undefined
if (editing) {
relates_to = {
rel_type: "m.replace",
event_id: editing.event_id,
}
} else if (replyToEvt) {
mentions.user_ids.push(replyToEvt.sender)
relates_to = {
"m.in_reply_to": {
event_id: replyToEvt.event_id,
},
}
if (replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread"
&& typeof replyToEvt.content?.["m.relates_to"]?.event_id === "string") {
relates_to.rel_type = "m.thread"
relates_to.event_id = replyToEvt.content?.["m.relates_to"].event_id
// TODO set this to true if replying to the last event in a thread?
relates_to.is_falling_back = false
}
}
let base_content: MessageEventContent | undefined
let extra: Record | undefined
if (state.media) {
base_content = state.media
} else if (state.location) {
base_content = {
body: "Location",
msgtype: "m.location",
geo_uri: `geo:${state.location.lat},${state.location.long}`,
}
extra = {
"org.matrix.msc3488.asset": {
type: "m.pin",
},
"org.matrix.msc3488.location": {
uri: `geo:${state.location.lat},${state.location.long}`,
description: state.text,
},
}
}
client.sendMessage({
room_id: room.roomID,
base_content,
extra,
text: state.text,
relates_to,
mentions,
}).catch(err => window.alert("Failed to send message: " + err))
})
const onComposerCaretChange = useEvent((evt: CaretEvent, newText?: string) => {
const area = evt.currentTarget
if (area.selectionStart <= (autocomplete?.startPos ?? 0)) {
if (autocomplete) {
setAutocomplete(null)
}
return
}
if (autocomplete?.frozenQuery) {
if (area.selectionEnd !== autocomplete.endPos) {
setAutocomplete(null)
}
} else if (autocomplete) {
const newQuery = (newText ?? state.text).slice(autocomplete.startPos, area.selectionEnd)
if (newQuery.includes(" ") || (autocomplete.type === "emoji" && !emojiQueryRegex.test(newQuery))) {
setAutocomplete(null)
} else if (newQuery !== autocomplete.query) {
setAutocomplete({ ...autocomplete, query: newQuery, endPos: area.selectionEnd })
}
} else if (area.selectionStart === area.selectionEnd) {
const acType = charToAutocompleteType(newText?.slice(area.selectionStart - 1, area.selectionStart))
if (
acType && (
area.selectionStart === 1
|| newText?.[area.selectionStart - 2] === " "
|| newText?.[area.selectionStart - 2] === "\n"
)
) {
setAutocomplete({
type: acType,
query: "",
startPos: area.selectionStart - 1,
endPos: area.selectionEnd,
})
}
}
})
const onComposerKeyDown = useEvent((evt: React.KeyboardEvent) => {
const inp = evt.currentTarget
const fullKey = keyToString(evt)
if (fullKey === "Enter" && (
// If the autocomplete already has a selected item or has no results, send message even if it's open.
// Otherwise, don't send message on enter, select the first autocomplete entry instead.
!autocomplete
|| autocomplete.selected !== undefined
|| !document.getElementById("composer-autocompletions")?.classList.contains("has-items")
)) {
sendMessage(evt)
} else if (autocomplete) {
let autocompleteUpdate: Partial | null | undefined
if (fullKey === "Tab" || fullKey === "ArrowDown") {
autocompleteUpdate = { selected: (autocomplete.selected ?? -1) + 1 }
} else if (fullKey === "Shift+Tab" || fullKey === "ArrowUp") {
autocompleteUpdate = { selected: (autocomplete.selected ?? 0) - 1 }
} else if (fullKey === "Enter") {
autocompleteUpdate = { selected: 0, close: true }
} else if (fullKey === "Escape") {
autocompleteUpdate = null
if (autocomplete.frozenQuery) {
setState({
text: state.text.slice(0, autocomplete.startPos)
+ autocomplete.frozenQuery
+ state.text.slice(autocomplete.endPos),
})
}
}
if (autocompleteUpdate !== undefined) {
setAutocomplete(autocompleteUpdate && { ...autocomplete, ...autocompleteUpdate })
evt.preventDefault()
}
} else if (fullKey === "ArrowUp" && inp.selectionStart === 0 && inp.selectionEnd === 0) {
const currentlyEditing = editing
? roomCtx.ownMessages.indexOf(editing.rowid)
: roomCtx.ownMessages.length
const prevEventToEditID = roomCtx.ownMessages[currentlyEditing - 1]
const prevEventToEdit = prevEventToEditID ? room.eventsByRowID.get(prevEventToEditID) : undefined
if (prevEventToEdit) {
roomCtx.setEditing(prevEventToEdit)
evt.preventDefault()
}
} else if (editing && fullKey === "ArrowDown" && inp.selectionStart === state.text.length) {
const currentlyEditingIdx = roomCtx.ownMessages.indexOf(editing.rowid)
const nextEventToEdit = currentlyEditingIdx
? room.eventsByRowID.get(roomCtx.ownMessages[currentlyEditingIdx + 1]) : undefined
roomCtx.setEditing(nextEventToEdit ?? null)
// This timeout is very hacky and probably doesn't work in every case
setTimeout(() => inp.setSelectionRange(0, 0), 0)
evt.preventDefault()
} else if (editing && fullKey === "Escape") {
evt.stopPropagation()
roomCtx.setEditing(null)
}
})
const onChange = useEvent((evt: React.ChangeEvent) => {
setState({ text: evt.target.value })
const now = Date.now()
if (evt.target.value !== "" && typingSentAt.current + 5_000 < now) {
typingSentAt.current = now
if (room.preferences.send_typing_notifications) {
client.rpc.setTyping(room.roomID, 10_000)
.catch(err => console.error("Failed to send typing notification:", err))
}
} else if (evt.target.value === "" && typingSentAt.current > 0) {
typingSentAt.current = 0
if (room.preferences.send_typing_notifications) {
client.rpc.setTyping(room.roomID, 0)
.catch(err => console.error("Failed to send stop typing notification:", err))
}
}
onComposerCaretChange(evt, evt.target.value)
})
const doUploadFile = useCallback((file: File | null | undefined) => {
if (!file) {
return
}
setLoadingMedia(true)
const encrypt = !!room.meta.current.encryption_event
fetch(`_gomuks/upload?encrypt=${encrypt}&filename=${encodeURIComponent(file.name)}`, {
method: "POST",
body: file,
})
.then(async res => {
const json = await res.json()
if (!res.ok) {
throw new Error(json.error)
} else {
setState({ media: json, location: null })
}
})
.catch(err => window.alert("Failed to upload file: " + err))
.finally(() => setLoadingMedia(false))
}, [room])
const onAttachFile = useEvent(
(evt: React.ChangeEvent) => doUploadFile(evt.target.files?.[0]),
)
const onPaste = useEvent((evt: React.ClipboardEvent) => {
const file = evt.clipboardData?.files?.[0]
const text = evt.clipboardData.getData("text/plain")
const input = evt.currentTarget
if (file) {
doUploadFile(file)
} else if (
input.selectionStart !== input.selectionEnd
&& (text.startsWith("http://") || text.startsWith("https://") || text.startsWith("matrix:"))
) {
document.execCommand("insertText", false, `[${
escapeMarkdown(state.text.slice(input.selectionStart, input.selectionEnd))
}](${escapeMarkdown(text)})`)
} else {
return
}
evt.preventDefault()
})
// To ensure the cursor jumps to the end, do this in an effect rather than as the initial value of useState
// To try to avoid the input bar flashing, use useLayoutEffect instead of useEffect
useLayoutEffect(() => {
const draft = draftStore.get(room.roomID)
setState(draft ?? emptyComposer)
setAutocomplete(null)
return () => {
if (typingSentAt.current > 0) {
typingSentAt.current = 0
if (room.preferences.send_typing_notifications) {
client.rpc.setTyping(room.roomID, 0)
.catch(err => console.error("Failed to send stop typing notification due to room switch:", err))
}
}
}
}, [client, room])
useLayoutEffect(() => {
if (!textInput.current) {
return
}
// This is a hacky way to auto-resize the text area. Setting the rows to 1 and then
// checking scrollHeight seems to be the only reliable way to get the size of the text.
textInput.current.rows = 1
const newTextRows = Math.min((textInput.current.scrollHeight - 16) / 20, MAX_TEXTAREA_ROWS)
if (newTextRows === MAX_TEXTAREA_ROWS) {
textInput.current.style.overflowY = "auto"
} else {
// There's a weird 1px scroll when using line-height, so set overflow to hidden when it's not needed
textInput.current.style.overflowY = "hidden"
}
textInput.current.rows = newTextRows
textRows.current = newTextRows
// This has to be called unconditionally, because setting rows = 1 messes up the scroll state otherwise
roomCtx.scrollToBottom()
}, [state.text, roomCtx])
// Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect.
useEffect(() => {
roomCtx.isEditing.emit(editing !== null)
if (state.uninited || editing) {
return
}
if (!state.text && !state.media && !state.replyTo && !state.location) {
draftStore.clear(room.roomID)
} else {
draftStore.set(room.roomID, state)
}
}, [roomCtx, room, state, editing])
const openFilePicker = useCallback(() => fileInput.current!.click(), [])
const clearMedia = useCallback(() => setState({ media: null, location: null }), [])
const onChangeLocation = useCallback((location: ComposerLocationValue) => setState({ location }), [])
const closeReply = useCallback((evt: React.MouseEvent) => {
evt.stopPropagation()
setState({ replyTo: null })
}, [])
const stopEditing = useCallback((evt: React.MouseEvent) => {
evt.stopPropagation()
roomCtx.setEditing(null)
}, [roomCtx])
const openEmojiPicker = useEvent(() => {
openModal({
content: setState({
text: state.text.slice(0, textInput.current?.selectionStart ?? 0)
+ emojiToMarkdown(emoji)
+ state.text.slice(textInput.current?.selectionEnd ?? 0),
})}
/>,
onClose: () => textInput.current?.focus(),
})
})
const openGIFPicker = useEvent(() => {
openModal({
content: setState({ media })}
/>,
onClose: () => textInput.current?.focus(),
})
})
const openLocationPicker = useEvent(() => {
setState({ location: { lat: 0, long: 0, prec: 1 }, media: null })
})
const Autocompleter = getAutocompleter(autocomplete, client, room)
let mediaDisabledTitle: string | undefined
let locationDisabledTitle: string | undefined
if (state.media) {
mediaDisabledTitle = "You can only attach one file at a time"
locationDisabledTitle = "You can't attach a location to a message with a file"
} else if (state.location) {
mediaDisabledTitle = "You can't attach a file to a message with a location"
locationDisabledTitle = "You can only attach one location at a time"
} else if (loadingMedia) {
mediaDisabledTitle = "Uploading file..."
locationDisabledTitle = "You can't attach a location to a message with a file"
}
return <>
{Autocompleter && autocomplete && }
{replyToEvt &&
}
{editing &&
}
{loadingMedia &&
}
{state.media &&
}
{state.location && }
>
}
interface ComposerMediaProps {
content: MediaMessageEventContent
clearMedia: () => void
}
const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
// TODO stickers?
const [mediaContent, containerClass, containerStyle] = useMediaContent(
content, "m.room.message", { height: 120, width: 360 },
)
return
}
interface ComposerLocationProps {
room: RoomStateStore
client: Client
location: ComposerLocationValue
onChange: (location: ComposerLocationValue) => void
clearLocation: () => void
}
const ComposerLocation = ({ client, room, location, onChange, clearLocation }: ComposerLocationProps) => {
const tileTemplate = usePreference(client.store, room, "leaflet_tile_template")
return
}
export default MessageComposer