forked from Mirrors/gomuks
web/composer: add support for sending location messages
This commit is contained in:
parent
77219cb26e
commit
12f9031ab1
10 changed files with 207 additions and 32 deletions
|
@ -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"`
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
1
web/src/icons/location.svg
Normal file
1
web/src/icons/location.svg
Normal 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 |
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
38
web/src/ui/maps/async.tsx
Normal 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>
|
||||
}
|
|
@ -27,7 +27,7 @@ L.Icon.Default.imagePath = ""
|
|||
|
||||
const attribution = `© <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}/>
|
||||
}
|
|
@ -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}`
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue