MiddleColumn: show similar channels after join channel (#4126)

This commit is contained in:
Alexander Zinchuk 2024-02-06 16:48:57 +01:00
parent c3c71cbc9e
commit 2bf38d0726
17 changed files with 675 additions and 30 deletions

View File

@ -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) {

View File

@ -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;

View File

@ -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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
>
{!isSuggestedAvatar && !isGiftCode && <span className="action-message-content">{renderContent()}</span>}
{!isSuggestedAvatar && !isGiftCode && !isJoinedMessage && (
<span className="action-message-content">{renderContent()}</span>
)}
{isGift && renderGift()}
{isGiftCode && renderGiftCode()}
{isSuggestedAvatar && (
<ActionMessageSuggestedAvatar
message={message}
renderContent={renderContent}
/>
<ActionMessageSuggestedAvatar message={message} renderContent={renderContent} />
)}
{isJoinedMessage && <SimilarChannels chatId={targetChatId!} />}
{contextMenuPosition && (
<ContextMenuContainer
isOpen={isContextMenuOpen}

View File

@ -219,6 +219,7 @@
}
}
.join-text,
.sticky-date,
.local-action-message,
.ActionMessage,

View File

@ -0,0 +1,247 @@
.root {
max-width: 45rem;
margin-top: 0.625rem;
position: relative;
--more-channel-background: #d8d8d8;
--more-channel-background-dark: #8f8f8f;
--more-channel-badge: #8f8f8f;
--more-channel-badge-overlay: #00000033;
}
.notch {
display: flex;
justify-content: center;
margin-top: 0.1875rem;
}
.notch-path {
fill: var(--color-background);
}
.join-text {
cursor: pointer;
}
.header {
padding: 0.375rem 0.375rem 0 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
left: 0;
}
.title {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.5rem;
}
.close {
width: auto;
height: auto;
font-size: 1.25rem;
padding: 0.125rem;
border-radius: 50%;
> .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;
}

View File

@ -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<HTMLDivElement>(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 (
<div className={buildClassName(styles.root)}>
<div className="join-text">
<span
className={buildClassName(areSimilarChannelsPresent && styles.joinText)}
onClick={areSimilarChannelsPresent ? handleToggle : undefined}
>
{lang('ChannelJoined')}
</span>
</div>
{shoulRenderSkeleton && <Skeleton className={styles.skeleton} />}
{shouldRenderChannels && (
<div
className={buildClassName(
isShowing && styles.isAppearing,
isHiding && styles.isHiding,
)}
>
<div className={styles.notch}>
<svg
width="19"
height="7"
viewBox="0 0 19 7"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
className={styles.notchPath}
fill-rule="evenodd"
clip-rule="evenodd"
d="M19 7C16.8992 7 13.59 3.88897 11.5003 1.67424C10.7648 0.894688 10.397 0.50491 10.0434 0.385149C9.70568 0.270811 9.4225 0.270474 9.08456 0.38401C8.73059 0.50293 8.36133 0.892443 7.62279 1.67147C5.52303 3.88637 2.18302 7 0 7L19 7Z"
fill="white"
/>
</svg>
</div>
<div className={styles.inner}>
<div className={styles.header}>
<span className={styles.title}>{lang('SimilarChannels')}</span>
<Button
className={styles.close}
color="translucent"
// eslint-disable-next-line react/jsx-no-bind
onClick={handleToggle}
>
<Icon name="close" />
</Button>
</div>
<div ref={ref} className={buildClassName(styles.channelList, 'no-scrollbar')}>
{firstSimilarChannels?.map((channel, i) => {
return i === SHOW_CHANNELS_NUMBER - 1 ? (
<MoreChannels
channel={channel}
chatId={chatId}
channelsCount={count - SHOW_CHANNELS_NUMBER + 1}
isCurrentUserPremium={isCurrentUserPremium}
/>
) : (
<SimilarChannel channel={channel} />
);
})}
</div>
</div>
</div>
)}
</div>
);
};
function SimilarChannel({ channel }: { channel: ApiChat }) {
const { openChat } = getActions();
const color = useAverageColor(channel);
return (
<div className={styles.item} onClick={() => openChat({ id: channel.id })}>
<Avatar className={styles.avatar} key={channel.id} size="large" peer={channel} />
<div style={`background: ${color}`} className={styles.badge}>
<i className={buildClassName(styles.icon, 'icon icon-user-filled')} />
<span className={styles.membersCount}>{formatIntegerCompact(channel?.membersCount || 0)}
</span>
</div>
<span className={styles.channelTitle}>{channel.title}</span>
</div>
);
}
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 (
<div
className={buildClassName(styles.item, styles.lastItem)}
onClick={() => handleClickMore()}
>
<Avatar className={styles.avatar} key={channel.id} size="large" peer={channel} />
<div className={styles.fakeAvatar}>
<div className={styles.fakeAvatarInner} />
</div>
<div className={buildClassName(styles.fakeAvatar, styles.lastFakeAvatar)}>
<div className={styles.fakeAvatarInner} />
</div>
<div className={styles.badge}>
<span className={styles.membersCount}>{`+${channelsCount}`}</span>
{!isCurrentUserPremium && <Icon name="lock-badge" className={styles.icon} />}
</div>
<span className={styles.channelTitle}>{lang('MoreSimilar')}</span>
</div>
);
}
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<OwnProps>((global, { chatId }): StateProps => {
const { similarChannelIds, shouldShowInChat, count } = selectSimilarChannelIds(global, chatId) || {};
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
return {
similarChannelIds,
shouldShowInChat,
count,
isCurrentUserPremium,
};
})(SimilarChannels),
);

