web/all: remove unnecessary uses of useCallback

This commit is contained in:
Tulir Asokan 2024-12-22 14:54:57 +02:00
parent 388be09795
commit 4160a33edb
23 changed files with 285 additions and 290 deletions

View file

@ -80,13 +80,13 @@ function useAutocompleter<T>({
}) })
document.querySelector(`div.autocompletion-item[data-index='${index}']`)?.scrollIntoView({ block: "nearest" }) 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") const idx = evt.currentTarget.getAttribute("data-index")
if (idx) { if (idx) {
onSelect(+idx) onSelect(+idx)
setAutocomplete(null) setAutocomplete(null)
} }
}) }
useEffect(() => { useEffect(() => {
if (params.selected !== undefined) { if (params.selected !== undefined) {
onSelect(params.selected) onSelect(params.selected)

View 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>
}

View file

@ -15,8 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // 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 React, { CSSProperties, use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners" import { ScaleLoader } from "react-spinners"
import Client from "@/api/client.ts" import { useRoomEvent } from "@/api/statestore"
import { RoomStateStore, usePreference, useRoomEvent } from "@/api/statestore"
import type { import type {
EventID, EventID,
MediaMessageEventContent, MediaMessageEventContent,
@ -29,21 +28,18 @@ import type {
import { PartialEmoji, emojiToMarkdown } from "@/util/emoji" import { PartialEmoji, emojiToMarkdown } from "@/util/emoji"
import { isMobileDevice } from "@/util/ismobile.ts" import { isMobileDevice } from "@/util/ismobile.ts"
import { escapeMarkdown } from "@/util/markdown.ts" import { escapeMarkdown } from "@/util/markdown.ts"
import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx" import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
import GIFPicker from "../emojipicker/GIFPicker.tsx" import GIFPicker from "../emojipicker/GIFPicker.tsx"
import StickerPicker from "../emojipicker/StickerPicker.tsx" import StickerPicker from "../emojipicker/StickerPicker.tsx"
import { keyToString } from "../keybindings.ts" import { keyToString } from "../keybindings.ts"
import { LeafletPicker } from "../maps/async.tsx"
import { ModalContext } from "../modal/Modal.tsx" import { ModalContext } from "../modal/Modal.tsx"
import { useRoomContext } from "../roomview/roomcontext.ts" import { useRoomContext } from "../roomview/roomcontext.ts"
import { ReplyBody } from "../timeline/ReplyBody.tsx" import { ReplyBody } from "../timeline/ReplyBody.tsx"
import { useMediaContent } from "../timeline/content/useMediaContent.tsx"
import type { AutocompleteQuery } from "./Autocompleter.tsx" import type { AutocompleteQuery } from "./Autocompleter.tsx"
import { ComposerLocation, ComposerLocationValue, ComposerMedia } from "./ComposerMedia.tsx"
import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./getAutocompleter.ts" import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./getAutocompleter.ts"
import AttachIcon from "@/icons/attach.svg?react" 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 EmojiIcon from "@/icons/emoji-categories/smileys-emotion.svg?react"
import GIFIcon from "@/icons/gif.svg?react" import GIFIcon from "@/icons/gif.svg?react"
import LocationIcon from "@/icons/location.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 StickerIcon from "@/icons/sticker.svg?react"
import "./MessageComposer.css" import "./MessageComposer.css"
export interface ComposerLocationValue {
lat: number
long: number
prec?: number
}
export interface ComposerState { export interface ComposerState {
text: string text: string
media: MediaMessageEventContent | null media: MediaMessageEventContent | null
@ -174,13 +164,13 @@ const MessageComposer = () => {
textInput.current?.focus() textInput.current?.focus()
}, [room.roomID]) }, [room.roomID])
const canSend = Boolean(state.text || state.media || state.location) const canSend = Boolean(state.text || state.media || state.location)
const sendMessage = useEvent((evt: React.FormEvent) => { const onClickSend = (evt: React.FormEvent) => {
evt.preventDefault() evt.preventDefault()
if (!canSend) { if (!canSend) {
return return
} }
doSendMessage(state) doSendMessage(state)
}) }
const doSendMessage = (state: ComposerState) => { const doSendMessage = (state: ComposerState) => {
if (editing) { if (editing) {
setState(draftStore.get(room.roomID) ?? emptyComposer) setState(draftStore.get(room.roomID) ?? emptyComposer)
@ -245,7 +235,7 @@ const MessageComposer = () => {
mentions, mentions,
}).catch(err => window.alert("Failed to send message: " + err)) }).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 const area = evt.currentTarget
if (area.selectionStart <= (autocomplete?.startPos ?? 0)) { if (area.selectionStart <= (autocomplete?.startPos ?? 0)) {
if (autocomplete) { 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 inp = evt.currentTarget
const fullKey = keyToString(evt) const fullKey = keyToString(evt)
const sendKey = fullKey === "Enter" || fullKey === "Ctrl+Enter" const sendKey = fullKey === "Enter" || fullKey === "Ctrl+Enter"
@ -295,7 +285,7 @@ const MessageComposer = () => {
|| autocomplete.selected !== undefined || autocomplete.selected !== undefined
|| !document.getElementById("composer-autocompletions")?.classList.contains("has-items") || !document.getElementById("composer-autocompletions")?.classList.contains("has-items")
)) { )) {
sendMessage(evt) onClickSend(evt)
} else if (autocomplete) { } else if (autocomplete) {
let autocompleteUpdate: Partial<AutocompleteQuery> | null | undefined let autocompleteUpdate: Partial<AutocompleteQuery> | null | undefined
if (fullKey === "Tab" || fullKey === "ArrowDown") { if (fullKey === "Tab" || fullKey === "ArrowDown") {
@ -340,8 +330,8 @@ const MessageComposer = () => {
evt.stopPropagation() evt.stopPropagation()
roomCtx.setEditing(null) roomCtx.setEditing(null)
} }
}) }
const onChange = useEvent((evt: React.ChangeEvent<HTMLTextAreaElement>) => { const onChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
setState({ text: evt.target.value }) setState({ text: evt.target.value })
const now = Date.now() const now = Date.now()
if (evt.target.value !== "" && typingSentAt.current + 5_000 < now) { if (evt.target.value !== "" && typingSentAt.current + 5_000 < now) {
@ -358,7 +348,7 @@ const MessageComposer = () => {
} }
} }
onComposerCaretChange(evt, evt.target.value) onComposerCaretChange(evt, evt.target.value)
}) }
const doUploadFile = useCallback((file: File | null | undefined) => { const doUploadFile = useCallback((file: File | null | undefined) => {
if (!file) { if (!file) {
return return
@ -380,10 +370,7 @@ const MessageComposer = () => {
.catch(err => window.alert("Failed to upload file: " + err)) .catch(err => window.alert("Failed to upload file: " + err))
.finally(() => setLoadingMedia(false)) .finally(() => setLoadingMedia(false))
}, [room]) }, [room])
const onAttachFile = useEvent( const onPaste = (evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
(evt: React.ChangeEvent<HTMLInputElement>) => doUploadFile(evt.target.files?.[0]),
)
const onPaste = useEvent((evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
const file = evt.clipboardData?.files?.[0] const file = evt.clipboardData?.files?.[0]
const text = evt.clipboardData.getData("text/plain") const text = evt.clipboardData.getData("text/plain")
const input = evt.currentTarget const input = evt.currentTarget
@ -400,7 +387,7 @@ const MessageComposer = () => {
return return
} }
evt.preventDefault() evt.preventDefault()
}) }
// To ensure the cursor jumps to the end, do this in an effect rather than as the initial value of useState // 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 // To try to avoid the input bar flashing, use useLayoutEffect instead of useEffect
useLayoutEffect(() => { useLayoutEffect(() => {
@ -450,7 +437,6 @@ const MessageComposer = () => {
draftStore.set(room.roomID, state) draftStore.set(room.roomID, state)
} }
}, [roomCtx, room, state, editing]) }, [roomCtx, room, state, editing])
const openFilePicker = useCallback(() => fileInput.current!.click(), [])
const clearMedia = useCallback(() => setState({ media: null, location: null }), []) const clearMedia = useCallback(() => setState({ media: null, location: null }), [])
const onChangeLocation = useCallback((location: ComposerLocationValue) => setState({ location }), []) const onChangeLocation = useCallback((location: ComposerLocationValue) => setState({ location }), [])
const closeReply = useCallback((evt: React.MouseEvent) => { const closeReply = useCallback((evt: React.MouseEvent) => {
@ -461,56 +447,6 @@ const MessageComposer = () => {
evt.stopPropagation() evt.stopPropagation()
roomCtx.setEditing(null) roomCtx.setEditing(null)
}, [roomCtx]) }, [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) const Autocompleter = getAutocompleter(autocomplete, client, room)
let mediaDisabledTitle: string | undefined let mediaDisabledTitle: string | undefined
let stickerDisabledTitle: string | undefined let stickerDisabledTitle: string | undefined
@ -533,7 +469,57 @@ const MessageComposer = () => {
} else if (state.text && !editing) { } else if (state.text && !editing) {
stickerDisabledTitle = "You can't attach a sticker to a message with text" 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 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 <> return <>
<button onClick={openEmojiPicker} title="Add emoji"><EmojiIcon/>{includeText && "Emoji"}</button> <button onClick={openEmojiPicker} title="Add emoji"><EmojiIcon/>{includeText && "Emoji"}</button>
<button <button
@ -556,13 +542,13 @@ const MessageComposer = () => {
title={locationDisabledTitle ?? "Add location"} title={locationDisabledTitle ?? "Add location"}
><LocationIcon/>{includeText && "Location"}</button> ><LocationIcon/>{includeText && "Location"}</button>
<button <button
onClick={openFilePicker} onClick={() => fileInput.current!.click()}
disabled={!!mediaDisabledTitle} disabled={!!mediaDisabledTitle}
title={mediaDisabledTitle ?? "Add file attachment"} title={mediaDisabledTitle ?? "Add file attachment"}
><AttachIcon/>{includeText && "File"}</button> ><AttachIcon/>{includeText && "File"}</button>
</> </>
} }
const openButtonsModal = useEvent(() => { const openButtonsModal = () => {
const style: CSSProperties = getEmojiPickerStyle() const style: CSSProperties = getEmojiPickerStyle()
style.left = style.right style.left = style.right
delete style.right delete style.right
@ -571,7 +557,7 @@ const MessageComposer = () => {
{makeAttachmentButtons(true)} {makeAttachmentButtons(true)}
</div>, </div>,
}) })
}) }
const inlineButtons = state.text === "" || window.innerWidth > 720 const inlineButtons = state.text === "" || window.innerWidth > 720
const showSendButton = canSend || window.innerWidth > 720 const showSendButton = canSend || window.innerWidth > 720
const disableClearMedia = editing && state.media?.msgtype === "m.sticker" const disableClearMedia = editing && state.media?.msgtype === "m.sticker"
@ -625,49 +611,19 @@ const MessageComposer = () => {
/> />
{inlineButtons && makeAttachmentButtons()} {inlineButtons && makeAttachmentButtons()}
{showSendButton && <button {showSendButton && <button
onClick={sendMessage} onClick={onClickSend}
disabled={!canSend || loadingMedia} disabled={!canSend || loadingMedia}
title="Send message" title="Send message"
><SendIcon/></button>} ><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>
</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 export default MessageComposer

View file

@ -13,11 +13,10 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 { stringToRoomStateGUID } from "@/api/types"
import useContentVisibility from "@/util/contentvisibility.ts" import useContentVisibility from "@/util/contentvisibility.ts"
import { CATEGORY_FREQUENTLY_USED, CustomEmojiPack, Emoji, categories } from "@/util/emoji" import { CATEGORY_FREQUENTLY_USED, CustomEmojiPack, Emoji, categories } from "@/util/emoji"
import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import renderEmoji from "./renderEmoji.tsx" import renderEmoji from "./renderEmoji.tsx"
@ -56,27 +55,25 @@ export const EmojiGroup = ({
} }
return emoji return emoji
} }
const onClickEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => const onMouseOverEmoji = setPreviewEmoji && ((evt: React.MouseEvent<HTMLButtonElement>) =>
onSelect(getEmojiFromAttrs(evt.currentTarget))) setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget)))
const onMouseOverEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => const onMouseOutEmoji = setPreviewEmoji && (() => setPreviewEmoji(undefined))
setPreviewEmoji?.(getEmojiFromAttrs(evt.currentTarget))) const onClickSubscribePack = (evt: React.MouseEvent<HTMLButtonElement>) => {
const onMouseOutEmoji = useCallback(() => setPreviewEmoji?.(undefined), [setPreviewEmoji])
const onClickSubscribePack = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => {
const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id")) const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
if (!guid) { if (!guid) {
return return
} }
client.subscribeToEmojiPack(guid, true) client.subscribeToEmojiPack(guid, true)
.catch(err => window.alert(`Failed to subscribe to emoji pack: ${err}`)) .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")) const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
if (!guid) { if (!guid) {
return return
} }
client.subscribeToEmojiPack(guid, false) client.subscribeToEmojiPack(guid, false)
.catch(err => window.alert(`Failed to unsubscribe from emoji pack: ${err}`)) .catch(err => window.alert(`Failed to unsubscribe from emoji pack: ${err}`))
}) }
let categoryName: string let categoryName: string
if (typeof categoryID === "number") { if (typeof categoryID === "number") {
@ -112,7 +109,7 @@ export const EmojiGroup = ({
data-emoji-index={idx} data-emoji-index={idx}
onMouseOver={onMouseOverEmoji} onMouseOver={onMouseOverEmoji}
onMouseOut={onMouseOutEmoji} onMouseOut={onMouseOutEmoji}
onClick={onClickEmoji} onClick={evt => onSelect(getEmojiFromAttrs(evt.currentTarget))}
title={emoji.t} title={emoji.t}
>{renderEmoji(emoji)}</button>) : null} >{renderEmoji(emoji)}</button>) : null}
</div> </div>

View file

@ -19,7 +19,6 @@ import { RoomStateStore, useCustomEmojis } from "@/api/statestore"
import { roomStateGUIDToString } from "@/api/types" import { roomStateGUIDToString } from "@/api/types"
import { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji" import { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji"
import { isMobileDevice } from "@/util/ismobile.ts" import { isMobileDevice } from "@/util/ismobile.ts"
import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import { ModalCloseContext } from "../modal/Modal.tsx" import { ModalCloseContext } from "../modal/Modal.tsx"
import { EmojiGroup } from "./EmojiGroup.tsx" import { EmojiGroup } from "./EmojiGroup.tsx"
@ -72,7 +71,6 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
frequentlyUsed: client.store.frequentlyUsedEmoji, frequentlyUsed: client.store.frequentlyUsedEmoji,
customEmojiPacks, customEmojiPacks,
}) })
const clearQuery = useCallback(() => setQuery(""), [])
const close = closeOnSelect ? use(ModalCloseContext) : null const close = closeOnSelect ? use(ModalCloseContext) : null
const onSelectWrapped = useCallback((emoji?: PartialEmoji) => { const onSelectWrapped = useCallback((emoji?: PartialEmoji) => {
if (!emoji) { if (!emoji) {
@ -85,12 +83,10 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
} }
close?.() close?.()
}, [onSelect, selected, client, close]) }, [onSelect, selected, client, close])
const onClickFreeformReact = useEvent(() => onSelectWrapped({ u: query })) const onClickCategoryButton = (evt: React.MouseEvent) => {
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
const categoryID = evt.currentTarget.getAttribute("data-category-id")! const categoryID = evt.currentTarget.getAttribute("data-category-id")!
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView() document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView()
}, []) }
return <div className="emoji-picker" style={style}> return <div className="emoji-picker" style={style}>
<div className="emoji-category-bar" ref={emojiCategoryBarRef}> <div className="emoji-category-bar" ref={emojiCategoryBarRef}>
<button <button
@ -123,12 +119,12 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
<div className="emoji-search"> <div className="emoji-search">
<input <input
autoFocus={!isMobileDevice} autoFocus={!isMobileDevice}
onChange={onChangeQuery} onChange={evt => setQuery(evt.target.value)}
value={query} value={query}
type="search" type="search"
placeholder="Search emojis" placeholder="Search emojis"
/> />
<button onClick={clearQuery} disabled={query === ""}> <button onClick={() => setQuery("")} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>} {query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button> </button>
</div> </div>
@ -155,7 +151,7 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
})} })}
{allowFreeform && query && <button {allowFreeform && query && <button
className="freeform-react" className="freeform-react"
onClick={onClickFreeformReact} onClick={() => onSelectWrapped({ u: query })}
>React with "{query}"</button>} >React with "{query}"</button>}
</div> </div>
</div> </div>

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 { RoomStateStore, usePreference } from "@/api/statestore"
import { MediaMessageEventContent } from "@/api/types" import { MediaMessageEventContent } from "@/api/types"
import { isMobileDevice } from "@/util/ismobile.ts" import { isMobileDevice } from "@/util/ismobile.ts"
@ -36,13 +36,11 @@ const GIFPicker = ({ style, onSelect, room }: MediaPickerProps) => {
const [results, setResults] = useState<GIF[]>([]) const [results, setResults] = useState<GIF[]>([])
const [error, setError] = useState<unknown>() const [error, setError] = useState<unknown>()
const close = use(ModalCloseContext) const close = use(ModalCloseContext)
const clearQuery = useCallback(() => setQuery(""), [])
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
const client = use(ClientContext)! const client = use(ClientContext)!
const provider = usePreference(client.store, room, "gif_provider") const provider = usePreference(client.store, room, "gif_provider")
const providerName = provider.slice(0, 1).toUpperCase() + provider.slice(1) const providerName = provider.slice(0, 1).toUpperCase() + provider.slice(1)
// const reuploadGIFs = room.preferences.reupload_gifs // 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") const idx = evt.currentTarget.getAttribute("data-gif-index")
if (!idx) { if (!idx) {
return return
@ -64,7 +62,7 @@ const GIFPicker = ({ style, onSelect, room }: MediaPickerProps) => {
url: gif.proxied_mxc, url: gif.proxied_mxc,
}) })
close() close()
}, [onSelect, close, results]) }
useEffect(() => { useEffect(() => {
if (!query) { if (!query) {
if (trendingCache.has(provider)) { if (trendingCache.has(provider)) {
@ -106,12 +104,12 @@ const GIFPicker = ({ style, onSelect, room }: MediaPickerProps) => {
<div className="gif-search"> <div className="gif-search">
<input <input
autoFocus={!isMobileDevice} autoFocus={!isMobileDevice}
onChange={onChangeQuery} onChange={evt => setQuery(evt.target.value)}
value={query} value={query}
type="search" type="search"
placeholder={`Search ${providerName}`} placeholder={`Search ${providerName}`}
/> />
<button onClick={clearQuery} disabled={query === ""}> <button onClick={() => setQuery("")} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>} {query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button> </button>
</div> </div>

View file

@ -39,7 +39,6 @@ const StickerPicker = ({ style, onSelect, room }: MediaPickerProps) => {
customEmojiPacks, customEmojiPacks,
stickers: true, stickers: true,
}) })
const clearQuery = useCallback(() => setQuery(""), [])
const close = use(ModalCloseContext) const close = use(ModalCloseContext)
const onSelectWrapped = useCallback((emoji?: Emoji) => { const onSelectWrapped = useCallback((emoji?: Emoji) => {
if (!emoji) { if (!emoji) {
@ -53,11 +52,10 @@ const StickerPicker = ({ style, onSelect, room }: MediaPickerProps) => {
}) })
close() close()
}, [onSelect, close]) }, [onSelect, close])
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), []) const onClickCategoryButton = (evt: React.MouseEvent) => {
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
const categoryID = evt.currentTarget.getAttribute("data-category-id")! const categoryID = evt.currentTarget.getAttribute("data-category-id")!
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView() document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView()
}, []) }
return <div className="sticker-picker" style={style}> return <div className="sticker-picker" style={style}>
<div className="emoji-category-bar" ref={emojiCategoryBarRef}> <div className="emoji-category-bar" ref={emojiCategoryBarRef}>
@ -76,12 +74,12 @@ const StickerPicker = ({ style, onSelect, room }: MediaPickerProps) => {
<div className="emoji-search"> <div className="emoji-search">
<input <input
autoFocus={!isMobileDevice} autoFocus={!isMobileDevice}
onChange={onChangeQuery} onChange={evt => setQuery(evt.target.value)}
value={query} value={query}
type="search" type="search"
placeholder="Search stickers" placeholder="Search stickers"
/> />
<button onClick={clearQuery} disabled={query === ""}> <button onClick={() => setQuery("")} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>} {query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button> </button>
</div> </div>

View file

@ -13,10 +13,9 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 * as beeper from "@/api/beeper.ts"
import type Client from "@/api/client.ts" import type Client from "@/api/client.ts"
import useEvent from "@/util/useEvent.ts"
interface BeeperLoginProps { interface BeeperLoginProps {
domain: string domain: string
@ -29,18 +28,18 @@ const BeeperLogin = ({ domain, client }: BeeperLoginProps) => {
const [code, setCode] = useState("") const [code, setCode] = useState("")
const [error, setError] = useState("") const [error, setError] = useState("")
const onChangeEmail = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => { const onChangeEmail = (evt: React.ChangeEvent<HTMLInputElement>) => {
setEmail(evt.target.value) 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) let codeDigits = evt.target.value.replace(/\D/g, "").slice(0, 6)
if (codeDigits.length > 3) { if (codeDigits.length > 3) {
codeDigits = codeDigits.slice(0, 3) + " " + codeDigits.slice(3) codeDigits = codeDigits.slice(0, 3) + " " + codeDigits.slice(3)
} }
setCode(codeDigits) setCode(codeDigits)
}, []) }
const requestCode = useEvent((evt: React.FormEvent) => { const requestCode = (evt: React.FormEvent) => {
evt.preventDefault() evt.preventDefault()
beeper.doStartLogin(domain).then( beeper.doStartLogin(domain).then(
request => beeper.doRequestCode(domain, request, email).then( request => beeper.doRequestCode(domain, request, email).then(
@ -49,8 +48,8 @@ const BeeperLogin = ({ domain, client }: BeeperLoginProps) => {
), ),
err => setError(`Failed to start login: ${err}`), err => setError(`Failed to start login: ${err}`),
) )
}) }
const submitCode = useEvent((evt: React.FormEvent) => { const submitCode = (evt: React.FormEvent) => {
evt.preventDefault() evt.preventDefault()
beeper.doSubmitCode(domain, requestID, code).then( beeper.doSubmitCode(domain, requestID, code).then(
token => { token => {
@ -61,7 +60,7 @@ const BeeperLogin = ({ domain, client }: BeeperLoginProps) => {
}, },
err => setError(`Failed to submit code: ${err}`), err => setError(`Failed to submit code: ${err}`),
) )
}) }
return <form onSubmit={requestID ? submitCode : requestCode} className="beeper-login"> return <form onSubmit={requestID ? submitCode : requestCode} className="beeper-login">
<h2>Beeper email login</h2> <h2>Beeper email login</h2>

View file

@ -16,7 +16,6 @@
import React, { useCallback, useEffect, useState } from "react" import React, { useCallback, useEffect, useState } from "react"
import type Client from "@/api/client.ts" import type Client from "@/api/client.ts"
import type { ClientState } from "@/api/types" import type { ClientState } from "@/api/types"
import useEvent from "@/util/useEvent.ts"
import BeeperLogin from "./BeeperLogin.tsx" import BeeperLogin from "./BeeperLogin.tsx"
import "./LoginScreen.css" import "./LoginScreen.css"
@ -34,7 +33,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
const [loginFlows, setLoginFlows] = useState<string[] | null>(null) const [loginFlows, setLoginFlows] = useState<string[] | null>(null)
const [error, setError] = useState("") const [error, setError] = useState("")
const loginSSO = useEvent(() => { const loginSSO = () => {
fetch("_gomuks/sso", { fetch("_gomuks/sso", {
method: "POST", method: "POST",
body: JSON.stringify({ homeserver_url: homeserverURL }), body: JSON.stringify({ homeserver_url: homeserverURL }),
@ -53,9 +52,9 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
}, },
err => setError(`Failed to start SSO login: ${err}`), err => setError(`Failed to start SSO login: ${err}`),
) )
}) }
const login = useEvent((evt: React.FormEvent) => { const login = (evt: React.FormEvent) => {
evt.preventDefault() evt.preventDefault()
if (!loginFlows) { if (!loginFlows) {
// do nothing // do nothing
@ -67,7 +66,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
err => setError(err.toString()), err => setError(err.toString()),
) )
} }
}) }
const resolveLoginFlows = useCallback((serverURL: string) => { const resolveLoginFlows = useCallback((serverURL: string) => {
client.rpc.getLoginFlows(serverURL).then( client.rpc.getLoginFlows(serverURL).then(
@ -108,16 +107,10 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
clearTimeout(timeout) clearTimeout(timeout)
} }
}, [homeserverURL, loginFlows, resolveLoginFlows]) }, [homeserverURL, loginFlows, resolveLoginFlows])
const onChangeUsername = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => { const onChangeHomeserverURL = (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>) => {
setLoginFlows(null) setLoginFlows(null)
setHomeserverURL(evt.target.value) setHomeserverURL(evt.target.value)
}, []) }
const supportsSSO = loginFlows?.includes("m.login.sso") ?? false const supportsSSO = loginFlows?.includes("m.login.sso") ?? false
const supportsPassword = loginFlows?.includes("m.login.password") const supportsPassword = loginFlows?.includes("m.login.password")
@ -130,7 +123,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
id="mxlogin-username" id="mxlogin-username"
placeholder="User ID" placeholder="User ID"
value={username} value={username}
onChange={onChangeUsername} onChange={evt => setUsername(evt.target.value)}
/> />
<input <input
type="text" type="text"
@ -144,7 +137,7 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
id="mxlogin-password" id="mxlogin-password"
placeholder="Password" placeholder="Password"
value={password} value={password}
onChange={onChangePassword} onChange={evt => setPassword(evt.target.value)}
/>} />}
<div className="buttons"> <div className="buttons">
{supportsSSO && <button {supportsSSO && <button

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 { LoginScreenProps } from "./LoginScreen.tsx"
import "./LoginScreen.css" import "./LoginScreen.css"
@ -24,13 +24,13 @@ export const VerificationScreen = ({ client, clientState }: LoginScreenProps) =>
const [recoveryKey, setRecoveryKey] = useState("") const [recoveryKey, setRecoveryKey] = useState("")
const [error, setError] = useState("") const [error, setError] = useState("")
const verify = useCallback((evt: React.FormEvent) => { const verify = (evt: React.FormEvent) => {
evt.preventDefault() evt.preventDefault()
client.rpc.verify(recoveryKey).then( client.rpc.verify(recoveryKey).then(
() => {}, () => {},
err => setError(err.toString()), err => setError(err.toString()),
) )
}, [recoveryKey, client]) }
return <main className="matrix-login"> return <main className="matrix-login">
<h1>gomuks web</h1> <h1>gomuks web</h1>

View file

@ -45,7 +45,7 @@ export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
history.back() history.back()
} }
}, []) }, [])
const onKeyWrapper = useCallback((evt: React.KeyboardEvent<HTMLDivElement>) => { const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => {
if (evt.key === "Escape") { if (evt.key === "Escape") {
setState(null) setState(null)
if (history.state?.modal) { if (history.state?.modal) {
@ -53,7 +53,7 @@ export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
} }
} }
evt.stopPropagation() evt.stopPropagation()
}, []) }
const openModal = useCallback((newState: ModalState) => { const openModal = useCallback((newState: ModalState) => {
if (!history.state?.modal) { if (!history.state?.modal) {
history.pushState({ ...(history.state ?? {}), modal: true }, "") history.pushState({ ...(history.state ?? {}), modal: true }, "")

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 { getAvatarURL } from "@/api/media.ts"
import { MemDBEvent, MemberEventContent } from "@/api/types" import { MemDBEvent, MemberEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts" import { getDisplayname } from "@/util/validation.ts"
@ -45,8 +45,6 @@ const MemberRow = ({ evt, onClick }: MemberRowProps) => {
const MemberList = () => { const MemberList = () => {
const [filter, setFilter] = useState("") const [filter, setFilter] = useState("")
const [limit, setLimit] = useState(30) 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) const roomCtx = use(RoomContext)
if (roomCtx?.store && !roomCtx?.store.membersRequested && !roomCtx?.store.fullMembersLoaded) { if (roomCtx?.store && !roomCtx?.store.membersRequested && !roomCtx?.store.fullMembersLoaded) {
roomCtx.store.membersRequested = true roomCtx.store.membersRequested = true
@ -69,10 +67,15 @@ const MemberList = () => {
} }
} }
return <> 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"> <div className="member-list">
{members} {members}
{memberEvents.length > limit ? <button onClick={increaseLimit}> {memberEvents.length > limit ? <button onClick={() => setLimit(limit => limit + 50)}>
and {memberEvents.length - limit} others and {memberEvents.length - limit} others
</button> : null} </button> : null}
</div> </div>

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 { ScaleLoader } from "react-spinners"
import Client from "@/api/client.ts" import Client from "@/api/client.ts"
import { RoomStateStore } from "@/api/statestore" import { RoomStateStore } from "@/api/statestore"
@ -34,17 +34,19 @@ const DeviceList = ({ client, room, userID }: DeviceListProps) => {
const [view, setEncryptionInfo] = useState<ProfileEncryptionInfo | null>(null) const [view, setEncryptionInfo] = useState<ProfileEncryptionInfo | null>(null)
const [errors, setErrors] = useState<string[] | null>(null) const [errors, setErrors] = useState<string[] | null>(null)
const [trackChangePending, startTransition] = useTransition() const [trackChangePending, startTransition] = useTransition()
const doTrackDeviceList = useCallback(() => { const doTrackDeviceList = () => {
startTransition(async () => { startTransition(async () => {
try { try {
const resp = await client.rpc.trackUserDevices(userID) const resp = await client.rpc.trackUserDevices(userID)
setEncryptionInfo(resp) startTransition(() => {
setErrors(resp.errors) setEncryptionInfo(resp)
setErrors(resp.errors)
})
} catch (err) { } catch (err) {
setErrors([`${err}`]) startTransition(() => setErrors([`${err}`]))
} }
}) })
}, [client, userID]) }
useEffect(() => { useEffect(() => {
setEncryptionInfo(null) setEncryptionInfo(null)
setErrors(null) setErrors(null)

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts" import { useEventAsState } from "@/util/eventdispatcher.ts"
import reverseMap from "@/util/reversemap.ts" import reverseMap from "@/util/reversemap.ts"
@ -38,18 +38,18 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
const [roomFilter, setRoomFilter] = useState("") const [roomFilter, setRoomFilter] = useState("")
const [realRoomFilter, setRealRoomFilter] = useState("") const [realRoomFilter, setRealRoomFilter] = useState("")
const updateRoomFilter = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => { const updateRoomFilter = (evt: React.ChangeEvent<HTMLInputElement>) => {
setRoomFilter(evt.target.value) setRoomFilter(evt.target.value)
client.store.currentRoomListFilter = toSearchableString(evt.target.value) client.store.currentRoomListFilter = toSearchableString(evt.target.value)
setRealRoomFilter(client.store.currentRoomListFilter) setRealRoomFilter(client.store.currentRoomListFilter)
}, [client]) }
const clearQuery = useCallback(() => { const clearQuery = () => {
setRoomFilter("") setRoomFilter("")
client.store.currentRoomListFilter = "" client.store.currentRoomListFilter = ""
setRealRoomFilter("") setRealRoomFilter("")
roomFilterRef.current?.focus() roomFilterRef.current?.focus()
}, [client]) }
const onKeyDown = useCallback((evt: React.KeyboardEvent<HTMLInputElement>) => { const onKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
const key = keyToString(evt) const key = keyToString(evt)
if (key === "Escape") { if (key === "Escape") {
clearQuery() clearQuery()
@ -62,7 +62,7 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
evt.stopPropagation() evt.stopPropagation()
evt.preventDefault() evt.preventDefault()
} }
}, [mainScreen, client.store, clearQuery]) }
return <div className="room-list-wrapper"> return <div className="room-list-wrapper">
<div className="room-search-wrapper"> <div className="room-search-wrapper">

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 { ScaleLoader } from "react-spinners"
import { getAvatarURL, getRoomAvatarURL } from "@/api/media.ts" import { getAvatarURL, getRoomAvatarURL } from "@/api/media.ts"
import { InvitedRoomStore } from "@/api/statestore/invitedroom.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 [loading, setLoading] = useState(false)
const [buttonClicked, setButtonClicked] = useState(false) const [buttonClicked, setButtonClicked] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const doJoinRoom = useCallback(() => { const doJoinRoom = () => {
let realVia = via let realVia = via
if (!via?.length && invite?.invited_by) { if (!via?.length && invite?.invited_by) {
realVia = [getServerName(invite.invited_by)] realVia = [getServerName(invite.invited_by)]
@ -54,8 +54,8 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
setButtonClicked(false) setButtonClicked(false)
}, },
) )
}, [client, roomID, via, alias, invite]) }
const doRejectInvite = useCallback(() => { const doRejectInvite = () => {
setButtonClicked(true) setButtonClicked(true)
client.rpc.leaveRoom(roomID).then( client.rpc.leaveRoom(roomID).then(
() => { () => {
@ -67,7 +67,7 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
setButtonClicked(false) setButtonClicked(false)
}, },
) )
}, [client, mainScreen, roomID]) }
useEffect(() => { useEffect(() => {
setSummary(null) setSummary(null)
setError(null) setError(null)

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 { getRoomAvatarURL } from "@/api/media.ts"
import { RoomStateStore } from "@/api/statestore" import { RoomStateStore } from "@/api/statestore"
import { useEventAsState } from "@/util/eventdispatcher.ts" import { useEventAsState } from "@/util/eventdispatcher.ts"
@ -35,14 +35,14 @@ const RoomViewHeader = ({ room }: RoomViewHeaderProps) => {
const roomMeta = useEventAsState(room.meta) const roomMeta = useEventAsState(room.meta)
const mainScreen = use(MainScreenContext) const mainScreen = use(MainScreenContext)
const openModal = use(ModalContext) const openModal = use(ModalContext)
const openSettings = useCallback(() => { const openSettings = () => {
openModal({ openModal({
dimmed: true, dimmed: true,
boxed: true, boxed: true,
innerBoxClass: "settings-view", innerBoxClass: "settings-view",
content: <SettingsView room={room} />, content: <SettingsView room={room} />,
}) })
}, [room, openModal]) }
return <div className="room-header"> return <div className="room-header">
<button className="back" onClick={mainScreen.clearActiveRoom}><BackIcon/></button> <button className="back" onClick={mainScreen.clearActiveRoom}><BackIcon/></button>
<img <img

View file

@ -45,52 +45,39 @@ interface PreferenceCellProps<T extends PreferenceValueType> {
inheritedValue: T inheritedValue: T
} }
const useRemover = ( const makeRemover = (
context: PreferenceContext, setPref: SetPrefFunc, name: keyof Preferences, value: PreferenceValueType | undefined, context: PreferenceContext, setPref: SetPrefFunc, name: keyof Preferences, value: PreferenceValueType | undefined,
) => { ) => {
const onClear = useCallback(() => {
setPref(context, name, undefined)
}, [setPref, context, name])
if (value === undefined) { if (value === undefined) {
return null 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 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"> return <div className="preference boolean-preference">
<Toggle checked={value ?? inheritedValue} onChange={onChange}/> <Toggle checked={value ?? inheritedValue} onChange={evt => setPref(context, name, evt.target.checked)}/>
{useRemover(context, setPref, name, value)} {makeRemover(context, setPref, name, value)}
</div> </div>
} }
const TextPreferenceCell = ({ context, name, setPref, value, inheritedValue }: PreferenceCellProps<string>) => { 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"> return <div className="preference string-preference">
<input value={value ?? inheritedValue} onChange={onChange}/> <input value={value ?? inheritedValue} onChange={evt => setPref(context, name, evt.target.value)}/>
{useRemover(context, setPref, name, value)} {makeRemover(context, setPref, name, value)}
</div> </div>
} }
const SelectPreferenceCell = ({ context, name, pref, setPref, value, inheritedValue }: PreferenceCellProps<string>) => { 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) { if (!pref.allowedValues) {
return null return null
} }
return <div className="preference select-preference"> 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 => {pref.allowedValues.map(value =>
<option key={value} value={value}>{value}</option>)} <option key={value} value={value}>{value}</option>)}
</select> </select>
{remover} {makeRemover(context, setPref, name, value)}
</div> </div>
} }
@ -186,7 +173,7 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta
const client = use(ClientContext)! const client = use(ClientContext)!
const appliedContext = getActiveCSSContext(client, room) const appliedContext = getActiveCSSContext(client, room)
const [context, setContext] = useState(appliedContext) const [context, setContext] = useState(appliedContext)
const getContextText = useCallback((context: PreferenceContext) => { const getContextText = (context: PreferenceContext) => {
if (context === PreferenceContext.Account) { if (context === PreferenceContext.Account) {
return client.store.serverPreferenceCache.custom_css return client.store.serverPreferenceCache.custom_css
} else if (context === PreferenceContext.Device) { } else if (context === PreferenceContext.Device) {
@ -196,17 +183,17 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta
} else if (context === PreferenceContext.RoomDevice) { } else if (context === PreferenceContext.RoomDevice) {
return room.localPreferenceCache.custom_css return room.localPreferenceCache.custom_css
} }
}, [client, room]) }
const origText = getContextText(context) const origText = getContextText(context)
const [text, setText] = useState(origText ?? "") 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 const newContext = evt.target.value as PreferenceContext
setContext(newContext) setContext(newContext)
setText(getContextText(newContext) ?? "") setText(getContextText(newContext) ?? "")
}, [getContextText]) }
const onChangeText = useCallback((evt: React.ChangeEvent<HTMLTextAreaElement>) => { const onChangeText = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(evt.target.value) setText(evt.target.value)
}, []) }
const onSave = useEvent(() => { const onSave = useEvent(() => {
if (vscodeOpen) { if (vscodeOpen) {
setText(vscodeContentRef.current) setText(vscodeContentRef.current)
@ -215,18 +202,18 @@ const CustomCSSInput = ({ setPref, room }: { setPref: SetPrefFunc, room: RoomSta
setPref(context, "custom_css", text) setPref(context, "custom_css", text)
} }
}) })
const onDelete = useEvent(() => { const onDelete = () => {
setPref(context, "custom_css", undefined) setPref(context, "custom_css", undefined)
setText("") setText("")
}) }
const [vscodeOpen, setVSCodeOpen] = useState(false) const [vscodeOpen, setVSCodeOpen] = useState(false)
const vscodeContentRef = useRef("") const vscodeContentRef = useRef("")
const vscodeInitialContentRef = useRef("") const vscodeInitialContentRef = useRef("")
const onClickVSCode = useEvent(() => { const onClickVSCode = () => {
vscodeContentRef.current = text vscodeContentRef.current = text
vscodeInitialContentRef.current = text vscodeInitialContentRef.current = text
setVSCodeOpen(true) setVSCodeOpen(true)
}) }
const closeVSCode = useCallback(() => { const closeVSCode = useCallback(() => {
setVSCodeOpen(false) setVSCodeOpen(false)
setText(vscodeContentRef.current) setText(vscodeContentRef.current)
@ -296,7 +283,9 @@ const SettingsView = ({ room }: SettingsViewProps) => {
const roomMeta = useEventAsState(room.meta) const roomMeta = useEventAsState(room.meta)
const client = use(ClientContext)! const client = use(ClientContext)!
const closeModal = use(ModalCloseContext) 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) { if (context === PreferenceContext.Account) {
client.rpc.setAccountData("fi.mau.gomuks.preferences", { client.rpc.setAccountData("fi.mau.gomuks.preferences", {
...client.store.serverPreferenceCache, ...client.store.serverPreferenceCache,
@ -321,15 +310,15 @@ const SettingsView = ({ room }: SettingsViewProps) => {
} }
} }
}, [client, room]) }, [client, room])
const onClickLogout = useCallback(() => { const onClickLogout = () => {
if (window.confirm("Really log out and delete all local data?")) { if (window.confirm("Really log out and delete all local data?")) {
client.logout().then( client.logout().then(
() => console.info("Successfully logged out"), () => console.info("Successfully logged out"),
err => window.alert(`Failed to log out: ${err}`), err => window.alert(`Failed to log out: ${err}`),
) )
} }
}, [client]) }
const onClickLeave = useCallback(() => { const onClickLeave = () => {
if (window.confirm(`Really leave ${room.meta.current.name}?`)) { if (window.confirm(`Really leave ${room.meta.current.name}?`)) {
client.rpc.leaveRoom(room.roomID).then( client.rpc.leaveRoom(room.roomID).then(
() => { () => {
@ -339,7 +328,7 @@ const SettingsView = ({ room }: SettingsViewProps) => {
err => window.alert(`Failed to leave room: ${err}`), err => window.alert(`Failed to leave room: ${err}`),
) )
} }
}, [client, room, closeModal]) }
usePreferences(client.store, room) usePreferences(client.store, room)
const globalServer = client.store.serverPreferenceCache const globalServer = client.store.serverPreferenceCache
const globalLocal = client.store.localPreferenceCache const globalLocal = client.store.localPreferenceCache

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
import { useRoomMember } from "@/api/statestore" import { useRoomMember } from "@/api/statestore"
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types" import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
@ -79,7 +79,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
const mainScreen = use(MainScreenContext) const mainScreen = use(MainScreenContext)
const openModal = use(ModalContext) const openModal = use(ModalContext)
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false) const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
const onContextMenu = useCallback((mouseEvt: React.MouseEvent) => { const onContextMenu = (mouseEvt: React.MouseEvent) => {
const targetElem = mouseEvt.target as HTMLElement const targetElem = mouseEvt.target as HTMLElement
if ( if (
!roomCtx.store.preferences.message_context_menu !roomCtx.store.preferences.message_context_menu
@ -97,7 +97,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies }: TimelineEven
style={getModalStyleFromMouse(mouseEvt, 9 * 40)} style={getModalStyleFromMouse(mouseEvt, 9 * 40)}
/>, />,
}) })
}, [openModal, evt, roomCtx]) }
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender) const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
const BodyType = getBodyType(evt) const BodyType = getBodyType(evt)

View file

@ -46,13 +46,13 @@ const TimelineView = () => {
// When the user scrolls the timeline manually, remember if they were at the bottom, // 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. // so that we can keep them at the bottom when new events are added.
const handleScroll = useCallback(() => { const handleScroll = () => {
if (!timelineViewRef.current) { if (!timelineViewRef.current) {
return return
} }
const timelineView = timelineViewRef.current const timelineView = timelineViewRef.current
roomCtx.scrolledToBottom = timelineView.scrollTop + timelineView.clientHeight + 1 >= timelineView.scrollHeight 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. // Save the scroll height prior to updating the timeline, so that we can adjust the scroll position if needed.
if (timelineViewRef.current) { if (timelineViewRef.current) {
oldScrollHeight.current = timelineViewRef.current.scrollHeight oldScrollHeight.current = timelineViewRef.current.scrollHeight

View file

@ -13,9 +13,9 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 { MemDBEvent } from "@/api/types"
import useEvent from "@/util/useEvent.ts" import { isMobileDevice } from "@/util/ismobile.ts"
import { ModalCloseContext } from "../../modal/Modal.tsx" import { ModalCloseContext } from "../../modal/Modal.tsx"
import TimelineEvent from "../TimelineEvent.tsx" import TimelineEvent from "../TimelineEvent.tsx"
@ -33,14 +33,11 @@ const ConfirmWithMessageModal = ({
}: ConfirmWithMessageProps) => { }: ConfirmWithMessageProps) => {
const [reason, setReason] = useState("") const [reason, setReason] = useState("")
const closeModal = use(ModalCloseContext) const closeModal = use(ModalCloseContext)
const onConfirmWrapped = useEvent((evt: React.FormEvent) => { const onConfirmWrapped = (evt: React.FormEvent) => {
evt.preventDefault() evt.preventDefault()
closeModal() closeModal()
onConfirm(reason) onConfirm(reason)
}) }
const onChangeReason = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
setReason(evt.target.value)
}, [])
return <form onSubmit={onConfirmWrapped}> return <form onSubmit={onConfirmWrapped}>
<h3>{title}</h3> <h3>{title}</h3>
<div className="timeline-event-container"> <div className="timeline-event-container">
@ -49,7 +46,13 @@ const ConfirmWithMessageModal = ({
<div className="confirm-description"> <div className="confirm-description">
{description} {description}
</div> </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"> <div className="confirm-buttons">
<button type="button" onClick={closeModal}>Cancel</button> <button type="button" onClick={closeModal}>Cancel</button>
<button type="submit">{confirmButton}</button> <button type="submit">{confirmButton}</button>

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 Client from "@/api/client.ts"
import { MemDBEvent } from "@/api/types" import { MemDBEvent } from "@/api/types"
import { emojiToReactionContent } from "@/util/emoji" import { emojiToReactionContent } from "@/util/emoji"
@ -43,11 +43,11 @@ export const usePrimaryItems = (
const closeModal = !isHover ? use(ModalCloseContext) : noop const closeModal = !isHover ? use(ModalCloseContext) : noop
const openModal = use(ModalContext) const openModal = use(ModalContext)
const onClickReply = useCallback(() => { const onClickReply = () => {
roomCtx.setReplyTo(evt.event_id) roomCtx.setReplyTo(evt.event_id)
closeModal() closeModal()
}, [roomCtx, evt.event_id, closeModal]) }
const onClickReact = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => { const onClickReact = (mevt: React.MouseEvent<HTMLButtonElement>) => {
const emojiPickerHeight = 34 * 16 const emojiPickerHeight = 34 * 16
setForceOpen?.(true) setForceOpen?.(true)
openModal({ openModal({
@ -63,20 +63,20 @@ export const usePrimaryItems = (
/>, />,
onClose: () => setForceOpen?.(false), onClose: () => setForceOpen?.(false),
}) })
}, [client, roomCtx, evt, style, setForceOpen, openModal]) }
const onClickEdit = useCallback(() => { const onClickEdit = () => {
closeModal() closeModal()
roomCtx.setEditing(evt) roomCtx.setEditing(evt)
}, [roomCtx, evt, closeModal]) }
const onClickResend = useCallback(() => { const onClickResend = () => {
if (!evt.transaction_id) { if (!evt.transaction_id) {
return return
} }
closeModal() closeModal()
client.resendEvent(evt.transaction_id) client.resendEvent(evt.transaction_id)
.catch(err => window.alert(`Failed to resend message: ${err}`)) .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 const moreMenuHeight = 4 * 40
setForceOpen!(true) setForceOpen!(true)
openModal({ openModal({
@ -87,7 +87,7 @@ export const usePrimaryItems = (
/>, />,
onClose: () => setForceOpen!(false), onClose: () => setForceOpen!(false),
}) })
}, [evt, roomCtx, setForceOpen, openModal]) }
const isEditing = useEventAsState(roomCtx.isEditing) const isEditing = useEventAsState(roomCtx.isEditing)
const [isPending, pendingTitle] = getPending(evt) const [isPending, pendingTitle] = getPending(evt)
const isEncrypted = getEncryption(roomCtx.store) const isEncrypted = getEncryption(roomCtx.store)

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 Client from "@/api/client.ts"
import { useRoomState } from "@/api/statestore" import { useRoomState } from "@/api/statestore"
import { MemDBEvent } from "@/api/types" import { MemDBEvent } from "@/api/types"
@ -35,14 +35,14 @@ export const useSecondaryItems = (
) => { ) => {
const closeModal = use(ModalCloseContext) const closeModal = use(ModalCloseContext)
const openModal = use(ModalContext) const openModal = use(ModalContext)
const onClickViewSource = useCallback(() => { const onClickViewSource = () => {
openModal({ openModal({
dimmed: true, dimmed: true,
boxed: true, boxed: true,
content: <JSONView data={evt} />, content: <JSONView data={evt} />,
}) })
}, [evt, openModal]) }
const onClickReport = useCallback(() => { const onClickReport = () => {
openModal({ openModal({
dimmed: true, dimmed: true,
boxed: true, boxed: true,
@ -61,8 +61,8 @@ export const useSecondaryItems = (
/> />
</RoomContext>, </RoomContext>,
}) })
}, [evt, roomCtx, openModal, client]) }
const onClickRedact = useCallback(() => { const onClickRedact = () => {
openModal({ openModal({
dimmed: true, dimmed: true,
boxed: true, boxed: true,
@ -81,17 +81,12 @@ export const useSecondaryItems = (
/> />
</RoomContext>, </RoomContext>,
}) })
}, [evt, roomCtx, openModal, client]) }
const onClickPin = useCallback(() => { const onClickPin = (pin: boolean) => () => {
closeModal() closeModal()
client.pinMessage(roomCtx.store, evt.event_id, true) client.pinMessage(roomCtx.store, evt.event_id, pin)
.catch(err => window.alert(`Failed to pin message: ${err}`)) .catch(err => window.alert(`Failed to ${pin ? "pin" : "unpin"} 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])
const [isPending, pendingTitle] = getPending(evt) const [isPending, pendingTitle] = getPending(evt)
useRoomState(roomCtx.store, "m.room.power_levels", "") useRoomState(roomCtx.store, "m.room.power_levels", "")
@ -109,8 +104,12 @@ export const useSecondaryItems = (
return <> return <>
<button onClick={onClickViewSource}><ViewSourceIcon/>View source</button> <button onClick={onClickViewSource}><ViewSourceIcon/>View source</button>
{ownPL >= pinPL && (pins.includes(evt.event_id) {ownPL >= pinPL && (pins.includes(evt.event_id)
? <button onClick={onClickUnpin}><UnpinIcon/>Unpin message</button> ? <button onClick={onClickPin(false)}>
: <button onClick={onClickPin} title={pendingTitle} disabled={isPending}><PinIcon/>Pin message</button>)} <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> <button onClick={onClickReport} disabled={isPending} title={pendingTitle}><ReportIcon/>Report</button>
{canRedact && <button {canRedact && <button
onClick={onClickRedact} onClick={onClickRedact}

View file

@ -14,7 +14,6 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { CSSProperties } from "react" import React, { CSSProperties } from "react"
import useEvent from "@/util/useEvent.ts"
import "./ResizeHandle.css" import "./ResizeHandle.css"
export interface ResizeHandleProps { export interface ResizeHandleProps {
@ -28,7 +27,7 @@ export interface ResizeHandleProps {
} }
const ResizeHandle = ({ width, minWidth, maxWidth, setWidth, style, className, inverted }: 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 origWidth = width
const startPos = evt.clientX const startPos = evt.clientX
const onMouseMove = (evt: MouseEvent) => { const onMouseMove = (evt: MouseEvent) => {
@ -46,7 +45,7 @@ const ResizeHandle = ({ width, minWidth, maxWidth, setWidth, style, className, i
document.addEventListener("mousemove", onMouseMove) document.addEventListener("mousemove", onMouseMove)
document.addEventListener("mouseup", onMouseUp) document.addEventListener("mouseup", onMouseUp)
evt.preventDefault() evt.preventDefault()
}) }
return <div className={`resize-handle-outer ${className ?? ""}`} style={style}> return <div className={`resize-handle-outer ${className ?? ""}`} style={style}>
<div className="resize-handle-inner" onMouseDown={onMouseDown}/> <div className="resize-handle-inner" onMouseDown={onMouseDown}/>
</div> </div>