diff --git a/ts/components/MemberListItem.tsx b/ts/components/MemberListItem.tsx index 6e6f7bc1f..972fbe19f 100644 --- a/ts/components/MemberListItem.tsx +++ b/ts/components/MemberListItem.tsx @@ -102,8 +102,8 @@ const StyledCheckContainer = styled.div` align-items: center; `; -type MemberListItemProps = { - pubkey: string; +type MemberListItemProps = { + pubkey: T; isSelected: boolean; // this bool is used to make a zombie appear with less opacity than a normal member isZombie?: boolean; @@ -112,8 +112,8 @@ type MemberListItemProps = { withBorder?: boolean; maxNameWidth?: string; isAdmin?: boolean; // if true, we add a small crown on top of their avatar - onSelect?: (pubkey: string) => void; - onUnselect?: (pubkey: string) => void; + onSelect?: (pubkey: T) => void; + onUnselect?: (pubkey: T) => void; dataTestId?: React.SessionDataTestId; displayGroupStatus?: boolean; groupPk?: string; @@ -125,7 +125,7 @@ const ResendContainer = ({ displayGroupStatus, groupPk, pubkey, -}: Pick) => { +}: Pick, 'displayGroupStatus' | 'pubkey' | 'groupPk'>) => { const weAreAdmin = useWeAreAdmin(groupPk); if ( @@ -220,7 +220,7 @@ const GroupStatusContainer = ({ displayGroupStatus, groupPk, pubkey, -}: Pick) => { +}: Pick, 'displayGroupStatus' | 'pubkey' | 'groupPk'>) => { if ( displayGroupStatus && groupPk && @@ -316,7 +316,7 @@ const PromoteButton = ({ groupPk, pubkey }: { pubkey: PubkeyType; groupPk: Group ); }; -export const MemberListItem = ({ +export const MemberListItem = ({ isSelected, pubkey, dataTestId, @@ -332,7 +332,7 @@ export const MemberListItem = ({ withBorder, maxNameWidth, hideRadioButton, -}: MemberListItemProps) => { +}: MemberListItemProps) => { const memberName = useNicknameOrProfileNameOrShortenedPubkey(pubkey); const ourName = isUsAnySogsFromCache(pubkey) ? localize('you').toString() : null; diff --git a/ts/components/conversation/SessionConversation.tsx b/ts/components/conversation/SessionConversation.tsx index 1b192f259..eecbcd9d2 100644 --- a/ts/components/conversation/SessionConversation.tsx +++ b/ts/components/conversation/SessionConversation.tsx @@ -62,6 +62,13 @@ import { InvitedToGroup, NoMessageInConversation } from './SubtleNotification'; import { PubKey } from '../../session/types'; import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys'; import { localize } from '../../localization/localeTools'; +import { + useSelectedConversationKey, + useSelectedIsPrivate, + useSelectedIsPublic, + useSelectedWeAreAdmin, +} from '../../state/selectors/selectedConversation'; +import { useAreLegacyGroupsDeprecatedYet } from '../../state/selectors/releasedFeatures'; const DEFAULT_JPEG_QUALITY = 0.85; @@ -253,7 +260,7 @@ export class SessionConversation extends Component { ourDisplayNameInProfile={ourDisplayNameInProfile} selectedConversation={selectedConversation} /> - + {isSelectedConvoInitialLoadingInProgress ? ( @@ -283,6 +290,7 @@ export class SessionConversation extends Component { {isDraggingFile && } + ; -}) { - const { selectedConversation } = props; +function OutdatedLegacyGroupBanner() { const dispatch = useDispatch(); + const weAreAdmin = useSelectedWeAreAdmin(); + const selectedConversationKey = useSelectedConversationKey(); + const isPrivate = useSelectedIsPrivate(); + const isPublic = useSelectedIsPublic(); + const deprecatedLegacyGroups = useAreLegacyGroupsDeprecatedYet(); + const isLegacyGroup = - !selectedConversation.isPrivate && - !selectedConversation.isPublic && - selectedConversation.id.startsWith('05'); + !isPrivate && !isPublic && selectedConversationKey && selectedConversationKey.startsWith('05'); + + // FIXME change the date here. Remove after QA + const text = deprecatedLegacyGroups + ? localize( + weAreAdmin ? 'groupLegacyBannerAdminDeprecated' : 'groupLegacyBannerMemberDeprecated' + ).toString() + : localize(weAreAdmin ? 'groupLegacyBannerAdmin' : 'groupLegacyBannerMember') + .withArgs({ date: '[Date]' }) + .toString(); return isLegacyGroup ? ( { showLinkVisitWarningDialog('https://getsession.org/groups', dispatch); }} diff --git a/ts/components/conversation/header/ConversationHeader.tsx b/ts/components/conversation/header/ConversationHeader.tsx index 7c1fd1ca0..ec90a9a61 100644 --- a/ts/components/conversation/header/ConversationHeader.tsx +++ b/ts/components/conversation/header/ConversationHeader.tsx @@ -1,16 +1,30 @@ import { useDispatch } from 'react-redux'; +import type { PubkeyType } from 'libsession_util_nodejs'; +import { useCallback } from 'react'; +import styled from 'styled-components'; import { openRightPanel } from '../../../state/ducks/conversations'; -import { useIsOutgoingRequest } from '../../../hooks/useParamSelector'; +import { + use05GroupMembers, + useConversationUsername, + useIsOutgoingRequest, +} from '../../../hooks/useParamSelector'; import { useIsMessageSelectionMode, useSelectedConversationKey, + useSelectedIsLegacyGroup, + useSelectedWeAreAdmin, } from '../../../state/selectors/selectedConversation'; import { Flex } from '../../basic/Flex'; import { AvatarHeader, CallButton } from './ConversationHeaderItems'; import { SelectionOverlay } from './ConversationHeaderSelectionOverlay'; import { ConversationHeaderTitle } from './ConversationHeaderTitle'; +import { localize } from '../../../localization/localeTools'; +import { groupInfoActions } from '../../../state/ducks/metaGroups'; +import { updateConfirmModal } from '../../../state/ducks/modalDialog'; +import { setLeftOverlayMode } from '../../../state/ducks/section'; +import { SessionButtonColor, SessionButton } from '../../basic/SessionButton'; export const ConversationHeaderWithDetails = () => { const isSelectionMode = useIsMessageSelectionMode(); @@ -42,6 +56,7 @@ export const ConversationHeaderWithDetails = () => { flexGrow={0} flexShrink={0} > + { @@ -57,3 +72,71 @@ export const ConversationHeaderWithDetails = () => { ); }; + +const RecreateGroupContainer = styled.div` + display: flex; + justify-content: center; + align-self: center; + width: 100%; + + .session-button { + padding-inline: var(--margins-3xl); + } +`; + +function useShowRecreateModal() { + const dispatch = useDispatch(); + + return useCallback( + (name: string, members: Array) => { + dispatch( + updateConfirmModal({ + title: localize('groupRecreate').toString(), + i18nMessage: { token: 'groupRecreateDescription' }, + okText: localize('theContinue').toString(), + cancelText: localize('cancel').toString(), + okTheme: SessionButtonColor.Danger, + onClickOk: () => { + dispatch(setLeftOverlayMode('closed-group')); + dispatch(groupInfoActions.updateGroupCreationName({ name })); + dispatch(groupInfoActions.setSelectedGroupMembers({ membersToSet: members })); + }, + onClickClose: () => { + dispatch(updateConfirmModal(null)); + }, + }) + ); + }, + [dispatch] + ); +} + +function RecreateGroupButton() { + const isLegacyGroup = useSelectedIsLegacyGroup(); + const selectedConvo = useSelectedConversationKey(); + + const name = useConversationUsername(selectedConvo); + const members = use05GroupMembers(selectedConvo); + + const weAreAdmin = useSelectedWeAreAdmin(); + + const showRecreateGroupModal = useShowRecreateModal(); + + if (!isLegacyGroup || !weAreAdmin) { + return null; + } + + return ( + + { + showRecreateGroupModal(name || 'Unknown group name', members); + }} + > + {localize('groupRecreate').toString()} + + + ); +} diff --git a/ts/components/conversation/header/ConversationHeaderSubtitle.tsx b/ts/components/conversation/header/ConversationHeaderSubtitle.tsx index 742ce17d1..e8845e3e9 100644 --- a/ts/components/conversation/header/ConversationHeaderSubtitle.tsx +++ b/ts/components/conversation/header/ConversationHeaderSubtitle.tsx @@ -17,7 +17,8 @@ export const StyledSubtitleContainer = styled.div` align-items: center; justify-content: center; margin: 0 auto; - min-width: 230px; + // with the "Recreate group" button (temporary) visible, at min-width we have less room available + min-width: 180px; div:first-child { span:last-child { diff --git a/ts/components/leftpane/overlay/OverlayClosedGroup.tsx b/ts/components/leftpane/overlay/OverlayClosedGroup.tsx index 7ee5e8bbf..b507166d2 100644 --- a/ts/components/leftpane/overlay/OverlayClosedGroup.tsx +++ b/ts/components/leftpane/overlay/OverlayClosedGroup.tsx @@ -6,6 +6,8 @@ import styled from 'styled-components'; import { concat, isEmpty } from 'lodash'; import useBoolean from 'react-use/lib/useBoolean'; +import useUpdate from 'react-use/lib/useUpdate'; +import type { PubkeyType } from 'libsession_util_nodejs'; import { MemberListItem } from '../../MemberListItem'; import { SessionButton } from '../../basic/SessionButton'; @@ -35,6 +37,8 @@ import { SessionInput } from '../../inputs'; import { SessionSpinner } from '../../loading'; import { StyledLeftPaneOverlay } from './OverlayMessage'; import { hasClosedGroupV2QAButtons } from '../../../shared/env_vars'; +import type { StateType } from '../../../state/reducer'; +import { PubKey } from '../../../session/types'; const StyledMemberListNoContacts = styled.div` text-align: center; @@ -115,18 +119,26 @@ export const OverlayClosedGroupV2 = () => { const us = useOurPkStr(); const privateContactsPubkeys = useContactsToInviteToGroup(); const isCreatingGroup = useIsCreatingGroupFromUIPending(); - const [groupName, setGroupName] = useState(''); + const groupName = useSelector((state: StateType) => state.groups.creationGroupName) || ''; const [inviteAsAdmin, setInviteAsAdmin] = useBoolean(false); const [groupNameError, setGroupNameError] = useState(); - const { - uniqueValues: selectedMemberIds, - addTo: addToSelected, - removeFrom: removeFromSelected, - } = useSet([]); const isSearch = useIsSearching(); const searchTerm = useSelector(getSearchTerm); const searchResultContactsOnly = useSelector(getSearchResultsContactOnly); + const forceRefresh = useUpdate(); + const selectedMemberIds = useSelector( + (state: StateType) => state.groups.creationMembersSelected || [] + ); + + function addMemberToSelection(member: PubkeyType) { + dispatch(groupInfoActions.addSelectedGroupMember({ memberToAdd: member })); + } + + function removeMemberFromSelection(member: PubkeyType) { + dispatch(groupInfoActions.removeSelectedGroupMember({ memberToRemove: member })); + } + function closeOverlay() { dispatch(clearSearch()); dispatch(resetLeftOverlayMode()); @@ -197,7 +209,9 @@ export const OverlayClosedGroupV2 = () => { type="text" placeholder={window.i18n('groupNameEnter')} value={groupName} - onValueChanged={setGroupName} + onValueChanged={value => { + dispatch(groupInfoActions.updateGroupCreationName({ name: value })); + }} onEnterPressed={onEnterPressed} error={groupNameError} maxLength={LIBSESSION_CONSTANTS.BASE_GROUP_MAX_NAME_LENGTH} @@ -221,8 +235,20 @@ export const OverlayClosedGroupV2 = () => { }} /> + + Deprecated Legacy groups?{' '} + { + window.sessionFeatureFlags.forceLegacyGroupsDeprecated = + !window.sessionFeatureFlags.forceLegacyGroupsDeprecated; + forceRefresh(); + }} + /> + )} + @@ -238,18 +264,24 @@ export const OverlayClosedGroupV2 = () => { ) : ( - contactsToRender.map((memberPubkey: string) => ( - - )) + contactsToRender.map((memberPubkey: string) => { + if (!PubKey.is05Pubkey(memberPubkey)) { + throw new Error('Invalid member rendered in member list'); + } + + return ( + + ); + }) )} diff --git a/ts/components/leftpane/overlay/choose-action/OverlayChooseAction.tsx b/ts/components/leftpane/overlay/choose-action/OverlayChooseAction.tsx index 00984c8fe..6b6badd30 100644 --- a/ts/components/leftpane/overlay/choose-action/OverlayChooseAction.tsx +++ b/ts/components/leftpane/overlay/choose-action/OverlayChooseAction.tsx @@ -8,6 +8,7 @@ import { SpacerSM } from '../../../basic/Text'; import { StyledLeftPaneOverlay } from '../OverlayMessage'; import { ActionRow, StyledActionRowContainer } from './ActionRow'; import { ContactsListWithBreaks } from './ContactsListWithBreaks'; +import { groupInfoActions } from '../../../../state/ducks/metaGroups'; export const OverlayChooseAction = () => { const dispatch = useDispatch(); @@ -21,6 +22,8 @@ export const OverlayChooseAction = () => { const openCreateGroup = useCallback(() => { dispatch(setLeftOverlayMode('closed-group')); + dispatch(groupInfoActions.updateGroupCreationName({ name: '' })); + dispatch(groupInfoActions.setSelectedGroupMembers({ membersToSet: [] })); }, [dispatch]); const openJoinCommunity = useCallback(() => { diff --git a/ts/state/ducks/metaGroups.ts b/ts/state/ducks/metaGroups.ts index 17767c083..135254ad3 100644 --- a/ts/state/ducks/metaGroups.ts +++ b/ts/state/ducks/metaGroups.ts @@ -8,7 +8,7 @@ import { UserGroupsGet, WithGroupPubkey, } from 'libsession_util_nodejs'; -import { concat, intersection, isEmpty, uniq } from 'lodash'; +import { concat, intersection, isEmpty, isNil, uniq } from 'lodash'; import { from_hex } from 'libsodium-wrappers-sumo'; import { ConfigDumpData } from '../../data/configDump/configDump'; import { HexString } from '../../node/hexStrings'; @@ -57,11 +57,15 @@ import { updateGroupNameModal } from './modalDialog'; export type GroupState = { infos: Record; members: Record>; - creationFromUIPending: boolean; memberChangesFromUIPending: boolean; nameChangesFromUIPending: boolean; membersInviteSending: Record>; membersPromoteSending: Record>; + + // those are group creation-related fields + creationFromUIPending: boolean; + creationMembersSelected: Array; + creationGroupName: string; }; export const initialGroupState: GroupState = { @@ -72,6 +76,8 @@ export const initialGroupState: GroupState = { nameChangesFromUIPending: false, membersInviteSending: {}, membersPromoteSending: {}, + creationMembersSelected: [], + creationGroupName: '', }; type GroupDetailsUpdate = { @@ -1256,6 +1262,44 @@ const metaGroupSlice = createSlice({ delete state.membersInviteSending[payload.groupPk]; delete state.membersPromoteSending[payload.groupPk]; }, + addSelectedGroupMember( + state: GroupState, + { payload }: PayloadAction<{ memberToAdd: PubkeyType }> + ) { + if (!state.creationMembersSelected?.length) { + state.creationMembersSelected = [payload.memberToAdd]; + return state; + } + if (state.creationMembersSelected.includes(payload.memberToAdd)) { + return state; + } + const together = state.creationMembersSelected.concat(payload.memberToAdd); + state.creationMembersSelected = uniq(together); + return state; + }, + + setSelectedGroupMembers( + state: GroupState, + { payload }: PayloadAction<{ membersToSet: Array }> + ) { + state.creationMembersSelected = uniq(payload.membersToSet); + return state; + }, + removeSelectedGroupMember( + state: GroupState, + { payload }: PayloadAction<{ memberToRemove: PubkeyType }> + ) { + const foundAt = state.creationMembersSelected?.indexOf(payload.memberToRemove); + if (state.creationMembersSelected && !isNil(foundAt) && foundAt >= 0) { + state.creationMembersSelected.splice(foundAt, 1); + } + return state; + }, + + updateGroupCreationName(state: GroupState, { payload }: PayloadAction<{ name: string }>) { + state.creationGroupName = payload.name; + return state; + }, }, extraReducers: builder => { builder.addCase(initNewGroupInWrapper.fulfilled, (state, action) => { diff --git a/ts/state/ducks/releasedFeatures.tsx b/ts/state/ducks/releasedFeatures.tsx index e66ec1bc0..346ec31cb 100644 --- a/ts/state/ducks/releasedFeatures.tsx +++ b/ts/state/ducks/releasedFeatures.tsx @@ -1,6 +1,8 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { DURATION } from '../../session/constants'; -export const LEGACY_GROUP_DEPRECATED_TIMESTAMP_MS = Date.now() + 10 * 1000; +// FIXME update this to the correct timestamp REMOVE AFTER QA +export const LEGACY_GROUP_DEPRECATED_TIMESTAMP_MS = Date.now() + DURATION.WEEKS * 52; export interface ReleasedFeaturesState { legacyGroupDeprecationTimestampRefreshAtMs: number; diff --git a/ts/state/selectors/releasedFeatures.ts b/ts/state/selectors/releasedFeatures.ts index d21b3c7d7..0f6865d63 100644 --- a/ts/state/selectors/releasedFeatures.ts +++ b/ts/state/selectors/releasedFeatures.ts @@ -2,7 +2,6 @@ import { useSelector } from 'react-redux'; import { NetworkTime } from '../../util/NetworkTime'; import { LEGACY_GROUP_DEPRECATED_TIMESTAMP_MS } from '../ducks/releasedFeatures'; - export const areLegacyGroupsDeprecatedYet = (): boolean => { const theyAreDeprecated = NetworkTime.now() >= LEGACY_GROUP_DEPRECATED_TIMESTAMP_MS; diff --git a/ts/state/selectors/selectedConversation.ts b/ts/state/selectors/selectedConversation.ts index 8e794fc62..f8f0b1749 100644 --- a/ts/state/selectors/selectedConversation.ts +++ b/ts/state/selectors/selectedConversation.ts @@ -258,10 +258,9 @@ export function useSelectedIsPublic() { */ export function useSelectedIsLegacyGroup() { const isGroupOrCommunity = useSelectedIsGroupOrCommunity(); - const isGroupV2 = useSelectedIsGroupV2(); - const isPublic = useSelectedIsPublic(); + const selectedConvoKey = useSelectedConversationKey(); - return isGroupOrCommunity && !isGroupV2 && !isPublic; + return isGroupOrCommunity && selectedConvoKey && PubKey.is05Pubkey(selectedConvoKey); } export function useSelectedIsPrivate() { diff --git a/ts/util/releaseFeature.ts b/ts/util/releaseFeature.ts index 7f24e48c9..76be91603 100644 --- a/ts/util/releaseFeature.ts +++ b/ts/util/releaseFeature.ts @@ -109,7 +109,6 @@ function isDisappearMessageV2FeatureReleasedCached(): boolean { return !!isDisappearingMessageFeatureReleased; } - export const ReleasedFeatures = { checkIsUserConfigFeatureReleased, checkIsDisappearMessageV2FeatureReleased,