Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
9 changes: 9 additions & 0 deletions app/definitions/ISubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ export interface ISubscription {
uploads: RelationModified<TUploadModel>;
disableNotifications?: boolean;
federated?: boolean;
inviter?: Required<Pick<IUser, '_id' | 'username'>> & Pick<IUser, 'name'>;
}

export interface IInviteSubscription extends ISubscription {
status: 'INVITED';
inviter: NonNullable<ISubscription['inviter']>;
}

export type TSubscriptionModel = ISubscription &
Expand Down Expand Up @@ -181,3 +187,6 @@ export interface IServerSubscription extends IRocketChatRecord {

department?: unknown;
}

export const isInviteSubscription = (subscription: ISubscription): subscription is IInviteSubscription =>
subscription?.status === 'INVITED' && !!subscription.inviter;
3 changes: 3 additions & 0 deletions app/definitions/rest/v1/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export type RoomsEndpoints = {
success: boolean;
};
};
'rooms.invite': {
POST: (params: { roomId: string; action: 'accept' | 'reject' }) => void;
};
};

export type TRoomsMediaResponse = {
Expand Down
6 changes: 6 additions & 0 deletions app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@
"error-invalid-file-type": "Invalid file type",
"error-invalid-password": "Invalid password",
"error-invalid-room-name": "{{room_name}} is not a valid room name",
"error-invitation-reply-action": "Error while sending invitation reply",
"error-no-tokens-for-this-user": "There are no tokens for this user",
"error-not-allowed": "Not allowed",
"error-not-permission-to-upload-file": "You don't have permission to upload files",
Expand Down Expand Up @@ -416,6 +417,10 @@
"Invite_user_to_join_channel_all_from": "Invite all users from [#channel] to join this channel",
"Invite_user_to_join_channel_all_to": "Invite all users from this channel to join [#channel]",
"Invite_users": "Invite users",
"invited_room_description_channel": "You've been invited by",
"invited_room_description_dm": "You've been invited to have a conversation with",
"invited_room_title_channel": "Invitation to join {{room_name}}",
"invited_room_title_dm": "Message request",
"IP": "IP",
"is_typing": "is typing",
"Italic": "Italic",
Expand Down Expand Up @@ -684,6 +689,7 @@
"Recording_audio_in_progress": "Recording audio message",
"Register": "Register",
"Registration_Succeeded": "Registration succeeded!",
"reject": "Reject",
"Remove": "Remove",
"remove": "remove",
"Remove_from_room": "Remove from room",
Expand Down
8 changes: 7 additions & 1 deletion app/lib/database/model/Subscription.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ export default class Subscription extends Model {

@field('federated') federated;

@field('status') status;

@json('inviter', sanitizer) inviter;

asPlain() {
return {
_id: this._id,
Expand Down Expand Up @@ -219,7 +223,9 @@ export default class Subscription extends Model {
usersCount: this.usersCount,
source: this.source,
disableNotifications: this.disableNotifications,
federated: this.federated
federated: this.federated,
status: this.status,
inviter: this.inviter
};
}
}
12 changes: 12 additions & 0 deletions app/lib/database/model/migrations.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,18 @@ export default schemaMigrations({
columns: [{ name: 'federated', type: 'boolean', isOptional: true }]
})
]
},
{
toVersion: 28,
steps: [
addColumns({
table: 'subscriptions',
columns: [
{ name: 'status', type: 'string', isOptional: true },
{ name: 'inviter', type: 'string', isOptional: true }
]
})
]
}
]
});
6 changes: 4 additions & 2 deletions app/lib/database/schema/app.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb';

