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":
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":
return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
@ -173,6 +173,7 @@ type cancelRequestParams struct {
type sendMessageParams struct {
RoomID id.RoomID `json:"room_id"`
BaseContent *event.MessageEventContent `json:"base_content"`
Extra map[string]any `json:"extra"`
Text string `json:"text"`
RelatesTo *event.RelatesTo `json:"relates_to"`
Mentions *event.Mentions `json:"mentions"`

View file

@ -66,6 +66,7 @@ func (h *HiClient) SendMessage(
ctx context.Context,
roomID id.RoomID,
base *event.MessageEventContent,
extra map[string]any,
text string,
relatesTo *event.RelatesTo,
mentions *event.Mentions,
@ -147,7 +148,7 @@ func (h *HiClient) SendMessage(
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 {

View file

@ -53,6 +53,7 @@ export class ErrorResponse extends Error {
export interface SendMessageParams {
room_id: RoomID
base_content?: MessageEventContent
extra?: Record<string, unknown>
text: string
media_path?: string
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;
padding: .5rem;
justify-content: space-between;
@ -47,4 +47,19 @@ div.message-composer {
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/>.
import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
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 {
EventID,
MediaMessageEventContent,
@ -31,6 +32,7 @@ import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
import { keyToString } from "../keybindings.ts"
import { LeafletPicker } from "../maps/async.tsx"
import { ModalContext } from "../modal/Modal.tsx"
import { useRoomContext } from "../roomview/roomcontext.ts"
import { ReplyBody } from "../timeline/ReplyBody.tsx"
@ -40,12 +42,20 @@ import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./get
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 LocationIcon from "@/icons/location.svg?react"
import SendIcon from "@/icons/send.svg?react"
import "./MessageComposer.css"
export interface ComposerLocationValue {
lat: number
long: number
prec?: number
}
export interface ComposerState {
text: string
media: MediaMessageEventContent | null
location: ComposerLocationValue | null
replyTo: EventID | null
uninited?: boolean
}
@ -53,7 +63,7 @@ export interface ComposerState {
const isMobileDevice = window.ontouchstart !== undefined && window.innerWidth < 800
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 composerReducer = (state: ComposerState, action: Partial<ComposerState>) =>
({ ...state, ...action, uninited: undefined })
@ -121,7 +131,7 @@ const MessageComposer = () => {
}, [room.roomID])
const sendMessage = useEvent((evt: React.FormEvent) => {
evt.preventDefault()
if (state.text === "" && !state.media) {
if (state.text === "" && !state.media && !state.location) {
return
}
if (editing) {
@ -156,9 +166,30 @@ const MessageComposer = () => {
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({
room_id: room.roomID,
base_content: state.media ?? undefined,
base_content,
extra,
text: state.text,
relates_to,
mentions,
@ -290,7 +321,7 @@ const MessageComposer = () => {
if (!res.ok) {
throw new Error(json.error)
} else {
setState({ media: json })
setState({ media: json, location: null })
}
})
.catch(err => window.alert("Failed to upload file: " + err))
@ -352,14 +383,15 @@ const MessageComposer = () => {
if (state.uninited || editing) {
return
}
if (!state.text && !state.media && !state.replyTo) {
if (!state.text && !state.media && !state.replyTo && !state.location) {
draftStore.clear(room.roomID)
} else {
draftStore.set(room.roomID, state)
}
}, [roomCtx, room, state, editing])
const openFilePicker = useCallback(() => fileInput.current!.click(), [])
const clearMedia = useCallback(() => setState({ media: null }), [])
const clearMedia = useCallback(() => setState({ media: null, location: null }), [])
const onChangeLocation = useCallback((location: ComposerLocationValue) => setState({ location }), [])
const closeReply = useCallback((evt: React.MouseEvent) => {
evt.stopPropagation()
setState({ replyTo: null })
@ -385,7 +417,22 @@ const MessageComposer = () => {
onClose: () => textInput.current?.focus(),
})
})
const openLocationPicker = useEvent(() => {
setState({ location: { lat: 0, long: 0, prec: 1 }, media: null })
})
const Autocompleter = getAutocompleter(autocomplete, client, room)
let mediaDisabledTitle: string | undefined
let locationDisabledTitle: string | undefined
if (state.media) {
mediaDisabledTitle = "You can only attach one file at a time"
locationDisabledTitle = "You can't attach a location to a message with a file"
} else if (state.location) {
mediaDisabledTitle = "You can't attach a file to a message with a location"
locationDisabledTitle = "You can only attach one location at a time"
} else if (loadingMedia) {
mediaDisabledTitle = "Uploading file..."
locationDisabledTitle = "You can't attach a location to a message with a file"
}
return <>
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
params={autocomplete}
@ -411,6 +458,10 @@ const MessageComposer = () => {
/>}
{loadingMedia && <div className="composer-media"><ScaleLoader/></div>}
{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">
<textarea
autoFocus={!isMobileDevice}
@ -426,14 +477,19 @@ const MessageComposer = () => {
id="message-composer"
/>
<button onClick={openEmojiPicker}><EmojiIcon/></button>
<button
onClick={openLocationPicker}
disabled={!!locationDisabledTitle}
title={locationDisabledTitle}
><LocationIcon/></button>
<button
onClick={openFilePicker}
disabled={!!state.media || loadingMedia}
title={state.media ? "You can only attach one file at a time" : ""}
disabled={!!mediaDisabledTitle}
title={mediaDisabledTitle}
><AttachIcon/></button>
<button
onClick={sendMessage}
disabled={(!state.text && !state.media) || loadingMedia}
disabled={(!state.text && !state.media && !state.location) || loadingMedia}
><SendIcon/></button>
<input ref={fileInput} onChange={onAttachFile} type="file" value=""/>
</div>
@ -459,4 +515,22 @@ const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
</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

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`
export interface GomuksLeafletProps extends HTMLAttributes<HTMLDivElement> {
export interface LeafletViewerProps extends HTMLAttributes<HTMLDivElement> {
tileTemplate: string
lat: number
long: number
@ -35,7 +35,7 @@ export interface GomuksLeafletProps extends HTMLAttributes<HTMLDivElement> {
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)
useEffect(() => {
const container = ref.current
@ -54,4 +54,54 @@ const GomuksLeaflet = ({ tileTemplate, lat, long, prec, marker, ...rest }: Gomuk
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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { Suspense, lazy, use } from "react"
import { GridLoader } from "react-spinners"
import { use } from "react"
import { usePreference } from "@/api/statestore"
import { LocationMessageEventContent } from "@/api/types"
import { GOOGLE_MAPS_API_KEY } from "@/util/keys.ts"
import { ensureString } from "@/util/validation.ts"
import ClientContext from "../../ClientContext.ts"
import { LeafletViewer } from "../../maps/async.tsx"
import EventContentProps from "./props.ts"
const GomuksLeaflet = lazy(() => import("./Leaflet.tsx"))
function parseGeoURI(uri: unknown): [lat: number, long: number, prec: number] {
const geoURI = ensureString(uri)
if (!geoURI.startsWith("geo:")) {
@ -31,7 +29,7 @@ function parseGeoURI(uri: unknown): [lat: number, long: number, prec: number] {
}
try {
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 prec = 13 // (+(decodedParams.get("u") ?? 0)) || 13
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 content = event.content as LocationMessageEventContent
const client = use(ClientContext)!
@ -51,9 +47,7 @@ const LocationMessageBody = ({ event, room }: EventContentProps) => {
const marker = ensureString(content["org.matrix.msc3488.location"]?.description ?? content.body)
if (mapProvider === "leaflet") {
return <div className="location-container leaflet">
<Suspense fallback={locationLoader}>
<GomuksLeaflet tileTemplate={tileTemplate} lat={lat} long={long} prec={prec} marker={marker}/>
</Suspense>
<LeafletViewer tileTemplate={tileTemplate} lat={lat} long={long} prec={prec} marker={marker}/>
</div>
} else if (mapProvider === "google") {
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;
width: 100%;
}
> div.location-importer {
display: flex;
justify-content: center;
align-items: center;
}
}
div.location-importer {
display: flex;
justify-content: center;
align-items: center;
}