Somewhere to send a location from

`sendMatrixLocation` has worked since #123 and had no caller anywhere in the
app. That issue was "stickers and locations can be read but not sent" and was
closed with the sticker half wired up and this half reachable by nothing.

A field rather than a map, deliberately. Drawing one means fetching tiles from
somebody's tile server, and the receiving side already decided against that -
a message that mentions a street corner should not tell a third party it was
read. Sending should not quietly undo the decision receiving made.

So it is a box, and the daemon does the hard part: it already accepts a pasted
map link, a `geo:` URI, or two numbers with a comma between them. That is what
people have to hand - nobody types coordinates from memory, they copy a link.

The label is offered first and the coordinates second, because the label is
the part a person reads: a pair of numbers arriving with no name is a pin in
the middle of nowhere as far as the recipient is concerned.

Its own button beside the sticker one, for the reason the sticker button sits
beside the emoji one: a location is the message rather than something typed
into it. Matrix only, which is the one service here that carries a place as
its own kind of message.

Closes #166.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 21:20:16 -04:00
co-authored by Claude Opus 5
parent e2504059f9
commit 36ae6fdfd0
4 changed files with 148 additions and 1 deletions
+1 -1
Submodule nobilis updated: 61bcc34ae9...b4055cb915
+20
View File
@@ -4522,6 +4522,26 @@ video.lightbox-media {
padding-top: var(--spacing-xs);
}
/* Sending a place: two fields and a button. Deliberately not a map - drawing
one means fetching tiles from somebody's server, and the receive side
already decided a message mentioning a street corner should not tell a
third party it was read. */
.place-form {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.place-form code {
font-family: var(--font-mono);
font-size: 0.9em;
}
.place-actions {
display: flex;
justify-content: flex-end;
}
/* Making an invite: three choices and the link they produce. A column
because each row is a question and its answer, and a form read down the
page is a form somebody finishes. */
+37
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { Avatar } from './Avatar'
import { EmojiPicker, type StickerEntry } from './EmojiPicker'
import { PlaceField } from './PlaceField'
import { useActiveBuffer, useChat, useStore } from '../state/hooks'
import { emojiPreview } from '../lib/format'
import { bufferDisplayName, classes, fileNameOf, isImageFile, resolveMediaUrl } from '../lib/util'
@@ -100,6 +101,8 @@ export function Composer(): JSX.Element | null {
/** The account's sticker packs, fetched when the picker is first opened. */
const [stickers, setStickers] = useState<StickerEntry[]>([])
const [stickerPicker, setStickerPicker] = useState(false)
/** The little form for sending a place, which is a field rather than a map. */
const [placeOpen, setPlaceOpen] = useState(false)
const seqRef = useRef(0)
const typingSentAt = useRef(0)
/**
@@ -154,6 +157,7 @@ export function Composer(): JSX.Element | null {
const [roles, setRoles] = useState<{ id: string; name: string; colour?: string }[]>([])
const emojiButtonRef = useRef<HTMLButtonElement>(null)
const stickerButtonRef = useRef<HTMLButtonElement>(null)
const placeButtonRef = useRef<HTMLButtonElement>(null)
const smilies = useChat((s) => s.smilies)
const bufferEmojiByBuffer = useChat((s) => s.bufferEmoji)
@@ -798,6 +802,26 @@ export function Composer(): JSX.Element | null {
</button>
)}
{/* Beside the sticker button for the same reason it is beside the
emoji one: a location is the message rather than something typed
into it. Matrix only, which is the one service here that carries
a place as its own kind of message. */}
{service === 'matrix' && (
<button
ref={placeButtonRef}
type="button"
className="icon-button"
title="Send a location"
onClick={() => {
setPickerOpen(false)
setStickerPicker(false)
setPlaceOpen(!placeOpen)
}}
>
<Icon name="location_on" size={18} />
</button>
)}
<IconButton
name="add"
title={
@@ -809,6 +833,19 @@ export function Composer(): JSX.Element | null {
<IconButton name="send" title="Send" onClick={submit} />
</div>
{placeOpen && (
<PlaceField
anchor={placeButtonRef.current}
onClose={() => setPlaceOpen(false)}
onSend={(place, label) => {
setPlaceOpen(false)
void window.moho
.rpc('sendMatrixLocation', { bufferId: buffer.id, place, label })
.catch((e: Error) => store.toast('error', e.message))
}}
/>
)}
{stickerPicker && (
<EmojiPicker
anchor={stickerButtonRef.current}
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from './Icon'
import { HeaderPopover } from './HeaderPopover'
/**
* Sending a place, as a field rather than as a map.
*
* Deliberately not a map picker. Drawing one means fetching tiles from
* somebody's tile server, and the receive side already decided against that -
* a message that mentions a street corner should not tell a third party it was
* read. Sending should not quietly undo the decision receiving made.
*
* So it is a box, and the daemon does the hard part: it accepts a pasted map
* link (OpenStreetMap, Google Maps, anything carrying the place in a query
* parameter), a `geo:` URI, or two numbers with a comma between them. Which is
* what people actually have to hand - nobody types coordinates from memory,
* they copy a link.
*
* The label is the part a person reads. A pair of coordinates arriving with no
* name is a pin in the middle of nowhere as far as the recipient is concerned,
* so it is offered first and the coordinates second.
*/
export function PlaceField({
anchor,
onSend,
onClose
}: {
anchor: HTMLElement | null
onSend: (place: string, label: string) => void
onClose: () => void
}): JSX.Element {
const [place, setPlace] = useState('')
const [label, setLabel] = useState('')
const field = useRef<HTMLInputElement>(null)
useEffect(() => {
field.current?.focus()
}, [])
const send = (): void => {
const trimmed = place.trim()
if (!trimmed) return
onSend(trimmed, label.trim())
}
return (
<HeaderPopover anchor={anchor} width={320} onClose={onClose}>
<div className="place-form">
<div className="small muted">
Paste a map link, or type coordinates as <code>51.5, -0.12</code>.
</div>
<div className="popover-field">
<Icon name="location_on" size={16} />
<input
ref={field}
type="text"
value={place}
placeholder="Map link or coordinates"
onChange={(e) => setPlace(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') send()
if (e.key === 'Escape') onClose()
}}
/>
</div>
<div className="popover-field">
<Icon name="label" size={16} />
<input
type="text"
value={label}
placeholder="What is there (optional)"
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') send()
if (e.key === 'Escape') onClose()
}}
/>
</div>
<div className="place-actions">
<button type="button" className="button primary" disabled={!place.trim()} onClick={send}>
Send
</button>
</div>
</div>
</HeaderPopover>
)
}