export default appSchema({
version: 27,
version: 28,
tables: [
tableSchema({
name: 'subscriptions',
Expand Down Expand Up @@ -70,7 +70,9 @@ export default appSchema({
{ name: 'users_count', type: 'number', isOptional: true },
{ name: 'unmuted', type: 'string', isOptional: true },
{ name: 'disable_notifications', type: 'boolean', isOptional: true },
{ name: 'federated', type: 'boolean', isOptional: true }
{ name: 'federated', type: 'boolean', isOptional: true },
{ name: 'status', type: 'string', isOptional: true },
{ name: 'inviter', type: 'string', isOptional: true }
]
}),
tableSchema({
Expand Down
21 changes: 21 additions & 0 deletions app/lib/methods/getInvitationData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { type IInviteSubscription } from '../../definitions';
import I18n from '../../i18n';
import { getRoomTitle } from './helpers';
import { replyRoomInvite } from './replyRoomInvite';

export const getInvitationData = (room: IInviteSubscription) => {
const title =
room.t === 'd'
? I18n.t('invited_room_title_dm')
: I18n.t('invited_room_title_channel', { room_name: getRoomTitle(room).slice(0, 30) });

const description = room.t === 'd' ? I18n.t('invited_room_description_dm') : I18n.t('invited_room_description_channel');

return {
title,
description,
inviter: room.inviter,
accept: () => replyRoomInvite(room.id, 'accept'),
reject: () => replyRoomInvite(room.id, 'reject')
};
};
3 changes: 3 additions & 0 deletions app/lib/methods/helpers/mergeSubscriptionsRooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '../../../definitions';
import { compareServerVersion } from './compareServerVersion';

// eslint-disable-next-line complexity
export const merge = (
subscription: ISubscription | IServerSubscription,
room?: IRoom | IServerRoom | IOmnichannelRoom
Expand Down Expand Up @@ -112,6 +113,8 @@ export const merge = (
mergedSubscription.autoTranslate = false;
}

mergedSubscription.status = mergedSubscription.status ?? undefined;
mergedSubscription.inviter = mergedSubscription.inviter ?? undefined;
mergedSubscription.blocker = !!mergedSubscription.blocker;
mergedSubscription.blocked = !!mergedSubscription.blocked;
mergedSubscription.hideMentionStatus = !!mergedSubscription.hideMentionStatus;
Expand Down
13 changes: 13 additions & 0 deletions app/lib/methods/replyRoomInvite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import i18n from '../../i18n';
import { sendInvitationReply } from '../services/restApi';
import { showErrorAlert } from './helpers';
import log from './helpers/log';

export const replyRoomInvite = async (rid: string, action: 'accept' | 'reject') => {
try {
await sendInvitationReply(rid, action);
} catch (e) {
showErrorAlert(i18n.t('error-invitation-reply-action'));
log(e);
}
};
2 changes: 2 additions & 0 deletions app/lib/services/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,8 @@ export function getUserInfo(userId: string) {

export const toggleFavorite = (roomId: string, favorite: boolean) => sdk.post('rooms.favorite', { roomId, favorite });

export const sendInvitationReply = (roomId: string, action: 'accept' | 'reject') => sdk.post('rooms.invite', { roomId, action });

export const videoConferenceJoin = (callId: string, cam?: boolean, mic?: boolean) =>
sdk.post('video-conference.join', { callId, state: { cam: !!cam, mic: mic === undefined ? true : mic } });

Expand Down
4 changes: 4 additions & 0 deletions app/views/RoomView/RightButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,10 @@ class RightButtonsContainer extends Component<IRightButtonsProps, IRigthButtonsS
return null;
}

if (status === 'INVITED') {
return null;
}

if (t === 'l') {
if (!this.isOmnichannelPreview()) {
return (
Expand Down
100 changes: 100 additions & 0 deletions app/views/RoomView/components/InvitedRoom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React, { type ReactElement } from 'react';
import { StyleSheet, Text, View } from 'react-native';

import { useTheme } from '../../../theme';
import { CustomIcon } from '../../../containers/CustomIcon';
import Button from '../../../containers/Button';
import sharedStyles from '../../Styles';
import I18n from '../../../i18n';
import type { IInviteSubscription } from '../../../definitions';
import Chip from '../../../containers/Chip';

const GAP = 32;

type InvitedRoomProps = {
title: string;
description: string;
inviter: IInviteSubscription['inviter'];
loading?: boolean;
onAccept: () => Promise<void>;
onReject: () => Promise<void>;
};

export const InvitedRoom = ({ title, description, inviter, loading, onAccept, onReject }: InvitedRoomProps): ReactElement => {
const { colors } = useTheme();
const styles = useStyle();

return (
<View style={styles.root}>
<View style={styles.container}>
<View style={styles.textView}>
<View style={styles.icon}>
<CustomIcon name='mail' size={42} color={colors.fontSecondaryInfo} />
</View>
<Text style={styles.title}>{title}</Text>
<Text style={styles.description}>{description}</Text>
<View style={styles.username}>
<Chip avatar={inviter.username} text={inviter.name || inviter.username} />
</View>
</View>
<Button title={I18n.t('accept')} loading={loading} onPress={onAccept} />
<Button
title={I18n.t('reject')}
type='secondary'
loading={loading}
backgroundColor={colors.surfaceTint}
onPress={onReject}
/>
</View>
</View>
);
};
Comment on lines 23 to 51
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Loading state is not wired up in the parent component.

The loading prop exists but is never passed from RoomView/index.tsx (line 1586). Users won't see feedback while accept/reject actions are in progress. Consider either:

  1. Managing loading state in the parent and passing it down
  2. Managing loading state internally in this component
🔎 Example: internal loading state
-export const InvitedRoom = ({ title, description, inviter, loading, onAccept, onReject }: InvitedRoomProps): ReactElement => {
+export const InvitedRoom = ({ title, description, inviter, onAccept, onReject }: InvitedRoomProps): ReactElement => {
 	const { colors } = useTheme();
 	const styles = useStyle();
+	const [loading, setLoading] = React.useState(false);
+
+	const handleAccept = async () => {
+		setLoading(true);
+		try {
+			await onAccept();
+		} finally {
+			setLoading(false);
+		}
+	};
+
+	const handleReject = async () => {
+		setLoading(true);
+		try {
+			await onReject();
+		} finally {
+			setLoading(false);
+		}
+	};
 
 	return (
 		// ...
-			<Button title={I18n.t('accept')} loading={loading} onPress={onAccept} />
+			<Button title={I18n.t('accept')} loading={loading} onPress={handleAccept} />
 			<Button
 				title={I18n.t('reject')}
 				type='secondary'
 				loading={loading}
 				backgroundColor={colors.surfaceTint}
-				onPress={onReject}
+				onPress={handleReject}
 			/>
 		// ...
 	);
 };
🤖 Prompt for AI Agents
In app/views/RoomView/components/InvitedRoom.tsx around lines 23-51: the
component accepts a loading prop but the parent (RoomView/index.tsx at ~line
1586) never passes it, so users get no feedback during accept/reject operations;
fix by either A) add loading state in the parent and pass it down: create a
boolean state (e.g., isInvitedActionLoading), set it true before awaiting the
accept/reject async call and false afterwards (handle errors and finally block),
then pass loading={isInvitedActionLoading} into <InvitedRoom>, or B) manage
loading internally in InvitedRoom: add a local useState loading flag, wrap
onAccept/onReject handlers to setLoading(true) before awaiting the provided
callback and setLoading(false) in finally, and use that state for the Button
loading prop; ensure callbacks remain async-safe and errors are handled.


const useStyle = () => {
const { colors } = useTheme();
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.surfaceRoom
},
container: {
flex: 1,
marginHorizontal: 24,
justifyContent: 'center'
},
textView: { alignItems: 'center' },
icon: {
width: 58,
height: 58,
borderRadius: 30,
marginBottom: GAP,
backgroundColor: colors.surfaceNeutral,
alignItems: 'center',
justifyContent: 'center'
},
title: {
...sharedStyles.textBold,
fontSize: 24,
lineHeight: 32,
textAlign: 'center',
color: colors.fontTitlesLabels,
marginBottom: GAP
},
description: {
...sharedStyles.textRegular,
fontSize: 16,
lineHeight: 24,
textAlign: 'center',
color: colors.fontDefault
},
username: {
...sharedStyles.textRegular,
fontSize: 16,
lineHeight: 24,
textAlign: 'center',
color: colors.fontDefault,
marginBottom: GAP
}
});
return styles;
};
4 changes: 3 additions & 1 deletion app/views/RoomView/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,7 @@ export const roomAttrsUpdate = [
'autoTranslateLanguage',
'unmuted',
'E2EKey',
'encrypted'
'encrypted',
'status',
'inviter'
] as TRoomUpdate[];
27 changes: 26 additions & 1 deletion app/views/RoomView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ import {
type TSubscriptionModel,
type IEmoji,
type TGetCustomEmoji,
type RoomType
type RoomType,
isInviteSubscription
} from '../../definitions';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/constants/keys';
import { MESSAGE_TYPE_ANY_LOAD, MessageTypeLoad } from '../../lib/constants/messageTypeLoad';
Expand Down Expand Up @@ -102,6 +103,8 @@ import UserPreferences from '../../lib/methods/userPreferences';
import { type IRoomViewProps, type IRoomViewState } from './definitions';
import { roomAttrsUpdate, stateAttrsUpdate } from './constants';
import { EncryptedRoom, MissingRoomE2EEKey } from './components';
import { InvitedRoom } from './components/InvitedRoom';
import { getInvitationData } from '../../lib/methods/getInvitationData';

class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
private rid?: string;
Expand Down Expand Up @@ -328,6 +331,11 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
) {
this.updateE2EEState();
}

// init() is skipped for invite subscriptions. Initialize when invite has been accepted
if (prevState.roomUpdate.status === 'INVITED' && roomUpdate.status !== 'INVITED') {
this.init();
}
}

updateOmnichannel = async () => {
Expand Down Expand Up @@ -535,6 +543,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
onPress={this.goRoomActionsView}
testID={`room-view-title-${title}`}
sourceType={sourceType}
disabled={isInviteSubscription(iSubRoom)}
/>
),
headerRight: () => (
Expand Down Expand Up @@ -637,6 +646,12 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
if (!this.rid) {
return;
}

if ('id' in room && isInviteSubscription(room)) {
this.setState({ loading: false });
return;
}

if (this.tmid) {
await loadThreadMessages({ tmid: this.tmid, rid: this.rid });
} else {
Expand Down Expand Up @@ -1563,6 +1578,16 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
({ bannerClosed, announcement } = room);
}

if ('id' in room && isInviteSubscription(room)) {
const { title, description, inviter, accept, reject } = getInvitationData(room);

return (
<SafeAreaView style={{ backgroundColor: themes[theme].surfaceRoom }} testID='room-view-invited'>
<InvitedRoom title={title} description={description} inviter={inviter} onAccept={accept} onReject={reject} />
</SafeAreaView>
);
}
Comment on lines +1581 to +1589
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add loading state management for invitation actions.

The InvitedRoom component accepts a loading prop (visible in the relevant snippet from InvitedRoom.tsx:22), but no loading state is passed at line 1586. When the user clicks accept or reject, they won't see any visual feedback until the status changes and the view updates. Add state to track the invitation reply in progress and pass it to InvitedRoom.

🔎 Suggested implementation

Add state for tracking invitation reply loading:

 	const { title, description, inviter, accept, reject } = getInvitationData(room);
+	const [invitationLoading, setInvitationLoading] = React.useState(false);
+	
+	const handleAccept = async () => {
+		setInvitationLoading(true);
+		try {
+			await accept();
+		} finally {
+			setInvitationLoading(false);
+		}
+	};
+	
+	const handleReject = async () => {
+		setInvitationLoading(true);
+		try {
+			await reject();
+		} finally {
+			setInvitationLoading(false);
+		}
+	};
 
 	return (
 		<SafeAreaView style={{ backgroundColor: themes[theme].surfaceRoom }} testID='room-view-invited'>
-			<InvitedRoom title={title} description={description} inviter={inviter} onAccept={accept} onReject={reject} />
+			<InvitedRoom 
+				title={title} 
+				description={description} 
+				inviter={inviter} 
+				loading={invitationLoading}
+				onAccept={handleAccept} 
+				onReject={handleReject} 
+			/>
 		</SafeAreaView>
 	);

Note: If RoomView is a class component (as it appears to be), use this.state.invitationLoading instead of useState.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In app/views/RoomView/index.tsx around lines 1581–1589, the InvitedRoom is
rendered without the loading prop even though it supports a loading state; add
an invitationLoading boolean to the RoomView component state
(this.state.invitationLoading) and pass it as
loading={this.state.invitationLoading} to InvitedRoom, and wrap the
accept/reject handlers returned by getInvitationData so they set this.setState({
invitationLoading: true }) before starting the async action and set
invitationLoading back to false after it completes or on error (ensure errors
are propagated/handled as before).


if ('encrypted' in room) {
// Missing room encryption key
if (showMissingE2EEKey) {
Expand Down
Loading