View File

@ -125,6 +125,7 @@ type StateProps = {
limitSimilarChannels: number;
isTopicInfo?: boolean;
isSavedDialog?: boolean;
forceScrollProfileTab?: boolean;
};
type TabProps = {
@ -180,6 +181,7 @@ const Profile: FC<OwnProps & StateProps> = ({
limitSimilarChannels,
isTopicInfo,
isSavedDialog,
forceScrollProfileTab,
}) => {
const {
setLocalMediaSearchType,
@ -254,10 +256,10 @@ const Profile: FC<OwnProps & StateProps> = ({
}, [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<OwnProps & StateProps> = ({
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<OwnProps>(
&& (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<OwnProps>(
storyByIds,
isChatProtected: chat?.isProtected,
nextProfileTab: selectTabState(global).nextProfileTab,
forceScrollProfileTab: selectTabState(global).forceScrollProfileTab,
shouldWarnAboutSvg: global.settings.byKey.shouldWarnAboutSvg,
similarChannels,
similarChannels: similarChannelIds,
isCurrentUserPremium,
isTopicInfo,
isSavedDialog,

View File

@ -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<HTMLDivElement>('.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]) => {

View File

@ -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<void> => {
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);
});

View File

@ -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': {

View File

@ -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);

View File

@ -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');

View File

@ -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 => {

View File

@ -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';
}

View File

@ -446,7 +446,9 @@ export function deleteTopic<T extends GlobalState>(
export function addSimilarChannels<T extends GlobalState>(
global: T,
chatId: string,
similarChannels: string[],
similarChannelIds: string[],
count?: number,
shouldShowInChat = true,
) {
return {
...global,
@ -454,7 +456,33 @@ export function addSimilarChannels<T extends GlobalState>(
...global.chats,
similarChannelsById: {
...global.chats.similarChannelsById,
[chatId]: similarChannels,
[chatId]: {
similarChannelIds,
count: count || similarChannelIds.length,
shouldShowInChat,
},
},
},
};
}
export function toggleSimilarChannels<T extends GlobalState>(
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,
},
},
},
};

View File

@ -325,7 +325,7 @@ export function selectRequestedChatTranslationLanguage<T extends GlobalState>(
export function selectSimilarChannelIds<T extends GlobalState>(
global: T,
chatId: string,
): string[] | undefined {
) {
return global.chats.similarChannelsById[chatId];
}

View File

@ -234,6 +234,7 @@ export type TabState = {
};
nextProfileTab?: ProfileTabType;
forceScrollProfileTab?: boolean;
nextSettingsScreen?: SettingsScreens;
nextFoldersAction?: ReducerAction<FoldersActions>;
shareFolderScreen?: {
@ -807,7 +808,14 @@ export type GlobalState = {
forDiscussionIds?: string[];
// Obtained from GetFullChat / GetFullChannel
fullInfoById: Record<string, ApiChatFullInfo>;
similarChannelsById: Record<string, string[]>;
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;