mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 18:43:41 -05:00
web/all: remove unnecessary uses of useCallback
This commit is contained in:
parent
388be09795
commit
4160a33edb
23 changed files with 285 additions and 290 deletions
|
@ -80,13 +80,13 @@ function useAutocompleter<T>({
|
|||
})
|
||||
document.querySelector(`div.autocompletion-item[data-index='${index}']`)?.scrollIntoView({ block: "nearest" })
|
||||
})
|
||||
const onClick = useEvent((evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
const onClick = (evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
const idx = evt.currentTarget.getAttribute("data-index")
|
||||
if (idx) {
|
||||
onSelect(+idx)
|
||||
setAutocomplete(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
if (params.selected !== undefined) {
|
||||
onSelect(params.selected)
|
||||
|
|
63
web/src/ui/composer/ComposerMedia.tsx
Normal file
63
web/src/ui/composer/ComposerMedia.tsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
// 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 <https://www.gnu.org/licenses/>.
|
||||
import Client from "@/api/client.ts"
|
||||
import { RoomStateStore, usePreference } from "@/api/statestore"
|
||||
import type { MediaMessageEventContent } from "@/api/types"
|
||||
import { LeafletPicker } from "../maps/async.tsx"
|
||||
import { useMediaContent } from "../timeline/content/useMediaContent.tsx"
|
||||
import CloseIcon from "@/icons/close.svg?react"
|
||||
import "./MessageComposer.css"
|
||||
|
||||
export interface ComposerMediaProps {
|
||||
content: MediaMessageEventContent
|
||||
clearMedia: false | (() => void)
|
||||
}
|
||||
|
||||
export const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
|
||||
const [mediaContent, containerClass, containerStyle] = useMediaContent(
|
||||
content, "m.room.message", { height: 120, width: 360 },
|
||||
)
|
||||
return <div className="composer-media">
|
||||
<div className={`media-container ${containerClass}`} style={containerStyle}>
|
||||
{mediaContent}
|
||||
</div>
|
||||
{clearMedia && <button onClick={clearMedia}><CloseIcon/></button>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export interface ComposerLocationValue {
|
||||
lat: number
|
||||
long: number
|
||||
prec?: number
|
||||
}
|
||||
|
||||
export interface ComposerLocationProps {
|
||||
room: RoomStateStore
|
||||
client: Client
|
||||
location: ComposerLocationValue
|
||||
onChange: (location: ComposerLocationValue) => void
|
||||
clearLocation: () => void
|
||||
}
|
||||
|
||||
export const ComposerLocation = ({ client, room, location, onChange, clearLocation }: ComposerLocationProps) => {
|
||||
const tileTemplate = usePreference(client.store, room, "leaflet_tile_template")
|
||||
return <div className="composer-location">
|
||||
<div className="location-container">
|
||||
<LeafletPicker tileTemplate={tileTemplate} onChange={onChange} initialLocation={location}/>
|
||||
</div>
|
||||
<button onClick={clearLocation}><CloseIcon/></button>
|
||||
</div>
|
||||
}
|
|
@ -15,8 +15,7 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { CSSProperties, 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 { useRoomEvent } from "@/api/statestore"
|
||||
import type {
|
||||
EventID,
|
||||
MediaMessageEventContent,
|
||||
|
@ -29,21 +28,18 @@ import type {
|
|||
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 StickerPicker from "../emojipicker/StickerPicker.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 { ComposerLocation, ComposerLocationValue, ComposerMedia } from "./ComposerMedia.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"
|
||||
|
@ -52,12 +48,6 @@ import SendIcon from "@/icons/send.svg?react"
|
|||
import StickerIcon from "@/icons/sticker.svg?react"
|
||||
import "./MessageComposer.css"
|
||||
|
||||
export interface ComposerLocationValue {
|
||||
lat: number
|
||||
long: number
|
||||
prec?: number
|
||||
}
|
||||
|
||||
export interface ComposerState {
|
||||
text: string
|
||||
media: MediaMessageEventContent | null
|
||||
|
@ -174,13 +164,13 @@ const MessageComposer = () => {
|
|||
textInput.current?.focus()
|
||||
}, [room.roomID])
|
||||
const canSend = Boolean(state.text || state.media || state.location)
|
||||
const sendMessage = useEvent((evt: React.FormEvent) => {
|
||||
const onClickSend = (evt: React.FormEvent) => {
|
||||
evt.preventDefault()
|
||||
if (!canSend) {
|
||||
return
|
||||
}
|
||||
doSendMessage(state)
|
||||
})
|
||||
}
|
||||
const doSendMessage = (state: ComposerState) => {
|
||||
if (editing) {
|
||||
setState(draftStore.get(room.roomID) ?? emptyComposer)
|
||||
|
@ -245,7 +235,7 @@ const MessageComposer = () => {
|
|||
mentions,
|
||||
}).catch(err => window.alert("Failed to send message: " + err))
|
||||
}
|
||||
const onComposerCaretChange = useEvent((evt: CaretEvent<HTMLTextAreaElement>, newText?: string) => {
|
||||
const onComposerCaretChange = (evt: CaretEvent<HTMLTextAreaElement>, newText?: string) => {
|
||||
const area = evt.currentTarget
|
||||
if (area.selectionStart <= (autocomplete?.startPos ?? 0)) {
|
||||
if (autocomplete) {
|
||||
|
@ -281,8 +271,8 @@ const MessageComposer = () => {
|
|||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
const onComposerKeyDown = useEvent((evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
}
|
||||
const onComposerKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const inp = evt.currentTarget
|
||||
const fullKey = keyToString(evt)
|
||||
const sendKey = fullKey === "Enter" || fullKey === "Ctrl+Enter"
|
||||
|
@ -295,7 +285,7 @@ const MessageComposer = () => {
|
|||
|| autocomplete.selected !== undefined
|
||||
|| !document.getElementById("composer-autocompletions")?.classList.contains("has-items")
|
||||
)) {
|
||||
sendMessage(evt)
|
||||
onClickSend(evt)
|
||||
} else if (autocomplete) {
|
||||
let autocompleteUpdate: Partial<AutocompleteQuery> | null | undefined
|
||||
if (fullKey === "Tab" || fullKey === "ArrowDown") {
|
||||
|
@ -340,8 +330,8 @@ const MessageComposer = () => {
|
|||
evt.stopPropagation()
|
||||
roomCtx.setEditing(null)
|
||||
}
|
||||
})
|
||||
const onChange = useEvent((evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
}
|
||||
const onChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setState({ text: evt.target.value })
|
||||
const now = Date.now()
|
||||
if (evt.target.value !== "" && typingSentAt.current + 5_000 < now) {
|
||||
|
@ -358,7 +348,7 @@ const MessageComposer = () => {
|
|||
}
|
||||
}
|
||||
onComposerCaretChange(evt, evt.target.value)
|
||||
})
|
||||
}
|
||||
const doUploadFile = useCallback((file: File | null | undefined) => {
|
||||
if (!file) {
|
||||
return
|
||||
|
@ -380,10 +370,7 @@ const MessageComposer = () => {
|
|||
.catch(err => window.alert("Failed to upload file: " + err))
|
||||
.finally(() => setLoadingMedia(false))
|
||||
}, [room])
|
||||
const onAttachFile = useEvent(
|
||||
(evt: React.ChangeEvent<HTMLInputElement>) => doUploadFile(evt.target.files?.[0]),
|
||||
)
|
||||
const onPaste = useEvent((evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const onPaste = (evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const file = evt.clipboardData?.files?.[0]
|
||||
const text = evt.clipboardData.getData("text/plain")
|
||||
const input = evt.currentTarget
|
||||
|
@ -400,7 +387,7 @@ const MessageComposer = () => {
|
|||
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(() => {
|
||||
|
@ -450,7 +437,6 @@ const MessageComposer = () => {
|
|||
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) => {
|
||||
|
@ -461,56 +447,6 @@ const MessageComposer = () => {
|
|||
evt.stopPropagation()
|
||||
roomCtx.setEditing(null)
|
||||
}, [roomCtx])
|
||||
const getEmojiPickerStyle = () => ({
|
||||
bottom: (composerRef.current?.clientHeight ?? 32) + 4 + 24,
|
||||
right: "var(--timeline-horizontal-padding)",
|
||||
})
|
||||
const openEmojiPicker = useEvent(() => {
|
||||
openModal({
|
||||
content: <EmojiPicker
|
||||
style={getEmojiPickerStyle()}
|
||||
room={roomCtx.store}
|
||||
onSelect={(emoji: PartialEmoji) => {
|
||||
const mdEmoji = emojiToMarkdown(emoji)
|
||||
setState({
|
||||
text: state.text.slice(0, textInput.current?.selectionStart ?? 0)
|
||||
+ mdEmoji
|
||||
+ state.text.slice(textInput.current?.selectionEnd ?? 0),
|
||||
})
|
||||
if (textInput.current) {
|
||||
textInput.current.setSelectionRange(textInput.current.selectionStart + mdEmoji.length, 0)
|
||||
}
|
||||
}}
|
||||
// TODO allow keeping open on select on non-mobile devices
|
||||
// (requires onSelect to be able to keep track of the state after updating it)
|
||||
closeOnSelect={true}
|
||||
/>,
|
||||
onClose: () => !isMobileDevice && textInput.current?.focus(),
|
||||
})
|
||||
})
|
||||
const openGIFPicker = useEvent(() => {
|
||||
openModal({
|
||||
content: <GIFPicker
|
||||
style={getEmojiPickerStyle()}
|
||||
room={roomCtx.store}
|
||||
onSelect={media => setState({ media })}
|
||||
/>,
|
||||
onClose: () => !isMobileDevice && textInput.current?.focus(),
|
||||
})
|
||||
})
|
||||
const openStickerPicker = useEvent(() => {
|
||||
openModal({
|
||||
content: <StickerPicker
|
||||
style={getEmojiPickerStyle()}
|
||||
room={roomCtx.store}
|
||||
onSelect={media => doSendMessage({ ...state, media, text: "" })}
|
||||
/>,
|
||||
onClose: () => !isMobileDevice && 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 stickerDisabledTitle: string | undefined
|
||||
|
@ -533,7 +469,57 @@ const MessageComposer = () => {
|
|||
} else if (state.text && !editing) {
|
||||
stickerDisabledTitle = "You can't attach a sticker to a message with text"
|
||||
}
|
||||
const getEmojiPickerStyle = () => ({
|
||||
bottom: (composerRef.current?.clientHeight ?? 32) + 4 + 24,
|
||||
right: "var(--timeline-horizontal-padding)",
|
||||
})
|
||||
const makeAttachmentButtons = (includeText = false) => {
|
||||
const openEmojiPicker = () => {
|
||||
openModal({
|
||||
content: <EmojiPicker
|
||||
style={getEmojiPickerStyle()}
|
||||
room={roomCtx.store}
|
||||
onSelect={(emoji: PartialEmoji) => {
|
||||
const mdEmoji = emojiToMarkdown(emoji)
|
||||
setState({
|
||||
text: state.text.slice(0, textInput.current?.selectionStart ?? 0)
|
||||
+ mdEmoji
|
||||
+ state.text.slice(textInput.current?.selectionEnd ?? 0),
|
||||
})
|
||||
if (textInput.current) {
|
||||
textInput.current.setSelectionRange(textInput.current.selectionStart + mdEmoji.length, 0)
|
||||
}
|
||||
}}
|
||||
// TODO allow keeping open on select on non-mobile devices
|
||||
// (requires onSelect to be able to keep track of the state after updating it)
|
||||
closeOnSelect={true}
|
||||
/>,
|
||||
onClose: () => !isMobileDevice && textInput.current?.focus(),
|
||||
})
|
||||
}
|
||||
const openGIFPicker = () => {
|
||||
openModal({
|
||||
content: <GIFPicker
|
||||
style={getEmojiPickerStyle()}
|
||||
room={roomCtx.store}
|
||||
onSelect={media => setState({ media })}
|
||||
/>,
|
||||
onClose: () => !isMobileDevice && textInput.current?.focus(),
|
||||
})
|
||||
}
|
||||
const openStickerPicker = () => {
|
||||
openModal({
|
||||
content: <StickerPicker
|
||||
style={getEmojiPickerStyle()}
|
||||
room={roomCtx.store}
|
||||
onSelect={media => doSendMessage({ ...state, media, text: "" })}
|
||||
/>,
|
||||
onClose: () => !isMobileDevice && textInput.current?.focus(),
|
||||
})
|
||||
}
|
||||
const openLocationPicker = () => {
|
||||
setState({ location: { lat: 0, long: 0, prec: 1 }, media: null })
|
||||
}
|
||||
return <>
|
||||
<button onClick={openEmojiPicker} title="Add emoji"><EmojiIcon/>{includeText && "Emoji"}</button>
|
||||
<button
|
||||
|
@ -556,13 +542,13 @@ const MessageComposer = () => {
|
|||
title={locationDisabledTitle ?? "Add location"}
|
||||
><LocationIcon/>{includeText && "Location"}</button>
|
||||
<button
|
||||
onClick={openFilePicker}
|
||||
onClick={() => fileInput.current!.click()}
|
||||
disabled={!!mediaDisabledTitle}
|
||||
title={mediaDisabledTitle ?? "Add file attachment"}
|
||||
><AttachIcon/>{includeText && "File"}</button>
|
||||
</>
|
||||
}
|
||||
const openButtonsModal = useEvent(() => {
|
||||
const openButtonsModal = () => {
|
||||
const style: CSSProperties = getEmojiPickerStyle()
|
||||
style.left = style.right
|
||||
delete style.right
|
||||
|
@ -571,7 +557,7 @@ const MessageComposer = () => {
|
|||
{makeAttachmentButtons(true)}
|
||||
</div>,
|
||||
})
|
||||
})
|
||||
}
|
||||
const inlineButtons = state.text === "" || window.innerWidth > 720
|
||||
const showSendButton = canSend || window.innerWidth > 720
|
||||
const disableClearMedia = editing && state.media?.msgtype === "m.sticker"
|
||||
|
@ -625,49 +611,19 @@ const MessageComposer = () => {
|
|||
/>
|
||||
{inlineButtons && makeAttachmentButtons()}
|
||||
{showSendButton && <button
|
||||
onClick={sendMessage}
|
||||
onClick={onClickSend}
|
||||
disabled={!canSend || loadingMedia}
|
||||
title="Send message"
|
||||
><SendIcon/></button>}
|
||||
<input ref={fileInput} onChange={onAttachFile} type="file" value=""/>
|
||||
<input
|
||||
ref={fileInput}
|
||||
onChange={evt => doUploadFile(evt.target.files?.[0])}
|
||||
type="file"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
interface ComposerMediaProps {
|
||||
content: MediaMessageEventContent
|
||||
clearMedia: false | (() => void)
|
||||
}
|
||||
|
||||
const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
|
||||
const [mediaContent, containerClass, containerStyle] = useMediaContent(
|
||||
content, "m.room.message", { height: 120, width: 360 },
|
||||
)
|
||||
return <div className="composer-media">
|
||||
<div className={`media-container ${containerClass}`} style={containerStyle}>
|
||||
{mediaContent}
|
||||
</div>
|
||||
{clearMedia && <button onClick={clearMedia}><CloseIcon/></button>}
|
||||
</div>
|
||||
}
|
||||
|
||||
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 <div className="composer-location">
|
||||
<div className="location-container">
|
||||
<LeafletPicker tileTemplate={tileTemplate} onChange={onChange} initialLocation={location}/>
|
||||
</div>
|
||||
<button onClick={clearLocation}><CloseIcon/></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default MessageComposer
|
||||
|
|
|
@ -13,11 +13,10 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { use, useCallback } from "react"
|
||||
import React, { use } from "react"
|
||||
import { stringToRoomStateGUID } from "@/api/types"
|
||||
import useContentVisibility from "@/util/contentvisibility.ts"
|
||||
import { CATEGORY_FREQUENTLY_USED, CustomEmojiPack, Emoji, categories } from "@/util/emoji"
|
||||
import useEvent from "@/util/useEvent.ts"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
import renderEmoji from "./renderEmoji.tsx"
|
||||
|
||||
|
@ -56,27 +55,25 @@ export const EmojiGroup = ({
|
|||
}
|
||||
return emoji
|
||||
}
|
||||
const onClickEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
|
||||
onSelect(getEmojiFromAttrs(evt.currentTarget)))
|
||||
const onMouseOverEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
|
||||
setPreviewEmoji?.(getEmojiFromAttrs(evt.currentTarget)))
|
||||
const onMouseOutEmoji = useCallback(() => setPreviewEmoji?.(undefined), [setPreviewEmoji])
|
||||
const onClickSubscribePack = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const onMouseOverEmoji = setPreviewEmoji && ((evt: React.MouseEvent<HTMLButtonElement>) =>
|
||||
setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget)))
|
||||
const onMouseOutEmoji = setPreviewEmoji && (() => setPreviewEmoji(undefined))
|
||||
const onClickSubscribePack = (evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
|
||||
if (!guid) {
|
||||
return
|
||||
}
|
||||
client.subscribeToEmojiPack(guid, true)
|
||||
.catch(err => window.alert(`Failed to subscribe to emoji pack: ${err}`))
|
||||
})
|
||||
const onClickUnsubscribePack = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
}
|
||||
const onClickUnsubscribePack = (evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
|
||||
if (!guid) {
|
||||
return
|
||||
}
|
||||
client.subscribeToEmojiPack(guid, false)
|
||||
.catch(err => window.alert(`Failed to unsubscribe from emoji pack: ${err}`))
|
||||
})
|
||||
}
|
||||
|
||||
let categoryName: string
|
||||
if (typeof categoryID === "number") {
|
||||
|
@ -112,7 +109,7 @@ export const EmojiGroup = ({
|
|||
data-emoji-index={idx}
|
||||
onMouseOver={onMouseOverEmoji}
|
||||
onMouseOut={onMouseOutEmoji}
|
||||
onClick={onClickEmoji}
|
||||
onClick={evt => onSelect(getEmojiFromAttrs(evt.currentTarget))}
|
||||
title={emoji.t}
|
||||
>{renderEmoji(emoji)}</button>) : null}
|
||||
</div>
|
||||
|
|
|
@ -19,7 +19,6 @@ import { RoomStateStore, useCustomEmojis } from "@/api/statestore"
|
|||
import { roomStateGUIDToString } from "@/api/types"
|
||||
import { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji"
|
||||
import { isMobileDevice } from "@/util/ismobile.ts"
|
||||
import useEvent from "@/util/useEvent.ts"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
import { ModalCloseContext } from "../modal/Modal.tsx"
|
||||
import { EmojiGroup } from "./EmojiGroup.tsx"
|
||||
|
@ -72,7 +71,6 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
|
|||
frequentlyUsed: client.store.frequentlyUsedEmoji,
|
||||
customEmojiPacks,
|
||||
})
|
||||
const clearQuery = useCallback(() => setQuery(""), [])
|
||||
const close = closeOnSelect ? use(ModalCloseContext) : null
|
||||
const onSelectWrapped = useCallback((emoji?: PartialEmoji) => {
|
||||
if (!emoji) {
|
||||
|
@ -85,12 +83,10 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
|
|||
}
|
||||
close?.()
|
||||
}, [onSelect, selected, client, close])
|
||||
const onClickFreeformReact = useEvent(() => onSelectWrapped({ u: query }))
|
||||
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
|
||||
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
|
||||
const onClickCategoryButton = (evt: React.MouseEvent) => {
|
||||
const categoryID = evt.currentTarget.getAttribute("data-category-id")!
|
||||
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView()
|
||||
}, [])
|
||||
}
|
||||
return <div className="emoji-picker" style={style}>
|
||||
<div className="emoji-category-bar" ref={emojiCategoryBarRef}>
|
||||
<button
|
||||
|
@ -123,12 +119,12 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
|
|||
<div className="emoji-search">
|
||||
<input
|
||||
autoFocus={!isMobileDevice}
|
||||
onChange={onChangeQuery}
|
||||
onChange={evt => setQuery(evt.target.value)}
|
||||
value={query}
|
||||
type="search"
|
||||
placeholder="Search emojis"
|
||||
/>
|
||||
<button onClick={clearQuery} disabled={query === ""}>
|
||||
<button onClick={() => setQuery("")} disabled={query === ""}>
|
||||
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
|
||||
</button>
|
||||
</div>
|
||||
|
@ -155,7 +151,7 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
|
|||
})}
|
||||
{allowFreeform && query && <button
|
||||
className="freeform-react"
|
||||
onClick={onClickFreeformReact}
|
||||
onClick={() => onSelectWrapped({ u: query })}
|
||||
>React with "{query}"</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { CSSProperties, use, useCallback, useEffect, useState } from "react"
|
||||
import React, { CSSProperties, use, useEffect, useState } from "react"
|
||||
import { RoomStateStore, usePreference } from "@/api/statestore"
|
||||
import { MediaMessageEventContent } from "@/api/types"
|
||||
import { isMobileDevice } from "@/util/ismobile.ts"
|
||||
|
@ -36,13 +36,11 @@ const GIFPicker = ({ style, onSelect, room }: MediaPickerProps) => {
|
|||
const [results, setResults] = useState<GIF[]>([])
|
||||
const [error, setError] = useState<unknown>()
|
||||
const close = use(ModalCloseContext)
|
||||
const clearQuery = useCallback(() => setQuery(""), [])
|
||||
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
|
||||
const client = use(ClientContext)!
|
||||
const provider = usePreference(client.store, room, "gif_provider")
|
||||
const providerName = provider.slice(0, 1).toUpperCase() + provider.slice(1)
|
||||
// const reuploadGIFs = room.preferences.reupload_gifs
|
||||
const onSelectGIF = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
const onSelectGIF = (evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
const idx = evt.currentTarget.getAttribute("data-gif-index")
|
||||
if (!idx) {
|
||||
return
|
||||
|
@ -64,7 +62,7 @@ const GIFPicker = ({ style, onSelect, room }: MediaPickerProps) => {
|
|||
url: gif.proxied_mxc,
|
||||
})
|
||||
close()
|
||||
}, [onSelect, close, results])
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
if (trendingCache.has(provider)) {
|
||||
|
@ -106,12 +104,12 @@ const GIFPicker = ({ style, onSelect, room }: MediaPickerProps) => {
|
|||
<div className="gif-search">
|
||||
<input
|
||||
autoFocus={!isMobileDevice}
|
||||
onChange={onChangeQuery}
|
||||
onChange={evt => setQuery(evt.target.value)}
|
||||
value={query}
|
||||
type="search"
|
||||
placeholder={`Search ${providerName}`}
|
||||
/>
|
||||
<button onClick={clearQuery} disabled={query === ""}>
|
||||
<button onClick={() => setQuery("")} disabled={query === ""}>
|
||||
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -39,7 +39,6 @@ const StickerPicker = ({ style, onSelect, room }: MediaPickerProps) => {
|
|||
customEmojiPacks,
|
||||
stickers: true,
|
||||
})
|
||||
const clearQuery = useCallback(() => setQuery(""), [])
|
||||
const close = use(ModalCloseContext)
|
||||
const onSelectWrapped = useCallback((emoji?: Emoji) => {
|
||||
if (!emoji) {
|
||||
|
@ -53,11 +52,10 @@ const StickerPicker = ({ style, onSelect, room }: MediaPickerProps) => {
|
|||
})
|
||||
close()
|
||||
}, [onSelect, close])
|
||||
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
|
||||
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
|
||||
const onClickCategoryButton = (evt: React.MouseEvent) => {
|
||||
const categoryID = evt.currentTarget.getAttribute("data-category-id")!
|
||||
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView()
|
||||
}, [])
|
||||
}
|
||||
|
||||
return <div className="sticker-picker" style={style}>
|
||||
<div className="emoji-category-bar" ref={emojiCategoryBarRef}>
|
||||
|
@ -76,12 +74,12 @@ const StickerPicker = ({ style, onSelect, room }: MediaPickerProps) => {
|
|||
<div className="emoji-search">
|
||||
<input
|
||||
autoFocus={!isMobileDevice}
|
||||
onChange={onChangeQuery}
|
||||
onChange={evt => setQuery(evt.target.value)}
|
||||
value={query}
|
||||
type="search"
|
||||
placeholder="Search stickers"
|
||||
/>
|
||||
<button onClick={clearQuery} disabled={query === ""}>
|
||||
<button onClick={() => setQuery("")} disabled={query === ""}>
|
||||
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -13,10 +13,9 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { useCallback, useState } from "react"
|
||||
import React, { useState } from "react"
|
||||
import * as beeper from "@/api/beeper.ts"
|
||||
import type Client from "@/api/client.ts"
|
||||
import useEvent from "@/util/useEvent.ts"
|
||||
|
||||
interface BeeperLoginProps {
|
||||
domain: string
|
||||
|
@ -29,18 +28,18 @@ const BeeperLogin = ({ domain, client }: BeeperLoginProps) => {
|
|||
const [code, setCode] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const onChangeEmail = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const onChangeEmail = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(evt.target.value)
|
||||
}, [])
|
||||
const onChangeCode = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
}
|
||||
const onChangeCode = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let codeDigits = evt.target.value.replace(/\D/g, "").slice(0, 6)
|
||||
if (codeDigits.length > 3) {
|
||||
codeDigits = codeDigits.slice(0, 3) + " " + codeDigits.slice(3)
|
||||
}
|
||||
setCode(codeDigits)
|
||||
}, [])
|
||||
}
|
||||
|
||||
const requestCode = useEvent((evt: React.FormEvent) => {
|
||||
const requestCode = (evt: React.FormEvent) => {
|
||||
evt.preventDefault()
|
||||
beeper.doStartLogin(domain).then(
|
||||
request => beeper.doRequestCode(domain, request, email).then(
|
||||
|
@ -49,8 +48,8 @@ const BeeperLogin = ({ domain, client }: BeeperLoginProps) => {
|
|||
),
|
||||
err => setError(`Failed to start login: ${err}`),
|
||||
)
|
||||
})
|
||||
const submitCode = useEvent((evt: React.FormEvent) => {
|
||||
}
|
||||
const submitCode = (evt: React.FormEvent) => {
|
||||
evt.preventDefault()
|
||||
beeper.doSubmitCode(domain, requestID, code).then(
|
||||
token => {
|
||||
|
@ -61,7 +60,7 @@ const BeeperLogin = ({ domain, client }: BeeperLoginProps) => {
|
|||
},
|
||||
err => setError(`Failed to submit code: ${err}`),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return <form onSubmit={requestID ? submitCode : requestCode} className="beeper-login">
|
||||
<h2>Beeper email login</h2>
|
||||
|
|
|
@ -16,7 +16,6 @@
|
|||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import type Client from "@/api/client.ts"
|
||||
import type { ClientState } from "@/api/types"
|
||||
import useEvent from "@/util/useEvent.ts"
|
||||
import BeeperLogin from "./BeeperLogin.tsx"
|
||||
import "./LoginScreen.css"
|
||||
|
||||
|
@ -34,7 +33,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
|
|||
const [loginFlows, setLoginFlows] = useState<string[] | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const loginSSO = useEvent(() => {
|
||||
const loginSSO = () => {
|
||||
fetch("_gomuks/sso", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ homeserver_url: homeserverURL }),
|
||||
|
@ -53,9 +52,9 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
|
|||
},
|
||||
err => setError(`Failed to start SSO login: ${err}`),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const login = useEvent((evt: React.FormEvent) => {
|
||||
const login = (evt: React.FormEvent) => {
|
||||
evt.preventDefault()
|
||||
if (!loginFlows) {
|
||||
// do nothing
|
||||
|
@ -67,7 +66,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
|
|||
err => setError(err.toString()),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resolveLoginFlows = useCallback((serverURL: string) => {
|
||||
client.rpc.getLoginFlows(serverURL).then(
|
||||
|
@ -108,16 +107,10 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
|
|||
clearTimeout(timeout)
|
||||
}
|
||||
}, [homeserverURL, loginFlows, resolveLoginFlows])
|
||||
const onChangeUsername = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUsername(evt.target.value)
|
||||
}, [])
|
||||
const onChangePassword = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(evt.target.value)
|
||||
}, [])
|
||||
const onChangeHomeserverURL = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const onChangeHomeserverURL = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLoginFlows(null)
|
||||
setHomeserverURL(evt.target.value)
|
||||
}, [])
|
||||
}
|
||||
|
||||
const supportsSSO = loginFlows?.includes("m.login.sso") ?? false
|
||||
const supportsPassword = loginFlows?.includes("m.login.password")
|
||||
|
@ -130,7 +123,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
|
|||
id="mxlogin-username"
|
||||
placeholder="User ID"
|
||||
value={username}
|
||||
onChange={onChangeUsername}
|
||||
onChange={evt => setUsername(evt.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
|
@ -144,7 +137,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
|
|||
id="mxlogin-password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={onChangePassword}
|
||||
onChange={evt => setPassword(evt.target.value)}
|
||||
/>}
|
||||
<div className="buttons">
|
||||
{supportsSSO && <button
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { useCallback, useState } from "react"
|
||||
import React, { useState } from "react"
|
||||
import { LoginScreenProps } from "./LoginScreen.tsx"
|
||||
import "./LoginScreen.css"
|
||||
|
||||
|
@ -24,13 +24,13 @@ export const VerificationScreen = ({ client, clientState }: LoginScreenProps) =>
|
|||
const [recoveryKey, setRecoveryKey] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const verify = useCallback((evt: React.FormEvent) => {
|
||||
const verify = (evt: React.FormEvent) => {
|
||||
evt.preventDefault()
|
||||
client.rpc.verify(recoveryKey).then(
|
||||
() => {},
|
||||
err => setError(err.toString()),
|
||||
)
|
||||
}, [recoveryKey, client])
|
||||
}
|
||||
|
||||
return <main className="matrix-login">
|
||||
<h1>gomuks web</h1>
|
||||
|
|
|
@ -45,7 +45,7 @@ export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||
history.back()
|
||||
}
|
||||
}, [])
|
||||
const onKeyWrapper = useCallback((evt: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (evt.key === "Escape") {
|
||||
setState(null)
|
||||
if (history.state?.modal) {
|
||||
|
@ -53,7 +53,7 @@ export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||
}
|
||||
}
|
||||
evt.stopPropagation()
|
||||
}, [])
|
||||
}
|
||||
const openModal = useCallback((newState: ModalState) => {
|
||||
if (!history.state?.modal) {
|
||||
history.pushState({ ...(history.state ?? {}), modal: true }, "")
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { use, useCallback, useState } from "react"
|
||||
import React, { use, useState } from "react"
|
||||
import { getAvatarURL } from "@/api/media.ts"
|
||||
import { MemDBEvent, MemberEventContent } from "@/api/types"
|
||||
import { getDisplayname } from "@/util/validation.ts"
|
||||
|
@ -45,8 +45,6 @@ const MemberRow = ({ evt, onClick }: MemberRowProps) => {
|
|||
const MemberList = () => {
|
||||
const [filter, setFilter] = useState("")
|
||||
const [limit, setLimit] = useState(30)
|
||||
const increaseLimit = useCallback(() => setLimit(limit => limit + 50), [])
|
||||
const onChangeFilter = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setFilter(e.target.value), [])
|
||||
const roomCtx = use(RoomContext)
|
||||
if (roomCtx?.store && !roomCtx?.store.membersRequested && !roomCtx?.store.fullMembersLoaded) {
|
||||
roomCtx.store.membersRequested = true
|
||||
|
@ -69,10 +67,15 @@ const MemberList = () => {
|
|||
}
|
||||
}
|
||||
return <>
|
||||
<input className="member-filter" value={filter} onChange={onChangeFilter} placeholder="Filter members" />
|
||||
<input
|
||||
className="member-filter"
|
||||
value={filter}
|
||||
onChange={evt => setFilter(evt.target.value)}
|
||||
placeholder="Filter members"
|
||||
/>
|
||||
<div className="member-list">
|
||||
{members}
|
||||
{memberEvents.length > limit ? <button onClick={increaseLimit}>
|
||||
{memberEvents.length > limit ? <button onClick={() => setLimit(limit => limit + 50)}>
|
||||
and {memberEvents.length - limit} others…
|
||||
</button> : null}
|
||||
</div>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { useCallback, useEffect, useState, useTransition } from "react"
|
||||
import { useEffect, useState, useTransition } from "react"
|
||||
import { ScaleLoader } from "react-spinners"
|
||||
import Client from "@/api/client.ts"
|
||||
import { RoomStateStore } from "@/api/statestore"
|
||||
|
@ -34,17 +34,19 @@ const DeviceList = ({ client, room, userID }: DeviceListProps) => {
|
|||
const [view, setEncryptionInfo] = useState<ProfileEncryptionInfo | null>(null)
|
||||
const [errors, setErrors] = useState<string[] | null>(null)
|
||||
const [trackChangePending, startTransition] = useTransition()
|
||||
const doTrackDeviceList = useCallback(() => {
|
||||
const doTrackDeviceList = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const resp = await client.rpc.trackUserDevices(userID)
|
||||
setEncryptionInfo(resp)
|
||||
setErrors(resp.errors)
|
||||
startTransition(() => {
|
||||
setEncryptionInfo(resp)
|
||||
setErrors(resp.errors)
|
||||
})
|
||||
} catch (err) {
|
||||
setErrors([`${err}`])
|
||||
startTransition(() => setErrors([`${err}`]))
|
||||
}
|
||||
})
|
||||
}, [client, userID])
|
||||
}
|
||||
useEffect(() => {
|
||||
setEncryptionInfo(null)
|
||||
setErrors(null)
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { use, useCallback, useRef, useState } from "react"
|
||||
import React, { use, useRef, useState } from "react"
|
||||
import type { RoomID } from "@/api/types"
|
||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import reverseMap from "@/util/reversemap.ts"
|
||||
|
@ -38,18 +38,18 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
|
|||
const [roomFilter, setRoomFilter] = useState("")
|
||||
const [realRoomFilter, setRealRoomFilter] = useState("")
|
||||
|
||||
const updateRoomFilter = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const updateRoomFilter = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRoomFilter(evt.target.value)
|
||||
client.store.currentRoomListFilter = toSearchableString(evt.target.value)
|
||||
setRealRoomFilter(client.store.currentRoomListFilter)
|
||||
}, [client])
|
||||
const clearQuery = useCallback(() => {
|
||||
}
|
||||
const clearQuery = () => {
|
||||
setRoomFilter("")
|
||||
client.store.currentRoomListFilter = ""
|
||||
setRealRoomFilter("")
|
||||
roomFilterRef.current?.focus()
|
||||
}, [client])
|
||||
const onKeyDown = useCallback((evt: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
}
|
||||
const onKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const key = keyToString(evt)
|
||||
if (key === "Escape") {
|
||||
clearQuery()
|
||||
|
@ -62,7 +62,7 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
|
|||
evt.stopPropagation()
|
||||
evt.preventDefault()
|
||||
}
|
||||
}, [mainScreen, client.store, clearQuery])
|
||||
}
|
||||
|
||||
return <div className="room-list-wrapper">
|
||||
<div className="room-search-wrapper">
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { use, useCallback, useEffect, useState } from "react"
|
||||
import { use, useEffect, useState } from "react"
|
||||
import { ScaleLoader } from "react-spinners"
|
||||
import { getAvatarURL, getRoomAvatarURL } from "@/api/media.ts"
|
||||
import { InvitedRoomStore } from "@/api/statestore/invitedroom.ts"
|
||||
|
@ -41,7 +41,7 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
|
|||
const [loading, setLoading] = useState(false)
|
||||
const [buttonClicked, setButtonClicked] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const doJoinRoom = useCallback(() => {
|
||||
const doJoinRoom = () => {
|
||||
let realVia = via
|
||||
if (!via?.length && invite?.invited_by) {
|
||||
realVia = [getServerName(invite.invited_by)]
|
||||
|
@ -54,8 +54,8 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
|
|||
setButtonClicked(false)
|
||||
},
|
||||
)
|
||||
}, [client, roomID, via, alias, invite])
|
||||
const doRejectInvite = useCallback(() => {
|
||||
}
|
||||
const doRejectInvite = () => {
|
||||
setButtonClicked(true)
|
||||
client.rpc.leaveRoom(roomID).then(
|
||||
() => {
|
||||
|
@ -67,7 +67,7 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
|
|||
setButtonClicked(false)
|
||||
},
|
||||
)
|
||||
}, [client, mainScreen, roomID])
|
||||
}
|
||||
useEffect(() => {
|
||||
setSummary(null)
|
||||
setError(null)
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { use, useCallback } from "react"
|
||||
import { use } from "react"
|
||||
import { getRoomAvatarURL } from "@/api/media.ts"
|
||||
import { RoomStateStore } from "@/api/statestore"
|
||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
|
@ -35,14 +35,14 @@ const RoomViewHeader = ({ room }: RoomViewHeaderProps) => {
|
|||
const roomMeta = useEventAsState(room.meta)
|
||||
const mainScreen = use(MainScreenContext)
|
||||
const openModal = use(ModalContext)
|
||||
const openSettings = useCallback(() => {
|
||||
const openSettings = () => {
|
||||
openModal({
|
||||
dimmed: true,
|
||||
boxed: true,
|
||||
innerBoxClass: "settings-view",
|
||||
content: <SettingsView room={room} />,
|
||||
})
|
||||
}, [room, openModal])
|
||||
}
|
||||
return <div className="room-header">
|
||||
<button className="back" onClick={mainScreen.clearActiveRoom}><BackIcon/></button>
|
||||
<img
|
||||
|
|
|
@ -45,52 +45,39 @@ interface PreferenceCellProps<T extends PreferenceValueType> {
|
|||
inheritedValue: T
|
||||
}
|
||||
|
||||
const useRemover = (
|
||||
const makeRemover = (
|
||||
context: PreferenceContext, setPref: SetPrefFunc, name: keyof Preferences, value: PreferenceValueType | undefined,
|
||||
) => {
|
||||
const onClear = useCallback(() => {
|
||||
setPref(context, name, undefined)
|
||||
}, [setPref, context, name])
|
||||
if (value === undefined) {
|
||||
return null
|
||||
}
|
||||
return <button onClick={onClear}><CloseIcon /></button>
|
||||
return <button onClick={() => setPref(context, name, undefined)}><CloseIcon /></button>
|
||||
}
|
||||
|
||||
const BooleanPreferenceCell = ({ context, name, setPref, value, inheritedValue }: PreferenceCellProps<boolean>) => {
|
||||
const onChange = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPref(context, name, evt.target.checked)
|
||||
}, [setPref, context, name])
|
||||
return <div className="preference boolean-preference">
|
||||
<Toggle checked={value ?? inheritedValue} onChange={onChange}/>
|
||||
{useRemover(context, setPref, name, value)}
|
||||
<Toggle checked={value ?? inheritedValue} onChange={evt => setPref(context, name, evt.target.checked)}/>
|
||||
{makeRemover(context, setPref, name, value)}
|
||||
</div>
|
||||
}
|
||||
|
||||
const TextPreferenceCell = ({ context, name, setPref, value, inheritedValue }: PreferenceCellProps<string>) => {
|
||||
const onChange = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPref(context, name, evt.target.value)
|
||||
}, [setPref, context, name])
|
||||
return <div className="preference string-preference">
|
||||
<input value={value ?? inheritedValue} onChange={onChange}/>
|
||||
{useRemover(context, setPref, name, value)}
|
||||
<input value={value ?? inheritedValue} onChange={evt => setPref(context, name, evt.target.value)}/>
|
||||
{makeRemover(context, setPref, name, value)}
|
||||
</div>
|
||||
}
|
||||
|
||||
const SelectPreferenceCell = ({ context, name, pref, setPref, value, inheritedValue }: PreferenceCellProps<string>) => {
|
||||
const onChange = useCallback((evt: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setPref(context, name, evt.target.value)
|
||||
}, [setPref, context, name])
|
||||
const remover = useRemover(context, setPref, name, value)
|
||||
if (!pref.allowedValues) {
|
||||
return null
|
||||
}
|
||||
return <div className="preference select-preference">
|
||||
<select value={value ?? inheritedValue} onChange={onChange}>
|
||||
<select value={value ?? inheritedValue} onChange={evt => setPref(context, name, evt.target.value)}>
|
||||
{pref.allowedValues.map(value =>
|
||||
<option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
{remover}
|
||||
{makeRemover(context, setPref, name, value)}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
@ -186,7 +173,7 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta
|
|||
const client = use(ClientContext)!
|
||||
const appliedContext = getActiveCSSContext(client, room)
|
||||
const [context, setContext] = useState(appliedContext)
|
||||
const getContextText = useCallback((context: PreferenceContext) => {
|
||||
const getContextText = (context: PreferenceContext) => {
|
||||
if (context === PreferenceContext.Account) {
|
||||
return client.store.serverPreferenceCache.custom_css
|
||||
} else if (context === PreferenceContext.Device) {
|
||||
|
@ -196,17 +183,17 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta
|
|||
} else if (context === PreferenceContext.RoomDevice) {
|
||||
return room.localPreferenceCache.custom_css
|
||||
}
|
||||
}, [client, room])
|
||||
}
|
||||
const origText = getContextText(context)
|
||||
const [text, setText] = useState(origText ?? "")
|
||||
const onChangeContext = useCallback((evt: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const onChangeContext = (evt: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newContext = evt.target.value as PreferenceContext
|
||||
setContext(newContext)
|
||||
setText(getContextText(newContext) ?? "")
|
||||
}, [getContextText])
|
||||
const onChangeText = useCallback((evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
}
|
||||
const onChangeText = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setText(evt.target.value)
|
||||
}, [])
|
||||
}
|
||||
const onSave = useEvent(() => {
|
||||
if (vscodeOpen) {
|
||||
setText(vscodeContentRef.current)
|
||||
|
@ -215,18 +202,18 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta
|
|||
setPref(context, "custom_css", text)
|
||||
}
|
||||
})
|
||||
const onDelete = useEvent(() => {
|
||||
const onDelete = () => {
|
||||
setPref(context, "custom_css", undefined)
|
||||
setText("")
|
||||
})
|
||||
}
|
||||
const [vscodeOpen, setVSCodeOpen] = useState(false)
|
||||
const vscodeContentRef = useRef("")
|
||||
const vscodeInitialContentRef = useRef("")
|
||||
const onClickVSCode = useEvent(() => {
|
||||
const onClickVSCode = () => {
|
||||
vscodeContentRef.current = text
|
||||
vscodeInitialContentRef.current = text
|
||||
setVSCodeOpen(true)
|
||||
})
|
||||
}
|
||||
const closeVSCode = useCallback(() => {
|
||||
setVSCodeOpen(false)
|
||||
setText(vscodeContentRef.current)
|
||||
|
@ -296,7 +283,9 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
|||
const roomMeta = useEventAsState(room.meta)
|
||||
const client = use(ClientContext)!
|
||||
const closeModal = use(ModalCloseContext)
|
||||
const setPref = useCallback((context: PreferenceContext, key: keyof Preferences, value: PreferenceValueType | undefined) => {
|
||||
const setPref = useCallback((
|
||||
context: PreferenceContext, key: keyof Preferences, value: PreferenceValueType | undefined,
|
||||
) => {
|
||||
if (context === PreferenceContext.Account) {
|
||||
client.rpc.setAccountData("fi.mau.gomuks.preferences", {
|
||||
...client.store.serverPreferenceCache,
|
||||
|
@ -321,15 +310,15 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
|||
}
|
||||
}
|
||||
}, [client, room])
|
||||
const onClickLogout = useCallback(() => {
|
||||
const onClickLogout = () => {
|
||||
if (window.confirm("Really log out and delete all local data?")) {
|
||||
client.logout().then(
|
||||
() => console.info("Successfully logged out"),
|
||||
err => window.alert(`Failed to log out: ${err}`),
|
||||
)
|
||||
}
|
||||
}, [client])
|
||||
const onClickLeave = useCallback(() => {
|
||||
}
|
||||
const onClickLeave = () => {
|
||||
if (window.confirm(`Really leave ${room.meta.current.name}?`)) {
|
||||
client.rpc.leaveRoom(room.roomID).then(
|
||||
() => {
|
||||
|
@ -339,7 +328,7 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
|||
err => window.alert(`Failed to leave room: ${err}`),
|
||||
)
|
||||
}
|
||||
}, [client, room, closeModal])
|
||||
}
|
||||
usePreferences(client.store, room)
|
||||
const globalServer = client.store.serverPreferenceCache
|
||||
const globalLocal = client.store.localPreferenceCache
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { JSX, use, useCallback, useState } from "react"
|
||||
import React, { JSX, use, useState } from "react"
|
||||
import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
|
||||
import { useRoomMember } from "@/api/statestore"
|
||||
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
|
||||
|
@ -79,7 +79,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
|
|||
const mainScreen = use(MainScreenContext)
|
||||
const openModal = use(ModalContext)
|
||||
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
|
||||
const onContextMenu = useCallback((mouseEvt: React.MouseEvent) => {
|
||||
const onContextMenu = (mouseEvt: React.MouseEvent) => {
|
||||
const targetElem = mouseEvt.target as HTMLElement
|
||||
if (
|
||||
!roomCtx.store.preferences.message_context_menu
|
||||
|
@ -97,7 +97,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
|
|||
style={getModalStyleFromMouse(mouseEvt, 9 * 40)}
|
||||
/>,
|
||||
})
|
||||
}, [openModal, evt, roomCtx])
|
||||
}
|
||||
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
|
||||
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
||||
const BodyType = getBodyType(evt)
|
||||
|
|
|
@ -46,13 +46,13 @@ const TimelineView = () => {
|
|||
|
||||
// When the user scrolls the timeline manually, remember if they were at the bottom,
|
||||
// so that we can keep them at the bottom when new events are added.
|
||||
const handleScroll = useCallback(() => {
|
||||
const handleScroll = () => {
|
||||
if (!timelineViewRef.current) {
|
||||
return
|
||||
}
|
||||
const timelineView = timelineViewRef.current
|
||||
roomCtx.scrolledToBottom = timelineView.scrollTop + timelineView.clientHeight + 1 >= timelineView.scrollHeight
|
||||
}, [roomCtx])
|
||||
}
|
||||
// Save the scroll height prior to updating the timeline, so that we can adjust the scroll position if needed.
|
||||
if (timelineViewRef.current) {
|
||||
oldScrollHeight.current = timelineViewRef.current.scrollHeight
|
||||
|
|
|
@ -13,9 +13,9 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { use, useCallback, useState } from "react"
|
||||
import React, { use, useState } from "react"
|
||||
import { MemDBEvent } from "@/api/types"
|
||||
import useEvent from "@/util/useEvent.ts"
|
||||
import { isMobileDevice } from "@/util/ismobile.ts"
|
||||
import { ModalCloseContext } from "../../modal/Modal.tsx"
|
||||
import TimelineEvent from "../TimelineEvent.tsx"
|
||||
|
||||
|
@ -33,14 +33,11 @@ const ConfirmWithMessageModal = ({
|
|||
}: ConfirmWithMessageProps) => {
|
||||
const [reason, setReason] = useState("")
|
||||
const closeModal = use(ModalCloseContext)
|
||||
const onConfirmWrapped = useEvent((evt: React.FormEvent) => {
|
||||
const onConfirmWrapped = (evt: React.FormEvent) => {
|
||||
evt.preventDefault()
|
||||
closeModal()
|
||||
onConfirm(reason)
|
||||
})
|
||||
const onChangeReason = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setReason(evt.target.value)
|
||||
}, [])
|
||||
}
|
||||
return <form onSubmit={onConfirmWrapped}>
|
||||
<h3>{title}</h3>
|
||||
<div className="timeline-event-container">
|
||||
|
@ -49,7 +46,13 @@ const ConfirmWithMessageModal = ({
|
|||
<div className="confirm-description">
|
||||
{description}
|
||||
</div>
|
||||
<input autoFocus value={reason} type="text" placeholder={placeholder} onChange={onChangeReason} />
|
||||
<input
|
||||
autoFocus={!isMobileDevice}
|
||||
value={reason}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
onChange={evt => setReason(evt.target.value)}
|
||||
/>
|
||||
<div className="confirm-buttons">
|
||||
<button type="button" onClick={closeModal}>Cancel</button>
|
||||
<button type="submit">{confirmButton}</button>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { CSSProperties, use, useCallback } from "react"
|
||||
import React, { CSSProperties, use } from "react"
|
||||
import Client from "@/api/client.ts"
|
||||
import { MemDBEvent } from "@/api/types"
|
||||
import { emojiToReactionContent } from "@/util/emoji"
|
||||
|
@ -43,11 +43,11 @@ export const usePrimaryItems = (
|
|||
const closeModal = !isHover ? use(ModalCloseContext) : noop
|
||||
const openModal = use(ModalContext)
|
||||
|
||||
const onClickReply = useCallback(() => {
|
||||
const onClickReply = () => {
|
||||
roomCtx.setReplyTo(evt.event_id)
|
||||
closeModal()
|
||||
}, [roomCtx, evt.event_id, closeModal])
|
||||
const onClickReact = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
}
|
||||
const onClickReact = (mevt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const emojiPickerHeight = 34 * 16
|
||||
setForceOpen?.(true)
|
||||
openModal({
|
||||
|
@ -63,20 +63,20 @@ export const usePrimaryItems = (
|
|||
/>,
|
||||
onClose: () => setForceOpen?.(false),
|
||||
})
|
||||
}, [client, roomCtx, evt, style, setForceOpen, openModal])
|
||||
const onClickEdit = useCallback(() => {
|
||||
}
|
||||
const onClickEdit = () => {
|
||||
closeModal()
|
||||
roomCtx.setEditing(evt)
|
||||
}, [roomCtx, evt, closeModal])
|
||||
const onClickResend = useCallback(() => {
|
||||
}
|
||||
const onClickResend = () => {
|
||||
if (!evt.transaction_id) {
|
||||
return
|
||||
}
|
||||
closeModal()
|
||||
client.resendEvent(evt.transaction_id)
|
||||
.catch(err => window.alert(`Failed to resend message: ${err}`))
|
||||
}, [client, evt.transaction_id, closeModal])
|
||||
const onClickMore = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
}
|
||||
const onClickMore = (mevt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const moreMenuHeight = 4 * 40
|
||||
setForceOpen!(true)
|
||||
openModal({
|
||||
|
@ -87,7 +87,7 @@ export const usePrimaryItems = (
|
|||
/>,
|
||||
onClose: () => setForceOpen!(false),
|
||||
})
|
||||
}, [evt, roomCtx, setForceOpen, openModal])
|
||||
}
|
||||
const isEditing = useEventAsState(roomCtx.isEditing)
|
||||
const [isPending, pendingTitle] = getPending(evt)
|
||||
const isEncrypted = getEncryption(roomCtx.store)
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { use, useCallback } from "react"
|
||||
import { use } from "react"
|
||||
import Client from "@/api/client.ts"
|
||||
import { useRoomState } from "@/api/statestore"
|
||||
import { MemDBEvent } from "@/api/types"
|
||||
|
@ -35,14 +35,14 @@ export const useSecondaryItems = (
|
|||
) => {
|
||||
const closeModal = use(ModalCloseContext)
|
||||
const openModal = use(ModalContext)
|
||||
const onClickViewSource = useCallback(() => {
|
||||
const onClickViewSource = () => {
|
||||
openModal({
|
||||
dimmed: true,
|
||||
boxed: true,
|
||||
content: <JSONView data={evt} />,
|
||||
})
|
||||
}, [evt, openModal])
|
||||
const onClickReport = useCallback(() => {
|
||||
}
|
||||
const onClickReport = () => {
|
||||
openModal({
|
||||
dimmed: true,
|
||||
boxed: true,
|
||||
|
@ -61,8 +61,8 @@ export const useSecondaryItems = (
|
|||
/>
|
||||
</RoomContext>,
|
||||
})
|
||||
}, [evt, roomCtx, openModal, client])
|
||||
const onClickRedact = useCallback(() => {
|
||||
}
|
||||
const onClickRedact = () => {
|
||||
openModal({
|
||||
dimmed: true,
|
||||
boxed: true,
|
||||
|
@ -81,17 +81,12 @@ export const useSecondaryItems = (
|
|||
/>
|
||||
</RoomContext>,
|
||||
})
|
||||
}, [evt, roomCtx, openModal, client])
|
||||
const onClickPin = useCallback(() => {
|
||||
}
|
||||
const onClickPin = (pin: boolean) => () => {
|
||||
closeModal()
|
||||
client.pinMessage(roomCtx.store, evt.event_id, true)
|
||||
.catch(err => window.alert(`Failed to pin message: ${err}`))
|
||||
}, [closeModal, client, roomCtx, evt.event_id])
|
||||
const onClickUnpin = useCallback(() => {
|
||||
closeModal()
|
||||
client.pinMessage(roomCtx.store, evt.event_id, false)
|
||||
.catch(err => window.alert(`Failed to unpin message: ${err}`))
|
||||
}, [closeModal, client, roomCtx, evt.event_id])
|
||||
client.pinMessage(roomCtx.store, evt.event_id, pin)
|
||||
.catch(err => window.alert(`Failed to ${pin ? "pin" : "unpin"} message: ${err}`))
|
||||
}
|
||||
|
||||
const [isPending, pendingTitle] = getPending(evt)
|
||||
useRoomState(roomCtx.store, "m.room.power_levels", "")
|
||||
|
@ -109,8 +104,12 @@ export const useSecondaryItems = (
|
|||
return <>
|
||||
<button onClick={onClickViewSource}><ViewSourceIcon/>View source</button>
|
||||
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
||||
? <button onClick={onClickUnpin}><UnpinIcon/>Unpin message</button>
|
||||
: <button onClick={onClickPin} title={pendingTitle} disabled={isPending}><PinIcon/>Pin message</button>)}
|
||||
? <button onClick={onClickPin(false)}>
|
||||
<UnpinIcon/>Unpin message
|
||||
</button>
|
||||
: <button onClick={onClickPin(true)} title={pendingTitle} disabled={isPending}>
|
||||
<PinIcon/>Pin message
|
||||
</button>)}
|
||||
<button onClick={onClickReport} disabled={isPending} title={pendingTitle}><ReportIcon/>Report</button>
|
||||
{canRedact && <button
|
||||
onClick={onClickRedact}
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { CSSProperties } from "react"
|
||||
import useEvent from "@/util/useEvent.ts"
|
||||
import "./ResizeHandle.css"
|
||||
|
||||
export interface ResizeHandleProps {
|
||||
|
@ -28,7 +27,7 @@ export interface ResizeHandleProps {
|
|||
}
|
||||
|
||||
const ResizeHandle = ({ width, minWidth, maxWidth, setWidth, style, className, inverted }: ResizeHandleProps) => {
|
||||
const onMouseDown = useEvent((evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
const onMouseDown = (evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
const origWidth = width
|
||||
const startPos = evt.clientX
|
||||
const onMouseMove = (evt: MouseEvent) => {
|
||||
|
@ -46,7 +45,7 @@ const ResizeHandle = ({ width, minWidth, maxWidth, setWidth, style, className, i
|
|||
document.addEventListener("mousemove", onMouseMove)
|
||||
document.addEventListener("mouseup", onMouseUp)
|
||||
evt.preventDefault()
|
||||
})
|
||||
}
|
||||
return <div className={`resize-handle-outer ${className ?? ""}`} style={style}>
|
||||
<div className="resize-handle-inner" onMouseDown={onMouseDown}/>
|
||||
</div>
|
||||
|
|
Loading…
Add table
Reference in a new issue