diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 231f9dd85..1891f7cd8 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -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) => ({ diff --git a/src/api/gramjs/methods/media.ts b/src/api/gramjs/methods/media.ts index 62e019b17..a68cf746b 100644 --- a/src/api/gramjs/methods/media.ts +++ b/src/api/gramjs/methods/media.ts @@ -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) diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 283875b7b..5e230f9e3 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -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; diff --git a/src/assets/map-pin.svg b/src/assets/map-pin.svg new file mode 100644 index 000000000..9f541c656 --- /dev/null +++ b/src/assets/map-pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/middle/message/Location.scss b/src/components/middle/message/Location.scss new file mode 100644 index 000000000..edc894ac2 --- /dev/null +++ b/src/components/middle/message/Location.scss @@ -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; + } +} diff --git a/src/components/middle/message/Location.tsx b/src/components/middle/message/Location.tsx new file mode 100644 index 000000000..159736925 --- /dev/null +++ b/src/components/middle/message/Location.tsx @@ -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: '' }; + +type OwnProps = { + message: ApiMessage; + peer?: ApiUser | ApiChat; + lastSyncTime?: number; + isInSelectMode?: boolean; + isSelected?: boolean; + theme: ISettings['theme']; + serverTimeOffset: number; +}; + +const Location: FC = ({ + message, + peer, + lastSyncTime, + isInSelectMode, + isSelected, + theme, + serverTimeOffset, +}) => { + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const countdownRef = useRef(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 = ` + ${text} + + + `; + } 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('.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 ( + + + {location.title} + + + {location.address} + + + ); + } + if (type === 'geoLive') { + return ( + + {lang('AttachLiveLocation')} + + {formatLastUpdated(lang, serverTime, message.editDate)} + + {!isExpired && } + + ); + } + return undefined; + } + + function renderMap() { + if (!mapBlobUrl) return ; + return ; + } + + function renderPin() { + const pinClassName = buildClassName( + 'pin', + type, + isExpired && 'expired', + ); + if (type === 'geoLive') { + return ( + + + {location.heading !== undefined && ( + + )} + + ); + } + + if (type === 'venue') { + const color = getVenueColor(location.venueType); + const icon = getVenueIconUrl(location.venueType); + return ( + + + + ); + } + + return ( + + ); + } + + function renderOverlay() { + if (!mapBlobUrl) return undefined; + + return ( + <> + {Boolean(accuracyRadiusPx) && !isExpired && ( + + )} + {renderPin()} + > + ); + } + + return ( + + + {renderMap()} + {renderOverlay()} + + {renderInfo()} + + ); +}; + +export default memo(Location); + diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index 03bd98a2f..2b34b6925 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -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; diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index eabe8ec03..d86335346 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -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 = ({ theme, forceSenderName, sender, + canShowSender, originSender, botSender, isThreadTop, @@ -247,6 +253,7 @@ const Message: FC = ({ isChannel, canReply, lastSyncTime, + serverTimeOffset, highlight, animatedEmoji, localSticker, @@ -357,8 +364,10 @@ const Message: FC = ({ }); }, [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 = ({ 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 = ({ 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 = ({ /> )} {invoice && } + {location && ( + + )} ); } 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 = ({ export default memo(withGlobal( (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( theme: selectTheme(global), chatUsername, forceSenderName, - sender: canShowSender ? sender : undefined, + sender, + canShowSender, originSender, botSender, shouldHideReply, @@ -1030,6 +1054,7 @@ export default memo(withGlobal( isChannel, canReply, lastSyncTime, + serverTimeOffset, highlight, isSingleEmoji: Boolean(singleEmoji), animatedEmoji: singleEmoji ? selectAnimatedEmoji(global, singleEmoji) : undefined, diff --git a/src/components/middle/message/Photo.tsx b/src/components/middle/message/Photo.tsx index 1cb4d97a3..a1632650e 100644 --- a/src/components/middle/message/Photo.tsx +++ b/src/components/middle/message/Photo.tsx @@ -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 = ({ id, message, diff --git a/src/components/middle/message/helpers/buildContentClassName.ts b/src/components/middle/message/helpers/buildContentClassName.ts index 28649bf7c..af5cab0ef 100644 --- a/src/components/middle/message/helpers/buildContentClassName.ts +++ b/src/components/middle/message/helpers/buildContentClassName.ts @@ -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'); } } diff --git a/src/components/ui/Skeleton.scss b/src/components/ui/Skeleton.scss new file mode 100644 index 000000000..a02aafc78 --- /dev/null +++ b/src/components/ui/Skeleton.scss @@ -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%); + } + } + } +} diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx new file mode 100644 index 000000000..46bdc6fa2 --- /dev/null +++ b/src/components/ui/Skeleton.tsx @@ -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 = ({ 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 ( + + ); +}; + +export default Skeleton; diff --git a/src/config.ts b/src/config.ts index 081c5dd29..a43a60d5b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 diff --git a/src/hooks/useTimeout.ts b/src/hooks/useTimeout.ts index 2a9645364..5cc3b18bd 100644 --- a/src/hooks/useTimeout.ts +++ b/src/hooks/useTimeout.ts @@ -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(() => { diff --git a/src/lib/gramjs/client/TelegramClient.d.ts b/src/lib/gramjs/client/TelegramClient.d.ts index 8496c94ae..56335a84a 100644 --- a/src/lib/gramjs/client/TelegramClient.d.ts +++ b/src/lib/gramjs/client/TelegramClient.d.ts @@ -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); diff --git a/src/lib/gramjs/client/TelegramClient.js b/src/lib/gramjs/client/TelegramClient.js index 21f01b65a..104b0306c 100644 --- a/src/lib/gramjs/client/TelegramClient.js +++ b/src/lib/gramjs/client/TelegramClient.js @@ -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 diff --git a/src/modules/helpers/messageMedia.ts b/src/modules/helpers/messageMedia.ts index e9f3ddf05..e52f35234 100644 --- a/src/modules/helpers/messageMedia.ts +++ b/src/modules/helpers/messageMedia.ts @@ -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) { diff --git a/src/modules/helpers/messageSummary.ts b/src/modules/helpers/messageSummary.ts index 224e320f0..78e5aefa9 100644 --- a/src/modules/helpers/messageSummary.ts +++ b/src/modules/helpers/messageSummary.ts @@ -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}"`; diff --git a/src/modules/helpers/messages.ts b/src/modules/helpers/messages.ts index 87179ac66..28dd14276 100644 --- a/src/modules/helpers/messages.ts +++ b/src/modules/helpers/messages.ts @@ -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); +} diff --git a/src/modules/selectors/messages.ts b/src/modules/selectors/messages.ts index 874252cb9..1a729e053 100644 --- a/src/modules/selectors/messages.ts +++ b/src/modules/selectors/messages.ts @@ -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 diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 92b20c4c3..c333e1594 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -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; diff --git a/src/util/dateFormat.ts b/src/util/dateFormat.ts index 2d93b6ac5..cbbf8817f 100644 --- a/src/util/dateFormat.ts +++ b/src/util/dateFormat.ts @@ -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, diff --git a/src/util/map.ts b/src/util/map.ts new file mode 100644 index 000000000..ec315b974 --- /dev/null +++ b/src/util/map.ts @@ -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); +}