Support local changelogs; Fix marking read for service notifications

This commit is contained in:
Alexander Zinchuk 2021-10-22 13:49:20 +03:00
parent aa2617a2fd
commit 691c0c4263
23 changed files with 267 additions and 87 deletions

View File

@ -21,7 +21,10 @@
"error", "error",
120 120
], ],
"array-bracket-newline": [2, "consistent"], "array-bracket-newline": [
2,
"consistent"
],
"no-null/no-null": 2, "no-null/no-null": 2,
"no-console": "error", "no-console": "error",
"semi": "error", "semi": "error",
@ -68,5 +71,8 @@
}, },
"parserOptions": { "parserOptions": {
"project": "./tsconfig.json" "project": "./tsconfig.json"
} },
"ignorePatterns": [
"webpack.config.js"
]
} }

View File

@ -12,7 +12,9 @@ import {
ApiChatAdminRights, ApiChatAdminRights,
} from '../../types'; } from '../../types';
import { DEBUG, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE } from '../../../config'; import {
DEBUG, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE, SERVICE_NOTIFICATIONS_USER_ID,
} from '../../../config';
import { invokeRequest, uploadFile } from './client'; import { invokeRequest, uploadFile } from './client';
import { import {
buildApiChatFromDialog, buildApiChatFromDialog,
@ -52,12 +54,14 @@ export async function fetchChats({
archived, archived,
withPinned, withPinned,
serverTimeOffset, serverTimeOffset,
lastLocalServiceMessage,
}: { }: {
limit: number; limit: number;
offsetDate?: number; offsetDate?: number;
archived?: boolean; archived?: boolean;
withPinned?: boolean; withPinned?: boolean;
serverTimeOffset: number; serverTimeOffset: number;
lastLocalServiceMessage?: ApiMessage;
}) { }) {
const result = await invokeRequest(new GramJs.messages.GetDialogs({ const result = await invokeRequest(new GramJs.messages.GetDialogs({
offsetPeer: new GramJs.InputPeerEmpty(), offsetPeer: new GramJs.InputPeerEmpty(),
@ -111,7 +115,17 @@ export async function fetchChats({
const peerEntity = peersByKey[getPeerKey(dialog.peer)]; const peerEntity = peersByKey[getPeerKey(dialog.peer)];
const chat = buildApiChatFromDialog(dialog, peerEntity, serverTimeOffset); const chat = buildApiChatFromDialog(dialog, peerEntity, serverTimeOffset);
chat.lastMessage = lastMessagesByChatId[chat.id];
if (
chat.id === SERVICE_NOTIFICATIONS_USER_ID
&& lastLocalServiceMessage
&& (!lastMessagesByChatId[chat.id] || lastLocalServiceMessage.date > lastMessagesByChatId[chat.id].date)
) {
chat.lastMessage = lastLocalServiceMessage;
} else {
chat.lastMessage = lastMessagesByChatId[chat.id];
}
chat.isListed = true; chat.isListed = true;
chats.push(chat); chats.push(chat);
@ -232,8 +246,10 @@ export async function fetchChat({
export async function requestChatUpdate({ export async function requestChatUpdate({
chat, chat,
serverTimeOffset, serverTimeOffset,
lastLocalMessage,
noLastMessage,
}: { }: {
chat: ApiChat; serverTimeOffset: number; chat: ApiChat; serverTimeOffset: number; lastLocalMessage?: ApiMessage; noLastMessage?: boolean;
}) { }) {
const { id, accessHash } = chat; const { id, accessHash } = chat;
@ -260,14 +276,23 @@ export async function requestChatUpdate({
updateLocalDb(result); updateLocalDb(result);
const lastMessage = buildApiMessage(result.messages[0]); let lastMessage: ApiMessage | undefined;
if (!noLastMessage) {
const lastRemoteMessage = buildApiMessage(result.messages[0]);
if (lastLocalMessage && (!lastRemoteMessage || (lastLocalMessage.date > lastRemoteMessage.date))) {
lastMessage = lastLocalMessage;
} else {
lastMessage = lastRemoteMessage;
}
}
onUpdate({ onUpdate({
'@type': 'updateChat', '@type': 'updateChat',
id, id,
chat: { chat: {
...buildApiChatFromDialog(dialog, peerEntity, serverTimeOffset), ...buildApiChatFromDialog(dialog, peerEntity, serverTimeOffset),
lastMessage, ...(lastMessage && { lastMessage }),
}, },
}); });
} }
@ -442,6 +467,7 @@ export async function updateChatMutedState({
void requestChatUpdate({ void requestChatUpdate({
chat, chat,
serverTimeOffset, serverTimeOffset,
noLastMessage: true,
}); });
} }

View File

@ -688,7 +688,7 @@ export async function markMessageListRead({
} }
if (threadId === MAIN_THREAD_ID) { if (threadId === MAIN_THREAD_ID) {
void requestChatUpdate({ chat, serverTimeOffset }); void requestChatUpdate({ chat, serverTimeOffset, noLastMessage: true });
} else { } else {
void requestThreadInfoUpdate({ chat, threadId }); void requestThreadInfoUpdate({ chat, threadId });
} }

View File

@ -82,7 +82,6 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
|| update instanceof GramJs.UpdateNewChannelMessage || update instanceof GramJs.UpdateNewChannelMessage
|| update instanceof GramJs.UpdateShortChatMessage || update instanceof GramJs.UpdateShortChatMessage
|| update instanceof GramJs.UpdateShortMessage || update instanceof GramJs.UpdateShortMessage
|| update instanceof GramJs.UpdateServiceNotification
) { ) {
let message: ApiMessage | undefined; let message: ApiMessage | undefined;
let shouldForceReply: boolean | undefined; let shouldForceReply: boolean | undefined;
@ -91,13 +90,6 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
message = buildApiMessageFromShortChat(update); message = buildApiMessageFromShortChat(update);
} else if (update instanceof GramJs.UpdateShortMessage) { } else if (update instanceof GramJs.UpdateShortMessage) {
message = buildApiMessageFromShort(update); message = buildApiMessageFromShort(update);
} else if (update instanceof GramJs.UpdateServiceNotification) {
const currentDate = Date.now() / 1000 + serverTimeOffset;
message = buildApiMessageFromNotification(update, currentDate);
if (isMessageWithMedia(update)) {
addMessageToLocalDb(buildMessageFromUpdate(message.id, message.chatId, update));
}
} else { } else {
// TODO Remove if proven not reproducing // TODO Remove if proven not reproducing
if (update.message instanceof GramJs.MessageEmpty) { if (update.message instanceof GramJs.MessageEmpty) {
@ -319,6 +311,18 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
}); });
}, DELETE_MISSING_CHANNEL_MESSAGE_DELAY); }, DELETE_MISSING_CHANNEL_MESSAGE_DELAY);
} }
} else if (update instanceof GramJs.UpdateServiceNotification) {
const currentDate = Date.now() / 1000 + serverTimeOffset;
const message = buildApiMessageFromNotification(update, currentDate);
if (isMessageWithMedia(update)) {
addMessageToLocalDb(buildMessageFromUpdate(message.id, message.chatId, update));
}
onUpdate({
'@type': 'updateServiceNotification',
message,
});
} else if (( } else if ((
originRequest instanceof GramJs.messages.SendMessage originRequest instanceof GramJs.messages.SendMessage
|| originRequest instanceof GramJs.messages.SendMedia || originRequest instanceof GramJs.messages.SendMedia

View File

@ -239,6 +239,11 @@ export type ApiUpdateMessagePollVote = {
options: string[]; options: string[];
}; };
export type ApiUpdateServiceNotification = {
'@type': 'updateServiceNotification';
message: ApiMessage;
};
export type ApiUpdateDeleteMessages = { export type ApiUpdateDeleteMessages = {
'@type': 'deleteMessages'; '@type': 'deleteMessages';
ids: number[]; ids: number[];
@ -381,7 +386,7 @@ export type ApiUpdate = (
ApiUpdateChatListType | ApiUpdateChatFolder | ApiUpdateChatFoldersOrder | ApiUpdateRecommendedChatFolders | ApiUpdateChatListType | ApiUpdateChatFolder | ApiUpdateChatFoldersOrder | ApiUpdateRecommendedChatFolders |
ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfo | ApiUpdateCommonBoxMessages | ApiUpdateChannelMessages | ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfo | ApiUpdateCommonBoxMessages | ApiUpdateChannelMessages |
ApiUpdateDeleteMessages | ApiUpdateMessagePoll | ApiUpdateMessagePollVote | ApiUpdateDeleteHistory | ApiUpdateDeleteMessages | ApiUpdateMessagePoll | ApiUpdateMessagePollVote | ApiUpdateDeleteHistory |
ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed | ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed | ApiUpdateServiceNotification |
ApiDeleteUser | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo | ApiUpdateDeleteProfilePhotos | ApiDeleteUser | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo | ApiUpdateDeleteProfilePhotos |
ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage | ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage |
ApiUpdateError | ApiUpdateResetContacts | ApiUpdateError | ApiUpdateResetContacts |

View File

@ -18,6 +18,7 @@ import {
selectIsForwardModalOpen, selectIsForwardModalOpen,
selectIsMediaViewerOpen, selectIsMediaViewerOpen,
selectIsRightColumnShown, selectIsRightColumnShown,
selectIsServiceChatReady,
} from '../../modules/selectors'; } from '../../modules/selectors';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -62,11 +63,12 @@ type StateProps = {
shouldSkipHistoryAnimations?: boolean; shouldSkipHistoryAnimations?: boolean;
language?: LangCode; language?: LangCode;
openedStickerSetShortName?: string; openedStickerSetShortName?: string;
isServiceChatReady?: boolean;
}; };
type DispatchProps = Pick<GlobalActions, ( type DispatchProps = Pick<GlobalActions, (
'loadAnimatedEmojis' | 'loadNotificationSettings' | 'loadNotificationExceptions' | 'updateIsOnline' | 'loadAnimatedEmojis' | 'loadNotificationSettings' | 'loadNotificationExceptions' | 'updateIsOnline' |
'loadTopInlineBots' | 'loadEmojiKeywords' | 'openStickerSetShortName' | 'loadCountryList' 'loadTopInlineBots' | 'loadEmojiKeywords' | 'openStickerSetShortName' | 'loadCountryList' | 'checkVersionNotification'
)>; )>;
const NOTIFICATION_INTERVAL = 1000; const NOTIFICATION_INTERVAL = 1000;
@ -92,6 +94,7 @@ const Main: FC<StateProps & DispatchProps> = ({
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
language, language,
openedStickerSetShortName, openedStickerSetShortName,
isServiceChatReady,
loadAnimatedEmojis, loadAnimatedEmojis,
loadNotificationSettings, loadNotificationSettings,
loadNotificationExceptions, loadNotificationExceptions,
@ -100,6 +103,7 @@ const Main: FC<StateProps & DispatchProps> = ({
loadEmojiKeywords, loadEmojiKeywords,
loadCountryList, loadCountryList,
openStickerSetShortName, openStickerSetShortName,
checkVersionNotification,
}) => { }) => {
if (DEBUG && !DEBUG_isLogged) { if (DEBUG && !DEBUG_isLogged) {
DEBUG_isLogged = true; DEBUG_isLogged = true;
@ -128,6 +132,12 @@ const Main: FC<StateProps & DispatchProps> = ({
loadTopInlineBots, loadEmojiKeywords, loadCountryList, language, loadTopInlineBots, loadEmojiKeywords, loadCountryList, language,
]); ]);
useEffect(() => {
if (lastSyncTime && isServiceChatReady) {
checkVersionNotification();
}
}, [lastSyncTime, isServiceChatReady, checkVersionNotification]);
useEffect(() => { useEffect(() => {
if (lastSyncTime && LOCATION_HASH.startsWith('#?tgaddr=')) { if (lastSyncTime && LOCATION_HASH.startsWith('#?tgaddr=')) {
processDeepLink(decodeURIComponent(LOCATION_HASH.substr('#?tgaddr='.length))); processDeepLink(decodeURIComponent(LOCATION_HASH.substr('#?tgaddr='.length)));
@ -302,10 +312,11 @@ export default memo(withGlobal(
shouldSkipHistoryAnimations: global.shouldSkipHistoryAnimations, shouldSkipHistoryAnimations: global.shouldSkipHistoryAnimations,
language: global.settings.byKey.language, language: global.settings.byKey.language,
openedStickerSetShortName: global.openedStickerSetShortName, openedStickerSetShortName: global.openedStickerSetShortName,
isServiceChatReady: selectIsServiceChatReady(global),
}; };
}, },
(setGlobal, actions): DispatchProps => pick(actions, [ (setGlobal, actions): DispatchProps => pick(actions, [
'loadAnimatedEmojis', 'loadNotificationSettings', 'loadNotificationExceptions', 'updateIsOnline', 'loadAnimatedEmojis', 'loadNotificationSettings', 'loadNotificationExceptions', 'updateIsOnline',
'loadTopInlineBots', 'loadEmojiKeywords', 'openStickerSetShortName', 'loadCountryList', 'loadTopInlineBots', 'loadEmojiKeywords', 'openStickerSetShortName', 'loadCountryList', 'checkVersionNotification',
]), ]),
)(Main)); )(Main));

View File

@ -396,7 +396,7 @@ const MessageList: FC<OwnProps & StateProps & DispatchProps> = ({
} }
const isResized = prevContainerHeight !== undefined && prevContainerHeight !== containerHeight; const isResized = prevContainerHeight !== undefined && prevContainerHeight !== containerHeight;
const anchor = anchorIdRef.current && container.querySelector(`#${anchorIdRef.current}`); const anchor = anchorIdRef.current && document.getElementById(anchorIdRef.current);
const unreadDivider = ( const unreadDivider = (
!anchor !anchor
&& memoUnreadDividerBeforeIdRef.current && memoUnreadDividerBeforeIdRef.current

View File

@ -297,17 +297,13 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
const senderPeer = forwardInfo ? originSender : sender; const senderPeer = forwardInfo ? originSender : sender;
const selectMessage = useCallback((e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => { const selectMessage = useCallback((e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => {
if (isLocal) {
return;
}
toggleMessageSelection({ toggleMessageSelection({
messageId, messageId,
groupedId, groupedId,
...(e?.shiftKey && { withShift: true }), ...(e?.shiftKey && { withShift: true }),
...(isAlbum && { childMessageIds: album!.messages.map(({ id }) => id) }), ...(isAlbum && { childMessageIds: album!.messages.map(({ id }) => id) }),
}); });
}, [isLocal, toggleMessageSelection, messageId, isAlbum, album]); }, [toggleMessageSelection, messageId, isAlbum, album]);
const { const {
handleMouseDown, handleMouseDown,
@ -320,7 +316,6 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
selectMessage, selectMessage,
ref, ref,
messageId, messageId,
isLocal,
isAlbum, isAlbum,
Boolean(isInSelectMode), Boolean(isInSelectMode),
Boolean(canReply), Boolean(canReply),
@ -705,12 +700,12 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
data-last-message-id={album ? album.messages[album.messages.length - 1].id : undefined} data-last-message-id={album ? album.messages[album.messages.length - 1].id : undefined}
data-has-unread-mention={message.hasUnreadMention} data-has-unread-mention={message.hasUnreadMention}
/> />
{!isLocal && !isInDocumentGroup && ( {!isInDocumentGroup && (
<div className="message-select-control"> <div className="message-select-control">
{isSelected && <i className="icon-select" />} {isSelected && <i className="icon-select" />}
</div> </div>
)} )}
{!isLocal && isLastInDocumentGroup && ( {isLastInDocumentGroup && (
<div <div
className={buildClassName('message-select-control group-select', isGroupSelected && 'is-selected')} className={buildClassName('message-select-control group-select', isGroupSelected && 'is-selected')}
onClick={handleDocumentGroupSelectAll} onClick={handleDocumentGroupSelectAll}

View File

@ -15,7 +15,6 @@ export default function useOuterHandlers(
selectMessage: (e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => void, selectMessage: (e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => void,
containerRef: RefObject<HTMLDivElement>, containerRef: RefObject<HTMLDivElement>,
messageId: number, messageId: number,
isLocal: boolean,
isAlbum: boolean, isAlbum: boolean,
isInSelectMode: boolean, isInSelectMode: boolean,
canReply: boolean, canReply: boolean,
@ -28,14 +27,11 @@ export default function useOuterHandlers(
function handleMouseDown(e: React.MouseEvent<HTMLDivElement, MouseEvent>) { function handleMouseDown(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
preventMessageInputBlur(e); preventMessageInputBlur(e);
handleBeforeContextMenu(e);
if (!isLocal) {
handleBeforeContextMenu(e);
}
} }
function handleClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>) { function handleClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (isInSelectMode && !isLocal) { if (isInSelectMode) {
selectMessage(e); selectMessage(e);
} else if (IS_ANDROID) { } else if (IS_ANDROID) {
const target = e.target as HTMLDivElement; const target = e.target as HTMLDivElement;
@ -111,7 +107,7 @@ export default function useOuterHandlers(
return { return {
handleMouseDown: !isInSelectMode ? handleMouseDown : undefined, handleMouseDown: !isInSelectMode ? handleMouseDown : undefined,
handleClick, handleClick,
handleContextMenu: !isInSelectMode && !isLocal ? handleContextMenu : undefined, handleContextMenu: !isInSelectMode ? handleContextMenu : undefined,
handleDoubleClick: !isInSelectMode ? handleContainerDoubleClick : undefined, handleDoubleClick: !isInSelectMode ? handleContainerDoubleClick : undefined,
handleContentDoubleClick: !IS_TOUCH_ENV ? stopPropagation : undefined, handleContentDoubleClick: !IS_TOUCH_ENV ? stopPropagation : undefined,
isSwiped, isSwiped,

View File

@ -127,6 +127,10 @@ function readCache(initialState: GlobalState): GlobalState {
byChatId: {}, byChatId: {},
}; };
} }
if (!cached.serviceNotifications) {
cached.serviceNotifications = [];
}
} }
const newState = { const newState = {
@ -171,6 +175,7 @@ function updateCache() {
'push', 'push',
'shouldShowContextMenuHint', 'shouldShowContextMenuHint',
'leftColumnWidth', 'leftColumnWidth',
'serviceNotifications',
]), ]),
isChatInfoShown: reduceShowChatInfo(global), isChatInfoShown: reduceShowChatInfo(global),
users: reduceUsers(global), users: reduceUsers(global),

View File

@ -167,4 +167,6 @@ export const INITIAL_STATE: GlobalState = {
activeDownloads: { activeDownloads: {
byChatId: {}, byChatId: {},
}, },
serviceNotifications: [],
}; };

View File

@ -72,6 +72,12 @@ export interface Thread {
replyStack?: number[]; replyStack?: number[];
} }
export interface ServiceNotification {
id: number;
message: ApiMessage;
version?: string;
}
export type GlobalState = { export type GlobalState = {
isChatInfoShown: boolean; isChatInfoShown: boolean;
isLeftColumnShown: boolean; isLeftColumnShown: boolean;
@ -443,6 +449,8 @@ export type GlobalState = {
}; };
shouldShowContextMenuHint?: boolean; shouldShowContextMenuHint?: boolean;
serviceNotifications: ServiceNotification[];
}; };
export type ActionTypes = ( export type ActionTypes = (
@ -521,7 +529,7 @@ export type ActionTypes = (
// misc // misc
'openMediaViewer' | 'closeMediaViewer' | 'openAudioPlayer' | 'closeAudioPlayer' | 'openPollModal' | 'closePollModal' | 'openMediaViewer' | 'closeMediaViewer' | 'openAudioPlayer' | 'closeAudioPlayer' | 'openPollModal' | 'closePollModal' |
'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' | 'setDeviceToken' | 'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' | 'setDeviceToken' |
'deleteDeviceToken' | 'deleteDeviceToken' | 'checkVersionNotification' | 'createServiceNotification' |
// payment // payment
'openPaymentModal' | 'closePaymentModal' | 'addPaymentError' | 'openPaymentModal' | 'closePaymentModal' | 'addPaymentError' |
'validateRequestedInfo' | 'setPaymentStep' | 'sendPaymentForm' | 'getPaymentForm' | 'getReceipt' | 'validateRequestedInfo' | 'setPaymentStep' | 'sendPaymentForm' | 'getPaymentForm' | 'getReceipt' |

View File

@ -15,7 +15,7 @@ import {
RE_TME_INVITE_LINK, RE_TME_INVITE_LINK,
RE_TME_LINK, RE_TME_LINK,
TIPS_USERNAME, TIPS_USERNAME,
LOCALIZED_TIPS, RE_TG_LINK, RE_TME_ADDSTICKERS_LINK, LOCALIZED_TIPS, RE_TG_LINK, RE_TME_ADDSTICKERS_LINK, SERVICE_NOTIFICATIONS_USER_ID,
} from '../../../config'; } from '../../../config';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { import {
@ -38,7 +38,7 @@ import {
selectChatByUsername, selectChatByUsername,
selectThreadTopMessageId, selectThreadTopMessageId,
selectCurrentMessageList, selectCurrentMessageList,
selectThreadInfo, selectCurrentChat, selectThreadInfo, selectCurrentChat, selectLastServiceNotification,
} from '../../selectors'; } from '../../selectors';
import { buildCollectionByKey } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
import { debounce, pause, throttle } from '../../../util/schedulers'; import { debounce, pause, throttle } from '../../../util/schedulers';
@ -255,6 +255,9 @@ addReducer('requestChatUpdate', (global, actions, payload) => {
void callApi('requestChatUpdate', { void callApi('requestChatUpdate', {
chat, chat,
serverTimeOffset, serverTimeOffset,
...(chatId === SERVICE_NOTIFICATIONS_USER_ID && {
lastLocalMessage: selectLastServiceNotification(global)?.message,
}),
}); });
}); });
@ -964,12 +967,15 @@ addReducer('deleteChatMember', (global, actions, payload) => {
}); });
async function loadChats(listType: 'active' | 'archived', offsetId?: number, offsetDate?: number) { async function loadChats(listType: 'active' | 'archived', offsetId?: number, offsetDate?: number) {
let global = getGlobal();
const result = await callApi('fetchChats', { const result = await callApi('fetchChats', {
limit: CHAT_LIST_LOAD_SLICE, limit: CHAT_LIST_LOAD_SLICE,
offsetDate, offsetDate,
archived: listType === 'archived', archived: listType === 'archived',
withPinned: getGlobal().chats.orderedPinnedIds[listType] === undefined, withPinned: global.chats.orderedPinnedIds[listType] === undefined,
serverTimeOffset: getGlobal().serverTimeOffset, serverTimeOffset: global.serverTimeOffset,
lastLocalServiceMessage: selectLastServiceNotification(global)?.message,
}); });
if (!result) { if (!result) {
@ -982,7 +988,7 @@ async function loadChats(listType: 'active' | 'archived', offsetId?: number, off
chatIds.shift(); chatIds.shift();
} }
let global = getGlobal(); global = getGlobal();
global = addUsers(global, buildCollectionByKey(result.users, 'id')); global = addUsers(global, buildCollectionByKey(result.users, 'id'));
global = updateChats(global, buildCollectionByKey(result.chats, 'id')); global = updateChats(global, buildCollectionByKey(result.chats, 'id'));

View File

@ -15,7 +15,7 @@ import {
} from '../../../api/types'; } from '../../../api/types';
import { LoadMoreDirection } from '../../../types'; import { LoadMoreDirection } from '../../../types';
import { MAX_MEDIA_FILES_FOR_ALBUM, MESSAGE_LIST_SLICE } from '../../../config'; import { MAX_MEDIA_FILES_FOR_ALBUM, MESSAGE_LIST_SLICE, SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import { callApi, cancelApiProgress } from '../../../api/gramjs'; import { callApi, cancelApiProgress } from '../../../api/gramjs';
import { areSortedArraysIntersecting, buildCollectionByKey, split } from '../../../util/iteratees'; import { areSortedArraysIntersecting, buildCollectionByKey, split } from '../../../util/iteratees';
import { import {
@ -663,11 +663,15 @@ async function loadViewportMessages(
messages, users, chats, threadInfos, messages, users, chats, threadInfos,
} = result; } = result;
const byId = buildCollectionByKey(messages, 'id');
const ids = Object.keys(byId).map(Number);
let global = getGlobal(); let global = getGlobal();
const localMessages = chatId === SERVICE_NOTIFICATIONS_USER_ID
? global.serviceNotifications.map(({ message }) => message)
: [];
const allMessages = ([] as ApiMessage[]).concat(messages, localMessages);
const byId = buildCollectionByKey(allMessages, 'id');
const ids = Object.keys(byId).map(Number);
global = addChatMessagesById(global, chatId, byId); global = addChatMessagesById(global, chatId, byId);
global = isOutlying global = isOutlying
? updateOutlyingIds(global, chatId, threadId, ids) ? updateOutlyingIds(global, chatId, threadId, ids)

View File

@ -3,12 +3,12 @@ import {
} from '../../../lib/teact/teactn'; } from '../../../lib/teact/teactn';
import { import {
ApiChat, ApiFormattedText, ApiUser, MAIN_THREAD_ID, ApiChat, ApiFormattedText, ApiMessage, ApiUser, MAIN_THREAD_ID,
} from '../../../api/types'; } from '../../../api/types';
import { GlobalActions } from '../../../global/types'; import { GlobalActions } from '../../../global/types';
import { import {
CHAT_LIST_LOAD_SLICE, DEBUG, MESSAGE_LIST_SLICE, CHAT_LIST_LOAD_SLICE, DEBUG, MESSAGE_LIST_SLICE, SERVICE_NOTIFICATIONS_USER_ID,
} from '../../../config'; } from '../../../config';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { buildCollectionByKey } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
@ -22,6 +22,9 @@ import {
updateChatListSecondaryInfo, updateChatListSecondaryInfo,
updateThreadInfos, updateThreadInfos,
replaceThreadParam, replaceThreadParam,
updateListedIds,
safeReplaceViewportIds,
addChatMessagesById,
} from '../../reducers'; } from '../../reducers';
import { import {
selectUser, selectUser,
@ -31,6 +34,7 @@ import {
selectChatMessage, selectChatMessage,
selectThreadInfo, selectThreadInfo,
selectCountNotMutedUnread, selectCountNotMutedUnread,
selectLastServiceNotification,
} from '../../selectors'; } from '../../selectors';
import { isChatPrivate } from '../../helpers'; import { isChatPrivate } from '../../helpers';
@ -91,16 +95,20 @@ async function afterSync(actions: GlobalActions) {
} }
async function loadAndReplaceChats() { async function loadAndReplaceChats() {
let global = getGlobal();
const result = await callApi('fetchChats', { const result = await callApi('fetchChats', {
limit: CHAT_LIST_LOAD_SLICE, limit: CHAT_LIST_LOAD_SLICE,
withPinned: true, withPinned: true,
serverTimeOffset: getGlobal().serverTimeOffset, serverTimeOffset: global.serverTimeOffset,
lastLocalServiceMessage: selectLastServiceNotification(global)?.message,
}); });
if (!result) { if (!result) {
return undefined; return undefined;
} }
let global = getGlobal(); global = getGlobal();
const { recentlyFoundChatIds } = global.globalSearch; const { recentlyFoundChatIds } = global.globalSearch;
const { userIds: contactIds } = global.contactList || {}; const { userIds: contactIds } = global.contactList || {};
@ -211,29 +219,25 @@ async function loadAndReplaceMessages(savedUsers?: ApiUser[]) {
if (result && newCurrentChatId === currentChatId) { if (result && newCurrentChatId === currentChatId) {
const currentMessageListInfo = global.messages.byChatId[currentChatId]; const currentMessageListInfo = global.messages.byChatId[currentChatId];
const byId = buildCollectionByKey(result.messages, 'id'); const localMessages = currentChatId === SERVICE_NOTIFICATIONS_USER_ID
? global.serviceNotifications.map(({ message }) => message)
: [];
const allMessages = ([] as ApiMessage[]).concat(result.messages, localMessages);
const byId = buildCollectionByKey(allMessages, 'id');
const listedIds = Object.keys(byId).map(Number); const listedIds = Object.keys(byId).map(Number);
global = { global = {
...global, ...global,
messages: { messages: {
...global.messages, ...global.messages,
byChatId: { byChatId: {},
[currentChatId]: {
byId,
threadsById: {
[MAIN_THREAD_ID]: {
...(currentMessageListInfo?.threadsById[MAIN_THREAD_ID]),
listedIds,
viewportIds: listedIds,
outlyingIds: undefined,
},
},
},
},
}, },
}; };
global = addChatMessagesById(global, currentChatId, byId);
global = updateListedIds(global, currentChatId, MAIN_THREAD_ID, listedIds);
global = safeReplaceViewportIds(global, currentChatId, MAIN_THREAD_ID, listedIds);
if (currentThreadId && threadInfo && threadInfo.originChannelId) { if (currentThreadId && threadInfo && threadInfo.originChannelId) {
const { originChannelId } = threadInfo; const { originChannelId } = threadInfo;
const currentMessageListInfoOrigin = global.messages.byChatId[originChannelId]; const currentMessageListInfoOrigin = global.messages.byChatId[originChannelId];
@ -275,6 +279,7 @@ async function loadAndReplaceMessages(savedUsers?: ApiUser[]) {
}; };
} }
} }
global = updateChats(global, buildCollectionByKey(result.chats, 'id')); global = updateChats(global, buildCollectionByKey(result.chats, 'id'));
global = updateThreadInfos(global, currentChatId, result.threadInfos); global = updateThreadInfos(global, currentChatId, result.threadInfos);

View File

@ -38,6 +38,7 @@ import {
selectFirstUnreadId, selectFirstUnreadId,
selectChat, selectChat,
selectIsChatWithBot, selectIsChatWithBot,
selectIsServiceChatReady,
} from '../../selectors'; } from '../../selectors';
import { getMessageContent, isChatPrivate, isMessageLocal } from '../../helpers'; import { getMessageContent, isChatPrivate, isMessageLocal } from '../../helpers';
@ -433,6 +434,16 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
break; break;
} }
case 'updateServiceNotification': {
const { message } = update;
if (selectIsServiceChatReady(global)) {
actions.createServiceNotification({ message });
}
break;
}
} }
}); });

View File

@ -1,9 +1,14 @@
import { addReducer, getGlobal, setGlobal } from '../../../lib/teact/teactn'; import { addReducer, getGlobal, setGlobal } from '../../../lib/teact/teactn';
import { MAIN_THREAD_ID } from '../../../api/types'; import { ApiMessage, MAIN_THREAD_ID } from '../../../api/types';
import { FocusDirection } from '../../../types'; import { FocusDirection } from '../../../types';
import { ANIMATION_END_DELAY, FAST_SMOOTH_MAX_DURATION } from '../../../config'; import {
ANIMATION_END_DELAY,
APP_VERSION,
FAST_SMOOTH_MAX_DURATION,
SERVICE_NOTIFICATIONS_USER_ID,
} from '../../../config';
import { IS_TOUCH_ENV } from '../../../util/environment'; import { IS_TOUCH_ENV } from '../../../util/environment';
import { import {
enterMessageSelectMode, enterMessageSelectMode,
@ -29,10 +34,15 @@ import {
selectReplyStack, selectReplyStack,
} from '../../selectors'; } from '../../selectors';
import { findLast } from '../../../util/iteratees'; import { findLast } from '../../../util/iteratees';
import { getServerTime } from '../../../util/serverTime';
// @ts-ignore
import versionNotification from '../../../versionNotification.txt';
const FOCUS_DURATION = 1500; const FOCUS_DURATION = 1500;
const FOCUS_NO_HIGHLIGHT_DURATION = FAST_SMOOTH_MAX_DURATION + ANIMATION_END_DELAY; const FOCUS_NO_HIGHLIGHT_DURATION = FAST_SMOOTH_MAX_DURATION + ANIMATION_END_DELAY;
const POLL_RESULT_OPEN_DELAY_MS = 450; const POLL_RESULT_OPEN_DELAY_MS = 450;
const SERVICE_NOTIFICATIONS_MAX_AMOUNT = 1e4;
let blurTimeout: number | undefined; let blurTimeout: number | undefined;
@ -506,3 +516,65 @@ addReducer('closePollModal', (global) => {
isPollModalOpen: false, isPollModalOpen: false,
}; };
}); });
addReducer('checkVersionNotification', (global, actions) => {
const currentVersion = APP_VERSION.split('.').slice(0, 2).join('.');
const { serviceNotifications } = global;
if (serviceNotifications.find(({ version }) => version === currentVersion)) {
return;
}
const message: Omit<ApiMessage, 'id'> = {
chatId: SERVICE_NOTIFICATIONS_USER_ID,
date: getServerTime(global.serverTimeOffset),
content: {
text: {
text: versionNotification,
},
},
isOutgoing: false,
};
actions.createServiceNotification({
message,
version: currentVersion,
});
});
addReducer('createServiceNotification', (global, actions, payload) => {
const { message, version } = payload;
const { serviceNotifications } = global;
const serviceChat = selectChat(global, SERVICE_NOTIFICATIONS_USER_ID)!;
const maxId = Math.max(
serviceChat.lastMessage?.id || 0,
...serviceNotifications.map(({ id }) => id),
);
const fractionalPart = (serviceNotifications.length + 1) / SERVICE_NOTIFICATIONS_MAX_AMOUNT;
// The fractional ID is made of the largest integer ID and an incremented fractional part
const id = Math.floor(maxId) + fractionalPart;
message.id = id;
const serviceNotification = {
id,
message,
version,
};
setGlobal({
...global,
serviceNotifications: [
...serviceNotifications,
serviceNotification,
],
});
actions.apiUpdate({
'@type': 'newMessage',
id: message.id,
chatId: message.chatId,
message,
});
});

View File

@ -199,7 +199,7 @@ export function isActionMessage(message: ApiMessage) {
} }
export function isServiceNotificationMessage(message: ApiMessage) { export function isServiceNotificationMessage(message: ApiMessage) {
return message.chatId === SERVICE_NOTIFICATIONS_USER_ID && isMessageLocal(message); return message.chatId === SERVICE_NOTIFICATIONS_USER_ID && Math.round(message.id) !== message.id;
} }
export function isAnonymousOwnMessage(message: ApiMessage) { export function isAnonymousOwnMessage(message: ApiMessage) {

View File

@ -366,14 +366,15 @@ export function safeReplaceViewportIds(
threadId: number, threadId: number,
newViewportIds: number[], newViewportIds: number[],
): GlobalState { ): GlobalState {
const viewportIds = selectViewportIds(global, chatId, threadId) || []; const currentIds = selectViewportIds(global, chatId, threadId) || [];
const newIds = orderHistoryIds(newViewportIds);
return replaceThreadParam( return replaceThreadParam(
global, global,
chatId, chatId,
threadId, threadId,
'viewportIds', 'viewportIds',
areSortedArraysEqual(viewportIds, newViewportIds) ? viewportIds : newViewportIds, areSortedArraysEqual(currentIds, newIds) ? currentIds : newIds,
); );
} }

View File

@ -5,7 +5,9 @@ import {
getPrivateChatUserId, isChatChannel, isChatPrivate, isHistoryClearMessage, isUserBot, isUserOnline, selectIsChatMuted, getPrivateChatUserId, isChatChannel, isChatPrivate, isHistoryClearMessage, isUserBot, isUserOnline, selectIsChatMuted,
} from '../helpers'; } from '../helpers';
import { selectUser } from './users'; import { selectUser } from './users';
import { ALL_FOLDER_ID, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE } from '../../config'; import {
ALL_FOLDER_ID, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE, SERVICE_NOTIFICATIONS_USER_ID,
} from '../../config';
import { selectNotifyExceptions, selectNotifySettings } from './settings'; import { selectNotifyExceptions, selectNotifySettings } from './settings';
export function selectChat(global: GlobalState, chatId: number): ApiChat | undefined { export function selectChat(global: GlobalState, chatId: number): ApiChat | undefined {
@ -175,3 +177,7 @@ export function selectCountNotMutedUnread(global: GlobalState) {
return acc; return acc;
}, 0); }, 0);
} }
export function selectIsServiceChatReady(global: GlobalState) {
return Boolean(selectChat(global, SERVICE_NOTIFICATIONS_USER_ID));
}

View File

@ -348,18 +348,20 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
const isBasicGroup = isChatBasicGroup(chat); const isBasicGroup = isChatBasicGroup(chat);
const isSuperGroup = isChatSuperGroup(chat); const isSuperGroup = isChatSuperGroup(chat);
const isChannel = isChatChannel(chat); const isChannel = isChatChannel(chat);
const isLocal = isMessageLocal(message);
const isServiceNotification = isServiceNotificationMessage(message); const isServiceNotification = isServiceNotificationMessage(message);
const isOwn = isOwnMessage(message); const isOwn = isOwnMessage(message);
const isAction = isActionMessage(message); const isAction = isActionMessage(message);
const { content } = message; const { content } = message;
const canEditMessagesIndefinitely = isChatWithSelf const canEditMessagesIndefinitely = isChatWithSelf
|| (isSuperGroup && getHasAdminRight(chat, 'pinMessages')) || (isSuperGroup && getHasAdminRight(chat, 'pinMessages'))
|| (isChannel && getHasAdminRight(chat, 'editMessages')); || (isChannel && getHasAdminRight(chat, 'editMessages'));
const isMessageEditable = ( const isMessageEditable = (
(canEditMessagesIndefinitely (
|| getServerTime(global.serverTimeOffset) - message.date < MESSAGE_EDIT_ALLOWED_TIME) canEditMessagesIndefinitely
&& !( || getServerTime(global.serverTimeOffset) - message.date < MESSAGE_EDIT_ALLOWED_TIME
) && !(
content.sticker || content.contact || content.poll || content.action || content.audio content.sticker || content.contact || content.poll || content.action || content.audio
|| (content.video?.isRound) || (content.video?.isRound)
) )
@ -367,7 +369,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
&& !message.viaBotId && !message.viaBotId
); );
const canReply = getCanPostInChat(chat, threadId) && !isServiceNotification; const canReply = !isLocal && !isServiceNotification && getCanPostInChat(chat, threadId);
const hasPinPermission = isPrivate || ( const hasPinPermission = isPrivate || (
chat.isCreator chat.isCreator
@ -375,7 +377,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
|| getHasAdminRight(chat, 'pinMessages') || getHasAdminRight(chat, 'pinMessages')
); );
let canPin = !isAction && hasPinPermission; let canPin = !isLocal && !isServiceNotification && !isAction && hasPinPermission;
let canUnpin = false; let canUnpin = false;
const pinnedMessageIds = selectPinnedIds(global, chat.id); const pinnedMessageIds = selectPinnedIds(global, chat.id);
@ -385,27 +387,29 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
canPin = !canUnpin; canPin = !canUnpin;
} }
const canDelete = isPrivate const canDelete = !isLocal && !isServiceNotification && (
isPrivate
|| isOwn || isOwn
|| isBasicGroup || isBasicGroup
|| chat.isCreator || chat.isCreator
|| getHasAdminRight(chat, 'deleteMessages'); || getHasAdminRight(chat, 'deleteMessages')
);
const canReport = !isPrivate && !isOwn; const canReport = !isPrivate && !isOwn;
const canDeleteForAll = canDelete && !isServiceNotification && ( const canDeleteForAll = canDelete && (
(isPrivate && !isChatWithSelf) (isPrivate && !isChatWithSelf)
|| (isBasicGroup && ( || (isBasicGroup && (
isOwn || getHasAdminRight(chat, 'deleteMessages') || chat.isCreator isOwn || getHasAdminRight(chat, 'deleteMessages') || chat.isCreator
)) ))
); );
const canEdit = !isAction && isMessageEditable && ( const canEdit = !isLocal && !isAction && isMessageEditable && (
isOwn isOwn
|| (isChannel && (chat.isCreator || getHasAdminRight(chat, 'editMessages'))) || (isChannel && (chat.isCreator || getHasAdminRight(chat, 'editMessages')))
); );
const canForward = !isAction && !isServiceNotification; const canForward = !isLocal && !isServiceNotification && !isAction;
const hasSticker = Boolean(message.content.sticker); const hasSticker = Boolean(message.content.sticker);
const hasFavoriteSticker = hasSticker && selectIsStickerFavorite(global, message.content.sticker!); const hasFavoriteSticker = hasSticker && selectIsStickerFavorite(global, message.content.sticker!);
@ -751,3 +755,10 @@ export function selectShouldAutoPlayMedia(global: GlobalState, message: ApiMessa
export function selectShouldLoopStickers(global: GlobalState) { export function selectShouldLoopStickers(global: GlobalState) {
return global.settings.byKey.shouldLoopStickers; return global.settings.byKey.shouldLoopStickers;
} }
export function selectLastServiceNotification(global: GlobalState) {
const { serviceNotifications } = global;
const maxId = Math.max(...serviceNotifications.map(({ id }) => id));
return serviceNotifications.find(({ id }) => id === maxId);
}

View File

@ -0,0 +1,3 @@
Welcome to dev version.
This is a demo notification.

View File

@ -1,12 +1,15 @@
const path = require('path'); const path = require('path');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const { EnvironmentPlugin, ProvidePlugin } = require('webpack'); const {
EnvironmentPlugin,
ProvidePlugin,
} = require('webpack');
const HtmlPlugin = require('html-webpack-plugin'); const HtmlPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const TerserJSPlugin = require('terser-webpack-plugin'); const TerserJSPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
dotenv.config(); dotenv.config();
@ -84,7 +87,7 @@ module.exports = (env = {}, argv = {}) => {
}, },
}, },
{ {
test: /\.tl$/i, test: /\.(txt|tl)$/i,
loader: 'raw-loader', loader: 'raw-loader',
}, },
], ],
@ -92,11 +95,11 @@ module.exports = (env = {}, argv = {}) => {
resolve: { resolve: {
extensions: ['.js', '.ts', '.tsx'], extensions: ['.js', '.ts', '.tsx'],
fallback: { fallback: {
path: require.resolve("path-browserify"), path: require.resolve('path-browserify'),
os: require.resolve("os-browserify/browser"), os: require.resolve('os-browserify/browser'),
buffer: require.resolve("buffer/"), buffer: require.resolve('buffer/'),
fs: false, fs: false,
} },
}, },
plugins: [ plugins: [
new HtmlPlugin({ new HtmlPlugin({