1
0
Fork 0
forked from Mirrors/gomuks

web/timeline: add support for location messages

This commit is contained in:
Tulir Asokan 2024-12-02 16:44:35 +02:00
parent b8fe8372f2
commit 68385fef5d
10 changed files with 201 additions and 3 deletions

25
web/package-lock.json generated
View file

@ -14,6 +14,7 @@
"@wailsio/runtime": "^3.0.0-alpha.29", "@wailsio/runtime": "^3.0.0-alpha.29",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"katex": "^0.16.11", "katex": "^0.16.11",
"leaflet": "^1.9.4",
"react": "^19.0.0-rc.1", "react": "^19.0.0-rc.1",
"react-blurhash": "^0.3.0", "react-blurhash": "^0.3.0",
"react-dom": "^19.0.0-rc.1", "react-dom": "^19.0.0-rc.1",
@ -23,6 +24,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/leaflet": "^1.9.14",
"@types/sanitize-html": "^2.13.0", "@types/sanitize-html": "^2.13.0",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.11.1", "eslint": "^9.11.1",
@ -1741,6 +1743,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.14",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz",
"integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -1762,6 +1771,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.14",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.14.tgz",
"integrity": "sha512-sx2q6MDJaajwhKeVgPSvqXd8rhNJSTA3tMidQGduZn9S6WBYxDkCpSpV5xXEmSg7Cgdk/5vJGhVF1kMYLzauBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"name": "types-react", "name": "types-react",
"version": "19.0.0-rc.1", "version": "19.0.0-rc.1",
@ -4165,6 +4184,12 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",

View file

@ -16,6 +16,7 @@
"@wailsio/runtime": "^3.0.0-alpha.29", "@wailsio/runtime": "^3.0.0-alpha.29",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"katex": "^0.16.11", "katex": "^0.16.11",
"leaflet": "^1.9.4",
"react": "^19.0.0-rc.1", "react": "^19.0.0-rc.1",
"react-blurhash": "^0.3.0", "react-blurhash": "^0.3.0",
"react-dom": "^19.0.0-rc.1", "react-dom": "^19.0.0-rc.1",
@ -25,6 +26,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/leaflet": "^1.9.14",
"@types/sanitize-html": "^2.13.0", "@types/sanitize-html": "^2.13.0",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.11.1", "eslint": "^9.11.1",

View file

@ -199,6 +199,13 @@ export interface MediaInfo {
export interface LocationMessageEventContent extends BaseMessageEventContent { export interface LocationMessageEventContent extends BaseMessageEventContent {
msgtype: "m.location" msgtype: "m.location"
geo_uri: string geo_uri: string
"org.matrix.msc3488.asset"?: {
type?: "m.pin"
}
"org.matrix.msc3488.location"?: {
uri: string
description?: string
}
} }
export type MessageEventContent = TextMessageEventContent | MediaMessageEventContent | LocationMessageEventContent export type MessageEventContent = TextMessageEventContent | MediaMessageEventContent | LocationMessageEventContent

View file

@ -26,8 +26,10 @@ export const codeBlockStyles = [
"solarized-dark", "solarized-light", "swapoff", "tango", "tokyonight-day", "tokyonight-moon", "tokyonight-night", "solarized-dark", "solarized-light", "swapoff", "tango", "tokyonight-day", "tokyonight-moon", "tokyonight-night",
"tokyonight-storm", "trac", "vim", "vs", "vulcan", "witchhazel", "xcode-dark", "xcode", "tokyonight-storm", "trac", "vim", "vs", "vulcan", "witchhazel", "xcode-dark", "xcode",
] as const ] as const
export const mapProviders = ["leaflet", "google", "none"] as const
export type CodeBlockStyle = typeof codeBlockStyles[number] export type CodeBlockStyle = typeof codeBlockStyles[number]
export type MapProvider = typeof mapProviders[number]
/* eslint-disable max-len */ /* eslint-disable max-len */
export const preferences = { export const preferences = {
@ -104,6 +106,19 @@ export const preferences = {
allowedContexts: anyContext, allowedContexts: anyContext,
defaultValue: true, defaultValue: true,
}), }),
map_provider: new Preference<MapProvider>({
displayName: "Map provider",
description: "The map provider to use for location messages.",
allowedValues: mapProviders,
allowedContexts: anyContext,
defaultValue: "leaflet",
}),
leaflet_tile_template: new Preference<string>({
displayName: "Leaflet tile URL template",
description: "When using Leaflet for maps, the URL template for map tile images.",
allowedContexts: anyContext,
defaultValue: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
}),
custom_notification_sound: new Preference<ContentURI>({ custom_notification_sound: new Preference<ContentURI>({
displayName: "Custom notification sound", displayName: "Custom notification sound",
description: "The mxc:// URI to a custom notification sound.", description: "The mxc:// URI to a custom notification sound.",

View file

@ -0,0 +1,49 @@
// 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 L from "leaflet"
import "leaflet/dist/leaflet.css"
import { HTMLAttributes, useLayoutEffect, useRef } from "react"
const attribution = `&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors`
export interface GomuksLeafletProps extends HTMLAttributes<HTMLDivElement> {
tileTemplate: string
lat: number
long: number
prec: number
marker: string
}
const GomuksLeaflet = ({ tileTemplate, lat, long, prec, marker, ...rest }: GomuksLeafletProps) => {
const ref = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const container = ref.current
if (!container) {
return
}
const rendered = L.map(container)
rendered.setView([lat, long], prec)
const markerElem = L.marker([lat, long]).addTo(rendered)
markerElem.bindPopup(marker).openPopup()
L.tileLayer(tileTemplate, { attribution }).addTo(rendered)
return () => {
rendered.remove()
}
}, [lat, long, prec, marker, tileTemplate])
return <div {...rest} ref={ref}/>
}
export default GomuksLeaflet

View file

@ -0,0 +1,73 @@
// 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, use } from "react"
import { GridLoader } from "react-spinners"
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 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:")) {
return [0, 0, 0]
}
try {
const [coordinates/*, params*/] = geoURI.slice("geo:".length).split(";")
const [lat, long] = coordinates.split(",").map(parseFloat)
// const decodedParams = new URLSearchParams(params)
const prec = 13 // (+(decodedParams.get("u") ?? 0)) || 13
return [lat, long, prec]
} catch {
return [0, 0, 0]
}
}
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)!
const mapProvider = usePreference(client.store, room, "map_provider")
const tileTemplate = usePreference(client.store, room, "leaflet_tile_template")
const [lat, long, prec] = parseGeoURI(content.geo_uri)
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>
</div>
} else if (mapProvider === "google") {
const url = `https://www.google.com/maps/embed/v1/place?key=${GOOGLE_MAPS_API_KEY}&q=${lat},${long}`
return <iframe className="location-container google" loading="lazy" referrerPolicy="no-referrer" src={url} />
} else {
return <div className="location-container blank">
<a
href={`https://www.openstreetmap.org/?mlat=${lat}&mlon=${long}`}
target="_blank" rel="noreferrer noopener"
>
{marker}
</a>
</div>
}
}
export default LocationMessageBody

