diff --git a/package.json b/package.json index 8558639470f..85a2bbee457 100644 --- a/package.json +++ b/package.json @@ -307,4 +307,4 @@ "engines": { "node": ">=20.0.0" } -} +} \ No newline at end of file diff --git a/res/css/components/views/location/_Marker.pcss b/res/css/components/views/location/_Marker.pcss index 5a8fef91992..de2244487e9 100644 --- a/res/css/components/views/location/_Marker.pcss +++ b/res/css/components/views/location/_Marker.pcss @@ -10,6 +10,69 @@ Please see LICENSE files in the repository root for full details. color: $accent; } +.mx_Marker_red { + color: red; +} + +.mx_Marker_green { + color: green; +} + +.mx_Marker_blue { + color: blue; +} + +.mx_Marker_yellow { + color: yellow; +} + +.mx_Marker_cyan { + color: cyan; +} + +.mx_Marker_magenta { + color: magenta; +} + +.mx_Marker_orange { + color: orange; +} + +.mx_Marker_purple { + color: purple; +} + +.mx_Marker_pink { + color: pink; +} + +.mx_Marker_brown { + color: brown; +} + +.mx_AnnotationMarker_border { + width: 22px; + height: 22px; + border-radius: 50%; + filter: drop-shadow(0px 3px 5px rgba(0, 0, 0, 0.2)); + background-color: currentColor; + + display: flex; + justify-content: center; + align-items: center; + + /* caret down */ + &::before { + content: ""; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid currentColor; + position: absolute; + bottom: -4px; + } +} + + .mx_Marker_border { width: 42px; height: 42px; diff --git a/src/components/views/location/Annotation.tsx b/src/components/views/location/Annotation.tsx new file mode 100644 index 00000000000..42bc9a5fbf8 --- /dev/null +++ b/src/components/views/location/Annotation.tsx @@ -0,0 +1,7 @@ +interface Annotation { + body: string, + geoUri: string, + color?: string, +} + +export default Annotation; \ No newline at end of file diff --git a/src/components/views/location/AnnotationDialog.tsx b/src/components/views/location/AnnotationDialog.tsx new file mode 100644 index 00000000000..a20fc75dd4c --- /dev/null +++ b/src/components/views/location/AnnotationDialog.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import BaseDialog from "../dialogs/BaseDialog"; + + +interface Props { + onFinished: () => void; + onSubmit: (title: string, color: string) => void; + displayBack?: boolean; +} + +const colorOptions = [ + { label: 'Red', value: 'red' }, + { label: 'Green', value: 'green' }, + { label: 'Blue', value: 'blue' }, + { label: 'Yellow', value: 'yellow' }, + { label: 'Cyan', value: 'cyan' }, + { label: 'Magenta', value: 'magenta' }, + { label: 'Orange', value: 'orange' }, + { label: 'Purple', value: 'purple' }, + { label: 'Pink', value: 'pink' }, + { label: 'Brown', value: 'brown' }, +]; + +const AnnotationDialog: React.FC = ({ onSubmit, onFinished }) => { + + const [title, setTitle] = useState(''); + const [color, setColor] = useState('red'); // Default color + + const handleSubmit = () => { + onSubmit(title, color); + onFinished(); + }; + + return ( + +
+ setTitle(e.target.value)} + placeholder="Enter title" + /> + +
+ + +
+ + +
+
+ ); +}; + +export default AnnotationDialog; \ No newline at end of file diff --git a/src/components/views/location/AnnotationMarker.tsx b/src/components/views/location/AnnotationMarker.tsx new file mode 100644 index 00000000000..f31e5c2b937 --- /dev/null +++ b/src/components/views/location/AnnotationMarker.tsx @@ -0,0 +1,71 @@ +import React, { ReactNode, useState } from "react"; +import classNames from "classnames"; +import LocationIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid"; + +const OptionalTooltip: React.FC<{ + tooltip?: React.ReactNode; + annotationKey: string; + children: React.ReactNode; + onDelete: (key: string) => void; // Optional delete function +}> = ({ tooltip, children, onDelete, annotationKey }) => { + const [isHovered, setIsHovered] = useState(false); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ position: "relative" }} // Set position for proper tooltip alignment + > + {tooltip && ( +
+ {tooltip} + {isHovered && ( + + )} +
+ )} + {children} +
+ ); +}; + +/** + * Generic location marker + */ + +interface Props { + id: string; + useColor?: string; + tooltip?: ReactNode; + onDelete: (annotationKey: string) => void; +} + +const AnnotationMarker = React.forwardRef(({ id, useColor, tooltip, onDelete}, ref) => { + return ( +
+ +
+ +
+
+
+ ); +}); + +export default AnnotationMarker; \ No newline at end of file diff --git a/src/components/views/location/AnnotationPin.ts b/src/components/views/location/AnnotationPin.ts new file mode 100644 index 00000000000..15c7c861139 --- /dev/null +++ b/src/components/views/location/AnnotationPin.ts @@ -0,0 +1,159 @@ +import { MatrixClient, MatrixEvent, Room, StateEvents, TimelineEvents } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import Annotation from "./Annotation"; + +enum CustomEventType { + MapAnnotation = "m.map.annotation" +} + +export const fetchAnnotationEvent = async (roomId: string, matrixClient: MatrixClient): Promise => { + try { + const room = matrixClient.getRoom(roomId); + if (!room) { + console.error("Room not found"); + return null; + } + const annotationEventId = getAnnotationEventId(room); + if (!annotationEventId) { + console.error("Read pins event ID not found"); + return null; + } + let localEvent = room.findEventById(annotationEventId); + + // Decrypt if necessary + if (localEvent?.isEncrypted()) { + await matrixClient.decryptEventIfNeeded(localEvent, { emit: false }); + } + + if (localEvent) { + return localEvent; // Return the pinned event itself + } + return null; + } catch (err) { + logger.error(`Error looking up pinned event in room ${roomId}`); + logger.error(err); + } + return null; +}; + +function getAnnotationEventId(room?: Room): string { + const events = room?.currentState.getStateEvents(CustomEventType.MapAnnotation); + if (!events || events.length === 0) { + return ""; // Return an empty array if no events are found + } + const content = events[0].getContent(); // Get content from the event + const annotationEventId = content.event_id || ""; + return annotationEventId; +} + +export async function extractAnnotations(roomId: string, matrixClient: MatrixClient): Promise { + try { + const pinEvent = await fetchAnnotationEvent(roomId, matrixClient); + if(!pinEvent) { + return null; + } + + let rawContent: string = pinEvent.getContent()["body"]; + return rawContent; + + } catch (error) { + console.error("Error retrieving content from pinned event:", error); + return null; + } +} + + +// Function to update annotations on the server +export const sendAnnotations = async (roomId: string, content: TimelineEvents[keyof TimelineEvents], matrixClient: MatrixClient) => { + try { + + let eventid = await matrixClient.sendEvent(roomId, (CustomEventType.MapAnnotation as unknown) as keyof TimelineEvents, content); + await matrixClient.sendStateEvent(roomId, (CustomEventType.MapAnnotation as unknown) as keyof StateEvents, eventid); + console.log("Annotations updated successfully!"); + } catch (error) { + console.error("Failed to update annotations:", error); + throw error; // Rethrow the error for handling in the calling component + } +}; + + +// Function to save a new annotation +// Function to save an annotation +export const saveAnnotation = async (roomId: string, matrixClient: MatrixClient, annotations: Annotation[]) => { + try { + // Convert annotations to a string + const stringifiedAnnotations = JSON.stringify(annotations); + if (!stringifiedAnnotations) { + return []; + } + const content = { + annotations: stringifiedAnnotations, // Use the stringified annotations + } as unknown as TimelineEvents[keyof TimelineEvents]; + + await sendAnnotations(roomId, content, matrixClient); + } catch (error) { + console.error("Failed to save annotation:", error); + // Handle the error appropriately (e.g., notify the user) + throw error; // Optionally rethrow the error for further handling + } +}; + +// Function to delete an annotation +export const deleteAnnotation = async (roomId: string, matrixClient: MatrixClient, geoUri: string, annotations: Annotation[]) => { + try { + // Convert annotations to a string + const stringifiedAnnotations = JSON.stringify(annotations); + if (!stringifiedAnnotations) { + return []; + } + const content = { + annotations: stringifiedAnnotations, // Use the stringified annotations + } as unknown as TimelineEvents[keyof TimelineEvents]; + + await sendAnnotations(roomId, content, matrixClient); + } catch (error) { + console.error("Failed to delete annotation:", error); + throw error; // Optionally rethrow the error for further handling + } +}; + +// Function to load annotations from the server +export const loadAnnotations = async (roomId: string, matrixClient: MatrixClient): Promise => { + try { + const room = matrixClient.getRoom(roomId); // Get the room object + const events = room?.currentState.getStateEvents("m.map.annotation"); + + if (!events || events.length === 0) { + return []; // Return an empty array if no events are found + } + + const event = await fetchAnnotationEvent(roomId, matrixClient); + if (!event) { + return []; + } + + const content = event.getContent(); // Get content from the event + const stringifiedAnnotations = content.annotations || []; + if (typeof stringifiedAnnotations !== 'string') { + console.warn("Content is not a string. Returning early."); + return []; // or handle accordingly + } + + // Parse the JSON string back to an array of annotations + const annotationsArray: Annotation[] = JSON.parse(stringifiedAnnotations); + + if (!Array.isArray(annotationsArray) || annotationsArray.length === 0) { + return []; // Return an empty array if parsing fails or array is empty + } + + return annotationsArray.map((annotation: { geoUri: string; body: string; color?: string; }) => ({ + geoUri: annotation.geoUri, + body: annotation.body, + color: annotation.color, + })); + + } catch (error) { + console.error("Failed to load annotations:", error); + return []; // Return an empty array in case of an error + } +}; \ No newline at end of file diff --git a/src/components/views/location/AnnotationSmartMarker.tsx b/src/components/views/location/AnnotationSmartMarker.tsx new file mode 100644 index 00000000000..42fae926eac --- /dev/null +++ b/src/components/views/location/AnnotationSmartMarker.tsx @@ -0,0 +1,90 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { ReactNode, useCallback, useEffect, useState } from "react"; +import * as maplibregl from "maplibre-gl"; + +import { parseGeoUri } from "../../../utils/location"; +import { createMarker } from "../../../utils/location/map"; +import AnnotationMarker from "./AnnotationMarker"; + +const useMapMarker = ( + map: maplibregl.Map, + geoUri: string, +): { marker?: maplibregl.Marker; onElementRef: (el: HTMLDivElement) => void } => { + const [marker, setMarker] = useState(); + + const onElementRef = useCallback( + (element: HTMLDivElement) => { + if (marker || !element) { + return; + } + const coords = parseGeoUri(geoUri); + if (coords) { + const newMarker = createMarker(coords, element); + newMarker.addTo(map); + setMarker(newMarker); + } + }, + [marker, geoUri, map], + ); + + useEffect(() => { + if (marker) { + const coords = parseGeoUri(geoUri); + if (coords) { + marker.setLngLat({ lon: coords.longitude, lat: coords.latitude }); + } + } + }, [marker, geoUri]); + + useEffect( + () => () => { + if (marker) { + marker.remove(); + } + }, + [marker], + ); + + return { + marker, + onElementRef, + }; +}; + +export interface AnnotationSmartMarkerProps { + map: maplibregl.Map; + geoUri: string; + id: string; + key: string; + useColor?: string; + tooltip?: ReactNode; + onDelete: (key: string) => void; +} + +/** + * Generic location marker + */ +const AnnotationSmartMarker: React.FC = ({ id, map, geoUri, useColor, tooltip, onDelete }) => { + const { onElementRef } = useMapMarker(map, geoUri); + + return ( + + + + ); +}; + +export default AnnotationSmartMarker; diff --git a/src/components/views/location/Map.tsx b/src/components/views/location/Map.tsx index 998dff1d157..dff8377e7d5 100644 --- a/src/components/views/location/Map.tsx +++ b/src/components/views/location/Map.tsx @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type ReactNode, useContext, useEffect, useState } from "react"; +import React, { ReactNode, useContext, useEffect, useState } from "react"; import classNames from "classnames"; import * as maplibregl from "maplibre-gl"; -import { ClientEvent, type IClientWellKnown } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, IClientWellKnown } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; @@ -21,6 +21,11 @@ import { type Bounds } from "../../../utils/beacon/bounds"; import Modal from "../../../Modal"; import ErrorDialog from "../dialogs/ErrorDialog"; import { _t } from "../../../languageHandler"; +import AnnotationSmartMarker from './AnnotationSmartMarker'; +import Annotation from './Annotation'; +import AnnotationDialog from "./AnnotationDialog"; +import { loadAnnotations, saveAnnotation, deleteAnnotation } from "../location/AnnotationPin"; +import { SdkContextClass } from "../../../contexts/SDKContext"; const useMapWithStyle = ({ id, @@ -131,6 +136,14 @@ const onGeolocateError = (e: GeolocationPositionError): void => { }); }; +interface ClickEvent { + point: { + x: number; + y: number; + }; + originalEvent: MouseEvent; // Adjust based on your event structure +} + export interface MapProps { id: string; interactive?: boolean; @@ -162,6 +175,77 @@ const MapComponent: React.FC = ({ onClick, }) => { const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive, bounds, allowGeolocate }); + const [annotations, setAnnotations] = useState([]); // Manage annotations state + const matrixClient = useContext(MatrixClientContext); + const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); + + useEffect(() => { + console.log("Updated annotations:", annotations); + }, [annotations]); + + useEffect(() => { + console.log("Current roomId:", roomId); + const fetchAnnotations = async () => { + if (!roomId || !matrixClient) { + return; + } + const annotations = await loadAnnotations(roomId, matrixClient); + setAnnotations(annotations); + }; + fetchAnnotations(); + }, [roomId, matrixClient]); + + const handleSaveAnnotation = (annotation: Annotation) => { + setAnnotations(prevAnnotations => { + const updatedAnnotations = [...prevAnnotations, annotation]; + + if (!roomId || !matrixClient) { + return prevAnnotations; + } + // Send the updated annotations to the server without awaiting + saveAnnotation(roomId, matrixClient, updatedAnnotations); + console.log("Annotation saved successfully!"); + + return updatedAnnotations; // Return the updated state + }); + }; + + const handleDeleteAnnotation = (geoUri: string) => { + setAnnotations(prevAnnotations => { + // Create a new array with the annotation removed + const updatedAnnotations = prevAnnotations.filter(annotation => annotation.geoUri !== geoUri); + + if (!roomId || !matrixClient) { + return prevAnnotations; + } + // Send the updated annotations to the server without awaiting + deleteAnnotation(roomId, matrixClient, geoUri, updatedAnnotations); + console.log("Annotation deleted successfully!"); + + return updatedAnnotations; // Return the updated state + }); + }; + + useEffect(() => { + if (map) { + const handleMapClick = (event: any) => { + const isAltClick = event.originalEvent.altKey; // Check if Alt key is pressed + + if (isAltClick) { + openDialog(event); + } + + onClick?.(); + }; + + map.on('click', handleMapClick); + + // Cleanup the event listener on component unmount + return () => { + map.off('click', handleMapClick); + }; + } + }, [map, onClick]); const onMapClick = (event: React.MouseEvent): void => { // Eat click events when clicking the attribution button @@ -173,9 +257,50 @@ const MapComponent: React.FC = ({ onClick?.(); }; + const openDialog = (event: ClickEvent) => { + Modal.createDialog>(AnnotationDialog, { + onSubmit: (title: string, color: string) => { + handleDialogSubmit(title, color, event); + }, + onFinished: () => { + // Handle dialog close if necessary + }, + }); + }; + + const handleDialogSubmit = (title: string, color: string, clickEvent: ClickEvent) => { + if (clickEvent && map) { + const { lng, lat } = map.unproject([clickEvent.point.x, clickEvent.point.y]); + + const newAnnotation = { + geoUri: `geo:${lat},${lng}`, // Format Geo URI + body: title, + color: color, // Include color in the annotation + }; + + handleSaveAnnotation?.(newAnnotation); + } + }; + + const onDelete = (key: string) => { + handleDeleteAnnotation?.(key); + }; + return (
{!!children && !!map && children({ map })} + + {map && annotations && annotations.map((annotation) => ( + + ))}
); };