diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index e484f145c..e5e39c013 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -2037,7 +2037,13 @@ export async function fetchChannelRecommendations({ chat }: { chat: ApiChat }) { updateLocalDb(result); - return result?.chats.map((_chat) => buildApiChatFromPreview(_chat)).filter(Boolean); + return { + similarChannels: result?.chats + .map((_chat) => buildApiChatFromPreview(_chat)) + .filter(Boolean), + count: + result instanceof GramJs.messages.ChatsSlice ? result.count : undefined, + }; } function handleUserPrivacyRestrictedUpdates(updates: GramJs.TypeUpdates) { diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 6ae8059b9..a303b279b 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -291,7 +291,14 @@ export interface ApiAction { text: string; targetUserIds?: string[]; targetChatId?: string; - type: 'historyClear' | 'contactSignUp' | 'chatCreate' | 'topicCreate' | 'suggestProfilePhoto' | 'other'; + type: + | 'historyClear' + | 'contactSignUp' + | 'chatCreate' + | 'topicCreate' + | 'suggestProfilePhoto' + | 'joinedChannel' + | 'other'; photo?: ApiPhoto; amount?: number; currency?: string; diff --git a/src/components/middle/ActionMessage.tsx b/src/components/middle/ActionMessage.tsx index 76c3faca8..410c3112e 100644 --- a/src/components/middle/ActionMessage.tsx +++ b/src/components/middle/ActionMessage.tsx @@ -13,7 +13,7 @@ import type { FocusDirection, ThreadId } from '../../types'; import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage'; import { - getChatTitle, getMessageHtmlId, isChatChannel, + getChatTitle, getMessageHtmlId, isChatChannel, isJoinedChannelMessage, } from '../../global/helpers'; import { getMessageReplyInfo } from '../../global/helpers/replies'; import { @@ -42,6 +42,7 @@ import useFocusMessage from './message/hooks/useFocusMessage'; import AnimatedIconFromSticker from '../common/AnimatedIconFromSticker'; import ActionMessageSuggestedAvatar from './ActionMessageSuggestedAvatar'; import ContextMenuContainer from './message/ContextMenuContainer.async'; +import SimilarChannels from './message/SimilarChannels'; type OwnProps = { message: ApiMessage; @@ -129,6 +130,7 @@ const ActionMessage: FC = ({ const isGift = Boolean(message.content.action?.text.startsWith('ActionGift')); const isGiftCode = Boolean(message.content.action?.text.startsWith('BoostingReceivedGift')); const isSuggestedAvatar = message.content.action?.type === 'suggestProfilePhoto' && message.content.action!.photo; + const isJoinedMessage = isJoinedChannelMessage(message); useEffect(() => { if (noAppearanceAnimation) { @@ -291,15 +293,15 @@ const ActionMessage: FC = ({ onMouseDown={handleMouseDown} onContextMenu={handleContextMenu} > - {!isSuggestedAvatar && !isGiftCode && {renderContent()}} + {!isSuggestedAvatar && !isGiftCode && !isJoinedMessage && ( + {renderContent()} + )} {isGift && renderGift()} {isGiftCode && renderGiftCode()} {isSuggestedAvatar && ( - + )} + {isJoinedMessage && } {contextMenuPosition && ( .icon { + margin-left: 0.0625rem; + } +} + +.skeleton { + height: 8.5rem; + border-radius: 0.9375rem; + margin-top: 0.625rem; +} + +.inner { + background: var(--color-background); + border-radius: 0.9375rem; +} + +.is-appearing { + animation: 0.15s ease-out channels-appear forwards; +} + +.is-hiding { + animation: 0.15s ease-out channels-disappear forwards; +} + +@keyframes channels-appear { + from { + transform: scale(0) translateY(-50%); + opacity: 0; + /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ + height: 0; + } + + to { + transform: none; + opacity: 1; + /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ + height: 8.9375rem; + } +} + +@keyframes channels-disappear { + from { + transform: none; + opacity: 1; + /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ + height: 8.9375rem; + } + + to { + transform: scale(0) translateY(-50%); + opacity: 0; + /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ + height: 0; + } +} + +.channel-list { + padding-bottom: 0.25rem; + padding-left: 0.25rem; + display: flex; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + border-bottom-left-radius: 0.9375rem; + border-bottom-right-radius: 0.9375rem; +} + +.item { + height: 6.375rem; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + margin-right: 0.5rem; + padding: 0.5rem 0.5rem 0.25rem 0.5rem; + + &:not(:last-child) { + &:hover { + background: var(--color-chat-hover); + border-radius: 0.625rem; + cursor: pointer; + } + } +} + +.last-item { + margin: 0; + padding: 0.5rem 0 0.25rem 0; + cursor: pointer; + min-width: 5rem; + margin-right: 0.75rem; + align-items: flex-start; +} + +.avatar { + width: 3.75rem; + height: 3.75rem; +} + +.last-item .avatar { + z-index: 3; + outline: 0.125rem solid var(--color-background); +} + +.last-item .badge { + z-index: 4; + align-self: center; + background: var(--more-channel-background); + + :global(.theme-dark) & { + background: var(--more-channel-background-dark); + + &::before { + background-color: unset; + } + } +} + +.badge { + max-width: 3.75rem; + height: 0.9375rem; + margin-top: -0.8125rem; + outline: 0.0625rem solid var(--color-background); + padding: 0.125rem 0.1875rem 0.125rem 0.25rem; + border-radius: 0.625rem; + z-index: 1; + display: flex; + justify-content: center; + align-items: center; + color: var(--color-white); + position: relative; + + &::before { + content: ""; + background-color: var(--more-channel-badge-overlay); + position: absolute; + max-width: 3.75rem; + width: 100%; + height: 0.9375rem; + border-radius: 0.625rem; + z-index: -1; + } +} + +.icon { + font-size: 0.4375rem; + margin-right: 0.0625rem; +} + +.members-count { + font-size: 0.5625rem; + font-weight: 700; + line-height: 0.6875rem; +} + +.channel-title { + text-align: center; + height: 1.625rem; + font-size: 0.6875rem; + font-weight: 400; + line-height: 0.8125rem; + max-width: 3.4375rem; + margin-top: 0.125rem; + white-space: normal; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + unicode-bidi: plaintext; +} + +.last-item .channel-title { + color: var(--color-text-secondary); + align-self: center; +} + +.fake-avatar { + border-radius: 50%; + width: 3.75rem; + height: 3.75rem; + position: absolute; + left: 0.625rem; + background: var(--more-channel-background); + outline: 0.125rem solid var(--color-background); + z-index: 2; + + :global(.theme-dark) & { + background: var(--more-channel-background-dark); + } +} + +.fake-avatar-inner { + width: 100%; + height: 100%; +} + +.last-fake-avatar { + left: 1.25rem; + z-index: 1; +} diff --git a/src/components/middle/message/SimilarChannels.tsx b/src/components/middle/message/SimilarChannels.tsx new file mode 100644 index 000000000..df1fabae2 --- /dev/null +++ b/src/components/middle/message/SimilarChannels.tsx @@ -0,0 +1,266 @@ +import React, { + memo, useEffect, useMemo, useRef, useState, +} from '../../../lib/teact/teact'; +import { getActions, getGlobal, withGlobal } from '../../../global'; + +import type { ApiChat } from '../../../api/types'; +import { ApiMediaFormat } from '../../../api/types'; + +import { getChatAvatarHash } from '../../../global/helpers'; +import { + selectChat, + selectIsCurrentUserPremium, + selectSimilarChannelIds, +} from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { getAverageColor, rgb2hex } from '../../../util/colors'; +import { formatIntegerCompact } from '../../../util/textFormat'; + +import useFlag from '../../../hooks/useFlag'; +import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; +import useLang from '../../../hooks/useLang'; +import useMedia from '../../../hooks/useMedia'; +import useTimeout from '../../../hooks/useTimeout'; + +import Avatar from '../../common/Avatar'; +import Icon from '../../common/Icon'; +import Button from '../../ui/Button'; +import Skeleton from '../../ui/placeholder/Skeleton'; + +import styles from './SimilarChannels.module.scss'; + +const DEFAULT_BADGE_COLOR = '#3C3C4399'; +const SHOW_CHANNELS_NUMBER = 10; +const MIN_SKELETON_DELAY = 300; +const MAX_SKELETON_DELAY = 2000; + +type OwnProps = { + chatId: string; +}; + +type StateProps = { + similarChannelIds?: string[]; + shouldShowInChat?: boolean; + count: number; + isCurrentUserPremium: boolean; +}; + +const SimilarChannels = ({ + chatId, + similarChannelIds, + shouldShowInChat, + count, + isCurrentUserPremium, +}: StateProps & OwnProps) => { + const lang = useLang(); + const { toggleChannelRecommendations } = getActions(); + const [isShowing, markShowing, markNotShowing] = useFlag(false); + const [isHiding, markHiding, markNotHiding] = useFlag(false); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + const similarChannels = useMemo(() => { + if (!similarChannelIds) { + return undefined; + } + + const global = getGlobal(); + return similarChannelIds.map((id) => selectChat(global, id)).filter(Boolean); + }, [similarChannelIds]); + // Show skeleton while loading similar channels + const [shoulRenderSkeleton, setShoulRenderSkeleton] = useState(!similarChannelIds); + const firstSimilarChannels = useMemo(() => similarChannels?.slice(0, SHOW_CHANNELS_NUMBER), [similarChannels]); + const areSimilarChannelsPresent = Boolean(firstSimilarChannels?.length); + useHorizontalScroll(ref, !areSimilarChannelsPresent || !shouldShowInChat || shoulRenderSkeleton, true); + const isAnimating = isHiding || isShowing; + const shouldRenderChannels = Boolean( + !shoulRenderSkeleton + && (shouldShowInChat || isAnimating) + && areSimilarChannelsPresent, + ); + + useTimeout(() => setShoulRenderSkeleton(false), MAX_SKELETON_DELAY, []); + + useEffect(() => { + if (shoulRenderSkeleton && similarChannels && shouldShowInChat) { + const id = setTimeout(() => { + setShoulRenderSkeleton(false); + }, MIN_SKELETON_DELAY); + + return () => clearTimeout(id); + } + + return undefined; + }, [similarChannels, shouldShowInChat, shoulRenderSkeleton]); + + const handleToggle = () => { + toggleChannelRecommendations({ chatId }); + if (shouldShowInChat) { + markNotShowing(); + markHiding(); + } else { + markShowing(); + markNotHiding(); + } + }; + + return ( +
+
+ + {lang('ChannelJoined')} + +
+ {shoulRenderSkeleton && } + {shouldRenderChannels && ( +
+
+ + + +
+
+
+ {lang('SimilarChannels')} + +
+
+ {firstSimilarChannels?.map((channel, i) => { + return i === SHOW_CHANNELS_NUMBER - 1 ? ( + + ) : ( + + ); + })} +
+
+
+ )} +
+ ); +}; + +function SimilarChannel({ channel }: { channel: ApiChat }) { + const { openChat } = getActions(); + const color = useAverageColor(channel); + + return ( +
openChat({ id: channel.id })}> + +
+ + {formatIntegerCompact(channel?.membersCount || 0)} + +
+ {channel.title} +
+ ); +} + +function MoreChannels({ + channel, + chatId, + channelsCount, + isCurrentUserPremium, +}: { + channel: ApiChat; + chatId: string; + channelsCount: number; + isCurrentUserPremium: boolean; +}) { + const { openPremiumModal, openChatWithInfo } = getActions(); + const lang = useLang(); + + const handleClickMore = () => { + if (isCurrentUserPremium) { + openChatWithInfo({ + id: chatId, shouldReplaceHistory: true, profileTab: 'similarChannels', forceScrollProfileTab: true, + }); + } else { + openPremiumModal(); + } + }; + + return ( +
handleClickMore()} + > + +
+
+
+
+
+
+
+ {`+${channelsCount}`} + {!isCurrentUserPremium && } +
+ {lang('MoreSimilar')} +
+ ); +} + +function useAverageColor(channel: ApiChat) { + const [color, setColor] = useState(DEFAULT_BADGE_COLOR); + const imgBlobUrl = useMedia(getChatAvatarHash(channel), false, ApiMediaFormat.BlobUrl); + + useEffect(() => { + (async () => { + if (!imgBlobUrl) { + return; + } + + const averageColor = await getAverageColor(imgBlobUrl); + setColor(`#${rgb2hex(averageColor)}`); + })(); + }, [imgBlobUrl]); + + return color; +} + +export default memo( + withGlobal((global, { chatId }): StateProps => { + const { similarChannelIds, shouldShowInChat, count } = selectSimilarChannelIds(global, chatId) || {}; + const isCurrentUserPremium = selectIsCurrentUserPremium(global); + + return { + similarChannelIds, + shouldShowInChat, + count, + isCurrentUserPremium, + }; + })(SimilarChannels), +); diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index 34698913b..487b46018 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -125,6 +125,7 @@ type StateProps = { limitSimilarChannels: number; isTopicInfo?: boolean; isSavedDialog?: boolean; + forceScrollProfileTab?: boolean; }; type TabProps = { @@ -180,6 +181,7 @@ const Profile: FC = ({ limitSimilarChannels, isTopicInfo, isSavedDialog, + forceScrollProfileTab, }) => { const { setLocalMediaSearchType, @@ -254,10 +256,10 @@ const Profile: FC = ({ }, [nextProfileTab, tabs]); useEffect(() => { - if (isChannel) { + if (isChannel && !similarChannels) { fetchChannelRecommendations({ chatId }); } - }, [chatId, isChannel]); + }, [chatId, isChannel, similarChannels]); const renderingActiveTab = activeTab > tabs.length - 1 ? tabs.length - 1 : activeTab; const tabType = tabs[renderingActiveTab].type as ProfileTabType; @@ -296,7 +298,13 @@ const Profile: FC = ({ usePeerStoriesPolling(resultType === 'members' ? viewportIds as string[] : undefined); - const { handleScroll } = useProfileState(containerRef, resultType, profileState, onProfileStateChange); + const { handleScroll } = useProfileState( + containerRef, + resultType, + profileState, + onProfileStateChange, + forceScrollProfileTab, + ); const { applyTransitionFix, releaseTransitionFix } = useTransitionFixes(containerRef); @@ -693,7 +701,7 @@ export default memo(withGlobal( && (getHasAdminRight(chat, 'inviteUsers') || !isUserRightBanned(chat, 'inviteUsers') || chat.isCreator); const canDeleteMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'banUsers') || chat.isCreator); const activeDownloads = selectActiveDownloads(global, chatId); - const similarChannels = selectSimilarChannelIds(global, chatId); + const { similarChannelIds } = selectSimilarChannelIds(global, chatId) || {}; const isCurrentUserPremium = selectIsCurrentUserPremium(global); let hasCommonChatsTab; @@ -739,8 +747,9 @@ export default memo(withGlobal( storyByIds, isChatProtected: chat?.isProtected, nextProfileTab: selectTabState(global).nextProfileTab, + forceScrollProfileTab: selectTabState(global).forceScrollProfileTab, shouldWarnAboutSvg: global.settings.byKey.shouldWarnAboutSvg, - similarChannels, + similarChannels: similarChannelIds, isCurrentUserPremium, isTopicInfo, isSavedDialog, diff --git a/src/components/right/hooks/useProfileState.ts b/src/components/right/hooks/useProfileState.ts index c15770b7d..b92d09cbb 100644 --- a/src/components/right/hooks/useProfileState.ts +++ b/src/components/right/hooks/useProfileState.ts @@ -20,10 +20,11 @@ export default function useProfileState( tabType: ProfileTabType, profileState: ProfileState, onProfileStateChange: (state: ProfileState) => void, + forceScrollProfileTab = false, ) { // Scroll to tabs if needed useEffectWithPrevDeps(([prevTabType]) => { - if (prevTabType && prevTabType !== tabType) { + if ((prevTabType && prevTabType !== tabType) || (tabType && forceScrollProfileTab)) { const container = containerRef.current!; const tabsEl = container.querySelector('.TabList')!; if (container.scrollTop < tabsEl.offsetTop) { @@ -35,7 +36,7 @@ export default function useProfileState( }, PROGRAMMATIC_SCROLL_TIMEOUT_MS); } } - }, [tabType, onProfileStateChange, containerRef]); + }, [tabType, onProfileStateChange, containerRef, forceScrollProfileTab]); // Scroll to top useEffectWithPrevDeps(([prevProfileState]) => { diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 3a773672d..aa70b93c0 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -44,6 +44,7 @@ import { isChatBasicGroup, isChatChannel, isChatSuperGroup, + isLocalMessageId, isUserBot, toChannelId, } from '../../helpers'; @@ -58,6 +59,7 @@ import { addUsers, addUserStatuses, addUsersToRestrictedInviteList, + deleteChatMessages, deleteTopic, leaveChat, removeChatFromChatLists, @@ -67,6 +69,7 @@ import { replaceThreadParam, replaceUsers, replaceUserStatuses, + toggleSimilarChannels, updateChat, updateChatFullInfo, updateChatLastMessageId, @@ -93,6 +96,7 @@ import { selectChatLastMessage, selectChatLastMessageId, selectChatListType, + selectChatMessages, selectCurrentChat, selectCurrentMessageList, selectDraft, @@ -798,7 +802,7 @@ addActionHandler('deleteChat', (global, actions, payload): ActionReturnType => { void callApi('deleteChat', { chatId: chat.id }); }); -addActionHandler('leaveChannel', (global, actions, payload): ActionReturnType => { +addActionHandler('leaveChannel', async (global, actions, payload): Promise => { const { chatId, tabId = getCurrentTabId() } = payload!; const chat = selectChat(global, chatId); if (!chat) { @@ -814,7 +818,12 @@ addActionHandler('leaveChannel', (global, actions, payload): ActionReturnType => const { id: channelId, accessHash } = chat; if (channelId && accessHash) { - void callApi('leaveChannel', { channelId, accessHash }); + await callApi('leaveChannel', { channelId, accessHash }); + global = getGlobal(); + const chatMessages = selectChatMessages(global, chatId); + const localMessageIds = Object.keys(chatMessages).map(Number).filter(isLocalMessageId); + global = deleteChatMessages(global, chatId, localMessageIds); + setGlobal(global); } }); @@ -2615,9 +2624,9 @@ addActionHandler('fetchChannelRecommendations', async (global, actions, payload) return; } - const similarChannels = await callApi('fetchChannelRecommendations', { + const { similarChannels, count } = await callApi('fetchChannelRecommendations', { chat, - }); + }) || {}; if (!similarChannels) { return; @@ -2625,8 +2634,19 @@ addActionHandler('fetchChannelRecommendations', async (global, actions, payload) global = getGlobal(); global = addChats(global, buildCollectionByKey(similarChannels, 'id')); - global = addSimilarChannels(global, chatId, similarChannels.map((channel) => channel.id)); + global = addSimilarChannels(global, chatId, similarChannels.map((channel) => channel.id), count); + setGlobal(global); +}); +addActionHandler('toggleChannelRecommendations', (global, actions, payload): ActionReturnType => { + const { chatId } = payload; + const chat = selectChat(global, chatId); + + if (!chat) { + return; + } + + global = toggleSimilarChannels(global, chatId); setGlobal(global); }); diff --git a/src/global/actions/apiUpdaters/chats.ts b/src/global/actions/apiUpdaters/chats.ts index e4d14ceb0..aac106b73 100644 --- a/src/global/actions/apiUpdaters/chats.ts +++ b/src/global/actions/apiUpdaters/chats.ts @@ -5,10 +5,13 @@ import { MAIN_THREAD_ID } from '../../../api/types'; import { ARCHIVED_FOLDER_ID, MAX_ACTIVE_PINNED_CHATS } from '../../../config'; import { buildCollectionByKey, omit } from '../../../util/iteratees'; import { closeMessageNotifications, notifyAboutMessage } from '../../../util/notifications'; +import { buildLocalMessage } from '../../../api/gramjs/apiBuilders/messages'; +import { isChatChannel, isLocalMessageId } from '../../helpers'; import { addActionHandler, getGlobal, setGlobal, } from '../../index'; import { + deleteChatMessages, leaveChat, replaceThreadParam, updateChat, @@ -23,7 +26,9 @@ import { updateTabState } from '../../reducers/tabs'; import { selectChat, selectChatFullInfo, + selectChatLastMessageId, selectChatListType, + selectChatMessages, selectCommonBoxChatId, selectCurrentMessageList, selectIsChatListed, @@ -87,6 +92,26 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { case 'updateChatJoin': { const listType = selectChatListType(global, update.id); + const chat = selectChat(global, update.id); + if (chat && isChatChannel(chat)) { + actions.fetchChannelRecommendations({ chatId: chat.id }); + const lastMessageId = selectChatLastMessageId(global, chat.id); + const localMessage = buildLocalMessage(chat, lastMessageId); + localMessage.content.action = { + text: 'you joined this channel', + translationValues: ['ChannelJoined'], + type: 'joinedChannel', + targetChatId: chat.id, + }; + + actions.apiUpdate({ + '@type': 'newMessage', + id: localMessage.id, + chatId: chat.id, + message: localMessage, + }); + } + if (!listType) { return undefined; } @@ -95,7 +120,6 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { global = updateChat(global, update.id, { isNotJoined: false }); setGlobal(global); - const chat = selectChat(global, update.id); if (chat) { actions.requestChatUpdate({ chatId: chat.id }); } @@ -104,7 +128,15 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { } case 'updateChatLeave': { - return leaveChat(global, update.id); + global = leaveChat(global, update.id); + const chat = selectChat(global, update.id); + if (chat && isChatChannel(chat)) { + const chatMessages = selectChatMessages(global, update.id); + const localMessageIds = Object.keys(chatMessages).map(Number).filter(isLocalMessageId); + global = deleteChatMessages(global, chat.id, localMessageIds); + } + + return global; } case 'updateChatInbox': { diff --git a/src/global/actions/ui/chats.ts b/src/global/actions/ui/chats.ts index b24ce4b9a..dd8074e40 100644 --- a/src/global/actions/ui/chats.ts +++ b/src/global/actions/ui/chats.ts @@ -97,12 +97,13 @@ addActionHandler('openPreviousChat', (global, actions, payload): ActionReturnTyp }); addActionHandler('openChatWithInfo', (global, actions, payload): ActionReturnType => { - const { profileTab, tabId = getCurrentTabId() } = payload; + const { profileTab, forceScrollProfileTab = false, tabId = getCurrentTabId() } = payload; global = updateTabState(global, { ...selectTabState(global, tabId), isChatInfoShown: true, nextProfileTab: profileTab, + forceScrollProfileTab, }, tabId); global = { ...global, lastIsChatInfoShown: true }; setGlobal(global); diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index c802d2b1f..c32fb04cb 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -21,7 +21,7 @@ import { getServerTime } from '../../../util/serverTime'; import { IS_TOUCH_ENV } from '../../../util/windowEnvironment'; import versionNotification from '../../../versionNotification.txt'; import { - getIsSavedDialog, getMessageSummaryText, getSenderTitle, isChatChannel, + getIsSavedDialog, getMessageSummaryText, getSenderTitle, isChatChannel, isJoinedChannelMessage, } from '../../helpers'; import { renderMessageSummaryHtml } from '../../helpers/renderMessageSummaryHtml'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; @@ -330,6 +330,13 @@ addActionHandler('focusLastMessage', (global, actions, payload): ActionReturnTyp lastMessageId = pinnedMessageIds[pinnedMessageIds.length - 1]; } else { lastMessageId = selectChatLastMessageId(global, chatId); + + const chatMessages = selectChatMessages(global, chatId); + // Workaround for scroll to local message 'you joined this channel' + const lastChatMessage = Object.values(chatMessages).reverse()[0]; + if (lastMessageId && isJoinedChannelMessage(lastChatMessage) && lastChatMessage.id > lastMessageId) { + lastMessageId = lastChatMessage.id; + } } } else if (isSavedDialog) { lastMessageId = selectChatLastMessageId(global, String(threadId), 'saved'); diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 8e995a55c..c53590139 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -151,7 +151,7 @@ addActionHandler('resetNextProfileTab', (global, actions, payload): ActionReturn return undefined; } - return updateTabState(global, { nextProfileTab: undefined }, tabId); + return updateTabState(global, { nextProfileTab: undefined, forceScrollProfileTab: false }, tabId); }); addActionHandler('toggleStatistics', (global, actions, payload): ActionReturnType => { diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index e4bec9ae4..013a57907 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -354,3 +354,7 @@ export function isExpiredMessage(message: ApiMessage) { export function hasMessageTtl(message: ApiMessage) { return message.content?.ttlSeconds !== undefined; } + +export function isJoinedChannelMessage(message: ApiMessage) { + return message.content.action && message.content.action.type === 'joinedChannel'; +} diff --git a/src/global/reducers/chats.ts b/src/global/reducers/chats.ts index 762c22ffc..f3cd470b7 100644 --- a/src/global/reducers/chats.ts +++ b/src/global/reducers/chats.ts @@ -446,7 +446,9 @@ export function deleteTopic( export function addSimilarChannels( global: T, chatId: string, - similarChannels: string[], + similarChannelIds: string[], + count?: number, + shouldShowInChat = true, ) { return { ...global, @@ -454,7 +456,33 @@ export function addSimilarChannels( ...global.chats, similarChannelsById: { ...global.chats.similarChannelsById, - [chatId]: similarChannels, + [chatId]: { + similarChannelIds, + count: count || similarChannelIds.length, + shouldShowInChat, + }, + }, + }, + }; +} + +export function toggleSimilarChannels( + global: T, + chatId: string, +) { + const similarChannels = global.chats.similarChannelsById[chatId]; + const shouldShowInChat = !global.chats.similarChannelsById[chatId].shouldShowInChat; + + return { + ...global, + chats: { + ...global.chats, + similarChannelsById: { + ...global.chats.similarChannelsById, + [chatId]: { + ...similarChannels, + shouldShowInChat, + }, }, }, }; diff --git a/src/global/selectors/chats.ts b/src/global/selectors/chats.ts index 8c9a72fb3..3222f2f7f 100644 --- a/src/global/selectors/chats.ts +++ b/src/global/selectors/chats.ts @@ -325,7 +325,7 @@ export function selectRequestedChatTranslationLanguage( export function selectSimilarChannelIds( global: T, chatId: string, -): string[] | undefined { +) { return global.chats.similarChannelsById[chatId]; } diff --git a/src/global/types.ts b/src/global/types.ts index e0630d036..4dab905fb 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -234,6 +234,7 @@ export type TabState = { }; nextProfileTab?: ProfileTabType; + forceScrollProfileTab?: boolean; nextSettingsScreen?: SettingsScreens; nextFoldersAction?: ReducerAction; shareFolderScreen?: { @@ -807,7 +808,14 @@ export type GlobalState = { forDiscussionIds?: string[]; // Obtained from GetFullChat / GetFullChannel fullInfoById: Record; - similarChannelsById: Record; + similarChannelsById: Record< + string, + { + shouldShowInChat: boolean; + similarChannelIds: string[]; + count: number; + } + >; }; messages: { @@ -1223,7 +1231,10 @@ export interface ActionPayloads { onReplace?: VoidFunction; shouldReplace?: boolean; }; - openChatWithInfo: ActionPayloads['openChat'] & { profileTab?: ProfileTabType } & WithTabId; + openChatWithInfo: ActionPayloads['openChat'] & { + profileTab?: ProfileTabType; + forceScrollProfileTab?: boolean; + } & WithTabId; openThreadWithInfo: ActionPayloads['openThread'] & WithTabId; openLinkedChat: { id: string } & WithTabId; loadMoreMembers: WithTabId | undefined; @@ -1809,6 +1820,9 @@ export interface ActionPayloads { fetchChannelRecommendations: { chatId: string; }; + toggleChannelRecommendations: { + chatId: string; + }; updateChatMutedState: { chatId: string; isMuted?: boolean;