Message: Introduce Geo Locations (#1716)
This commit is contained in:
parent
39ae9189d2
commit
531c2de36c
@ -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) => ({
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
1
src/assets/map-pin.svg
Normal 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 |
196
src/components/middle/message/Location.scss
Normal file
196
src/components/middle/message/Location.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
282
src/components/middle/message/Location.tsx
Normal file
282
src/components/middle/message/Location.tsx
Normal 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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
53
src/components/ui/Skeleton.scss
Normal file
53
src/components/ui/Skeleton.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/components/ui/Skeleton.tsx
Normal file
23
src/components/ui/Skeleton.tsx
Normal 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;
|
||||
@ -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
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
2
src/lib/gramjs/client/TelegramClient.d.ts
vendored
2
src/lib/gramjs/client/TelegramClient.d.ts
vendored
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}"`;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
57
src/util/map.ts
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user