Message: Introduce Geo Locations (#1716)

This commit is contained in:
Alexander Zinchuk 2022-03-04 16:20:12 +03:00
parent 39ae9189d2
commit 531c2de36c
23 changed files with 911 additions and 37 deletions

View File

@ -28,6 +28,7 @@ import {
ApiAvailableReaction,
ApiSponsoredMessage,
ApiUser,
ApiLocation,
} from '../../types';
import {
@ -173,7 +174,8 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM
...(replyToPeerId && { replyToChatId: getApiChatIdFromMtpPeer(replyToPeerId) }),
...(replyToTopId && { replyToTopMessageId: replyToTopId }),
...(forwardInfo && { forwardInfo }),
...(isEdited && { isEdited, editDate: mtpMessage.editDate }),
...(isEdited && { isEdited }),
...(mtpMessage.editDate && { editDate: mtpMessage.editDate }),
...(isMediaUnread && { isMediaUnread }),
...(mtpMessage.mentioned && isMediaUnread && { hasUnreadMention: true }),
...(mtpMessage.mentioned && { isMentioned: true }),
@ -321,6 +323,9 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): ApiMes
const invoice = buildInvoiceFromMedia(media);
if (invoice) return { invoice };
const location = buildLocationFromMedia(media);
if (location) return { location };
return undefined;
}
@ -571,6 +576,63 @@ function buildInvoiceFromMedia(media: GramJs.TypeMessageMedia): ApiInvoice | und
return buildInvoice(media);
}
function buildLocationFromMedia(media: GramJs.TypeMessageMedia): ApiLocation | undefined {
if (media instanceof GramJs.MessageMediaGeo) {
return buildGeo(media);
}
if (media instanceof GramJs.MessageMediaVenue) {
return buildVenue(media);
}
if (media instanceof GramJs.MessageMediaGeoLive) {
return buildGeoLive(media);
}
return undefined;
}
function buildGeo(media: GramJs.MessageMediaGeo): ApiLocation | undefined {
const point = buildGeoPoint(media.geo);
return point && { type: 'geo', geo: point };
}
function buildVenue(media: GramJs.MessageMediaVenue): ApiLocation | undefined {
const { geo, title, provider, address, venueId, venueType } = media;
const point = buildGeoPoint(geo);
return point && {
type: 'venue',
geo: point,
title,
provider,
address,
venueId,
venueType,
};
}
function buildGeoLive(media: GramJs.MessageMediaGeoLive): ApiLocation | undefined {
const { geo, period, heading } = media;
const point = buildGeoPoint(geo);
return point && {
type: 'geoLive',
geo: point,
period,
heading,
};
}
function buildGeoPoint(geo: GramJs.TypeGeoPoint): ApiLocation['geo'] | undefined {
if (geo instanceof GramJs.GeoPointEmpty) return undefined;
const { long, lat, accuracyRadius, accessHash } = geo;
return {
long,
lat,
accessHash: accessHash.toString(),
accuracyRadius,
};
}
export function buildPoll(poll: GramJs.Poll, pollResults: GramJs.PollResults): ApiPoll {
const { id, answers: rawAnswers } = poll;
const answers = rawAnswers.map((answer) => ({

View File

@ -17,7 +17,7 @@ import * as cacheApi from '../../../util/cacheApi';
type EntityType = (
'msg' | 'sticker' | 'wallpaper' | 'gif' | 'channel' | 'chat' | 'user' | 'photo' | 'stickerSet' | 'webDocument' |
'document'
);
);
const MEDIA_ENTITY_TYPES = new Set(['msg', 'sticker', 'gif', 'wallpaper', 'photo', 'webDocument', 'document']);
const TGS_MIME_TYPE = 'application/x-tgsticker';
@ -74,11 +74,13 @@ async function download(
mediaFormat?: ApiMediaFormat,
isHtmlAllowed?: boolean,
) {
const mediaMatch = url.startsWith('webDocument')
? url.match(/(webDocument):(.+)/)
: url.match(
/(avatar|profile|photo|msg|stickerSet|sticker|wallpaper|gif|file|document)([-\d\w./]+)(?::\d+)?(\?size=\w+)?/,
);
const mediaMatch = url.startsWith('staticMap')
? url.match(/(staticMap):([0-9-]+)(\?.+)/)
: url.startsWith('webDocument')
? url.match(/(webDocument):(.+)/)
: url.match(
/(avatar|profile|photo|msg|stickerSet|sticker|wallpaper|gif|file|document)([-\d\w./]+)(?::\d+)?(\?size=\w+)?/,
);
if (!mediaMatch) {
return undefined;
}
@ -102,6 +104,25 @@ async function download(
GramJs.Document | GramJs.StickerSet | GramJs.TypeWebDocument | undefined
);
if (mediaMatch[1] === 'staticMap') {
const accessHash = mediaMatch[2];
const params = mediaMatch[3];
const parsedParams = new URLSearchParams(params);
const long = parsedParams.get('long');
const lat = parsedParams.get('lat');
const w = parsedParams.get('w');
const h = parsedParams.get('h');
const zoom = parsedParams.get('zoom');
const scale = parsedParams.get('scale');
const accuracyRadius = parsedParams.get('accuracy_radius');
const data = await client.downloadStaticMap(accessHash, long, lat, w, h, zoom, scale, accuracyRadius);
return {
mimeType: 'image/png',
data,
};
}
if (mediaMatch[1] === 'avatar' || mediaMatch[1] === 'profile') {
entityType = getEntityTypeById(entityId);
} else {
@ -204,6 +225,12 @@ function getMessageMediaMimeType(message: GramJs.Message, sizeType?: string) {
return 'image/jpeg';
}
if (message.media instanceof GramJs.MessageMediaGeo
|| message.media instanceof GramJs.MessageMediaVenue
|| message.media instanceof GramJs.MessageMediaGeoLive) {
return 'image/png';
}
if (message.media instanceof GramJs.MessageMediaDocument && message.media.document instanceof GramJs.Document) {
if (sizeType) {
return message.media.document!.attributes.some((a) => a instanceof GramJs.DocumentAttributeSticker)

View File

@ -143,6 +143,37 @@ export interface ApiInvoice {
isTest?: boolean;
}
interface ApiGeoPoint {
long: number;
lat: number;
accessHash: string;
accuracyRadius?: number;
}
interface ApiGeo {
type: 'geo';
geo: ApiGeoPoint;
}
interface ApiVenue {
type: 'venue';
geo: ApiGeoPoint;
title: string;
address: string;
provider: string;
venueId: string;
venueType: string;
}
interface ApiGeoLive {
type: 'geoLive';
geo: ApiGeoPoint;
heading?: number;
period: number;
}
export type ApiLocation = ApiGeo | ApiVenue | ApiGeoLive;
export type ApiNewPoll = {
summary: ApiPoll['summary'];
quiz?: {
@ -240,6 +271,7 @@ export interface ApiMessage {
audio?: ApiAudio;
voice?: ApiVoice;
invoice?: ApiInvoice;
location?: ApiLocation;
};
date: number;
isOutgoing: boolean;

1
src/assets/map-pin.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21.333" height="37.218" viewBox="0 0 20 34.892"><g transform="translate(-965.773 -331.784) scale(1.18559)"><path d="M817.112 282.971c-1.258 1.343-2.046 3.299-2.015 5.139.064 3.845 1.797 5.3 4.568 10.592.999 2.328 2.04 4.792 3.031 8.873.138.602.272 1.16.335 1.21.062.048.196-.513.334-1.115.99-4.081 2.033-6.543 3.031-8.871 2.771-5.292 4.504-6.748 4.568-10.592.031-1.84-.759-3.798-2.017-5.14-1.437-1.535-3.605-2.67-5.916-2.717-2.312-.048-4.481 1.087-5.919 2.621z" style="display:inline;opacity:1;fill:#ff4646;fill-opacity:1;stroke:#d73534;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/><circle r="3.035" cy="288.253" cx="823.031" style="display:inline;opacity:1;fill:#590000;fill-opacity:1;stroke-width:0"/></g></svg>

After

Width:  |  Height:  |  Size: 791 B

View File

@ -0,0 +1,196 @@
.Location {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 0 !important;
.location-accuracy {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
@keyframes accuracy-wave {
0% {
transform: translate(-50%, -50%) scale(0);
background-color: var(--color-primary);
}
100% {
transform: translate(-50%, -50%) scale(1);
background-color: transparent;
}
}
animation: accuracy-wave 5s ease-out infinite forwards;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.map-wrapper {
overflow: hidden;
position: relative;
}
.map {
animation: fade-in 0.3s forwards;
}
.pin {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -100%);
animation: fade-in 0.3s forwards;
--pin-color: var(--color-primary);
}
.geoLive {
&::before {
content: "";
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, 50%);
width: 1rem;
height: 1rem;
background: var(--color-primary);
border: 2px solid white;
border-radius: 50%;
z-index: 3;
}
.round-pin {
bottom: 0.5rem;
}
}
.direction {
position: absolute;
top: 50%;
left: 50%;
transition: transform 0.3s ease-out;
transform: translate(-50%, 0.3125rem) rotate(var(--direction));
transform-origin: bottom;
width: 1.5rem;
height: 2rem;
clip-path: polygon(50% 100%, 0 0, 100% 0);
background: radial-gradient(circle, var(--color-primary) -100%, transparent 100%);
border-radius: 40%;
z-index: 2;
}
.location-avatar {
position: relative;
overflow: hidden;
margin-right: 0;
margin-bottom: 1.3125rem;
z-index: 5;
}
.venue-icon {
position: absolute;
bottom: -0.5rem;
left: 50%;
transform: translate(-50%, -50%);
width: 3rem;
height: 3rem;
z-index: 5;
}
.geo {
height: 2.5rem;
}
.geoLive,
.venue {
filter: drop-shadow(0 0 2px var(--color-default-shadow));
}
.expired {
--pin-color: white;
.location-avatar::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.3);
}
}
.round-pin {
fill: var(--pin-color);
position: absolute;
left: 50%;
bottom: 0;
transform: translate(-50%, 0);
width: 5rem;
z-index: 4;
}
.location-info {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr 1fr;
width: 100%;
padding: 0.3125rem 0.5rem 0.375rem;
&-title {
font-weight: 500;
grid-area: 1 / 1 / 2 / 2;
}
&-subtitle {
color: var(--color-text-secondary);
line-height: 1;
font-size: 0.875rem;
grid-area: 2 / 1 / 2 / 2;
.Message.own & {
color: var(--color-message-meta-own);
}
}
}
.geo-countdown {
grid-area: 1 / 2 / 3 / 3;
position: relative;
width: 2rem;
height: 2rem;
}
.geo-countdown-text {
color: var(--accent-color);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.875rem;
font-weight: 500;
}
.geo-countdown-progress {
stroke: var(--accent-color);
fill: transparent;
stroke-width: 2;
stroke-linecap: round;
transition: stroke-dashoffset 2s, stroke 0.2s;
}
}

View File

@ -0,0 +1,282 @@
import React, {
FC, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { ApiChat, ApiMessage, ApiUser } from '../../../api/types';
import { ISettings } from '../../../types';
import { CUSTOM_APPENDIX_ATTRIBUTE } from '../../../config';
import {
getMessageLocation,
buildStaticMapHash,
isGeoLiveExpired,
isOwnMessage,
isUserId,
} from '../../../modules/helpers';
import useMedia from '../../../hooks/useMedia';
import getCustomAppendixBg from './helpers/getCustomAppendixBg';
import { formatCountdownShort, formatLastUpdated } from '../../../util/dateFormat';
import useLang from '../../../hooks/useLang';
import useForceUpdate from '../../../hooks/useForceUpdate';
import useTimeout from '../../../hooks/useTimeout';
import { getMetersPerPixel, getVenueColor, getVenueIconUrl, prepareMapUrl } from '../../../util/map';
import buildClassName from '../../../util/buildClassName';
import usePrevious from '../../../hooks/usePrevious';
import useInterval from '../../../hooks/useInterval';
import { getServerTime } from '../../../util/serverTime';
import Avatar from '../../common/Avatar';
import Skeleton from '../../ui/Skeleton';
import mapPin from '/src/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'];
serverTimeOffset: number;
};
const Location: FC<OwnProps> = ({
message,
peer,
lastSyncTime,
isInSelectMode,
isSelected,
theme,
serverTimeOffset,
}) => {
// 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(serverTimeOffset);
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);
window.open(url, '_blank')?.focus();
};
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(serverTimeOffset);
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, serverTimeOffset, lang]);
useLayoutEffect(() => {
if (countdownRef.current) {
updateCountdown(countdownRef.current);
}
}, [updateCountdown]);
useLayoutEffect(() => {
if (shouldRenderText) return;
const contentEl = ref.current!.closest<HTMLDivElement>('.message-content')!;
if (mapBlobUrl) {
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, '');
});
}
}, [isOwn, isInSelectMode, isSelected, theme, mapBlobUrl, shouldRenderText]);
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"
width={DEFAULT_MAP_CONFIG.width}
height={DEFAULT_MAP_CONFIG.height}
/>;
}
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 icon = getVenueIconUrl(location.venueType);
return (
<div className={pinClassName} dangerouslySetInnerHTML={SVG_PIN} style={`--pin-color: ${color}`}>
<img src={icon} 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);

