2023-02-08 00:43:47 +01:00

296 lines
9.7 KiB
TypeScript

import React, {
memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiChat, ApiMessage, ApiUser } from '../../../api/types';
import type { ISettings } from '../../../types';
import { CUSTOM_APPENDIX_ATTRIBUTE, MESSAGE_CONTENT_SELECTOR } from '../../../config';
import {
getMessageLocation,
buildStaticMapHash,
isGeoLiveExpired,
isOwnMessage,
isUserId,
} from '../../../global/helpers';
import getCustomAppendixBg from './helpers/getCustomAppendixBg';
import { formatCountdownShort, formatLastUpdated } from '../../../util/dateFormat';
import {
getMetersPerPixel, getVenueColor, getVenueIconUrl, prepareMapUrl,
} from '../../../util/map';
import { getServerTime } from '../../../util/serverTime';
import useMedia from '../../../hooks/useMedia';
import useLang from '../../../hooks/useLang';
import useForceUpdate from '../../../hooks/useForceUpdate';
import useTimeout from '../../../hooks/useTimeout';
import buildClassName from '../../../util/buildClassName';
import usePrevious from '../../../hooks/usePrevious';
import useInterval from '../../../hooks/useInterval';
import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps';
import Avatar from '../../common/Avatar';
import Skeleton from '../../ui/Skeleton';
import mapPin from '../../../assets/map-pin.svg';
import './Location.scss';
const MOVE_THRESHOLD = 0.0001; // ~11m
const DEFAULT_MAP_CONFIG = {
width: 400,
height: 300,
zoom: 16,
scale: 2,
};
// eslint-disable-next-line max-len
const SVG_PIN = { __html: '<svg version="1.1" class="round-pin" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve"><g><circle cx="32" cy="32" r="24.5"/><path d="M32,8c13.23,0,24,10.77,24,24S45.23,56,32,56S8,45.23,8,32S18.77,8,32,8 M32,7C18.19,7,7,18.19,7,32s11.19,25,25,25 s25-11.19,25-25S45.81,7,32,7L32,7z"/></g><g><polygon points="29.38,57.67 27.4,56.08 30.42,54.42 32,51.54 33.58,54.42 36.6,56.08 34.69,57.61 32,60.73"/><path d="M32,52.58l1.07,1.95l0.14,0.26l0.26,0.14l2.24,1.22l-1.33,1.06l-0.07,0.06l-0.06,0.07L32,59.96l-2.24-2.61l-0.06-0.07 l-0.07-0.06l-1.33-1.06l2.24-1.22l0.26-0.14l0.14-0.26L32,52.58 M32,50.5l-1.94,3.56L26.5,56l2.5,2l3,3.5l3-3.5l2.5-2l-3.56-1.94 L32,50.5L32,50.5z"/></g></svg>' };
type OwnProps = {
message: ApiMessage;
peer?: ApiUser | ApiChat;
lastSyncTime?: number;
isInSelectMode?: boolean;
isSelected?: boolean;
theme: ISettings['theme'];
};
const Location: FC<OwnProps> = ({
message,
peer,
lastSyncTime,
isInSelectMode,
isSelected,
theme,
}) => {
const { openUrl } = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const countdownRef = useRef<HTMLDivElement>(null);
const lang = useLang();
const forceUpdate = useForceUpdate();
const location = getMessageLocation(message)!;
const { type, geo } = location;
const serverTime = getServerTime();
const isExpired = isGeoLiveExpired(message, serverTime);
const secondsBeforeEnd = (type === 'geoLive' && !isExpired) ? message.date + location.period - serverTime
: undefined;
const [point, setPoint] = useState(geo);
const shouldRenderText = type === 'venue' || (type === 'geoLive' && !isExpired);
const {
width, height, zoom, scale,
} = DEFAULT_MAP_CONFIG;
const mediaHash = Boolean(lastSyncTime) && buildStaticMapHash(point, width, height, zoom, scale);
const mediaBlobUrl = useMedia(mediaHash);
const prevMediaBlobUrl = usePrevious(mediaBlobUrl);
const mapBlobUrl = mediaBlobUrl || prevMediaBlobUrl;
const isPeerUser = peer && isUserId(peer.id);
const avatarUser = (peer && isPeerUser) ? peer as ApiUser : undefined;
const avatarChat = (peer && !isPeerUser) ? peer as ApiChat : undefined;
const isOwn = isOwnMessage(message);
const accuracyRadiusPx = useMemo(() => {
if (type !== 'geoLive' || !point.accuracyRadius) {
return 0;
}
const { lat, accuracyRadius } = point;
return accuracyRadius / getMetersPerPixel(lat, zoom);
}, [type, point, zoom]);
const handleClick = () => {
const url = prepareMapUrl(point.lat, point.long, zoom);
openUrl({ url });
};
const updateCountdown = useCallback((countdownEl: HTMLDivElement) => {
if (type !== 'geoLive') return;
const radius = 12;
const circumference = radius * 2 * Math.PI;
const svgEl = countdownEl.lastElementChild;
const timerEl = countdownEl.firstElementChild as SVGElement;
const timeLeft = message.date + location.period - getServerTime();
const strokeDashOffset = (1 - timeLeft / location.period) * circumference;
const text = formatCountdownShort(lang, timeLeft * 1000);
if (!svgEl || !timerEl) {
countdownEl.innerHTML = `
<span class="geo-countdown-text">${text}</span>
<svg width="32px" height="32px">
<circle cx="16" cy="16" r="${radius}" class="geo-countdown-progress" transform="rotate(-90, 16, 16)"
stroke-dasharray="${circumference} ${circumference}"
stroke-dashoffset="-${strokeDashOffset}"
/>
</svg>`;
} else {
timerEl.textContent = text;
svgEl.firstElementChild!.setAttribute('stroke-dashoffset', `-${strokeDashOffset}`);
}
}, [type, message.date, location, lang]);
useLayoutEffect(() => {
if (countdownRef.current) {
updateCountdown(countdownRef.current);
}
}, [updateCountdown]);
useLayoutEffectWithPrevDeps(([prevShouldRenderText]) => {
if (shouldRenderText) {
if (!prevShouldRenderText) {
ref.current!.closest<HTMLDivElement>(MESSAGE_CONTENT_SELECTOR)!.removeAttribute(CUSTOM_APPENDIX_ATTRIBUTE);
}
return;
}
if (mapBlobUrl) {
const contentEl = ref.current!.closest<HTMLDivElement>(MESSAGE_CONTENT_SELECTOR)!;
getCustomAppendixBg(mapBlobUrl, isOwn, isInSelectMode, isSelected, theme).then((appendixBg) => {
contentEl.style.setProperty('--appendix-bg', appendixBg);
contentEl.classList.add('has-appendix-thumb');
contentEl.setAttribute(CUSTOM_APPENDIX_ATTRIBUTE, '');
});
}
}, [shouldRenderText, isOwn, isInSelectMode, isSelected, theme, mapBlobUrl]);
useEffect(() => {
// Prevent map refetching for slight location changes
if (Math.abs(geo.lat - point.lat) < MOVE_THRESHOLD && Math.abs(geo.long - point.long) < MOVE_THRESHOLD) {
if (point.accuracyRadius !== geo.accuracyRadius) {
setPoint({
...point,
accuracyRadius: geo.accuracyRadius,
});
}
return;
}
setPoint(geo);
}, [geo, point]);
useTimeout(() => {
forceUpdate();
}, !isExpired ? (secondsBeforeEnd || 0) * 1000 : undefined);
useInterval(() => {
const countdownEl = countdownRef.current;
if (countdownEl) {
updateCountdown(countdownEl);
}
}, secondsBeforeEnd ? 1000 : undefined);
function renderInfo() {
if (!shouldRenderText) return undefined;
if (type === 'venue') {
return (
<div className="location-info">
<div className="location-info-title">
{location.title}
</div>
<div className="location-info-subtitle">
{location.address}
</div>
</div>
);
}
if (type === 'geoLive') {
return (
<div className="location-info">
<div className="location-info-title">{lang('AttachLiveLocation')}</div>
<div className="location-info-subtitle">
{formatLastUpdated(lang, serverTime, message.editDate)}
</div>
{!isExpired && <div className="geo-countdown" ref={countdownRef} />}
</div>
);
}
return undefined;
}
function renderMap() {
if (!mapBlobUrl) return <Skeleton width={width} height={height} />;
return (
<img
className="full-media map"
src={mapBlobUrl}
alt="Location on a map"
style={`width: ${DEFAULT_MAP_CONFIG.width}px; height: ${DEFAULT_MAP_CONFIG.height}px;`}
/>
);
}
function renderPin() {
const pinClassName = buildClassName(
'pin',
type,
isExpired && 'expired',
);
if (type === 'geoLive') {
return (
<div className={pinClassName} dangerouslySetInnerHTML={SVG_PIN}>
<Avatar chat={avatarChat} user={avatarUser} className="location-avatar" />
{location.heading !== undefined && (
<div className="direction" style={`--direction: ${location.heading}deg`} />
)}
</div>
);
}
if (type === 'venue') {
const color = getVenueColor(location.venueType);
const iconSrc = getVenueIconUrl(location.venueType);
return (
<div className={pinClassName} dangerouslySetInnerHTML={SVG_PIN} style={`--pin-color: ${color}`}>
<img src={iconSrc} className="venue-icon" alt="" />
</div>
);
}
return (
<img className={pinClassName} src={mapPin} alt="" />
);
}
function renderOverlay() {
if (!mapBlobUrl) return undefined;
return (
<>
{Boolean(accuracyRadiusPx) && !isExpired && (
<div
className="location-accuracy"
style={`width: ${accuracyRadiusPx * 2}px; height: ${accuracyRadiusPx * 2}px`}
/>
)}
{renderPin()}
</>
);
}
return (
<div
ref={ref}
className="Location media-inner interactive"
onClick={handleClick}
>
<div className="map-wrapper">
{renderMap()}
{renderOverlay()}
</div>
{renderInfo()}
</div>
);
};
export default memo(Location);