diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f057a1b..6494af9 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -16,7 +16,7 @@ import { CachedEventDispatcher } from "../util/eventdispatcher.ts" import RPCClient, { SendMessageParams } from "./rpc.ts" import { RoomStateStore, StateStore } from "./statestore" -import type { ClientState, EventID, RPCEvent, RoomID, UserID } from "./types" +import type { ClientState, EventID, EventType, RPCEvent, RoomID, UserID } from "./types" export default class Client { readonly state = new CachedEventDispatcher() @@ -75,6 +75,19 @@ export default class Client { await this.rpc.setState(room.roomID, "m.room.pinned_events", "", { pinned: pinnedEvents }) } + async sendEvent(roomID: RoomID, type: EventType, content: Record): Promise { + const room = this.store.rooms.get(roomID) + if (!room) { + throw new Error("Room not found") + } + const dbEvent = await this.rpc.sendEvent(roomID, type, content) + if (!room.eventsByRowID.has(dbEvent.rowid)) { + room.pendingEvents.push(dbEvent.rowid) + room.applyEvent(dbEvent, true) + room.notifyTimelineSubscribers() + } + } + async sendMessage(params: SendMessageParams): Promise { const room = this.store.rooms.get(params.room_id) if (!room) { diff --git a/web/src/icons/emoji-categories/activities.svg b/web/src/icons/emoji-categories/activities.svg new file mode 100644 index 0000000..5162f7d --- /dev/null +++ b/web/src/icons/emoji-categories/activities.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/emoji-categories/animals-nature.svg b/web/src/icons/emoji-categories/animals-nature.svg new file mode 100644 index 0000000..efab71b --- /dev/null +++ b/web/src/icons/emoji-categories/animals-nature.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/emoji-categories/flags.svg b/web/src/icons/emoji-categories/flags.svg new file mode 100644 index 0000000..bab5214 --- /dev/null +++ b/web/src/icons/emoji-categories/flags.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/emoji-categories/food-beverage.svg b/web/src/icons/emoji-categories/food-beverage.svg new file mode 100644 index 0000000..8b336e1 --- /dev/null +++ b/web/src/icons/emoji-categories/food-beverage.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/emoji-categories/objects.svg b/web/src/icons/emoji-categories/objects.svg new file mode 100644 index 0000000..15b72da --- /dev/null +++ b/web/src/icons/emoji-categories/objects.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/emoji-categories/people-body.svg b/web/src/icons/emoji-categories/people-body.svg new file mode 100644 index 0000000..be6bdd2 --- /dev/null +++ b/web/src/icons/emoji-categories/people-body.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/emoji-categories/smileys-emotion.svg b/web/src/icons/emoji-categories/smileys-emotion.svg new file mode 100644 index 0000000..9ea109e --- /dev/null +++ b/web/src/icons/emoji-categories/smileys-emotion.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/emoji-categories/symbols.svg b/web/src/icons/emoji-categories/symbols.svg new file mode 100644 index 0000000..9dc60b9 --- /dev/null +++ b/web/src/icons/emoji-categories/symbols.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/emoji-categories/travel-places.svg b/web/src/icons/emoji-categories/travel-places.svg new file mode 100644 index 0000000..97fb25f --- /dev/null +++ b/web/src/icons/emoji-categories/travel-places.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/schedule.svg b/web/src/icons/schedule.svg new file mode 100644 index 0000000..aaefe52 --- /dev/null +++ b/web/src/icons/schedule.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/search.svg b/web/src/icons/search.svg new file mode 100644 index 0000000..169dc75 --- /dev/null +++ b/web/src/icons/search.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index ddf879e..2c4fa58 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -27,6 +27,8 @@ import type { } from "@/api/types" import useEvent from "@/util/useEvent.ts" import { ClientContext } from "../ClientContext.ts" +import EmojiPicker from "../emojipicker/EmojiPicker.tsx" +import { ModalContext } from "../modal/Modal.tsx" import { useRoomContext } from "../roomcontext.ts" import { ReplyBody } from "../timeline/ReplyBody.tsx" import { useMediaContent } from "../timeline/content/useMediaContent.tsx" @@ -34,6 +36,7 @@ import type { AutocompleteQuery } from "./Autocompleter.tsx" import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./getAutocompleter.ts" import AttachIcon from "@/icons/attach.svg?react" import CloseIcon from "@/icons/close.svg?react" +import EmojiIcon from "@/icons/emoji-categories/smileys-emotion.svg?react" import SendIcon from "@/icons/send.svg?react" import "./MessageComposer.css" @@ -73,12 +76,14 @@ const MessageComposer = () => { const roomCtx = useRoomContext() const room = roomCtx.store const client = use(ClientContext)! + const openModal = use(ModalContext) const [autocomplete, setAutocomplete] = useState(null) const [state, setState] = useReducer(composerReducer, uninitedComposer) const [editing, rawSetEditing] = useState(null) const [loadingMedia, setLoadingMedia] = useState(false) const fileInput = useRef(null) const textInput = useRef(null) + const composerRef = useRef(null) const textRows = useRef(1) const typingSentAt = useRef(0) const replyToEvt = useRoomEvent(room, state.replyTo) @@ -308,8 +313,23 @@ const MessageComposer = () => { evt.stopPropagation() roomCtx.setEditing(null) }, [roomCtx]) + const openEmojiPicker = useEvent(() => { + openModal({ + content: { + setState({ + text: state.text.slice(0, textInput.current?.selectionStart ?? 0) + + emoji.u + + state.text.slice(textInput.current?.selectionEnd ?? 0), + }) + }} + />, + onClose: () => textInput.current?.focus(), + }) + }) const Autocompleter = getAutocompleter(autocomplete) - return
+ return
{Autocompleter && autocomplete &&
{ placeholder="Send a message" id="message-composer" /> + ) + } + return
+
+ + {sortedEmojiCategories.map(cat => + , + )} +
+
+ + +
+
+ {cats} + {allowFreeform && query && } +
+ {previewEmoji ?
+
{renderEmoji(previewEmoji)}
+
{previewEmoji.t}
+
:{previewEmoji.n}:
+
:
} +
+} + +export default EmojiPicker diff --git a/web/src/ui/modal/Modal.tsx b/web/src/ui/modal/Modal.tsx index 43b6078..2cc7017 100644 --- a/web/src/ui/modal/Modal.tsx +++ b/web/src/ui/modal/Modal.tsx @@ -27,9 +27,14 @@ type openModal = (state: ModalState) => void export const ModalContext = createContext(() => console.error("Tried to open modal without being inside context")) +export const ModalCloseContext = createContext<() => void>(() => {}) + export const ModalWrapper = ({ children }: { children: React.ReactNode }) => { const [state, setState] = useState(null) - const onClose = useCallback(() => { + const onClickWrapper = useCallback((evt?: React.MouseEvent) => { + if (evt && evt.target !== evt.currentTarget) { + return + } setState(null) state?.onClose?.() }, [state]) @@ -39,9 +44,11 @@ export const ModalWrapper = ({ children }: { children: React.ReactNode }) => { {state &&
- {state.content} + + {state.content} +
} } diff --git a/web/src/ui/timeline/EventMenu.tsx b/web/src/ui/timeline/EventMenu.tsx index 6a15729..f5baa8b 100644 --- a/web/src/ui/timeline/EventMenu.tsx +++ b/web/src/ui/timeline/EventMenu.tsx @@ -13,11 +13,13 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { use, useCallback } from "react" +import { CSSProperties, use, useCallback, useRef } from "react" import { useRoomState } from "@/api/statestore" import { MemDBEvent, PowerLevelEventContent } from "@/api/types" import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" import { ClientContext } from "../ClientContext.ts" +import EmojiPicker from "../emojipicker/EmojiPicker.tsx" +import { ModalContext } from "../modal/Modal.tsx" import { useRoomContext } from "../roomcontext.ts" import EditIcon from "../../icons/edit.svg?react" import MoreIcon from "../../icons/more.svg?react" @@ -29,12 +31,15 @@ import "./EventMenu.css" interface EventHoverMenuProps { evt: MemDBEvent + setForceOpen: (forceOpen: boolean) => void } -const EventMenu = ({ evt }: EventHoverMenuProps) => { +const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => { const client = use(ClientContext)! const userID = client.userID const roomCtx = useRoomContext() + const openModal = use(ModalContext) + const contextMenuRef = useRef(null) const onClickReply = useCallback(() => roomCtx.setReplyTo(evt.event_id), [roomCtx, evt.event_id]) const onClickPin = useCallback(() => { client.pinMessage(roomCtx.store, evt.event_id, true) @@ -45,8 +50,42 @@ const EventMenu = ({ evt }: EventHoverMenuProps) => { .catch(err => window.alert(`Failed to unpin message: ${err}`)) }, [client, roomCtx, evt.event_id]) const onClickReact = useCallback(() => { - window.alert("No reactions yet :(") - }, []) + const reactionButton = contextMenuRef.current?.getElementsByClassName("reaction-button")?.[0] + if (!reactionButton) { + return + } + const rect = reactionButton.getBoundingClientRect() + const style: CSSProperties = { right: window.innerWidth - rect.right } + const emojiPickerHeight = 30 * 16 + if (rect.bottom + emojiPickerHeight > window.innerHeight) { + style.bottom = window.innerHeight - rect.top + } else { + style.top = rect.bottom + } + setForceOpen(true) + openModal({ + content: { + const content: Record = { + "m.relates_to": { + rel_type: "m.annotation", + event_id: evt.event_id, + key: emoji.u, + }, + } + if (emoji.u?.startsWith("mxc://") && emoji.n) { + content["com.beeper.emoji.shortcode"] = emoji.n + } + client.sendEvent(evt.room_id, "m.reaction", content) + .catch(err => window.alert(`Failed to send reaction: ${err}`)) + }} + closeOnSelect={true} + allowFreeform={true} + />, + onClose: () => setForceOpen(false), + }) + }, [client, evt, setForceOpen, openModal]) const onClickEdit = useCallback(() => { roomCtx.setEditing(evt) }, [roomCtx, evt]) @@ -61,19 +100,20 @@ const EventMenu = ({ evt }: EventHoverMenuProps) => { const pins = roomCtx.store.getPinnedEvents() const ownPL = pls.users?.[userID] ?? pls.users_default ?? 0 const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50 - return
- + return
+ {evt.sender === userID && evt.type === "m.room.message" && evt.relation_type !== "m.replace" - && } + && } {ownPL >= pinPL && (pins.includes(evt.event_id) - ? - : )} - + ? + : )} +
} diff --git a/web/src/ui/timeline/TimelineEvent.css b/web/src/ui/timeline/TimelineEvent.css index f0073d1..e4861d9 100644 --- a/web/src/ui/timeline/TimelineEvent.css +++ b/web/src/ui/timeline/TimelineEvent.css @@ -103,7 +103,7 @@ div.timeline-event { display: none; } - &:hover > div.context-menu-container { + &:hover > div.context-menu-container, > div.context-menu-container.force-open { display: block; } diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 57270f4..7069f70 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { use } from "react" +import React, { use, useState } from "react" import { getAvatarURL, getMediaURL } from "@/api/media.ts" import { useRoomState } from "@/api/statestore" import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types" @@ -70,6 +70,7 @@ function isSmallEvent(bodyType: React.FunctionComponent): boo const TimelineEvent = ({ evt, prevEvt }: TimelineEventProps) => { const roomCtx = useRoomContext() const client = use(ClientContext)! + const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false) const memberEvt = useRoomState(roomCtx.store, "m.room.member", evt.sender) const memberEvtContent = memberEvt?.content as MemberEventContent | undefined const BodyType = getBodyType(evt) @@ -95,8 +96,8 @@ const TimelineEvent = ({ evt, prevEvt }: TimelineEventProps) => { const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"] const replyTo = relatesTo?.["m.in_reply_to"]?.event_id const mainEvent =
-
- +
+