View File

@ -61,6 +61,8 @@
bottom: 0;
left: 0;
margin-right: 0.3125rem;
@media (max-width: 600px) {
width: 2.5rem;
height: 2.5rem;
@ -553,10 +555,6 @@
}
}
.Avatar {
margin-right: 0.3125rem;
}
.message-content {
&.has-replies:not(.custom-shape):not(.has-reactions) .WebPage.with-video .media-inner {
margin-bottom: 1.5rem !important;

View File

@ -68,6 +68,7 @@ import {
getSenderTitle,
getUserColorKey,
areReactionsEmpty,
isGeoLiveExpired,
} from '../../../modules/helpers';
import buildClassName from '../../../util/buildClassName';
import useEnsureMessage from '../../../hooks/useEnsureMessage';
@ -86,6 +87,7 @@ import useFlag from '../../../hooks/useFlag';
import useFocusMessage from './hooks/useFocusMessage';
import useOuterHandlers from './hooks/useOuterHandlers';
import useInnerHandlers from './hooks/useInnerHandlers';
import { getServerTime } from '../../../util/serverTime';
import Button from '../../ui/Button';
import Avatar from '../../common/Avatar';
@ -102,6 +104,7 @@ import Contact from './Contact';
import Poll from './Poll';
import WebPage from './WebPage';
import Invoice from './Invoice';
import Location from './Location';
import Album from './Album';
import RoundVideo from './RoundVideo';
import InlineButtons from './InlineButtons';
@ -143,6 +146,7 @@ type StateProps = {
forceSenderName?: boolean;
chatUsername?: string;
sender?: ApiUser | ApiChat;
canShowSender: boolean;
originSender?: ApiUser | ApiChat;
botSender?: ApiUser;
isThreadTop?: boolean;
@ -163,6 +167,7 @@ type StateProps = {
isChannel?: boolean;
canReply?: boolean;
lastSyncTime?: number;
serverTimeOffset: number;
highlight?: string;
isSingleEmoji?: boolean;
animatedEmoji?: ApiSticker;
@ -227,6 +232,7 @@ const Message: FC<OwnProps & StateProps> = ({
theme,
forceSenderName,
sender,
canShowSender,
originSender,
botSender,
isThreadTop,
@ -247,6 +253,7 @@ const Message: FC<OwnProps & StateProps> = ({
isChannel,
canReply,
lastSyncTime,
serverTimeOffset,
highlight,
animatedEmoji,
localSticker,
@ -357,8 +364,10 @@ const Message: FC<OwnProps & StateProps> = ({
});
}, [toggleMessageSelection, messageId, isAlbum, album]);
const avatarPeer = forwardInfo && (isChatWithSelf || isRepliesChat || !sender) ? originSender : sender;
const senderPeer = forwardInfo ? originSender : sender;
const messageSender = canShowSender ? sender : undefined;
const avatarPeer = forwardInfo && (isChatWithSelf || isRepliesChat || !messageSender) ? originSender : messageSender;
const senderPeer = forwardInfo ? originSender : messageSender;
const {
handleMouseDown,
@ -449,6 +458,11 @@ const Message: FC<OwnProps & StateProps> = ({
transitionClassNames,
Boolean(activeReaction) && 'has-active-reaction',
);
const {
text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location,
} = getMessageContent(message);
const contentClassName = buildContentClassName(message, {
hasReply,
customShape,
@ -459,15 +473,12 @@ const Message: FC<OwnProps & StateProps> = ({
hasComments: threadInfo && threadInfo?.messagesCount > 0,
hasActionButton: canForward || canFocus,
hasReactions,
isGeoLiveActive: location?.type === 'geoLive' && !isGeoLiveExpired(message, getServerTime(serverTimeOffset)),
});
const withAppendix = contentClassName.includes('has-appendix');
const textParts = renderMessageText(message, highlight, isEmojiOnlyMessage(customShape));
const {
text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice,
} = getMessageContent(message);
let metaPosition!: MetaPosition;
if (isInDocumentGroupNotLast) {
metaPosition = 'none';
@ -764,13 +775,25 @@ const Message: FC<OwnProps & StateProps> = ({
/>
)}
{invoice && <Invoice message={message} />}
{location && (
<Location
message={message}
lastSyncTime={lastSyncTime}
isInSelectMode={isInSelectMode}
isSelected={isSelected}
theme={theme}
peer={sender}
serverTimeOffset={serverTimeOffset}
/>
)}
</div>
);
}
function renderSenderName() {
const media = photo || video || location;
const shouldRender = !(customShape && !viaBotId) && (
(withSenderName && !photo && !video) || asForwarded || viaBotId || forceSenderName
(withSenderName && !media) || asForwarded || viaBotId || forceSenderName
) && !isInDocumentGroupNotFirst && !(hasReply && customShape);
if (!shouldRender) {
@ -940,7 +963,7 @@ const Message: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global, ownProps): StateProps => {
const { focusedMessage, forwardMessages, lastSyncTime } = global;
const { focusedMessage, forwardMessages, lastSyncTime, serverTimeOffset } = global;
const {
message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup,
} = ownProps;
@ -1013,7 +1036,8 @@ export default memo(withGlobal<OwnProps>(
theme: selectTheme(global),
chatUsername,
forceSenderName,
sender: canShowSender ? sender : undefined,
sender,
canShowSender,
originSender,
botSender,
shouldHideReply,
@ -1030,6 +1054,7 @@ export default memo(withGlobal<OwnProps>(
isChannel,
canReply,
lastSyncTime,
serverTimeOffset,
highlight,
isSingleEmoji: Boolean(singleEmoji),
animatedEmoji: singleEmoji ? selectAnimatedEmoji(global, singleEmoji) : undefined,

View File

@ -6,6 +6,7 @@ import { ApiMessage } from '../../../api/types';
import { ISettings } from '../../../types';
import { IMediaDimensions } from './helpers/calculateAlbumLayout';
import { CUSTOM_APPENDIX_ATTRIBUTE } from '../../../config';
import {
getMessagePhoto,
getMessageWebPagePhoto,
@ -45,8 +46,6 @@ export type OwnProps = {
onCancelUpload?: (message: ApiMessage) => void;
};
const CUSTOM_APPENDIX_ATTRIBUTE = 'data-has-custom-appendix';
const Photo: FC<OwnProps> = ({
id,
message,

View File

@ -18,6 +18,7 @@ export function buildContentClassName(
hasComments,
hasActionButton,
hasReactions,
isGeoLiveActive,
}: {
hasReply?: boolean;
customShape?: boolean | number;
@ -28,20 +29,22 @@ export function buildContentClassName(
hasComments?: boolean;
hasActionButton?: boolean;
hasReactions?: boolean;
isGeoLiveActive?: boolean;
} = {},
) {
const {
text, photo, video, audio, voice, document, poll, webPage, contact,
text, photo, video, audio, voice, document, poll, webPage, contact, location,
} = getMessageContent(message);
const classNames = ['message-content'];
const isMedia = photo || video;
const isMediaWithNoText = isMedia && !text;
const isMedia = photo || video || location;
const hasText = text || location?.type === 'venue' || isGeoLiveActive;
const isMediaWithNoText = isMedia && !hasText;
const isViaBot = Boolean(message.viaBotId);
if (isEmojiOnlyMessage(customShape)) {
classNames.push(`emoji-only emoji-only-${customShape}`);
} else if (text) {
} else if (hasText) {
classNames.push('text');
}
@ -59,7 +62,7 @@ export function buildContentClassName(
classNames.push('has-comments');
}
}
if (photo || video) {
if (isMedia) {
classNames.push('media');
} else if (audio) {
classNames.push('audio');
@ -114,7 +117,7 @@ export function buildContentClassName(
classNames.push('has-solid-background');
}
if (isLastInGroup && (photo || !isMediaWithNoText)) {
if (isLastInGroup && (photo || (location && !hasText) || !isMediaWithNoText)) {
classNames.push('has-appendix');
}
}

View File

@ -0,0 +1,53 @@
.Skeleton {
position: relative;
background-color: var(--color-skeleton-background);
&.round {
border-radius: 50%;
}
&.rounded-rect {
border-radius: 1rem;
}
&.pulse::before {
content: "";
display: block;
width: 100%;
height: 100%;
background-color: var(--color-skeleton-foreground);
animation: skeleton-pulse 2s ease-in-out 0.5s infinite;
@keyframes skeleton-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.4;
}
100% {
opacity: 1;
}
}
}
&.wave::before {
content: "";
display: block;
width: 100%;
height: 100%;
background: linear-gradient(to right, transparent 0%, var(--color-skeleton-foreground) 50%, transparent 100%);
animation: skeleton-wave 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
@keyframes skeleton-wave {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
}
}

View File

@ -0,0 +1,23 @@
import React, { FC } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import './Skeleton.scss';
type OwnProps = {
variant?: 'rectangular' | 'rounded-rect' | 'round';
animation?: 'wave' | 'pulse';
width?: number;
height?: number;
className?: string;
};
const Skeleton: FC<OwnProps> = ({ variant = 'rectangular', animation = 'wave', width, height, className }) => {
const classNames = buildClassName('Skeleton', variant, animation, className);
const style = (width ? `width: ${width}px;` : '') + (height ? `height: ${height}px;` : '');
return (
<div className={classNames} style={style} />
);
};
export default Skeleton;

View File

@ -85,6 +85,8 @@ export const SEND_MESSAGE_ACTION_INTERVAL = 3000; // 3s
export const EDITABLE_INPUT_ID = 'editable-message-text';
export const EDITABLE_INPUT_MODAL_ID = 'editable-message-text-modal';
export const CUSTOM_APPENDIX_ATTRIBUTE = 'data-has-custom-appendix';
// Screen width where Pinned Message / Audio Player in the Middle Header can be safely displayed
export const SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN = 1440; // px
// Screen width where Pinned Message / Audio Player in the Middle Header shouldn't collapse with ChatInfo

View File

@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useRef } from '../lib/teact/teact';
function useTimeout(callback: () => void, delay: number | null) {
function useTimeout(callback: () => void, delay?: number) {
const savedCallback = useRef(callback);
useLayoutEffect(() => {

View File

@ -6,7 +6,7 @@ import { downloadFile, DownloadFileParams } from './downloadFile';
import { TwoFaParams, updateTwoFaSettings } from './2fa';
declare class TelegramClient {
constructor(...args: any)
constructor(...args: any);
async start(authParams: UserAuthParams | BotAuthParams);

View File

@ -186,6 +186,7 @@ class TelegramClient {
}
// set defaults vars
this._sender.userDisconnected = false;
// eslint-disable-next-line camelcase
this._sender._user_connected = false;
this._sender.isReconnecting = false;
this._sender._disconnected = true;
@ -678,7 +679,6 @@ class TelegramClient {
throw new Error('not implemented');
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async _downloadWebDocument(media) {
try {
const buff = [];
@ -715,6 +715,59 @@ class TelegramClient {
}
}
async downloadStaticMap(accessHash, long, lat, w, h, zoom, scale, accuracyRadius) {
try {
const buff = [];
let offset = 0;
while (true) {
try {
const downloaded = new requests.upload.GetWebFile({
location: new constructors.InputWebFileGeoPointLocation({
geoPoint: new constructors.InputGeoPoint({
lat,
long,
accuracyRadius,
}),
accessHash,
w,
h,
zoom,
scale,
}),
offset,
limit: WEBDOCUMENT_REQUEST_PART_SIZE,
});
const sender = await this._borrowExportedSender(WEBDOCUMENT_DC_ID);
const res = await sender.send(downloaded);
offset += 131072;
if (res.bytes.length) {
buff.push(res.bytes);
if (res.bytes.length < WEBDOCUMENT_REQUEST_PART_SIZE) {
break;
}
} else {
break;
}
} catch (err) {
if (err instanceof errors.FloodWaitError) {
// eslint-disable-next-line no-console
console.warn(`getWebFile: sleeping for ${err.seconds}s on flood wait`);
await sleep(err.seconds * 1000);
continue;
}
}
}
return Buffer.concat(buff);
} catch (e) {
// the file is no longer saved in telegram's cache.
if (e.message === 'WEBFILE_NOT_AVAILABLE') {
return Buffer.alloc(0);
} else {
throw e;
}
}
}
// region Invoking Telegram request
/**
* Invokes a MTProtoRequest (sends and receives it) and returns its result

View File

@ -1,5 +1,5 @@
import {
ApiAudio, ApiMediaFormat, ApiMessage, ApiMessageSearchType, ApiPhoto, ApiVideo, ApiDimensions,
ApiAudio, ApiMediaFormat, ApiMessage, ApiMessageSearchType, ApiPhoto, ApiVideo, ApiDimensions, ApiLocation,
} from '../../api/types';
import { IS_OPUS_SUPPORTED, IS_PROGRESSIVE_SUPPORTED, IS_SAFARI } from '../../util/environment';
@ -88,6 +88,10 @@ export function getMessageInvoice(message: ApiMessage) {
return message.content.invoice;
}
export function getMessageLocation(message: ApiMessage) {
return message.content.location;
}
export function getMessageWebPage(message: ApiMessage) {
return message.content.webPage;
}
@ -123,6 +127,19 @@ export function getMessageMediaThumbDataUri(message: ApiMessage) {
return getMessageMediaThumbnail(message)?.dataUri;
}
export function buildStaticMapHash(
geo: ApiLocation['geo'],
width: number,
height: number,
zoom: number,
scale: number,
) {
const { long, lat, accessHash, accuracyRadius } = geo;
// eslint-disable-next-line max-len
return `staticMap:${accessHash}?lat=${lat}&long=${long}&w=${width}&h=${height}&zoom=${zoom}&scale=${scale}&accuracyRadius=${accuracyRadius}`;
}
export function getMessageMediaHash(
message: ApiMessage,
target: Target,
@ -136,11 +153,13 @@ export function getMessageMediaHash(
const messageVideo = video || webPageVideo;
const messagePhoto = photo || webPagePhoto;
if (!(messagePhoto || messageVideo || sticker || audio || voice || document)) {
const content = messagePhoto || messageVideo || sticker || audio || voice || document;
if (!content) {
return undefined;
}
const mediaId = (messagePhoto || messageVideo || sticker || audio || voice || document)!.id;
const mediaId = content.id;
const base = `${getMessageKey(message)}${mediaId ? `:${mediaId}` : ''}`;
if (messageVideo) {

View File

@ -118,6 +118,7 @@ export function getMessageSummaryDescription(
contact,
poll,
invoice,
location,
} = message.content;
let summary: string | TextPart[] | undefined;
@ -170,6 +171,14 @@ export function getMessageSummaryDescription(
}
}
if (location?.type === 'geo' || location?.type === 'venue') {
summary = lang('Message.Location');
}
if (location?.type === 'geoLive') {
summary = lang('Message.LiveLocation');
}
const reaction = !noReactions && getMessageRecentReaction(message);
if (summary && reaction) {
summary = `to your "${summary}"`;

View File

@ -40,14 +40,15 @@ export function getMessageOriginalId(message: ApiMessage) {
export function getMessageText(message: ApiMessage) {
const {
text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice,
text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice, location,
} = message.content;
if (text) {
return text.text;
}
if (sticker || photo || video || audio || voice || document || contact || poll || webPage || invoice) {
if (sticker || photo || video || audio || voice || document
|| contact || poll || webPage || invoice || location) {
return undefined;
}
@ -215,3 +216,9 @@ export function getMessageContentFilename(message: ApiMessage) {
export function areReactionsEmpty(reactions: ApiReactions) {
return !reactions.results.some((l) => l.count > 0);
}
export function isGeoLiveExpired(message: ApiMessage, timestamp = Date.now() / 1000) {
const { location } = message.content;
if (location?.type !== 'geoLive') return false;
return (timestamp - (message.date || 0) >= location.period);
}

View File

@ -368,7 +368,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
|| getServerTime(global.serverTimeOffset) - message.date < MESSAGE_EDIT_ALLOWED_TIME
) && !(
content.sticker || content.contact || content.poll || content.action || content.audio
|| (content.video?.isRound)
|| (content.video?.isRound) || content.location
)
&& !isForwardedMessage(message)
&& !message.viaBotId

View File

@ -162,6 +162,9 @@ $color-message-reaction-own-hover: #b5e0a4;
--color-default-shadow: #72727240;
--color-light-shadow: #7272722b;
--color-skeleton-background: rgba(33, 33, 33, 0.15);
--color-skeleton-foreground: rgba(232, 232, 232, 0.2);
--vh: 1vh;
--border-radius-default: 0.75rem;

View File

@ -106,6 +106,29 @@ export function formatCountdown(
}
}
export function formatCountdownShort(lang: LangFn, msLeft: number) {
if (msLeft < 60 * 1000) {
return Math.ceil(msLeft / 1000);
} else if (msLeft < 60 * 60 * 1000) {
return Math.ceil(msLeft / (60 * 1000));
} else if (msLeft < MILLISECONDS_IN_DAY) {
return lang('MessageTimer.ShortHours', Math.ceil(msLeft / (60 * 60 * 1000)));
} else {
return lang('MessageTimer.ShortDays', Math.ceil(msLeft / MILLISECONDS_IN_DAY));
}
}
export function formatLastUpdated(lang: LangFn, currentTime: number, lastUpdated = currentTime) {
const seconds = currentTime - lastUpdated;
if (seconds < 60) {
return lang('LiveLocationUpdated.JustNow');
} else if (seconds < 60 * 60) {
return lang('LiveLocationUpdated.MinutesAgo', Math.floor(seconds / 60));
} else {
return lang('LiveLocationUpdated.TodayAt', formatTime(lang, lastUpdated));
}
}
export function formatHumanDate(
lang: LangFn,
datetime: number | Date,

57
src/util/map.ts Normal file
View File

@ -0,0 +1,57 @@
const PROVIDER = 'http://maps.google.com/maps';
// eslint-disable-next-line max-len
// https://github.com/TelegramMessenger/Telegram-iOS/blob/2a32c871882c4e1b1ccdecd34fccd301723b30d9/submodules/LocationResources/Sources/VenueIconResources.swift#L82
const VENUE_COLORS = new Map(Object.entries({
'building/medical': '#43b3f4',
'building/gym': '#43b3f4',
'arts_entertainment': '#e56dd6',
'travel/bedandbreakfast': '#9987ff',
'travel/hotel': '#9987ff',
'travel/hostel': '#9987ff',
'travel/resort': '#9987ff',
'building': '#6e81b2',
'education': '#a57348',
'event': '#959595',
'food': '#f7943f',
'education/cafeteria': '#f7943f',
'nightlife': '#e56dd6',
'travel/hotel_bar': '#e56dd6',
'parks_outdoors': '#6cc039',
'shops': '#ffb300',
'travel': '#1c9fff',
'work': '#ad7854',
'home': '#00aeef',
}));
const RANDOM_COLORS = [
'#e56cd5', '#f89440', '#9986ff', '#44b3f5', '#6dc139', '#ff5d5a', '#f87aad', '#6e82b3', '#f5ba21',
];
export function prepareMapUrl(lat: number, long: number, zoom: number) {
return `${PROVIDER}/place/${lat}+${long}/@${lat},${long},${zoom}z`;
}
export function getMetersPerPixel(lat: number, zoom: number) {
// https://groups.google.com/g/google-maps-js-api-v3/c/hDRO4oHVSeM/m/osOYQYXg2oUJ
return 156543.03392 * Math.cos(lat * Math.PI / 180) / Math.pow(2, zoom);
}
export function getVenueIconUrl(type?: string) {
if (!type) return '';
return `https://ss3.4sqi.net/img/categories_v2/${type}_88.png`;
}
// eslint-disable-next-line max-len
// https://github.com/TelegramMessenger/Telegram-iOS/blob/2a32c871882c4e1b1ccdecd34fccd301723b30d9/submodules/LocationResources/Sources/VenueIconResources.swift#L104
export function getVenueColor(type?: string) {
if (!type) return '#008df2';
return VENUE_COLORS.get(type)
|| VENUE_COLORS.get(type.split('/')[0])
|| RANDOM_COLORS[stringToNumber(type) % RANDOM_COLORS.length];
}
function stringToNumber(str: string) {
return str.split('').reduce((prevHash, currVal) =>
(((prevHash << 5) - prevHash) + currVal.charCodeAt(0)) | 0, 0);
}