mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
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":
|
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"`
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
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;
|
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
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`
|
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
|
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}/>
|
||||||
|
}
|
|
@ -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}`
|
||||||
|
|
|
@ -254,10 +254,10 @@ div.location-container.leaflet {
|
||||||
height: 25rem;
|
height: 25rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> div.location-importer {
|
div.location-importer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue