1
0
Fork 0
forked from Mirrors/gomuks

web/composer: add support for sending location messages

This commit is contained in:
Tulir Asokan 2024-12-02 19:17:21 +02:00
parent 77219cb26e
commit 12f9031ab1
10 changed files with 207 additions and 32 deletions

View file

@ -42,7 +42,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
}) })
case "send_message": case "send_message":
return unmarshalAndCall(req.Data, func(params *sendMessageParams) (*database.Event, error) { return unmarshalAndCall(req.Data, func(params *sendMessageParams) (*database.Event, error) {
return h.SendMessage(ctx, params.RoomID, params.BaseContent, params.Text, params.RelatesTo, params.Mentions) return h.SendMessage(ctx, params.RoomID, params.BaseContent, params.Extra, params.Text, params.RelatesTo, params.Mentions)
}) })
case "send_event": case "send_event":
return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) { return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
@ -173,6 +173,7 @@ type cancelRequestParams struct {
type sendMessageParams struct { type sendMessageParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
BaseContent *event.MessageEventContent `json:"base_content"` BaseContent *event.MessageEventContent `json:"base_content"`
Extra map[string]any `json:"extra"`
Text string `json:"text"` Text string `json:"text"`
RelatesTo *event.RelatesTo `json:"relates_to"` RelatesTo *event.RelatesTo `json:"relates_to"`
Mentions *event.Mentions `json:"mentions"` Mentions *event.Mentions `json:"mentions"`

View file

@ -66,6 +66,7 @@ func (h *HiClient) SendMessage(
ctx context.Context, ctx context.Context,
roomID id.RoomID, roomID id.RoomID,
base *event.MessageEventContent, base *event.MessageEventContent,
extra map[string]any,
text string, text string,
relatesTo *event.RelatesTo, relatesTo *event.RelatesTo,
mentions *event.Mentions, mentions *event.Mentions,
@ -147,7 +148,7 @@ func (h *HiClient) SendMessage(
content.RelatesTo = relatesTo content.RelatesTo = relatesTo
} }
} }
return h.send(ctx, roomID, event.EventMessage, &content, origText) return h.send(ctx, roomID, event.EventMessage, &event.Content{Parsed: content, Raw: extra}, origText)
} }
func (h *HiClient) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType) error { func (h *HiClient) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType) error {

View file

@ -53,6 +53,7 @@ export class ErrorResponse extends Error {
export interface SendMessageParams { export interface SendMessageParams {
room_id: RoomID room_id: RoomID
base_content?: MessageEventContent base_content?: MessageEventContent
extra?: Record<string, unknown>
text: string text: string
media_path?: string media_path?: string
relates_to?: RelatesTo relates_to?: RelatesTo

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z"/></svg>

After

Width:  |  Height:  |  Size: 446 B

View file

@ -36,7 +36,7 @@ div.message-composer {
} }
} }
> div.composer-media { > div.composer-media, > div.composer-location {
display: flex; display: flex;
padding: .5rem; padding: .5rem;
justify-content: space-between; justify-content: space-between;
@ -47,4 +47,19 @@ div.message-composer {
padding: .5rem; padding: .5rem;
} }
} }
> div.composer-location {
height: 15rem;
> div.location-container {
height: 15rem;
max-width: 40rem;
width: 100%;
> div {
height: 15rem;
width: 100%;
}
}
}
} }

View file

@ -15,7 +15,8 @@
// 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, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react" import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners" import { ScaleLoader } from "react-spinners"
import { useRoomEvent } from "@/api/statestore" import Client from "@/api/client.ts"
import { RoomStateStore, usePreference, useRoomEvent } from "@/api/statestore"
import type { import type {
EventID, EventID,
MediaMessageEventContent, MediaMessageEventContent,
@ -31,6 +32,7 @@ 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 { 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"
@ -40,12 +42,20 @@ import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./get
import AttachIcon from "@/icons/attach.svg?react" import AttachIcon from "@/icons/attach.svg?react"
import CloseIcon from "@/icons/close.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 LocationIcon from "@/icons/location.svg?react"
import SendIcon from "@/icons/send.svg?react" import SendIcon from "@/icons/send.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
location: ComposerLocationValue | null
replyTo: EventID | null replyTo: EventID | null
uninited?: boolean uninited?: boolean
} }
@ -53,7 +63,7 @@ export interface ComposerState {
const isMobileDevice = window.ontouchstart !== undefined && window.innerWidth < 800 const isMobileDevice = window.ontouchstart !== undefined && window.innerWidth < 800
const MAX_TEXTAREA_ROWS = 10 const MAX_TEXTAREA_ROWS = 10
const emptyComposer: ComposerState = { text: "", media: null, replyTo: null } const emptyComposer: ComposerState = { text: "", media: null, replyTo: null, location: null }
const uninitedComposer: ComposerState = { ...emptyComposer, uninited: true } const uninitedComposer: ComposerState = { ...emptyComposer, uninited: true }
const composerReducer = (state: ComposerState, action: Partial<ComposerState>) => const composerReducer = (state: ComposerState, action: Partial<ComposerState>) =>
({ ...state, ...action, uninited: undefined }) ({ ...state, ...action, uninited: undefined })
@ -121,7 +131,7 @@ const MessageComposer = () => {
}, [room.roomID]) }, [room.roomID])
const sendMessage = useEvent((evt: React.FormEvent) => { const sendMessage = useEvent((evt: React.FormEvent) => {
evt.preventDefault() evt.preventDefault()
if (state.text === "" && !state.media) { if (state.text === "" && !state.media && !state.location) {
return return
} }
if (editing) { if (editing) {
@ -156,9 +166,30 @@ const MessageComposer = () => {
relates_to.is_falling_back = false relates_to.is_falling_back = false
} }
} }
let base_content: MessageEventContent | undefined
let extra: Record<string, unknown> | undefined
if (state.media) {
base_content = state.media
} else if (state.location) {
base_content = {
body: "Location",
msgtype: "m.location",
geo_uri: `geo:${state.location.lat},${state.location.long}`,
}
extra = {
"org.matrix.msc3488.asset": {
type: "m.pin",
},
"org.matrix.msc3488.location": {
uri: `geo:${state.location.lat},${state.location.long}`,
description: state.text,
},
}
}
client.sendMessage({ client.sendMessage({
room_id: room.roomID, room_id: room.roomID,
base_content: state.media ?? undefined, base_content,
extra,
text: state.text, text: state.text,
relates_to, relates_to,
mentions, mentions,
@ -290,7 +321,7 @@ const MessageComposer = () => {
if (!res.ok) { if (!res.ok) {
throw new Error(json.error) throw new Error(json.error)
} else { } else {
setState({ media: json }) setState({ media: json, location: null })
} }
}) })
.catch(err => window.alert("Failed to upload file: " + err)) .catch(err => window.alert("Failed to upload file: " + err))
@ -352,14 +383,15 @@ const MessageComposer = () => {
if (state.uninited || editing) { if (state.uninited || editing) {
return return
} }
if (!state.text && !state.media && !state.replyTo) { if (!state.text && !state.media && !state.replyTo && !state.location) {
draftStore.clear(room.roomID) draftStore.clear(room.roomID)
} else { } else {
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 openFilePicker = useCallback(() => fileInput.current!.click(), [])
const clearMedia = useCallback(() => setState({ media: null }), []) const clearMedia = useCallback(() => setState({ media: null, location: null }), [])
const onChangeLocation = useCallback((location: ComposerLocationValue) => setState({ location }), [])
const closeReply = useCallback((evt: React.MouseEvent) => { const closeReply = useCallback((evt: React.MouseEvent) => {
evt.stopPropagation() evt.stopPropagation()
setState({ replyTo: null }) setState({ replyTo: null })
@ -385,7 +417,22 @@ const MessageComposer = () => {
onClose: () => textInput.current?.focus(), onClose: () => 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 locationDisabledTitle: string | undefined
if (state.media) {
mediaDisabledTitle = "You can only attach one file at a time"
locationDisabledTitle = "You can't attach a location to a message with a file"
} else if (state.location) {
mediaDisabledTitle = "You can't attach a file to a message with a location"
locationDisabledTitle = "You can only attach one location at a time"
} else if (loadingMedia) {
mediaDisabledTitle = "Uploading file..."
locationDisabledTitle = "You can't attach a location to a message with a file"
}
return <> return <>
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter {Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
params={autocomplete} params={autocomplete}
@ -411,6 +458,10 @@ const MessageComposer = () => {
/>} />}
{loadingMedia && <div className="composer-media"><ScaleLoader/></div>} {loadingMedia && <div className="composer-media"><ScaleLoader/></div>}
{state.media && <ComposerMedia content={state.media} clearMedia={clearMedia}/>} {state.media && <ComposerMedia content={state.media} clearMedia={clearMedia}/>}
{state.location && <ComposerLocation
room={room} client={client}
location={state.location} onChange={onChangeLocation} clearLocation={clearMedia}
/>}
<div className="input-area"> <div className="input-area">
<textarea <textarea
autoFocus={!isMobileDevice} autoFocus={!isMobileDevice}
@ -426,14 +477,19 @@ const MessageComposer = () => {
id="message-composer" id="message-composer"
/> />
<button onClick={openEmojiPicker}><EmojiIcon/></button> <button onClick={openEmojiPicker}><EmojiIcon/></button>
<button
onClick={openLocationPicker}
disabled={!!locationDisabledTitle}
title={locationDisabledTitle}
><LocationIcon/></button>
<button <button
onClick={openFilePicker} onClick={openFilePicker}
disabled={!!state.media || loadingMedia} disabled={!!mediaDisabledTitle}
title={state.media ? "You can only attach one file at a time" : ""} title={mediaDisabledTitle}
><AttachIcon/></button> ><AttachIcon/></button>
<button <button
onClick={sendMessage} onClick={sendMessage}
disabled={(!state.text && !state.media) || loadingMedia} disabled={(!state.text && !state.media && !state.location) || loadingMedia}
><SendIcon/></button> ><SendIcon/></button>
<input ref={fileInput} onChange={onAttachFile} type="file" value=""/> <input ref={fileInput} onChange={onAttachFile} type="file" value=""/>
</div> </div>
@ -459,4 +515,22 @@ const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
</div> </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

38
web/src/ui/maps/async.tsx Normal file
View file

@ -0,0 +1,38 @@
// 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 { Suspense, lazy } from "react"
import { GridLoader } from "react-spinners"
import type { LeafletPickerProps, LeafletViewerProps } from "./leaflet.tsx"
const locationLoader = <div className="location-importer"><GridLoader color="var(--primary-color)" size={25}/></div>
const LazyLeafletViewer = lazy(
() => import("./leaflet.tsx").then(res => ({ default: res.LeafletViewer })))
const LazyLeafletPicker = lazy(
() => import("./leaflet.tsx").then(res => ({ default: res.LeafletPicker })))
export const LeafletViewer = (props: LeafletViewerProps) => {
return <Suspense fallback={locationLoader}>
<LazyLeafletViewer {...props}/>
</Suspense>
}
export const LeafletPicker = (props: LeafletPickerProps) => {
return <Suspense fallback={locationLoader}>
<LazyLeafletPicker {...props}/>
</Suspense>
}

View file

@ -27,7 +27,7 @@ L.Icon.Default.imagePath = ""
const attribution = `&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors` const attribution = `&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors`
export interface GomuksLeafletProps extends HTMLAttributes<HTMLDivElement> { export interface LeafletViewerProps extends HTMLAttributes<HTMLDivElement> {
tileTemplate: string tileTemplate: string
lat: number lat: number
long: number long: number
@ -35,7 +35,7 @@ export interface GomuksLeafletProps extends HTMLAttributes<HTMLDivElement> {
marker: string marker: string
} }
const GomuksLeaflet = ({ tileTemplate, lat, long, prec, marker, ...rest }: GomuksLeafletProps) => { export const LeafletViewer = ({ tileTemplate, lat, long, prec, marker, ...rest }: LeafletViewerProps) => {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const container = ref.current const container = ref.current
@ -54,4 +54,54 @@ const GomuksLeaflet = ({ tileTemplate, lat, long, prec, marker, ...rest }: Gomuk
return <div {...rest} ref={ref}/> return <div {...rest} ref={ref}/>
} }
export default GomuksLeaflet export interface LocationValue {
lat: number
long: number
prec?: number
}
export interface LeafletPickerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
tileTemplate: string
onChange: (geoURI: LocationValue) => void
initialLocation?: LocationValue
}
export const LeafletPicker = ({ tileTemplate, onChange, initialLocation, ...rest }: LeafletPickerProps) => {
const ref = useRef<HTMLDivElement>(null)
const leafletRef = useRef<L.Map>(null)
const markerRef = useRef<L.Marker>(null)
useEffect(() => {
const container = ref.current
if (!container) {
return
}
const rendered = L.map(container)
if (initialLocation) {
rendered.setView([initialLocation.lat, initialLocation.long], initialLocation.prec ?? 13)
markerRef.current = L.marker([initialLocation.lat, initialLocation.long]).addTo(rendered)
}
leafletRef.current = rendered
L.tileLayer(tileTemplate, { attribution }).addTo(rendered)
return () => {
rendered.remove()
}
// initialLocation is intentionally immutable/only read once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tileTemplate])
useEffect(() => {
const map = leafletRef.current
if (!map) {
return
}
const handler = (evt: L.LeafletMouseEvent) => {
markerRef.current?.removeFrom(map)
markerRef.current = L.marker(evt.latlng).addTo(map)
onChange({ lat: evt.latlng.lat, long: evt.latlng.lng })
}
map.on("click", handler)
return () => {
map.off("click", handler)
}
}, [onChange])
return <div {...rest} ref={ref}/>
}

View file

@ -13,17 +13,15 @@
// //
// 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 { Suspense, lazy, use } from "react" import { use } from "react"
import { GridLoader } from "react-spinners"
import { usePreference } from "@/api/statestore" import { usePreference } from "@/api/statestore"
import { LocationMessageEventContent } from "@/api/types" import { LocationMessageEventContent } from "@/api/types"
import { GOOGLE_MAPS_API_KEY } from "@/util/keys.ts" import { GOOGLE_MAPS_API_KEY } from "@/util/keys.ts"
import { ensureString } from "@/util/validation.ts" import { ensureString } from "@/util/validation.ts"
import ClientContext from "../../ClientContext.ts" import ClientContext from "../../ClientContext.ts"
import { LeafletViewer } from "../../maps/async.tsx"
import EventContentProps from "./props.ts" import EventContentProps from "./props.ts"
const GomuksLeaflet = lazy(() => import("./Leaflet.tsx"))
function parseGeoURI(uri: unknown): [lat: number, long: number, prec: number] { function parseGeoURI(uri: unknown): [lat: number, long: number, prec: number] {
const geoURI = ensureString(uri) const geoURI = ensureString(uri)
if (!geoURI.startsWith("geo:")) { if (!geoURI.startsWith("geo:")) {
@ -31,7 +29,7 @@ function parseGeoURI(uri: unknown): [lat: number, long: number, prec: number] {
} }
try { try {
const [coordinates/*, params*/] = geoURI.slice("geo:".length).split(";") const [coordinates/*, params*/] = geoURI.slice("geo:".length).split(";")
const [lat, long] = coordinates.split(",").map(parseFloat) const [lat, long/*, altitude*/] = coordinates.split(",").map(parseFloat)
// const decodedParams = new URLSearchParams(params) // const decodedParams = new URLSearchParams(params)
const prec = 13 // (+(decodedParams.get("u") ?? 0)) || 13 const prec = 13 // (+(decodedParams.get("u") ?? 0)) || 13
return [lat, long, prec] return [lat, long, prec]
@ -40,8 +38,6 @@ function parseGeoURI(uri: unknown): [lat: number, long: number, prec: number] {
} }
} }
const locationLoader = <div className="location-importer"><GridLoader color="var(--primary-color)" size={25}/></div>
const LocationMessageBody = ({ event, room }: EventContentProps) => { const LocationMessageBody = ({ event, room }: EventContentProps) => {
const content = event.content as LocationMessageEventContent const content = event.content as LocationMessageEventContent
const client = use(ClientContext)! const client = use(ClientContext)!
@ -51,9 +47,7 @@ const LocationMessageBody = ({ event, room }: EventContentProps) => {
const marker = ensureString(content["org.matrix.msc3488.location"]?.description ?? content.body) const marker = ensureString(content["org.matrix.msc3488.location"]?.description ?? content.body)
if (mapProvider === "leaflet") { if (mapProvider === "leaflet") {
return <div className="location-container leaflet"> return <div className="location-container leaflet">
<Suspense fallback={locationLoader}> <LeafletViewer tileTemplate={tileTemplate} lat={lat} long={long} prec={prec} marker={marker}/>
<GomuksLeaflet tileTemplate={tileTemplate} lat={lat} long={long} prec={prec} marker={marker}/>
</Suspense>
</div> </div>
} else if (mapProvider === "google") { } else if (mapProvider === "google") {
const url = `https://www.google.com/maps/embed/v1/place?key=${GOOGLE_MAPS_API_KEY}&q=${lat},${long}` const url = `https://www.google.com/maps/embed/v1/place?key=${GOOGLE_MAPS_API_KEY}&q=${lat},${long}`

View file

@ -254,10 +254,10 @@ div.location-container.leaflet {
height: 25rem; height: 25rem;
width: 100%; width: 100%;
} }
}
> div.location-importer {
display: flex; div.location-importer {
justify-content: center; display: flex;
align-items: center; justify-content: center;
} align-items: center;
} }