View file

@ -238,3 +238,26 @@ div.media-container {
max-height: 100%; max-height: 100%;
} }
} }
iframe.location-container.google {
height: 25rem;
width: 100%;
max-width: 50rem;
border: none;
}
div.location-container.leaflet {
height: 25rem;
max-width: 50rem;
> div {
height: 25rem;
width: 100%;
}
> div.location-importer {
display: flex;
justify-content: center;
align-items: center;
}
}

View file

@ -3,6 +3,7 @@ import { MemDBEvent } from "@/api/types"
import ACLBody from "./ACLBody.tsx" import ACLBody from "./ACLBody.tsx"
import EncryptedBody from "./EncryptedBody.tsx" import EncryptedBody from "./EncryptedBody.tsx"
import HiddenEvent from "./HiddenEvent.tsx" import HiddenEvent from "./HiddenEvent.tsx"
import LocationMessageBody from "./LocationMessageBody.tsx"
import MediaMessageBody from "./MediaMessageBody.tsx" import MediaMessageBody from "./MediaMessageBody.tsx"
import MemberBody from "./MemberBody.tsx" import MemberBody from "./MemberBody.tsx"
import PinnedEventsBody from "./PinnedEventsBody.tsx" import PinnedEventsBody from "./PinnedEventsBody.tsx"
@ -20,6 +21,7 @@ export { default as ContentErrorBoundary } from "./ContentErrorBoundary.tsx"
export { default as EncryptedBody } from "./EncryptedBody.tsx" export { default as EncryptedBody } from "./EncryptedBody.tsx"
export { default as HiddenEvent } from "./HiddenEvent.tsx" export { default as HiddenEvent } from "./HiddenEvent.tsx"
export { default as MediaMessageBody } from "./MediaMessageBody.tsx" export { default as MediaMessageBody } from "./MediaMessageBody.tsx"
export { default as LocationMessageBody } from "./LocationMessageBody.tsx"
export { default as MemberBody } from "./MemberBody.tsx" export { default as MemberBody } from "./MemberBody.tsx"
export { default as PinnedEventsBody } from "./PinnedEventsBody.tsx" export { default as PinnedEventsBody } from "./PinnedEventsBody.tsx"
export { default as PowerLevelBody } from "./PowerLevelBody.tsx" export { default as PowerLevelBody } from "./PowerLevelBody.tsx"
@ -53,8 +55,7 @@ export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionCo
} }
return MediaMessageBody return MediaMessageBody
case "m.location": case "m.location":
// return LocationMessageBody return LocationMessageBody
// fallthrough
default: default:
return UnknownMessageBody return UnknownMessageBody
} }

3
web/src/util/keys.ts Normal file
View file

@ -0,0 +1,3 @@
export const TENOR_API_KEY = "AIzaSyDCLXZasjWAGuxEGggz7ND-2BFHNIDyhGw"
export const GOOGLE_MAPS_API_KEY = "AIzaSyDOrgHKWCoWyceDPAZDNQ0zU0vWwH8BzM4"
export const GIPHY_API_KEY = "HQku8974Uq5MZn3MZns46kXn2R4GDm75"

View file

@ -11,7 +11,7 @@ export default defineConfig({
manualChunks: id => { manualChunks: id => {
if (id.includes("wailsio")) { if (id.includes("wailsio")) {
return "wails" return "wails"
} else if (id.includes("node_modules") && !id.includes("katex")) { } else if (id.includes("node_modules") && !id.includes("katex") && !id.includes("leaflet")) {
return "vendor" return "vendor"
} else if (id.endsWith("/emoji/data.json")) { } else if (id.endsWith("/emoji/data.json")) {
return "emoji" return "emoji"