Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/app/features/community-settings/AppCommunitySettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
getCommunityRole,
getCommunityVisibility,
getInviteBlockReason,
inviteToCommunity,
InviteToCommunityResult,
parseInviteUserId,
setCommunityVisibility,
SetCommunityVisibilityResult,
Expand Down Expand Up @@ -359,13 +361,8 @@ function InviteSection({ community, canInvite }: { community: Room; canInvite: b
const [invited, setInvited] = useState<string>();
const defaultServer = getMxIdServer(mx.getSafeUserId());

const [inviteState, invite] = useAsyncCallback<void, MatrixError, [string]>(
useCallback(
async (userId) => {
await mx.invite(community.roomId, userId);
},
[mx, community.roomId]
)
const [inviteState, invite] = useAsyncCallback<InviteToCommunityResult, MatrixError, [string]>(
useCallback((userId) => inviteToCommunity(mx, community, userId), [mx, community])
);
const inviting = inviteState.status === AsyncStatus.Loading;

Expand Down Expand Up @@ -432,6 +429,15 @@ function InviteSection({ community, canInvite }: { community: Room; canInvite: b
{invited && inviteState.status === AsyncStatus.Success && (
<span className={css.Success}>Invited {invited}.</span>
)}
{invited &&
inviteState.status === AsyncStatus.Success &&
inviteState.data.failedRoomIds.length > 0 && (
<span className={css.Hint}>
Couldn&apos;t invite them to{' '}
{pluralize(inviteState.data.failedRoomIds.length, 'room', 'rooms')}, so they may not
see older messages there.
</span>
)}
</form>
</section>
);
Expand Down
42 changes: 42 additions & 0 deletions src/app/features/community-settings/communitySettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getSpaceVisibilityChanges,
isCommunityAdmin,
parseInviteUserId,
shouldInviteToChildRoom,
shouldUpdateChildJoinRule,
} from './communitySettings';

Expand Down Expand Up @@ -164,3 +165,44 @@ describe('getInviteBlockReason', () => {
expect(getInviteBlockReason('leave')).toBeUndefined();
});
});

describe('shouldInviteToChildRoom', () => {
const base = {
encrypted: true,
historyVisibility: 'shared',
inviterMembership: 'join',
inviteeMembership: undefined,
canInvite: true,
};

it('invites to encrypted rooms with shared history the inviter is in', () => {
expect(shouldInviteToChildRoom(base)).toBe(true);
expect(shouldInviteToChildRoom({ ...base, historyVisibility: 'world_readable' })).toBe(true);
});

it('treats unset history visibility as shared', () => {
expect(shouldInviteToChildRoom({ ...base, historyVisibility: undefined })).toBe(true);
});

it('skips rooms whose history new members cannot read', () => {
expect(shouldInviteToChildRoom({ ...base, historyVisibility: 'joined' })).toBe(false);
expect(shouldInviteToChildRoom({ ...base, historyVisibility: 'invited' })).toBe(false);
});

it('skips unencrypted rooms, where there are no keys to share', () => {
expect(shouldInviteToChildRoom({ ...base, encrypted: false })).toBe(false);
});

it('skips rooms the inviter is not in or cannot invite to', () => {
expect(shouldInviteToChildRoom({ ...base, inviterMembership: 'leave' })).toBe(false);
expect(shouldInviteToChildRoom({ ...base, canInvite: false })).toBe(false);
});

it('only invites people who are not already in, invited to or banned from the room', () => {
expect(shouldInviteToChildRoom({ ...base, inviteeMembership: 'leave' })).toBe(true);
expect(shouldInviteToChildRoom({ ...base, inviteeMembership: 'knock' })).toBe(true);
expect(shouldInviteToChildRoom({ ...base, inviteeMembership: 'join' })).toBe(false);
expect(shouldInviteToChildRoom({ ...base, inviteeMembership: 'invite' })).toBe(false);
expect(shouldInviteToChildRoom({ ...base, inviteeMembership: 'ban' })).toBe(false);
});
});
97 changes: 96 additions & 1 deletion src/app/features/community-settings/communitySettings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { JoinRule, MatrixClient, RestrictedAllowType, Room } from 'matrix-js-sdk';
import {
HistoryVisibility,
JoinRule,
MatrixClient,
RestrictedAllowType,
Room,
} from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { Membership, StateEvent } from '../../../types/matrix/room';
import {
Expand Down Expand Up @@ -218,3 +224,92 @@ export const getInviteBlockReason = (membership: string | undefined): string | u
return undefined;
}
};

export type ChildRoomInviteCheck = {
encrypted: boolean;
historyVisibility: string | undefined;
inviterMembership: string | undefined;
inviteeMembership: string | undefined;
canInvite: boolean;
};

/**
* Whether inviting someone to a community should also invite them to one of
* its rooms. People joining a community reach its rooms through the space
* (restricted join), which never gives them the keys for encrypted messages
* sent before they joined: matrix-js-sdk only shares room history when
* inviting someone. So we invite them to the encrypted rooms whose history
* visibility lets new members read past messages, when we can.
*/
export const shouldInviteToChildRoom = ({
encrypted,
historyVisibility,
inviterMembership,
inviteeMembership,
canInvite,
}: ChildRoomInviteCheck): boolean => {
if (!encrypted || !canInvite) return false;
if (inviterMembership !== Membership.Join) return false;
// Unset history visibility defaults to shared.
const visibility = historyVisibility ?? HistoryVisibility.Shared;
if (visibility !== HistoryVisibility.Shared && visibility !== HistoryVisibility.WorldReadable) {
return false;
}
return (
inviteeMembership === undefined ||
inviteeMembership === Membership.Leave ||
inviteeMembership === Membership.Knock
);
};

export type InviteToCommunityResult = {
/** Rooms in the community the user could not be invited to. */
failedRoomIds: string[];
};

/**
* Invites a user to a community, then to its encrypted rooms (see
* shouldInviteToChildRoom) so they get the room history's keys. The space
* invite must succeed; room failures are collected so one room doesn't hide
* that the community invite went through.
*/
export const inviteToCommunity = async (
mx: MatrixClient,
space: Room,
userId: string
): Promise<InviteToCommunityResult> => {
await mx.invite(space.roomId, userId);

const myUserId = mx.getSafeUserId();
const childRooms = getSpaceChildren(space)
.map((roomId) => mx.getRoom(roomId))
.filter((room): room is Room => !!room && !room.isSpaceRoom())
.filter((room) => {
const powerLevels = getPowersLevelFromMatrixEvent(
getStateEvent(room, StateEvent.RoomPowerLevels)
);
const permissions = getRoomPermissionsAPI(
getRoomCreatorsForRoomId(mx, room.roomId),
powerLevels
);
return shouldInviteToChildRoom({
encrypted: room.hasEncryptionStateEvent(),
historyVisibility: room.getHistoryVisibility(),
inviterMembership: room.getMyMembership(),
inviteeMembership: room.getMember(userId)?.membership,
canInvite: permissions.action('invite', myUserId),
});
});

const results = await Promise.all(
childRooms.map(async (room): Promise<string | undefined> => {
try {
await mx.invite(room.roomId, userId);
return undefined;
} catch {
return room.roomId;
}
})
);
return { failedRoomIds: results.filter((roomId): roomId is string => !!roomId) };
};
6 changes: 5 additions & 1 deletion src/app/pages/app-shell/auth/useAppRegisterComplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getAppLoginPath, getAppPath, withSearchParam } from '../../pathUtils';
import { LoginPathSearchParams } from '../../paths';
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
import { setFallbackSession } from '../../../state/sessions';
import { markNewAccountForCrossSigning } from '../../../utils/newAccountCrossSigning';

/**
* Forked from registerUtil.ts's useRegisterComplete: identical auto-session
Expand All @@ -20,12 +21,15 @@ export const useAppRegisterComplete = (data?: CustomRegisterResponse) => {

useEffect(() => {
if (data) {
const { response, baseUrl } = data;
const { response, baseUrl, password } = data;

const userId = response.user_id;
const accessToken = response.access_token;
const deviceId = response.device_id;

// Also when we're sent to login instead: the account is still new.
markNewAccountForCrossSigning(userId, password);

if (accessToken && deviceId) {
setFallbackSession(accessToken, deviceId, userId, baseUrl);
const afterLoginRedirectPath = getAfterLoginRedirectPath();
Expand Down
9 changes: 8 additions & 1 deletion src/app/pages/auth/register/registerUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils';
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
import { setFallbackSession } from '../../../state/sessions';
import { markNewAccountForCrossSigning } from '../../../utils/newAccountCrossSigning';

export enum RegisterError {
UserTaken = 'UserTaken',
Expand All @@ -33,6 +34,8 @@ export enum RegisterError {
export type CustomRegisterResponse = {
baseUrl: string;
response: RegisterResponse;
/** Kept (in memory only) to authorize setting up cross-signing after registering. */
password?: string;
};
export type RegisterResult = [IAuthData, undefined] | [undefined, CustomRegisterResponse];
export const register = async (
Expand Down Expand Up @@ -103,6 +106,7 @@ export const register = async (
{
baseUrl: mx.baseUrl,
response: res,
password: requestData.password,
},
];
};
Expand All @@ -112,12 +116,15 @@ export const useRegisterComplete = (data?: CustomRegisterResponse) => {

useEffect(() => {
if (data) {
const { response, baseUrl } = data;
const { response, baseUrl, password } = data;

const userId = response.user_id;
const accessToken = response.access_token;
const deviceId = response.device_id;

// Also when we're sent to login instead: the account is still new.
markNewAccountForCrossSigning(userId, password);

if (accessToken && deviceId) {
setFallbackSession(accessToken, deviceId, userId, baseUrl);
const afterLoginRedirectPath = getAfterLoginRedirectPath();
Expand Down
15 changes: 15 additions & 0 deletions src/app/pages/client/ClientNonUIFeatures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useAppRoomNavigate } from '../../features/app-inbox/useAppRoomNavigate';
import { isPostsRoomName } from '../../features/app-feed/findPostsRoom';
import { getMessageNotificationBody } from '../../features/app-inbox/notificationBody';
import { bootstrapNewAccountCrossSigning } from '../../utils/newAccountCrossSigning';

function SystemEmojiFeature() {
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
Expand Down Expand Up @@ -273,6 +274,19 @@ function MessageNotifications() {
);
}

function NewAccountCrossSigning() {
const mx = useMatrixClient();

useEffect(() => {
bootstrapNewAccountCrossSigning(mx).catch((e) => {
// eslint-disable-next-line no-console
console.error('Failed to set up cross-signing for the new account', e);
});
}, [mx]);

return null;
}

type ClientNonUIFeaturesProps = {
children: ReactNode;
};
Expand All @@ -285,6 +299,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<FaviconUpdater />
<InviteNotifications />
<MessageNotifications />
<NewAccountCrossSigning />
{children}
</>
);
Expand Down
Loading
Loading