diff --git a/app/api/server/index.js b/app/api/server/index.js index 82d23266efb0..d07484217d9a 100644 --- a/app/api/server/index.js +++ b/app/api/server/index.js @@ -40,5 +40,6 @@ import './v1/custom-user-status'; import './v1/instances'; import './v1/banners'; import './v1/email-inbox'; +import './v1/teams'; export { API, APIClass, defaultRateLimiterOptions } from './api'; diff --git a/app/api/server/lib/rooms.js b/app/api/server/lib/rooms.js index 12d807f3cf4c..4a8737cb9ee3 100644 --- a/app/api/server/lib/rooms.js +++ b/app/api/server/lib/rooms.js @@ -1,5 +1,6 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { Rooms } from '../../../models/server/raw'; +import { Subscriptions } from '../../../models'; export async function findAdminRooms({ uid, filter, types = [], pagination: { offset, count, sort } }) { if (!await hasPermissionAsync(uid, 'view-room-administration')) { @@ -24,11 +25,15 @@ export async function findAdminRooms({ uid, filter, types = [], pagination: { of msgs: 1, archived: 1, tokenpass: 1, + teamId: 1, + teamMain: 1, }; const name = filter && filter.trim(); const discussion = types && types.includes('discussions'); - const showTypes = Array.isArray(types) ? types.filter((type) => type !== 'discussions') : []; + const includeTeams = types && types.includes('teams'); + const typesToRemove = ['discussions', 'teams']; + const showTypes = Array.isArray(types) ? types.filter((type) => !typesToRemove.includes(type)) : []; const options = { fields, sort: sort || { default: -1, name: 1 }, @@ -36,12 +41,14 @@ export async function findAdminRooms({ uid, filter, types = [], pagination: { of limit: count, }; - let cursor = Rooms.findByNameContaining(name, discussion, options); + let cursor; if (name && showTypes.length) { - cursor = Rooms.findByNameContainingAndTypes(name, showTypes, discussion, options); + cursor = Rooms.findByNameContainingAndTypes(name, showTypes, discussion, includeTeams, options); } else if (showTypes.length) { - cursor = Rooms.findByTypes(showTypes, discussion, options); + cursor = Rooms.findByTypes(showTypes, discussion, includeTeams, options); + } else { + cursor = Rooms.findByNameContaining(name, discussion, includeTeams, options); } const total = await cursor.count(); @@ -93,6 +100,7 @@ export async function findChannelAndPrivateAutocomplete({ uid, selector }) { const options = { fields: { _id: 1, + fname: 1, name: 1, t: 1, avatarETag: 1, @@ -102,8 +110,11 @@ export async function findChannelAndPrivateAutocomplete({ uid, selector }) { name: 1, }, }; + const userRooms = Subscriptions.cachedFindByUserId(uid, { fields: { rid: 1 } }) + .fetch() + .map((item) => item.rid); - const rooms = await Rooms.findChannelAndPrivateByNameStarting(selector.name, options).toArray(); + const rooms = await Rooms.findChannelAndPrivateByNameStarting(selector.name, userRooms, options).toArray(); return { items: rooms, diff --git a/app/api/server/v1/channels.js b/app/api/server/v1/channels.js index baf85dac4781..0c287890b8f5 100644 --- a/app/api/server/v1/channels.js +++ b/app/api/server/v1/channels.js @@ -2,11 +2,12 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { Rooms, Subscriptions, Messages, Uploads, Integrations, Users } from '../../../models'; -import { hasPermission, hasAtLeastOnePermission } from '../../../authorization/server'; +import { hasPermission, hasAtLeastOnePermission, hasAllPermission } from '../../../authorization/server'; import { mountIntegrationQueryBasedOnPermissions } from '../../../integrations/server/lib/mountQueriesBasedOnPermission'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { API } from '../api'; import { settings } from '../../../settings'; +import { Team } from '../../../../server/sdk'; // Returns the channel IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property @@ -184,6 +185,10 @@ function createChannelValidator(params) { if (params.customFields && params.customFields.value && !(typeof params.customFields.value === 'object')) { throw new Error(`Param "${ params.customFields.key }" must be an object if provided`); } + + if (params.teams.value && !Array.isArray(params.teams.value)) { + throw new Error(`Param ${ params.teams.key } must be an array`); + } } function createChannel(userId, params) { @@ -220,6 +225,10 @@ API.v1.addRoute('channels.create', { authRequired: true }, { value: bodyParams.members, key: 'members', }, + teams: { + value: bodyParams.teams, + key: 'teams', + }, }); } catch (e) { if (e.message === 'unauthorized') { @@ -233,6 +242,21 @@ API.v1.addRoute('channels.create', { authRequired: true }, { return error; } + if (bodyParams.teams) { + const canSeeAllTeams = hasPermission(this.userId, 'view-all-teams'); + const teams = Promise.await(Team.listByNames(bodyParams.teams, { projection: { _id: 1 } })); + const teamMembers = []; + + for (const team of teams) { + const { records: members } = Promise.await(Team.members(this.userId, team._id, undefined, canSeeAllTeams, { offset: 0, count: Number.MAX_SAFE_INTEGER })); + const uids = members.map((member) => member.user.username); + teamMembers.push(...uids); + } + + const membersToAdd = new Set([...teamMembers, ...bodyParams.members]); + bodyParams.members = [...membersToAdd]; + } + return API.v1.success(API.channels.create.execute(userId, bodyParams)); }, }); @@ -472,6 +496,24 @@ API.v1.addRoute('channels.list', { authRequired: true }, { ourQuery._id = { $in: roomIds }; } + // teams filter - I would love to have a way to apply this filter @ db level :( + const ids = Subscriptions.cachedFindByUserId(this.userId, { fields: { rid: 1 } }) + .fetch() + .map((item) => item.rid); + + ourQuery.$or = [{ + teamId: { + $exists: false, + }, + }, { + teamId: { + $exists: true, + }, + _id: { + $in: ids, + }, + }]; + const cursor = Rooms.find(ourQuery, { sort: sort || { name: 1 }, skip: offset, @@ -1050,3 +1092,51 @@ API.v1.addRoute('channels.anonymousread', { authRequired: false }, { }); }, }); + +API.v1.addRoute('channels.convertToTeam', { authRequired: true }, { + post() { + if (!hasAllPermission(this.userId, ['create-team', 'edit-room'])) { + return API.v1.unauthorized(); + } + + const { channelId, channelName } = this.bodyParams; + + if (!channelId && !channelName) { + return API.v1.failure('The parameter "channelId" or "channelName" is required'); + } + + const room = findChannelByIdOrName({ + params: { + roomId: channelId, + roomName: channelName, + }, + userId: this.userId, + }); + + if (!room) { + return API.v1.failure('Channel not found'); + } + + const subscriptions = Subscriptions.findByRoomId(room._id, { + fields: { 'u._id': 1 }, + }); + + const members = subscriptions.fetch().map((s) => s.u && s.u._id); + + const teamData = { + team: { + name: room.name, + type: room.t === 'c' ? 0 : 1, + }, + members, + room: { + name: room.name, + id: room._id, + }, + }; + + const team = Promise.await(Team.create(this.userId, teamData)); + + return API.v1.success({ team }); + }, +}); diff --git a/app/api/server/v1/groups.js b/app/api/server/v1/groups.js index c3f41db768f7..ec62530be526 100644 --- a/app/api/server/v1/groups.js +++ b/app/api/server/v1/groups.js @@ -4,9 +4,10 @@ import { Match } from 'meteor/check'; import { mountIntegrationQueryBasedOnPermissions } from '../../../integrations/server/lib/mountQueriesBasedOnPermission'; import { Subscriptions, Rooms, Messages, Uploads, Integrations, Users } from '../../../models/server'; -import { hasPermission, hasAtLeastOnePermission, canAccessRoom } from '../../../authorization/server'; +import { hasPermission, hasAtLeastOnePermission, canAccessRoom, hasAllPermission } from '../../../authorization/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { API } from '../api'; +import { Team } from '../../../../server/sdk'; // Returns the private group subscription IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property export function findPrivateGroupByIdOrName({ params, userId, checkedArchived = true }) { @@ -840,3 +841,51 @@ API.v1.addRoute('groups.setEncrypted', { authRequired: true }, { }); }, }); + +API.v1.addRoute('groups.convertToTeam', { authRequired: true }, { + post() { + if (!hasAllPermission(this.userId, ['create-team', 'edit-room'])) { + return API.v1.unauthorized(); + } + + const { roomId, roomName } = this.requestParams(); + + if (!roomId && !roomName) { + return API.v1.failure('The parameter "roomId" or "roomName" is required'); + } + + const room = findPrivateGroupByIdOrName({ + params: { + roomId, + roomName, + }, + userId: this.userId, + }); + + if (!room) { + return API.v1.failure('Private group not found'); + } + + const subscriptions = Subscriptions.findByRoomId(room.rid, { + fields: { 'u._id': 1 }, + }); + + const members = subscriptions.fetch().map((s) => s.u && s.u._id); + + const teamData = { + team: { + name: room.name, + type: 1, + }, + members, + room: { + name: room.name, + id: room.rid, + }, + }; + + const team = Promise.await(Team.create(this.userId, teamData)); + + return API.v1.success({ team }); + }, +}); diff --git a/app/api/server/v1/teams.ts b/app/api/server/v1/teams.ts new file mode 100644 index 000000000000..9932e6be998e --- /dev/null +++ b/app/api/server/v1/teams.ts @@ -0,0 +1,321 @@ +import { Promise } from 'meteor/promise'; + +import { API } from '../api'; +import { Team } from '../../../../server/sdk'; +import { hasAtLeastOnePermission, hasPermission } from '../../../authorization/server'; +import { Rooms, Subscriptions } from '../../../models/server'; + +API.v1.addRoute('teams.list', { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { sort, query } = this.parseJsonQuery(); + + const { records, total } = Promise.await(Team.list(this.userId, { offset, count }, { sort, query })); + + return API.v1.success({ + teams: records, + total, + count: records.length, + offset, + }); + }, +}); + +API.v1.addRoute('teams.listAll', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'view-all-teams')) { + return API.v1.unauthorized(); + } + + const { offset, count } = this.getPaginationItems(); + + const { records, total } = Promise.await(Team.listAll({ offset, count })); + + return API.v1.success({ + teams: records, + total, + count: records.length, + offset, + }); + }, +}); + +API.v1.addRoute('teams.create', { authRequired: true }, { + post() { + if (!hasPermission(this.userId, 'create-team')) { + return API.v1.unauthorized(); + } + const { name, type, members, room, owner } = this.bodyParams; + + if (!name) { + return API.v1.failure('Body param "name" is required'); + } + + const team = Promise.await(Team.create(this.userId, { + team: { + name, + type, + }, + room, + members, + owner, + })); + + return API.v1.success({ team }); + }, +}); + +API.v1.addRoute('teams.addRoom', { authRequired: true }, { + post() { + const { roomId, teamId, isDefault } = this.bodyParams; + + if (!hasPermission(this.userId, 'add-team-channel')) { + return API.v1.unauthorized(); + } + + const room = Promise.await(Team.addRoom(this.userId, roomId, teamId, isDefault)); + + return API.v1.success({ room }); + }, +}); + +API.v1.addRoute('teams.addRooms', { authRequired: true }, { + post() { + const { rooms, teamId } = this.bodyParams; + + if (!hasPermission(this.userId, 'add-team-channel')) { + return API.v1.unauthorized(); + } + + const validRooms = Promise.await(Team.addRooms(this.userId, rooms, teamId)); + + return API.v1.success({ rooms: validRooms }); + }, +}); + +API.v1.addRoute('teams.removeRoom', { authRequired: true }, { + post() { + const { roomId, teamId } = this.bodyParams; + + if (!hasPermission(this.userId, 'remove-team-channel')) { + return API.v1.unauthorized(); + } + + const canRemoveAny = !!hasPermission(this.userId, 'view-all-team-channels'); + + const room = Promise.await(Team.removeRoom(this.userId, roomId, teamId, canRemoveAny)); + + return API.v1.success({ room }); + }, +}); + +API.v1.addRoute('teams.updateRoom', { authRequired: true }, { + post() { + const { roomId, isDefault } = this.bodyParams; + + if (!hasPermission(this.userId, 'edit-team-channel')) { + return API.v1.unauthorized(); + } + const canUpdateAny = !!hasPermission(this.userId, 'view-all-team-channels'); + + const room = Promise.await(Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny)); + + return API.v1.success({ room }); + }, +}); + +API.v1.addRoute('teams.listRooms', { authRequired: true }, { + get() { + const { teamId } = this.queryParams; + const { offset, count } = this.getPaginationItems(); + + const allowPrivateTeam = hasPermission(this.userId, 'view-all-teams'); + + let getAllRooms = false; + if (hasPermission(this.userId, 'view-all-team-channels')) { + getAllRooms = true; + } + + const { records, total } = Promise.await(Team.listRooms(this.userId, teamId, getAllRooms, allowPrivateTeam, { offset, count })); + + return API.v1.success({ + rooms: records, + total, + count: records.length, + offset, + }); + }, +}); + +API.v1.addRoute('teams.listRoomsOfUser', { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { teamId, userId } = this.queryParams; + + const allowPrivateTeam = hasPermission(this.userId, 'view-all-teams'); + + if (!hasPermission(this.userId, 'view-all-team-channels')) { + return API.v1.unauthorized(); + } + + const { records, total } = Promise.await(Team.listRoomsOfUser(this.userId, teamId, userId, allowPrivateTeam, { offset, count })); + + return API.v1.success({ + rooms: records, + total, + count: records.length, + offset: 0, + }); + }, +}); + +API.v1.addRoute('teams.members', { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { teamId, teamName } = this.queryParams; + const { query } = this.parseJsonQuery(); + const canSeeAllMembers = hasPermission(this.userId, 'view-all-teams'); + + const { records, total } = Promise.await(Team.members(this.userId, teamId, teamName, canSeeAllMembers, { offset, count }, { query })); + + return API.v1.success({ + members: records, + total, + count: records.length, + offset, + }); + }, +}); + +API.v1.addRoute('teams.addMembers', { authRequired: true }, { + post() { + if (!hasAtLeastOnePermission(this.userId, ['add-team-member', 'edit-team-member'])) { + return API.v1.unauthorized(); + } + + const { teamId, teamName, members } = this.bodyParams; + + Promise.await(Team.addMembers(this.userId, teamId, teamName, members)); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('teams.updateMember', { authRequired: true }, { + post() { + if (!hasAtLeastOnePermission(this.userId, ['edit-team-member'])) { + return API.v1.unauthorized(); + } + + const { teamId, teamName, member } = this.bodyParams; + + Promise.await(Team.updateMember(teamId, teamName, member)); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('teams.removeMembers', { authRequired: true }, { + post() { + if (!hasAtLeastOnePermission(this.userId, ['edit-team-member'])) { + return API.v1.unauthorized(); + } + + const { teamId, teamName, members, rooms } = this.bodyParams; + + Promise.await(Team.removeMembers(teamId, teamName, members)); + + if (rooms?.length) { + Subscriptions.removeByRoomIdsAndUserId(rooms, this.userId); + } + + return API.v1.success(); + }, +}); + +API.v1.addRoute('teams.leave', { authRequired: true }, { + post() { + const { teamId, teamName, rooms } = this.bodyParams; + + Promise.await(Team.removeMembers(teamId, teamName, [{ + userId: this.userId, + }])); + + if (rooms?.length) { + Subscriptions.removeByRoomIdsAndUserId(rooms, this.userId); + } + + return API.v1.success(); + }, +}); + +API.v1.addRoute('teams.info', { authRequired: true }, { + get() { + const { teamId, teamName } = this.queryParams; + + if (!teamId && !teamName) { + return API.v1.failure('Provide either the "teamId" or "teamName"'); + } + + const teamInfo = teamId + ? Promise.await(Team.getInfoById(teamId)) + : Promise.await(Team.getInfoByName(teamName)); + + if (!teamInfo) { + return API.v1.failure('Team not found'); + } + + return API.v1.success({ teamInfo }); + }, +}); + +API.v1.addRoute('teams.delete', { authRequired: true }, { + post() { + if (!hasPermission(this.userId, 'delete-team')) { + return API.v1.unauthorized(); + } + + const { teamId, teamName, roomsToRemove } = this.bodyParams; + + if (!teamId && !teamName) { + return API.v1.failure('Provide either the "teamId" or "teamName"'); + } + + if (roomsToRemove && !Array.isArray(roomsToRemove)) { + return API.v1.failure('The list of rooms to remove is invalid.'); + } + + const team = teamId ? Promise.await(Team.getOneById(teamId)) : Promise.await(Team.getOneByName(teamName)); + if (!team) { + return API.v1.failure('Team not found.'); + } + + const rooms = Promise.await(Team.getMatchingTeamRooms(team._id, roomsToRemove)); + + // Remove the team's main room + Rooms.removeById(team.roomId); + + // If we got a list of rooms to delete along with the team, remove them first + if (rooms.length) { + Rooms.removeByIds(rooms); + } + + // Move every other room back to the workspace + Promise.await(Team.unsetTeamIdOfRooms(team._id)); + + // And finally delete the team itself + Promise.await(Team.deleteById(team._id)); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('teams.autocomplete', { authRequired: true }, { + get() { + const { name, userId } = this.queryParams; + + const teams = Promise.await(Team.autocomplete(userId, name)); + + return API.v1.success({ teams }); + }, +}); diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index 27512e9bfadd..40151040e6d8 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -25,6 +25,7 @@ import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; import { setUserStatus } from '../../../../imports/users-presence/server/activeUsers'; import { resetTOTP } from '../../../2fa/server/functions/resetTOTP'; +import { Team } from '../../../../server/sdk'; API.v1.addRoute('users.create', { authRequired: true }, { post() { @@ -909,3 +910,18 @@ API.v1.addRoute('users.resetTOTP', { authRequired: true, twoFactorRequired: true return API.v1.success(); }, }); + +API.v1.addRoute('users.listTeams', { authRequired: true }, { + get() { + const { userId } = this.bodyParams; + + // If the caller has permission to view all teams, there's no need to filter the teams + const adminId = hasPermission(this.userId, 'view-all-teams') ? '' : this.userId; + + const teams = Promise.await(Team.findBySubscribedUserIds(userId, adminId)); + + return API.v1.success({ + teams, + }); + }, +}); diff --git a/app/apps/client/gameCenter/tabBar.ts b/app/apps/client/gameCenter/tabBar.ts index a110c45bca12..264800c935bb 100644 --- a/app/apps/client/gameCenter/tabBar.ts +++ b/app/apps/client/gameCenter/tabBar.ts @@ -18,7 +18,7 @@ addAction('game-center', () => { && !hasError && hasExternalComponents ? { - groups: ['channel', 'group', 'direct'], + groups: ['channel', 'group', 'direct', 'team'], id: 'game-center', title: 'Apps_Game_Center', icon: 'game', diff --git a/app/authorization/server/startup.js b/app/authorization/server/startup.js index 759c363bb3d5..2b1b423c7539 100644 --- a/app/authorization/server/startup.js +++ b/app/authorization/server/startup.js @@ -124,6 +124,16 @@ Meteor.startup(function() { { _id: 'send-omnichannel-chat-transcript', roles: ['livechat-manager', 'admin'] }, { _id: 'mail-messages', roles: ['admin'] }, { _id: 'toggle-room-e2e-encryption', roles: ['owner'] }, + { _id: 'create-team', roles: ['admin', 'user'] }, + { _id: 'delete-team', roles: ['admin', 'team-owner'] }, + { _id: 'edit-team', roles: ['admin', 'team-owner'] }, + { _id: 'add-team-member', roles: ['admin', 'team-owner', 'team-moderator'] }, + { _id: 'edit-team-member', roles: ['admin', 'team-owner', 'team-moderator'] }, + { _id: 'add-team-channel', roles: ['admin', 'team-owner', 'team-moderator'] }, + { _id: 'edit-team-channel', roles: ['admin', 'team-owner', 'team-moderator'] }, + { _id: 'remove-team-channel', roles: ['admin', 'team-owner', 'team-moderator'] }, + { _id: 'view-all-team-channels', roles: ['admin', 'team-owner'] }, + { _id: 'view-all-teams', roles: ['admin'] }, ]; for (const permission of permissions) { @@ -135,6 +145,9 @@ Meteor.startup(function() { { name: 'moderator', scope: 'Subscriptions', description: 'Moderator' }, { name: 'leader', scope: 'Subscriptions', description: 'Leader' }, { name: 'owner', scope: 'Subscriptions', description: 'Owner' }, + { name: 'team-owner', scope: 'Subscriptions', description: 'Team Owner' }, + { name: 'team-moderator', scope: 'Subscriptions', description: 'Team Moderator' }, + { name: 'team-leader', scope: 'Subscriptions', description: 'Team Leader' }, { name: 'user', scope: 'Users', description: '' }, { name: 'bot', scope: 'Users', description: '' }, { name: 'app', scope: 'Users', description: '' }, diff --git a/app/autotranslate/client/lib/tabBar.ts b/app/autotranslate/client/lib/tabBar.ts index eb65fabc8287..57363564129c 100644 --- a/app/autotranslate/client/lib/tabBar.ts +++ b/app/autotranslate/client/lib/tabBar.ts @@ -8,7 +8,7 @@ addAction('autotranslate', () => { const hasPermission = usePermission('auto-translate'); const autoTranslateEnabled = useSetting('AutoTranslate_Enabled'); return useMemo(() => (hasPermission && autoTranslateEnabled ? { - groups: ['channel', 'group', 'direct'], + groups: ['channel', 'group', 'direct', 'team'], id: 'autotranslate', title: 'Auto_Translate', icon: 'language', diff --git a/app/discussion/client/tabBar.ts b/app/discussion/client/tabBar.ts index e41c36d8bf65..bcdfb59b8034 100644 --- a/app/discussion/client/tabBar.ts +++ b/app/discussion/client/tabBar.ts @@ -9,7 +9,7 @@ addAction('discussions', () => { const discussionEnabled = useSetting('Discussion_enabled'); return useMemo(() => (discussionEnabled ? { - groups: ['channel', 'group', 'direct'], + groups: ['channel', 'group', 'direct', 'team'], id: 'discussions', title: 'Discussions', icon: 'discussion', diff --git a/app/e2e/client/tabbar.ts b/app/e2e/client/tabbar.ts index 8a45bf4bdb1b..e3d23015dd3e 100644 --- a/app/e2e/client/tabbar.ts +++ b/app/e2e/client/tabbar.ts @@ -21,7 +21,7 @@ addAction('e2e', ({ room }) => { const enabledOnRoom = !!room.encrypted; return useMemo(() => (e2eEnabled && hasPermission ? { - groups: ['direct', 'group'], + groups: ['direct', 'group', 'team'], id: 'e2e', title: enabledOnRoom ? 'E2E_disable' : 'E2E_enable', icon: 'key', diff --git a/app/lib/server/functions/addUserToRoom.js b/app/lib/server/functions/addUserToRoom.js index 69b7bd21f0ad..1d82e7b1b54d 100644 --- a/app/lib/server/functions/addUserToRoom.js +++ b/app/lib/server/functions/addUserToRoom.js @@ -4,6 +4,7 @@ import { Meteor } from 'meteor/meteor'; import { AppEvents, Apps } from '../../../apps/server'; import { callbacks } from '../../../callbacks'; import { Messages, Rooms, Subscriptions } from '../../../models'; +import { Team } from '../../../../server/sdk'; import { RoomMemberActions, roomTypes } from '../../../utils/server'; export const addUserToRoom = function(rid, user, inviter, silenced) { @@ -84,5 +85,10 @@ export const addUserToRoom = function(rid, user, inviter, silenced) { }); } + if (room.teamMain && room.teamId) { + // if user is joining to main team channel, create a membership + Promise.await(Team.addMember(inviter, user._id, room.teamId)); + } + return true; }; diff --git a/app/lib/server/functions/createRoom.js b/app/lib/server/functions/createRoom.js index 8bc4c170b67f..5d61b5e8d04b 100644 --- a/app/lib/server/functions/createRoom.js +++ b/app/lib/server/functions/createRoom.js @@ -9,9 +9,10 @@ import { callbacks } from '../../../callbacks'; import { Rooms, Subscriptions, Users } from '../../../models'; import { getValidRoomName } from '../../../utils'; import { createDirectRoom } from './createDirectRoom'; +import { Team } from '../../../../server/sdk'; -export const createRoom = function(type, name, owner, members = [], readOnly, extraData = {}, options = {}) { +export const createRoom = function(type, name, owner, members = [], readOnly, { teamId, ...extraData } = {}, options = {}) { callbacks.run('beforeCreateRoom', { type, name, owner, members, readOnly, extraData, options }); if (type === 'd') { @@ -64,6 +65,13 @@ export const createRoom = function(type, name, owner, members = [], readOnly, ex ro: readOnly === true, }; + if (teamId) { + const team = Promise.await(Team.getOneById(teamId, { projection: { _id: 1 } })); + if (team) { + room.teamId = team._id; + } + } + room._USERNAMES = members; const prevent = Promise.await(Apps.triggerEvent('IPreRoomCreatePrevent', room).catch((error) => { diff --git a/app/lib/server/functions/removeUserFromRoom.js b/app/lib/server/functions/removeUserFromRoom.js index 23ca26355af4..823f82ba9881 100644 --- a/app/lib/server/functions/removeUserFromRoom.js +++ b/app/lib/server/functions/removeUserFromRoom.js @@ -4,6 +4,7 @@ import { Meteor } from 'meteor/meteor'; import { Rooms, Messages, Subscriptions } from '../../../models'; import { AppEvents, Apps } from '../../../apps/server'; import { callbacks } from '../../../callbacks'; +import { Team } from '../../../../server/sdk'; export const removeUserFromRoom = function(rid, user, options = {}) { const room = Rooms.findOneById(rid); @@ -40,6 +41,10 @@ export const removeUserFromRoom = function(rid, user, options = {}) { Subscriptions.removeByRoomIdAndUserId(rid, user._id); + if (room.teamId && room.teamMain) { + Promise.await(Team.removeMember(room.teamId, user._id)); + } + Meteor.defer(function() { // TODO: CACHE: maybe a queue? callbacks.run('afterLeaveRoom', user, room); diff --git a/app/livestream/client/tabBar.tsx b/app/livestream/client/tabBar.tsx index 6020b8f6daf4..f3d391985780 100644 --- a/app/livestream/client/tabBar.tsx +++ b/app/livestream/client/tabBar.tsx @@ -13,7 +13,7 @@ addAction('livestream', ({ room }) => { const isLive = room && room.streamingOptions && room.streamingOptions.id && room.streamingOptions.type === 'livestream'; return useMemo(() => (enabled ? { - groups: ['channel', 'group'], + groups: ['channel', 'group', 'team'], id: 'livestream', title: 'Livestream', icon: 'podcast', diff --git a/app/mentions-flextab/client/tabBar.ts b/app/mentions-flextab/client/tabBar.ts index 5c47f6bd0caa..fa88a0f2d96d 100644 --- a/app/mentions-flextab/client/tabBar.ts +++ b/app/mentions-flextab/client/tabBar.ts @@ -1,7 +1,7 @@ import { addAction } from '../../../client/views/room/lib/Toolbox'; addAction('mentions', { - groups: ['channel', 'group'], + groups: ['channel', 'group', 'team'], id: 'mentions', title: 'Mentions', icon: 'at', diff --git a/app/message-pin/client/tabBar.ts b/app/message-pin/client/tabBar.ts index 8fcbbc1b8b54..cc0eb2abfccf 100644 --- a/app/message-pin/client/tabBar.ts +++ b/app/message-pin/client/tabBar.ts @@ -6,7 +6,7 @@ import { useSetting } from '../../../client/contexts/SettingsContext'; addAction('pinned-messages', () => { const pinningAllowed = useSetting('Message_AllowPinning'); return useMemo(() => (pinningAllowed ? { - groups: ['channel', 'group', 'direct'], + groups: ['channel', 'group', 'direct', 'team'], id: 'pinned-messages', title: 'Pinned_Messages', icon: 'pin', diff --git a/app/message-snippet/client/tabBar/tabBar.ts b/app/message-snippet/client/tabBar/tabBar.ts index 4139a842512e..b6f0df301b8a 100644 --- a/app/message-snippet/client/tabBar/tabBar.ts +++ b/app/message-snippet/client/tabBar/tabBar.ts @@ -6,7 +6,7 @@ import { useSetting } from '../../../../client/contexts/SettingsContext'; addAction('snippeted-messages', () => { const snippetingEnabled = useSetting('Message_AllowSnippeting'); return useMemo(() => (snippetingEnabled ? { - groups: ['channel', 'group', 'direct'], + groups: ['channel', 'group', 'direct', 'team'], id: 'snippeted-messages', title: 'snippet-message', icon: 'code', diff --git a/app/message-star/client/tabBar.ts b/app/message-star/client/tabBar.ts index 904186ba5d56..e86ccacfa28e 100644 --- a/app/message-star/client/tabBar.ts +++ b/app/message-star/client/tabBar.ts @@ -1,7 +1,7 @@ import { addAction } from '../../../client/views/room/lib/Toolbox'; addAction('starred-messages', { - groups: ['channel', 'group', 'direct', 'live'], + groups: ['channel', 'group', 'direct', 'live', 'team'], id: 'starred-messages', title: 'Starred_Messages', icon: 'star', diff --git a/app/models/server/models/Rooms.js b/app/models/server/models/Rooms.js index d1eb3cf486bd..581638022caf 100644 --- a/app/models/server/models/Rooms.js +++ b/app/models/server/models/Rooms.js @@ -40,7 +40,6 @@ export class Rooms extends Base { return this.findOne(query, options); } - setJitsiTimeout(_id, time) { const query = { _id, @@ -133,7 +132,7 @@ export class Rooms extends Base { } setLastMessageSnippeted(roomId, message, snippetName, snippetedBy, snippeted, snippetedAt) { - const query = { _id: roomId }; + const query = { _id: roomId }; const msg = `\`\`\`${ message.msg }\`\`\``; @@ -261,7 +260,6 @@ export class Rooms extends Base { return this.update({ _id }, update); } - setSystemMessagesById = function(_id, systemMessages) { const query = { _id, @@ -275,6 +273,7 @@ export class Rooms extends Base { sysMes: '', }, }; + return this.update(query, update); } @@ -339,6 +338,9 @@ export class Rooms extends Base { const query = { name, t: type, + teamId: { + $exists: false, + }, }; return this.findOne(query, options); @@ -388,20 +390,36 @@ export class Rooms extends Base { } findBySubscriptionUserId(userId, options) { - const data = Subscriptions.findByUserId(userId, { fields: { rid: 1 } }).fetch() + const data = Subscriptions.cachedFindByUserId(userId, { fields: { rid: 1 } }) + .fetch() .map((item) => item.rid); const query = { _id: { $in: data, }, + $or: [{ + teamId: { + $exists: false, + }, + }, { + teamId: { + $exists: true, + }, + _id: { + $in: data, + }, + }], }; return this.find(query, options); } findBySubscriptionTypeAndUserId(type, userId, options) { - const data = Subscriptions.findByUserIdAndType(userId, type, { fields: { rid: 1 } }).fetch() + const data = Subscriptions.findByUserIdAndType(userId, type, { + fields: { rid: 1 }, + }) + .fetch() .map((item) => item.rid); const query = { @@ -415,7 +433,8 @@ export class Rooms extends Base { } findBySubscriptionUserIdUpdatedAfter(userId, _updatedAt, options) { - const ids = Subscriptions.findByUserId(userId, { fields: { rid: 1 } }).fetch() + const ids = Subscriptions.findByUserId(userId, { fields: { rid: 1 } }) + .fetch() .map((item) => item.rid); const query = { @@ -425,6 +444,18 @@ export class Rooms extends Base { _updatedAt: { $gt: _updatedAt, }, + $or: [{ + teamId: { + $exists: false, + }, + }, { + teamId: { + $exists: true, + }, + _id: { + $in: ids, + }, + }], }; return this.find(query, options); @@ -478,6 +509,9 @@ export class Rooms extends Base { findByNameOrFNameAndType(name, type, options) { const query = { t: type, + teamId: { + $exists: false, + }, $or: [{ name, }, { @@ -496,6 +530,9 @@ export class Rooms extends Base { default: { $ne: true, }, + teamId: { + $exists: false, + }, }; // do not use cache @@ -510,6 +547,21 @@ export class Rooms extends Base { t: { $in: types, }, + $or: [ + { + teamId: { + $exists: false, + }, + }, + { + teamId: { + $exists: true, + }, + _id: { + $in: ids, + }, + }, + ], name, }; @@ -517,7 +569,7 @@ export class Rooms extends Base { return this._db.find(query, options); } - findChannelAndPrivateByNameStarting(name, options) { + findChannelAndPrivateByNameStarting(name, sIds, options) { const nameRegex = new RegExp(`^${ s.trim(escapeRegExp(name)) }`, 'i'); const query = { @@ -525,6 +577,21 @@ export class Rooms extends Base { $in: ['c', 'p'], }, name: nameRegex, + teamMain: { + $exists: false, + }, + $or: [{ + teamId: { + $exists: false, + }, + }, { + teamId: { + $exists: true, + }, + _id: { + $in: sIds, + }, + }], }; return this.find(query, options); @@ -572,10 +639,7 @@ export class Rooms extends Base { findByTypeAndNameOrId(type, identifier, options) { const query = { t: type, - $or: [ - { name: identifier }, - { _id: identifier }, - ], + $or: [{ name: identifier }, { _id: identifier }], }; return this.findOne(query, options); @@ -710,7 +774,9 @@ export class Rooms extends Base { } incMsgCountAndSetLastMessageById(_id, inc, lastMessageTimestamp, lastMessage) { - if (inc == null) { inc = 1; } + if (inc == null) { + inc = 1; + } const query = { _id }; const update = { @@ -745,6 +811,22 @@ export class Rooms extends Base { return this.update(query, update); } + incUsersCountByIds(ids, inc = 1) { + const query = { + _id: { + $in: ids, + }, + }; + + const update = { + $inc: { + usersCount: inc, + }, + }; + + return this.update(query, update, { multi: true }); + } + incUsersCountNotDMsByIds(ids, inc = 1) { const query = { _id: { @@ -978,7 +1060,7 @@ export class Rooms extends Base { const update = { ...favorite && defaultValue && { $set: { favorite } }, - ...(!favorite || !defaultValue) && { $unset: { favorite: 1 } }, + ...(!favorite || !defaultValue) && { $unset: { favorite: 1 } }, }; return this.update(query, update); diff --git a/app/models/server/models/Subscriptions.js b/app/models/server/models/Subscriptions.js index fcd24f02c1dc..c8d1033ee4ff 100644 --- a/app/models/server/models/Subscriptions.js +++ b/app/models/server/models/Subscriptions.js @@ -1,6 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Match } from 'meteor/check'; import _ from 'underscore'; +import mem from 'mem'; import { Base } from './_Base'; import Rooms from './Rooms'; @@ -422,11 +423,13 @@ export class Subscriptions extends Base { // FIND findByUserId(userId, options) { - const query = { 'u._id': userId }; + const query = { 'u._id': userId }; return this.find(query, options); } + cachedFindByUserId = mem(this.findByUserId.bind(this), { maxAge: 5000 }); + findByUserIdExceptType(userId, typeException, options) { const query = { 'u._id': userId, @@ -436,6 +439,19 @@ export class Subscriptions extends Base { return this.find(query, options); } + findByUserIdWithRoomInfo(userId, options) { + const userSubs = this.find({ 'u._id': userId }, options); + const roomIds = userSubs.map((sub) => sub.rid); + const rooms = Rooms.findByIds(roomIds, { projection: { _id: 1, teamId: 1, teamMain: 1 } }).fetch(); + + return userSubs.map((sub) => { + const roomSub = rooms.find((r) => r._id === sub.rid); + sub.teamMain = roomSub?.teamMain || false; + sub.teamId = roomSub?.teamId || undefined; + return sub; + }); + } + findByUserIdAndType(userId, type, options) { const query = { 'u._id': userId, @@ -592,7 +608,7 @@ export class Subscriptions extends Base { // UPDATE archiveByRoomId(roomId) { - const query = { rid: roomId }; + const query = { rid: roomId }; const update = { $set: { @@ -1258,6 +1274,18 @@ export class Subscriptions extends Base { return result; } + removeByRoomIdsAndUserId(rids, userId) { + const result = this.remove({ rid: { $in: rids }, 'u._id': userId }); + + if (Match.test(result, Number) && result > 0) { + Rooms.incUsersCountByIds(rids, -1); + } + + Users.removeRoomsByRoomIdsAndUserId(rids, userId); + + return result; + } + // ////////////////////////////////////////////////////////////////// // threads diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 6d9cf9d972b1..ad9929c6b262 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -443,6 +443,15 @@ export class Users extends Base { }, { multi: true }); } + removeRoomsByRoomIdsAndUserId(rids, userId) { + return this.update({ + _id: userId, + __rooms: { $in: rids }, + }, { + $pullAll: { __rooms: rids }, + }, { multi: true }); + } + update2FABackupCodesByUserId(userId, backupCodes) { return this.update({ _id: userId, diff --git a/app/models/server/raw/BaseRaw.ts b/app/models/server/raw/BaseRaw.ts index f26ef02922b4..ff7ef38c98a8 100644 --- a/app/models/server/raw/BaseRaw.ts +++ b/app/models/server/raw/BaseRaw.ts @@ -6,6 +6,7 @@ import { FilterQuery, FindOneOptions, InsertOneWriteOpResult, + InsertWriteOpResult, ObjectID, ObjectId, OptionalId, @@ -113,6 +114,19 @@ export class BaseRaw implements IBaseRaw { return this.col.updateMany(filter, update, options); } + insertMany(docs: Array>, options?: CollectionInsertOneOptions): Promise>> { + docs = docs.map((doc) => { + if (!doc._id || typeof doc._id !== 'string') { + const oid = new ObjectID(); + return { _id: oid.toHexString(), ...doc }; + } + return doc; + }); + + // TODO reavaluate following type casting + return this.col.insertMany(docs as unknown as Array>, options); + } + insertOne(doc: ModelOptionalId, options?: CollectionInsertOneOptions): Promise>> { if (!doc._id || typeof doc._id !== 'string') { const oid = new ObjectID(); diff --git a/app/models/server/raw/Rooms.js b/app/models/server/raw/Rooms.js index c971dc0b5165..768521735d92 100644 --- a/app/models/server/raw/Rooms.js +++ b/app/models/server/raw/Rooms.js @@ -2,15 +2,25 @@ import { escapeRegExp } from '../../../../lib/escapeRegExp'; import { BaseRaw } from './BaseRaw'; export class RoomsRaw extends BaseRaw { - findOneByRoomIdAndUserId(rid, uid, options) { + findOneByRoomIdAndUserId(rid, uid, options = {}) { const query = { - rid, + _id: rid, 'u._id': uid, }; return this.findOne(query, options); } + findManyByRoomIds(roomIds, options = {}) { + const query = { + _id: { + $in: roomIds, + }, + }; + + return this.find(query, options); + } + async getMostRecentAverageChatDurationTime(numberMostRecentChats, department) { const aggregate = [ { @@ -32,8 +42,15 @@ export class RoomsRaw extends BaseRaw { return statistic; } - findByNameContainingAndTypes(name, types, discussion = false, options = {}) { + findByNameContainingAndTypes(name, types, discussion = false, teams = false, options = {}) { const nameRegex = new RegExp(escapeRegExp(name).trim(), 'i'); + + const teamCondition = teams ? {} : { + teamMain: { + $exists: false, + }, + }; + const query = { t: { $in: types, @@ -46,23 +63,37 @@ export class RoomsRaw extends BaseRaw { usernames: nameRegex, }, ], + ...teamCondition, }; return this.find(query, options); } - findByTypes(types, discussion = false, options = {}) { + findByTypes(types, discussion = false, teams = false, options = {}) { + const teamCondition = teams ? {} : { + teamMain: { + $exists: false, + }, + }; + const query = { t: { $in: types, }, prid: { $exists: discussion }, + ...teamCondition, }; return this.find(query, options); } - findByNameContaining(name, discussion = false, options = {}) { + findByNameContaining(name, discussion = false, teams = false, options = {}) { const nameRegex = new RegExp(escapeRegExp(name).trim(), 'i'); + const teamCondition = teams ? {} : { + teamMain: { + $exists: false, + }, + }; + const query = { prid: { $exists: discussion }, $or: [ @@ -72,11 +103,44 @@ export class RoomsRaw extends BaseRaw { usernames: nameRegex, }, ], + ...teamCondition, + }; + + return this.find(query, options); + } + + findByTeamId(teamId, options = {}) { + const query = { + teamId, + teamMain: { + $exists: false, + }, }; + + return this.find(query, options); + } + + findByTeamIdAndRoomsId(teamId, rids, options = {}) { + const query = { + teamId, + _id: { + $in: rids, + }, + }; + return this.find(query, options); } - findChannelAndPrivateByNameStarting(name, options) { + findPublicByTeamId(uid, teamId, options = {}) { + const query = { + teamId, + t: 'c', + }; + + return this.find(query, options); + } + + findChannelAndPrivateByNameStarting(name, sIds, options) { const nameRegex = new RegExp(`^${ escapeRegExp(name).trim() }`, 'i'); const query = { @@ -84,11 +148,58 @@ export class RoomsRaw extends BaseRaw { $in: ['c', 'p'], }, name: nameRegex, + teamMain: { + $exists: false, + }, + $or: [{ + teamId: { + $exists: false, + }, + }, { + teamId: { + $exists: true, + }, + _id: { + $in: sIds, + }, + }], }; return this.find(query, options); } + unsetTeamId(teamId, options = {}) { + const query = { teamId }; + const update = { + $unset: { + teamId: '', + teamDefault: '', + }, + }; + + return this.update(query, update, options); + } + + unsetTeamById(rid, options = {}) { + return this.updateOne({ _id: rid }, { $unset: { teamId: '', teamDefault: '' } }, options); + } + + setTeamById(rid, teamId, teamDefault, options = {}) { + return this.updateOne({ _id: rid }, { $set: { teamId, teamDefault } }, options); + } + + setTeamMainById(rid, teamId, options = {}) { + return this.updateOne({ _id: rid }, { $set: { teamId, teamMain: true } }, options); + } + + setTeamByIds(rids, teamId, options = {}) { + return this.updateMany({ _id: { $in: rids } }, { $set: { teamId } }, options); + } + + setTeamDefaultById(rid, teamDefault, options = {}) { + return this.updateOne({ _id: rid }, { $set: { teamDefault } }, options); + } + findChannelsWithNumberOfMessagesBetweenDate({ start, end, startOfLastWeek, endOfLastWeek, onlyCount = false, options = {} }) { const lookup = { $lookup: { @@ -191,4 +302,18 @@ export class RoomsRaw extends BaseRaw { return this.col.aggregate(params).toArray(); } + + findOneByName(name, options = {}) { + return this.col.findOne({ name }, options); + } + + findDefaultRoomsForTeam(teamId) { + return this.col.find({ + teamId, + teamDefault: true, + teamMain: { + $exists: false, + }, + }); + } } diff --git a/app/models/server/raw/Subscriptions.ts b/app/models/server/raw/Subscriptions.ts index 1cd61692b69c..3af361ad400b 100644 --- a/app/models/server/raw/Subscriptions.ts +++ b/app/models/server/raw/Subscriptions.ts @@ -14,6 +14,17 @@ export class SubscriptionsRaw extends BaseRaw { return this.findOne(query, options); } + findByUserIdAndRoomIds(userId: string, roomIds: Array, options: FindOneOptions = {}): Cursor { + const query = { + 'u._id': userId, + rid: { + $in: roomIds, + }, + }; + + return this.find(query, options); + } + findByRoomIdAndNotUserId(roomId: string, userId: string, options: FindOneOptions = {}): Cursor { const query = { rid: roomId, diff --git a/app/models/server/raw/Team.ts b/app/models/server/raw/Team.ts new file mode 100644 index 000000000000..b6143aab8284 --- /dev/null +++ b/app/models/server/raw/Team.ts @@ -0,0 +1,73 @@ +import { Collection, FindOneOptions, Cursor, UpdateWriteOpResult, DeleteWriteOpResultObject, FilterQuery } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; +import { ITeam } from '../../../../definition/ITeam'; + +type T = ITeam; +export class TeamRaw extends BaseRaw { + constructor( + public readonly col: Collection, + public readonly trash?: Collection, + ) { + super(col, trash); + + this.col.createIndex({ name: 1 }, { unique: true }); + + // this.col.createIndexes([ + // { key: { status: 1, expireAt: 1 } }, + // ]); + } + + findByNames(names: Array, options?: FindOneOptions): Cursor { + return this.col.find({ name: { $in: names } }, options); + } + + findByIds(ids: Array, options?: FindOneOptions, query?: FilterQuery): Cursor { + return this.col.find({ _id: { $in: ids }, ...query }, options); + } + + findByIdsAndType(ids: Array, type: number, options?: FindOneOptions): Cursor { + return this.col.find({ _id: { $in: ids }, type }, options); + } + + findByNameAndTeamIds(name: string | RegExp, teamIds: Array, options?: FindOneOptions): Cursor { + return this.col.find({ + name, + $or: [{ + type: 0, + }, { + _id: { + $in: teamIds, + }, + }], + }, options); + } + + findOneByName(name: string, options?: FindOneOptions): Promise { + return this.col.findOne({ name }, options); + } + + findOneByMainRoomId(roomId: string, options?: FindOneOptions): Promise { + return this.col.findOne({ roomId }, options); + } + + updateMainRoomForTeam(id: string, roomId: string): Promise { + return this.col.updateOne({ + _id: id, + }, { + $set: { + roomId, + }, + }); + } + + deleteOneById(id: string): Promise { + return this.col.deleteOne({ + _id: id, + }); + } + + deleteOneByName(name: string): Promise { + return this.col.deleteOne({ name }); + } +} diff --git a/app/models/server/raw/TeamMember.ts b/app/models/server/raw/TeamMember.ts new file mode 100644 index 000000000000..c3fcab877ae7 --- /dev/null +++ b/app/models/server/raw/TeamMember.ts @@ -0,0 +1,105 @@ +import { Collection, FindOneOptions, Cursor, InsertOneWriteOpResult, UpdateWriteOpResult, DeleteWriteOpResultObject, FilterQuery } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; +import { ITeamMember } from '../../../../definition/ITeam'; +import { IUser } from '../../../../definition/IUser'; + +type T = ITeamMember; +export class TeamMemberRaw extends BaseRaw { + constructor( + public readonly col: Collection, + public readonly trash?: Collection, + ) { + super(col, trash); + + this.col.createIndexes([ + { key: { teamId: 1 } }, + ]); + + // teamId => userId should be unique + this.col.createIndex({ teamId: 1, userId: 1 }, { unique: true }); + } + + findByUserId(userId: string, options?: FindOneOptions): Cursor { + return this.col.find({ userId }, options); + } + + findOneByUserIdAndTeamId(userId: string, teamId: string, options?: FindOneOptions): Promise { + return this.col.findOne({ userId, teamId }, options); + } + + findByTeamId(teamId: string, options?: FindOneOptions): Cursor { + return this.col.find({ teamId }, options); + } + + findByTeamIdAndRole(teamId: string, role?: string, options?: FindOneOptions): Cursor { + return this.col.find({ teamId, roles: role }, options); + } + + findByUserIdAndTeamIds(userId: string, teamIds: Array, options: FindOneOptions = {}): Cursor { + const query = { + 'u._id': userId, + teamId: { + $in: teamIds, + }, + }; + + return this.col.find(query, options); + } + + findMembersInfoByTeamId(teamId: string, limit: number, skip: number, query?: FilterQuery): Cursor { + return this.col.find({ teamId, ...query }, { + limit, + skip, + projection: { + userId: 1, + roles: 1, + createdBy: 1, + createdAt: 1, + }, + } as FindOneOptions); + } + + updateOneByUserIdAndTeamId(userId: string, teamId: string, update: Partial): Promise { + return this.col.updateOne({ userId, teamId }, { $set: update }); + } + + createOneByTeamIdAndUserId(teamId: string, userId: string, createdBy: Pick): Promise> { + return this.insertOne({ + teamId, + userId, + createdAt: new Date(), + _updatedAt: new Date(), + createdBy, + }); + } + + updateRolesByTeamIdAndUserId(teamId: string, userId: string, roles: Array): Promise { + return this.col.updateOne({ + teamId, + userId, + }, { + $addToSet: { + roles: { $each: roles }, + }, + }); + } + + removeRolesByTeamIdAndUserId(teamId: string, userId: string, roles: Array): Promise { + return this.col.updateOne({ + teamId, + userId, + }, { + $pull: { + roles: { $in: roles }, + }, + }); + } + + deleteByUserIdAndTeamId(userId: string, teamId: string): Promise { + return this.col.deleteOne({ + teamId, + userId, + }); + } +} diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index b3d798557faa..dc4dfa79d1e7 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -110,6 +110,15 @@ export class UsersRaw extends BaseRaw { return this.find(query, options); } + findActiveByIds(userIds, options = {}) { + const query = { + _id: { $in: userIds }, + active: true, + }; + + return this.find(query, options); + } + findOneByUsernameIgnoringCase(username, options) { if (typeof username === 'string') { username = new RegExp(`^${ escapeRegExp(username) }$`, 'i'); diff --git a/app/push-notifications/client/tabBar.ts b/app/push-notifications/client/tabBar.ts index 3fd640652c03..35ddbc59afd7 100644 --- a/app/push-notifications/client/tabBar.ts +++ b/app/push-notifications/client/tabBar.ts @@ -3,7 +3,7 @@ import { lazy } from 'react'; import { addAction } from '../../../client/views/room/lib/Toolbox'; addAction('push-notifications', { - groups: ['channel', 'group', 'direct', 'live'], + groups: ['channel', 'group', 'direct', 'live', 'team'], id: 'push-notifications', title: 'Notifications_Preferences', icon: 'bell', diff --git a/app/statistics/server/lib/statistics.js b/app/statistics/server/lib/statistics.js index 3711557513f1..cd0a1663eaab 100644 --- a/app/statistics/server/lib/statistics.js +++ b/app/statistics/server/lib/statistics.js @@ -24,6 +24,7 @@ import { NotificationQueue, Users as UsersRaw } from '../../../models/server/raw import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; import { getAppsStatistics } from './getAppsStatistics'; import { getStatistics as getEnterpriseStatistics } from '../../../../ee/app/license/server'; +import { Team } from '../../../../server/sdk'; const wizardFields = [ 'Organization_Type', @@ -103,6 +104,9 @@ export const statistics = { statistics.totalDiscussions = Rooms.countDiscussions(); statistics.totalThreads = Messages.countThreads(); + // Teams statistics + statistics.teams = Promise.await(Team.getStatistics()); + // livechat visitors statistics.totalLivechatVisitors = LivechatVisitors.find().count(); diff --git a/app/threads/client/components/ThreadComponent.tsx b/app/threads/client/components/ThreadComponent.tsx index 574b9c062659..fadcd0414277 100644 --- a/app/threads/client/components/ThreadComponent.tsx +++ b/app/threads/client/components/ThreadComponent.tsx @@ -22,7 +22,7 @@ const subscriptionFields = {}; const useThreadMessage = (tmid: string): IMessage => { const [message, setMessage] = useState(() => Tracker.nonreactive(() => ChatMessage.findOne({ _id: tmid }))); const getMessage = useEndpoint('GET', 'chat.getMessage'); - const getMessageParsed = useCallback<(params: Mongo.Query) => Promise>(async (params) => { + const getMessageParsed = useCallback<(params: Parameters[0]) => Promise>(async (params) => { const { message } = await getMessage(params); return { ...message, diff --git a/app/ui-cached-collection/client/models/CachedCollection.js b/app/ui-cached-collection/client/models/CachedCollection.js index 5ca803847ae6..107024926b70 100644 --- a/app/ui-cached-collection/client/models/CachedCollection.js +++ b/app/ui-cached-collection/client/models/CachedCollection.js @@ -13,10 +13,6 @@ import Notifications from '../../../notifications/client/lib/Notifications'; import { getConfig } from '../../../ui-utils/client/config'; import { callMethod } from '../../../ui-utils/client/lib/callMethod'; -const fromEntries = Object.fromEntries || function fromEntries(iterable) { - return [...iterable].reduce((obj, { 0: key, 1: val }) => Object.assign(obj, { [key]: val }), {}); -}; - const wrap = (fn) => (...args) => new Promise((resolve, reject) => { fn(...args, (err, result) => { if (err) { @@ -129,7 +125,7 @@ export class CachedCollection extends Emitter { userRelated = true, listenChangesForLoggedUsersOnly = false, useSync = true, - version = 15, + version = 16, maxCacheTime = 60 * 60 * 24 * 30, onSyncData = (/* action, record */) => {}, }) { @@ -210,7 +206,7 @@ export class CachedCollection extends Emitter { } }); - this.collection._collection._docs._map = fromEntries(data.records.map((record) => [record._id, record])); + this.collection._collection._docs._map = Object.fromEntries(data.records.map((record) => [record._id, record])); this.updatedAt = data.updatedAt || this.updatedAt; Object.values(this.collection._collection.queries).forEach((query) => this.collection._collection._recomputeResults(query)); diff --git a/app/utils/server/index.js b/app/utils/server/index.js index c48c3a317c75..55574648e17c 100644 --- a/app/utils/server/index.js +++ b/app/utils/server/index.js @@ -3,7 +3,7 @@ export { getDefaultSubscriptionPref } from '../lib/getDefaultSubscriptionPref'; export { Info } from '../rocketchat.info'; export { getUserPreference } from '../lib/getUserPreference'; export { fileUploadMediaWhiteList, fileUploadIsValidContentType } from '../lib/fileUploadRestrictions'; -export { roomTypes } from './lib/roomTypes'; +export { roomTypes, searchableRoomTypes } from './lib/roomTypes'; export { RoomTypeRouteConfig, RoomTypeConfig, RoomSettingsEnum, RoomMemberActions, UiTextContext } from '../lib/RoomTypeConfig'; export { RoomTypesCommon } from '../lib/RoomTypesCommon'; export { isDocker } from './functions/isDocker'; diff --git a/app/utils/server/lib/roomTypes.js b/app/utils/server/lib/roomTypes.js index d4b2adcab3da..bda5f5ed2006 100644 --- a/app/utils/server/lib/roomTypes.js +++ b/app/utils/server/lib/roomTypes.js @@ -8,10 +8,13 @@ export const roomTypes = new class roomTypesServer extends RoomTypesCommon { * * @param {string} roomType room type (e.g.: c (for channels), d (for direct channels)) * @param {function} callback function that will return the publish's data - */ + */ setPublish(roomType, callback) { if (this.roomTypes[roomType] && this.roomTypes[roomType].publish != null) { - throw new Meteor.Error('route-publish-exists', 'Publish for the given type already exists'); + throw new Meteor.Error( + 'route-publish-exists', + 'Publish for the given type already exists', + ); } if (this.roomTypes[roomType] == null) { this.roomTypes[roomType] = {}; @@ -21,7 +24,10 @@ export const roomTypes = new class roomTypesServer extends RoomTypesCommon { setRoomFind(roomType, callback) { if (this.roomTypes[roomType] && this.roomTypes[roomType].roomFind != null) { - throw new Meteor.Error('room-find-exists', 'Room find for the given type already exists'); + throw new Meteor.Error( + 'room-find-exists', + 'Room find for the given type already exists', + ); } if (this.roomTypes[roomType] == null) { this.roomTypes[roomType] = {}; @@ -34,7 +40,11 @@ export const roomTypes = new class roomTypesServer extends RoomTypesCommon { } getRoomName(roomType, roomData) { - return this.roomTypes[roomType] && this.roomTypes[roomType].roomName && this.roomTypes[roomType].roomName(roomData); + return ( + this.roomTypes[roomType] + && this.roomTypes[roomType].roomName + && this.roomTypes[roomType].roomName(roomData) + ); } /** @@ -43,8 +53,17 @@ export const roomTypes = new class roomTypesServer extends RoomTypesCommon { * @param scope Meteor publish scope * @param {string} roomType room type (e.g.: c (for channels), d (for direct channels)) * @param identifier identifier of the room - */ + */ runPublish(scope, roomType, identifier) { - return this.roomTypes[roomType] && this.roomTypes[roomType].publish && this.roomTypes[roomType].publish.call(scope, identifier); + return ( + this.roomTypes[roomType] + && this.roomTypes[roomType].publish + && this.roomTypes[roomType].publish.call(scope, identifier) + ); } }(); + +export const searchableRoomTypes = () => + Object.entries(roomTypes.roomTypes) + .filter((roomType) => roomType[1].includeInRoomSearch()) + .map((roomType) => roomType[0]); diff --git a/app/videobridge/client/tabBar.tsx b/app/videobridge/client/tabBar.tsx index 55a591c2d6d8..7f3fc4beac33 100644 --- a/app/videobridge/client/tabBar.tsx +++ b/app/videobridge/client/tabBar.tsx @@ -28,7 +28,7 @@ addAction('bbb_video', ({ room }) => { return useMemo(() => (enabled ? { groups, id: 'bbb_video', - title: 'BBB Video Call', + title: 'BBB_Video_Call', icon: 'phone', template: templateBBB, order: live ? -1 : 0, diff --git a/client/components/Breadcrumbs/index.js b/client/components/Breadcrumbs/index.js index 3c46d5773d43..c9e18d541e3a 100644 --- a/client/components/Breadcrumbs/index.js +++ b/client/components/Breadcrumbs/index.js @@ -5,6 +5,7 @@ import { css } from '@rocket.chat/css-in-js'; const BreadcrumbsSeparator = () => /; const BreadcrumbsIcon = ({ name, color, children }) => {name ? : children}; +const BreadcrumbsIconSmall = ({ name, color, children }) => {name ? : children}; const BreadcrumbsLink = (props) => ; const BreadcrumbsItem = (props) => ; +const BreadcrumbsTag = (props) => ; const Breadcrumbs = ({ children }) => {children}; @@ -32,8 +34,10 @@ Object.assign(Breadcrumbs, { Text: BreadcrumbsText, Link: BreadcrumbsLink, Icon: BreadcrumbsIcon, + IconSmall: BreadcrumbsIconSmall, Separator: BreadcrumbsSeparator, Item: BreadcrumbsItem, + Tag: BreadcrumbsTag, }); export default Breadcrumbs; diff --git a/client/components/GenericModal.stories.js b/client/components/GenericModal.stories.js new file mode 100644 index 000000000000..e362ea9bea45 --- /dev/null +++ b/client/components/GenericModal.stories.js @@ -0,0 +1,18 @@ +import React from 'react'; + +import GenericModal, { GenericModalDoNotAskAgain } from './GenericModal'; + + +export default { + title: 'components/GenericModal', + component: GenericModal, +}; + +const func = () => null; +const defaultProps = { onClose: func, onConfirm: func, onCancel: func }; + +export const _default = () => ; +export const Danger = () => ; +export const Warning = () => ; +export const Success = () => ; +export const WithDontAskAgain = () => ; diff --git a/client/components/GenericModal.tsx b/client/components/GenericModal.tsx new file mode 100644 index 000000000000..aaeb869cc4a0 --- /dev/null +++ b/client/components/GenericModal.tsx @@ -0,0 +1,77 @@ +import { Box, Button, ButtonGroup, Icon, Modal, ButtonProps } from '@rocket.chat/fuselage'; +import React, { FC } from 'react'; + +import { useTranslation } from '../contexts/TranslationContext'; +import { withDoNotAskAgain, RequiredModalProps } from './withDoNotAskAgain'; + +type VariantType = 'danger' | 'warning' | 'info' | 'success'; + +type GenericModalProps = RequiredModalProps & { + variant?: VariantType; + cancelText?: string; + confirmText?: string; + title?: string; + icon?: string; + onCancel?: () => void; + onClose: () => void; + onConfirm: () => void; +}; + +const iconMap = { + danger: 'modal-warning', + warning: 'modal-warning', + info: 'info', + success: 'check', +}; + +const getButtonProps = (variant: VariantType): ButtonProps => { + switch (variant) { + case 'danger': + return { primary: true, danger: true }; + case 'warning': + return { primary: true }; + default: + return { }; + } +}; + +const GenericModal: FC = ({ + variant = 'info', + children, + cancelText, + confirmText, + title, + icon, + onCancel, + onClose, + onConfirm, + dontAskAgain, + ...props +}) => { + const t = useTranslation(); + + return + + {icon !== null && } + {title ?? t('Are_you_sure')} + + + + {children} + + + + {dontAskAgain} + + {onCancel && } + + + + + ; +}; + +// TODO update withDoNotAskAgain to use onConfirm istead of confirm +export const GenericModalDoNotAskAgain = withDoNotAskAgain(({ confirm, ...props }) => ); + +export default GenericModal; diff --git a/client/components/GenericTable/index.js b/client/components/GenericTable/index.js index 52ccdf832f09..147537c74f4e 100644 --- a/client/components/GenericTable/index.js +++ b/client/components/GenericTable/index.js @@ -18,6 +18,7 @@ const GenericTable = ({ results, setParams = () => { }, total, + pagination = true, ...props }, ref) => { const t = useTranslation(); @@ -69,7 +70,7 @@ const GenericTable = ({ - + />} } ; diff --git a/client/components/Message/Actions/index.tsx b/client/components/Message/Actions/index.tsx index 2a7611c43a3b..5f9bc4afa46d 100644 --- a/client/components/Message/Actions/index.tsx +++ b/client/components/Message/Actions/index.tsx @@ -1,7 +1,7 @@ import React, { FC } from 'react'; import { IconProps, Icon, Button, ButtonGroup } from '@rocket.chat/fuselage'; -import { useTranslation } from '../../../contexts/TranslationContext'; +import { TranslationKey, useTranslation } from '../../../contexts/TranslationContext'; import { Content } from '..'; type RunAction = () => void; @@ -10,7 +10,7 @@ type ActionOptions = { mid: string; id: string; icon: IconProps['name']; - i18nLabel?: string; + i18nLabel?: TranslationKey; label?: string; runAction?: RunAction; }; diff --git a/client/components/ScrollableContentWrapper.tsx b/client/components/ScrollableContentWrapper.tsx index da33c1ae9046..c61d1ab97058 100644 --- a/client/components/ScrollableContentWrapper.tsx +++ b/client/components/ScrollableContentWrapper.tsx @@ -17,7 +17,7 @@ export type CustomScrollbarsProps = { renderTrackHorizontal?: typeof Scrollbars.defaultProps.renderTrackHorizontal; } -const ScrollableContentWrapper = forwardRef(({ children, style, onScroll, renderView, renderTrackHorizontal }, ref) => { +const ScrollableContentWrapper = forwardRef(({ children, style, onScroll, renderView }, ref) => { const scrollbarsStyle = useMemo(() => ({ ...style, ...styleDefault }), [style]) as CSSProperties; return ( style={scrollbarsStyle} onScrollFrame={onScroll} renderView={renderView} - renderTrackHorizontal={renderTrackHorizontal} + renderTrackHorizontal={(props): React.ReactElement =>
} renderThumbVertical={ ({ style, ...props }): JSX.Element => (
) } diff --git a/client/components/avatar/BaseAvatar.js b/client/components/avatar/BaseAvatar.js deleted file mode 100644 index 1430ecdc8fdd..000000000000 --- a/client/components/avatar/BaseAvatar.js +++ /dev/null @@ -1,14 +0,0 @@ -import React, { useState } from 'react'; -import { Avatar, Skeleton } from '@rocket.chat/fuselage'; - -function BaseAvatar(props) { - const [error, setError] = useState(false); - - if (error) { - return ; - } - - return ; -} - -export default BaseAvatar; diff --git a/client/components/avatar/BaseAvatar.tsx b/client/components/avatar/BaseAvatar.tsx new file mode 100644 index 000000000000..91cb3a379b45 --- /dev/null +++ b/client/components/avatar/BaseAvatar.tsx @@ -0,0 +1,16 @@ +import React, { FC, useState } from 'react'; +import { Avatar, AvatarProps, Skeleton } from '@rocket.chat/fuselage'; + +export type BaseAvatarProps = AvatarProps; + +const BaseAvatar: FC = ({ size, ...props }) => { + const [error, setError] = useState(false); + + if (error) { + return ; + } + + return ; +}; + +export default BaseAvatar; diff --git a/client/components/avatar/UserAvatar.js b/client/components/avatar/UserAvatar.js deleted file mode 100644 index c76201d14c71..000000000000 --- a/client/components/avatar/UserAvatar.js +++ /dev/null @@ -1,12 +0,0 @@ -import React, { memo } from 'react'; - -import BaseAvatar from './BaseAvatar'; -import { useUserAvatarPath } from '../../contexts/AvatarUrlContext'; - -function UserAvatar({ username, etag, ...rest }) { - const getUserAvatarPath = useUserAvatarPath(); - const { url = getUserAvatarPath(username, etag), ...props } = rest; - return ; -} - -export default memo(UserAvatar); diff --git a/client/components/avatar/UserAvatar.tsx b/client/components/avatar/UserAvatar.tsx new file mode 100644 index 000000000000..344cefc5ea0c --- /dev/null +++ b/client/components/avatar/UserAvatar.tsx @@ -0,0 +1,22 @@ +import React, { FC, memo } from 'react'; + +import BaseAvatar, { BaseAvatarProps } from './BaseAvatar'; +import { useUserAvatarPath } from '../../contexts/AvatarUrlContext'; + +type UserAvatarProps = Omit & { + username: string; + etag?: string; + url?: string; +}; + +const UserAvatar: FC = ({ username, etag, ...rest }) => { + const getUserAvatarPath = useUserAvatarPath(); + const { + url = getUserAvatarPath(username, etag), + ...props + } = rest; + + return ; +}; + +export default memo(UserAvatar); diff --git a/client/contexts/ServerContext.ts b/client/contexts/ServerContext.ts deleted file mode 100644 index bb252b2ba37a..000000000000 --- a/client/contexts/ServerContext.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createContext, useCallback, useContext, useMemo } from 'react'; - -type ServerContextValue = { - info: {}; - absoluteUrl: (path: string) => string; - callMethod: (methodName: string, ...args: any[]) => Promise; - callEndpoint: (httpMethod: 'GET' | 'POST' | 'DELETE', endpoint: string, ...args: any[]) => Promise; - uploadToEndpoint: (endpoint: string, params: any, formData: any) => Promise; - getStream: (streamName: string, options?: {}) => (eventName: string, callback: (data: T) => void) => () => void; -}; - -export const ServerContext = createContext({ - info: {}, - absoluteUrl: (path) => path, - callMethod: async () => undefined, - callEndpoint: async () => undefined, - uploadToEndpoint: async () => undefined, - getStream: () => () => (): void => undefined, -}); - -export const useServerInformation = (): {} => useContext(ServerContext).info; - -export const useAbsoluteUrl = (): ((path: string) => string) => useContext(ServerContext).absoluteUrl; - -export const useMethod = (methodName: string): (...args: any[]) => Promise => { - const { callMethod } = useContext(ServerContext); - return useCallback((...args: any[]) => callMethod(methodName, ...args), [callMethod, methodName]); -}; - -export const useEndpoint = (httpMethod: 'GET' | 'POST' | 'DELETE', endpoint: string): (...args: any[]) => Promise => { - const { callEndpoint } = useContext(ServerContext); - return useCallback((...args: any[]) => callEndpoint(httpMethod, endpoint, ...args), [callEndpoint, httpMethod, endpoint]); -}; - -export const useUpload = (endpoint: string): (params: any, formData: any) => Promise => { - const { uploadToEndpoint } = useContext(ServerContext); - return useCallback((params, formData: any) => uploadToEndpoint(endpoint, params, formData), [endpoint, uploadToEndpoint]); -}; - -export const useStream = ( - streamName: string, - options?: {}, -): (eventName: string, callback: (data: T) => void) => (() => void) => { - const { getStream } = useContext(ServerContext); - return useMemo(() => getStream(streamName, options), [getStream, streamName, options]); -}; diff --git a/client/contexts/ServerContext/ServerContext.ts b/client/contexts/ServerContext/ServerContext.ts new file mode 100644 index 000000000000..7a93daea696e --- /dev/null +++ b/client/contexts/ServerContext/ServerContext.ts @@ -0,0 +1,72 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; + +import { ServerEndpointMethodOf, ServerEndpointPath, ServerEndpointFunction, ServerEndpointRequestPayload, ServerEndpointFormData, ServerEndpointResponsePayload } from './endpoints'; +import { ServerMethodFunction, ServerMethodName, ServerMethodParameters, ServerMethodReturn, ServerMethods } from './methods'; + +type ServerContextValue = { + info: {}; + absoluteUrl: (path: string) => string; + callMethod?: (methodName: MethodName, ...args: ServerMethodParameters) => Promise>; + callEndpoint?: < + Method extends ServerEndpointMethodOf, + Path extends ServerEndpointPath + >(httpMethod: Method, endpoint: Path, params: ServerEndpointRequestPayload, formData?: ServerEndpointFormData) => Promise>; + uploadToEndpoint: (endpoint: string, params: any, formData: any) => Promise; + getStream: (streamName: string, options?: {}) => (eventName: string, callback: (data: T) => void) => () => void; +}; + +export const ServerContext = createContext({ + info: {}, + absoluteUrl: (path) => path, + uploadToEndpoint: async () => undefined, + getStream: () => () => (): void => undefined, +}); + +export const useServerInformation = (): {} => useContext(ServerContext).info; + +export const useAbsoluteUrl = (): ((path: string) => string) => useContext(ServerContext).absoluteUrl; + +export const useMethod = ( + methodName: MethodName, +): ServerMethodFunction => { + const { callMethod } = useContext(ServerContext); + + return useCallback( + (...args: ServerMethodParameters) => { + if (!callMethod) { + throw new Error(`cannot use useMethod(${ methodName }) hook without a wrapping ServerContext`); + } + + return callMethod(methodName, ...args); + }, + [callMethod, methodName], + ); +}; + +export const useEndpoint = < + Method extends ServerEndpointMethodOf, + Path extends ServerEndpointPath +>(httpMethod: Method, endpoint: Path): ServerEndpointFunction => { + const { callEndpoint } = useContext(ServerContext); + + return useCallback((params: ServerEndpointRequestPayload, formData?: ServerEndpointFormData) => { + if (!callEndpoint) { + throw new Error(`cannot use useEndpoint(${ httpMethod }, ${ endpoint }) hook without a wrapping ServerContext`); + } + + return callEndpoint(httpMethod, endpoint, params, formData); + }, [callEndpoint, endpoint, httpMethod]); +}; + +export const useUpload = (endpoint: string): (params: any, formData: any) => Promise => { + const { uploadToEndpoint } = useContext(ServerContext); + return useCallback((params, formData: any) => uploadToEndpoint(endpoint, params, formData), [endpoint, uploadToEndpoint]); +}; + +export const useStream = ( + streamName: string, + options?: {}, +): (eventName: string, callback: (data: T) => void) => (() => void) => { + const { getStream } = useContext(ServerContext); + return useMemo(() => getStream(streamName, options), [getStream, streamName, options]); +}; diff --git a/client/contexts/ServerContext/endpoints.ts b/client/contexts/ServerContext/endpoints.ts new file mode 100644 index 000000000000..1da5d4cc8a39 --- /dev/null +++ b/client/contexts/ServerContext/endpoints.ts @@ -0,0 +1,70 @@ +import { FollowMessageEndpoint as ChatFollowMessageEndpoint } from './endpoints/v1/chat/followMessage'; +import { GetMessageEndpoint as ChatGetMessageEndpoint } from './endpoints/v1/chat/getMessage'; +import { UnfollowMessageEndpoint as ChatUnfollowMessageEndpoint } from './endpoints/v1/chat/unfollowMessage'; +import { AutocompleteEndpoint as UsersAutocompleteEndpoint } from './endpoints/v1/users/autocomplete'; +import { AutocompleteChannelAndPrivateEndpoint as RoomsAutocompleteEndpoint } from './endpoints/v1/rooms/autocompleteChannelAndPrivate'; +import { AppearanceEndpoint as LivechatAppearanceEndpoint } from './endpoints/v1/livechat/appearance'; +import { ListEndpoint as CustomUserStatusListEndpoint } from './endpoints/v1/custom-user-status/list'; +import { ExternalComponentsEndpoint as AppsExternalComponentsEndpoint } from './endpoints/apps/externalComponents'; +import { ManualRegisterEndpoint as CloudManualRegisterEndpoint } from './endpoints/v1/cloud/manualRegister'; +import { FilesEndpoint as GroupsFilesEndpoint } from './endpoints/v1/groups/files'; +import { FilesEndpoint as ImFilesEndpoint } from './endpoints/v1/im/files'; +import { AddRoomsEndpoint as TeamsAddRoomsEndpoint } from './endpoints/v1/teams/addRooms'; +import { FilesEndpoint as ChannelsFilesEndpoint } from './endpoints/v1/channels/files'; +import { ListEndpoint as EmojiCustomListEndpoint } from './endpoints/v1/emoji-custom/list'; +import { GetDiscussionsEndpoint as ChatGetDiscussionsEndpoint } from './endpoints/v1/chat/getDiscussions'; +import { GetThreadsListEndpoint as ChatGetThreadsListEndpoint } from './endpoints/v1/chat/getThreadsList'; +import { LivechatVisitorInfoEndpoint } from './endpoints/v1/livechat/visitorInfo'; + +export type ServerEndpoints = { + 'chat.getMessage': ChatGetMessageEndpoint; + 'chat.followMessage': ChatFollowMessageEndpoint; + 'chat.unfollowMessage': ChatUnfollowMessageEndpoint; + 'cloud.manualRegister': CloudManualRegisterEndpoint; + 'chat.getDiscussions': ChatGetDiscussionsEndpoint; + 'chat.getThreadsList': ChatGetThreadsListEndpoint; + 'emoji-custom.list': EmojiCustomListEndpoint; + 'channels.files': ChannelsFilesEndpoint; + 'im.files': ImFilesEndpoint; + 'groups.files': GroupsFilesEndpoint; + 'users.autocomplete': UsersAutocompleteEndpoint; + 'livechat/appearance': LivechatAppearanceEndpoint; + 'custom-user-status.list': CustomUserStatusListEndpoint; + '/apps/externalComponents': AppsExternalComponentsEndpoint; + 'rooms.autocomplete.channelAndPrivate': RoomsAutocompleteEndpoint; + 'teams.addRooms': TeamsAddRoomsEndpoint; + 'livechat/visitors.info': LivechatVisitorInfoEndpoint; +}; + +export type ServerEndpointPath = keyof ServerEndpoints; +export type ServerEndpointMethodOf = keyof ServerEndpoints[Path] & ('GET' | 'POST' | 'DELETE'); + +type ServerEndpoint< + Method extends ServerEndpointMethodOf, + Path extends ServerEndpointPath +> = ServerEndpoints[Path][Method] extends (...args: any[]) => any + ? ServerEndpoints[Path][Method] + : (...args: any[]) => any; + +export type ServerEndpointRequestPayload< + Method extends ServerEndpointMethodOf, + Path extends ServerEndpointPath +> = Parameters>[0]; + +export type ServerEndpointFormData< + Method extends ServerEndpointMethodOf, + Path extends ServerEndpointPath +> = Parameters>[1]; + +export type ServerEndpointResponsePayload< + Method extends ServerEndpointMethodOf, + Path extends ServerEndpointPath +> = ReturnType>; + +export type ServerEndpointFunction< + Method extends ServerEndpointMethodOf, + Path extends ServerEndpointPath +> = { + (params: ServerEndpointRequestPayload): Promise>; + (params: ServerEndpointRequestPayload, formData: ServerEndpointFormData): Promise>; +}; diff --git a/client/contexts/ServerContext/endpoints/apps/externalComponents.ts b/client/contexts/ServerContext/endpoints/apps/externalComponents.ts new file mode 100644 index 000000000000..8367ebdd6a19 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/apps/externalComponents.ts @@ -0,0 +1,5 @@ +import { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent'; + +export type ExternalComponentsEndpoint = { + GET: (params: Record) => { externalComponents: IExternalComponent[] }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/channels/files.ts b/client/contexts/ServerContext/endpoints/v1/channels/files.ts new file mode 100644 index 000000000000..4b449aee5a18 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/channels/files.ts @@ -0,0 +1,15 @@ +import { IMessage } from '../../../../../../definition/IMessage'; +import { IRoom } from '../../../../../../definition/IRoom'; +import { ObjectFromApi } from '../../../../../../definition/ObjectFromApi'; + +export type FilesEndpoint = { + GET: (params: { + roomId: IRoom['_id']; + count: number; + sort: string; + query: string; + }) => { + files: ObjectFromApi[]; + total: number; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/chat/followMessage.ts b/client/contexts/ServerContext/endpoints/v1/chat/followMessage.ts new file mode 100644 index 000000000000..0f6ca4d4ccb6 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/chat/followMessage.ts @@ -0,0 +1,9 @@ +import { IMessage } from '../../../../../../definition/IMessage'; + +export type FollowMessageEndpoint = { + POST: (params: { mid: IMessage['_id'] }) => { + success: true; + statusCode: 200; + body: {}; + }; +} diff --git a/client/contexts/ServerContext/endpoints/v1/chat/getDiscussions.ts b/client/contexts/ServerContext/endpoints/v1/chat/getDiscussions.ts new file mode 100644 index 000000000000..4434cdea83e3 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/chat/getDiscussions.ts @@ -0,0 +1,15 @@ +import { IRoom } from '../../../../../../definition/IRoom'; +import { ObjectFromApi } from '../../../../../../definition/ObjectFromApi'; +import { IMessage } from '../../../../../../definition/IMessage'; + +export type GetDiscussionsEndpoint = { + GET: (params: { + roomId: IRoom['_id']; + text?: string; + offset: number; + count: number; + }) => { + messages: ObjectFromApi[]; + total: number; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/chat/getMessage.ts b/client/contexts/ServerContext/endpoints/v1/chat/getMessage.ts new file mode 100644 index 000000000000..ac868502ef5e --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/chat/getMessage.ts @@ -0,0 +1,7 @@ +import { IMessage } from '../../../../../../definition/IMessage'; + +export type GetMessageEndpoint = { + GET: (params: { msgId: IMessage['_id'] }) => { + message: IMessage; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/chat/getThreadsList.ts b/client/contexts/ServerContext/endpoints/v1/chat/getThreadsList.ts new file mode 100644 index 000000000000..688768c5a152 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/chat/getThreadsList.ts @@ -0,0 +1,16 @@ +import { IRoom } from '../../../../../../definition/IRoom'; +import { ObjectFromApi } from '../../../../../../definition/ObjectFromApi'; +import { IMessage } from '../../../../../../definition/IMessage'; + +export type GetThreadsListEndpoint = { + GET: (params: { + rid: IRoom['_id']; + type: 'unread' | 'following' | 'all'; + text?: string; + offset: number; + count: number; + }) => { + threads: ObjectFromApi[]; + total: number; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/chat/unfollowMessage.ts b/client/contexts/ServerContext/endpoints/v1/chat/unfollowMessage.ts new file mode 100644 index 000000000000..4f3b48d80024 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/chat/unfollowMessage.ts @@ -0,0 +1,9 @@ +import { IMessage } from '../../../../../../definition/IMessage'; + +export type UnfollowMessageEndpoint = { + POST: (params: { mid: IMessage['_id'] }) => { + success: true; + statusCode: 200; + body: {}; + }; +} diff --git a/client/contexts/ServerContext/endpoints/v1/cloud/manualRegister.ts b/client/contexts/ServerContext/endpoints/v1/cloud/manualRegister.ts new file mode 100644 index 000000000000..77db549901a7 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/cloud/manualRegister.ts @@ -0,0 +1,3 @@ +export type ManualRegisterEndpoint = { + POST: (params: Record, formData: { cloudBlob: string }) => void; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/custom-user-status/list.ts b/client/contexts/ServerContext/endpoints/v1/custom-user-status/list.ts new file mode 100644 index 000000000000..aa81cfe70469 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/custom-user-status/list.ts @@ -0,0 +1,5 @@ +export type ListEndpoint = { + GET: (params: { query: string }) => { + statuses: unknown[]; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/emoji-custom/list.ts b/client/contexts/ServerContext/endpoints/v1/emoji-custom/list.ts new file mode 100644 index 000000000000..ca83af01bc83 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/emoji-custom/list.ts @@ -0,0 +1,14 @@ +type EmojiDescriptor = { + _id: string; + name: string; + aliases: string[]; + extension: string; +}; + +export type ListEndpoint = { + GET: (params: { query: string }) => { + emojis?: { + update: EmojiDescriptor[]; + }; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/groups/files.ts b/client/contexts/ServerContext/endpoints/v1/groups/files.ts new file mode 100644 index 000000000000..4b449aee5a18 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/groups/files.ts @@ -0,0 +1,15 @@ +import { IMessage } from '../../../../../../definition/IMessage'; +import { IRoom } from '../../../../../../definition/IRoom'; +import { ObjectFromApi } from '../../../../../../definition/ObjectFromApi'; + +export type FilesEndpoint = { + GET: (params: { + roomId: IRoom['_id']; + count: number; + sort: string; + query: string; + }) => { + files: ObjectFromApi[]; + total: number; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/im/files.ts b/client/contexts/ServerContext/endpoints/v1/im/files.ts new file mode 100644 index 000000000000..4b449aee5a18 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/im/files.ts @@ -0,0 +1,15 @@ +import { IMessage } from '../../../../../../definition/IMessage'; +import { IRoom } from '../../../../../../definition/IRoom'; +import { ObjectFromApi } from '../../../../../../definition/ObjectFromApi'; + +export type FilesEndpoint = { + GET: (params: { + roomId: IRoom['_id']; + count: number; + sort: string; + query: string; + }) => { + files: ObjectFromApi[]; + total: number; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/livechat/appearance.ts b/client/contexts/ServerContext/endpoints/v1/livechat/appearance.ts new file mode 100644 index 000000000000..e27fc9e1d20f --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/livechat/appearance.ts @@ -0,0 +1,8 @@ +import { ISetting } from '../../../../../../definition/ISetting'; + +export type AppearanceEndpoint = { + GET: (params: Record) => { + success: boolean; + appearance: ISetting[]; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/livechat/visitorInfo.ts b/client/contexts/ServerContext/endpoints/v1/livechat/visitorInfo.ts new file mode 100644 index 000000000000..e35c4b44745d --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/livechat/visitorInfo.ts @@ -0,0 +1,10 @@ +export type LivechatVisitorInfoEndpoint = { + GET: (visitorId: string) => { + success: boolean; + visitor: { + visitorEmails: Array<{ + address: string; + }>; + }; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/rooms/autocompleteChannelAndPrivate.ts b/client/contexts/ServerContext/endpoints/v1/rooms/autocompleteChannelAndPrivate.ts new file mode 100644 index 000000000000..0b84607ca8d9 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/rooms/autocompleteChannelAndPrivate.ts @@ -0,0 +1,5 @@ +import { IRoom } from '../../../../../../definition/IRoom'; + +export type AutocompleteChannelAndPrivateEndpoint = { + GET: (params: { selector: string }) => { items: IRoom[] }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/teams/addRooms.ts b/client/contexts/ServerContext/endpoints/v1/teams/addRooms.ts new file mode 100644 index 000000000000..cc1a8fc9ef47 --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/teams/addRooms.ts @@ -0,0 +1,9 @@ +import { IRoom } from '../../../../../../definition/IRoom'; + +export type AddRoomsEndpoint = { + POST: (params: { rooms: IRoom['_id'][]; teamId: string }) => { + success: true; + statusCode: 200; + body: IRoom[]; + }; +}; diff --git a/client/contexts/ServerContext/endpoints/v1/users/autocomplete.ts b/client/contexts/ServerContext/endpoints/v1/users/autocomplete.ts new file mode 100644 index 000000000000..4b3d41eeb0be --- /dev/null +++ b/client/contexts/ServerContext/endpoints/v1/users/autocomplete.ts @@ -0,0 +1,5 @@ +import { IUser } from '../../../../../../definition/IUser'; + +export type AutocompleteEndpoint = { + GET: (params: { selector: string }) => { items: IUser[] }; +}; diff --git a/client/contexts/ServerContext/index.ts b/client/contexts/ServerContext/index.ts new file mode 100644 index 000000000000..a2807467fddf --- /dev/null +++ b/client/contexts/ServerContext/index.ts @@ -0,0 +1,3 @@ +export * from './ServerContext'; +export * from './endpoints'; +export * from './methods'; diff --git a/client/contexts/ServerContext/methods.ts b/client/contexts/ServerContext/methods.ts new file mode 100644 index 000000000000..140fccd770aa --- /dev/null +++ b/client/contexts/ServerContext/methods.ts @@ -0,0 +1,140 @@ +import { FollowMessageMethod } from './methods/followMessage'; +import { RoomNameExistsMethod } from './methods/roomNameExists'; +import { SaveRoomSettingsMethod } from './methods/saveRoomSettings'; +import { SaveSettingsMethod } from './methods/saveSettings'; +import { SaveUserPreferencesMethod } from './methods/saveUserPreferences'; +import { UnfollowMessageMethod } from './methods/unfollowMessage'; + +export type ServerMethods = { + '2fa:checkCodesRemaining': (...args: any[]) => any; + '2fa:disable': (...args: any[]) => any; + '2fa:enable': (...args: any[]) => any; + '2fa:regenerateCodes': (...args: any[]) => any; + '2fa:validateTempToken': (...args: any[]) => any; + 'addOAuthApp': (...args: any[]) => any; + 'addOAuthService': (...args: any[]) => any; + 'addUsersToRoom': (...args: any[]) => any; + 'apps/go-enable': (...args: any[]) => any; + 'apps/is-enabled': (...args: any[]) => any; + 'authorization:addPermissionToRole': (...args: any[]) => any; + 'authorization:addUserToRole': (...args: any[]) => any; + 'authorization:deleteRole': (...args: any[]) => any; + 'authorization:removeRoleFromPermission': (...args: any[]) => any; + 'authorization:removeUserFromRole': (...args: any[]) => any; + 'authorization:saveRole': (...args: any[]) => any; + 'bbbEnd': (...args: any[]) => any; + 'bbbJoin': (...args: any[]) => any; + 'blockUser': (...args: any[]) => any; + 'checkUsernameAvailability': (...args: any[]) => any; + 'cleanRoomHistory': (...args: any[]) => any; + 'clearIntegrationHistory': (...args: any[]) => any; + 'cloud:checkRegisterStatus': (...args: any[]) => any; + 'cloud:checkUserLoggedIn': (...args: any[]) => any; + 'cloud:connectWorkspace': (...args: any[]) => any; + 'cloud:disconnectWorkspace': (...args: any[]) => any; + 'cloud:finishOAuthAuthorization': (...args: any[]) => any; + 'cloud:getOAuthAuthorizationUrl': (...args: any[]) => any; + 'cloud:getWorkspaceRegisterData': (...args: any[]) => any; + 'cloud:logout': (...args: any[]) => any; + 'cloud:registerWorkspace': (...args: any[]) => any; + 'cloud:syncWorkspace': (...args: any[]) => any; + 'deleteCustomSound': (...args: any[]) => any; + 'deleteCustomUserStatus': (...args: any[]) => any; + 'deleteFileMessage': (...args: any[]) => any; + 'deleteOAuthApp': (...args: any[]) => any; + 'deleteUserOwnAccount': (...args: any[]) => any; + 'e2e.resetOwnE2EKey': (...args: any[]) => any; + 'eraseRoom': (...args: any[]) => any; + 'followMessage': FollowMessageMethod; + 'getAvatarSuggestion': (...args: any[]) => any; + 'getSetupWizardParameters': (...args: any[]) => any; + 'getUsersOfRoom': (...args: any[]) => any; + 'hideRoom': (...args: any[]) => any; + 'ignoreUser': (...args: any[]) => any; + 'insertOrUpdateSound': (...args: any[]) => any; + 'insertOrUpdateUserStatus': (...args: any[]) => any; + 'instances/get': (...args: any[]) => any; + 'jitsi:generateAccessToken': (...args: any[]) => any; + 'jitsi:updateTimeout': (...args: any[]) => any; + 'leaveRoom': (...args: any[]) => any; + 'license:getTags': (...args: any[]) => any; + 'livechat:addMonitor': (...args: any[]) => any; + 'livechat:changeLivechatStatus': (...args: any[]) => any; + 'livechat:closeRoom': (...args: any[]) => any; + 'livechat:discardTranscript': (...args: any[]) => any; + 'livechat:facebook': (...args: any[]) => any; + 'livechat:getAgentOverviewData': (...args: any[]) => any; + 'livechat:getAnalyticsChartData': (...args: any[]) => any; + 'livechat:getAnalyticsOverviewData': (...args: any[]) => any; + 'livechat:getRoutingConfig': (...args: any[]) => any; + 'livechat:removeAllClosedRooms': (...args: any[]) => any; + 'livechat:removeBusinessHour': (...args: any[]) => any; + 'livechat:removeCustomField': (...args: any[]) => any; + 'livechat:removeMonitor': (...args: any[]) => any; + 'livechat:removePriority': (...args: any[]) => any; + 'livechat:removeRoom': (...args: any[]) => any; + 'livechat:removeTag': (...args: any[]) => any; + 'livechat:removeTrigger': (...args: any[]) => any; + 'livechat:removeUnit': (...args: any[]) => any; + 'livechat:requestTranscript': (...args: any[]) => any; + 'livechat:returnAsInquiry': (...args: any[]) => any; + 'livechat:sendTranscript': (...args: any[]) => any; + 'livechat:transfer': (...args: any[]) => any; + 'livechat:saveAgentInfo': (...args: any[]) => any; + 'livechat:saveAppearance': (...args: any[]) => any; + 'livechat:saveBusinessHour': (...args: any[]) => any; + 'livechat:saveCustomField': (...args: any[]) => any; + 'livechat:saveDepartment': (...args: any[]) => any; + 'livechat:saveIntegration': (...args: any[]) => any; + 'livechat:savePriority': (...args: any[]) => any; + 'livechat:saveTag': (...args: any[]) => any; + 'livechat:saveTrigger': (...args: any[]) => any; + 'livechat:saveUnit': (...args: any[]) => any; + 'livechat:webhookTest': (...args: any[]) => any; + 'logoutOtherClients': (...args: any[]) => any; + 'Mailer.sendMail': (...args: any[]) => any; + 'muteUserInRoom': (...args: any[]) => any; + 'personalAccessTokens:generateToken': (...args: any[]) => any; + 'personalAccessTokens:regenerateToken': (...args: any[]) => any; + 'personalAccessTokens:removeToken': (...args: any[]) => any; + 'readMessages': (...args: any[]) => any; + 'refreshClients': (...args: any[]) => any; + 'refreshOAuthService': (...args: any[]) => any; + 'registerUser': (...args: any[]) => any; + 'removeOAuthService': (...args: any[]) => any; + 'removeWebdavAccount': (...args: any[]) => any; + 'replayOutgoingIntegration': (...args: any[]) => any; + 'requestDataDownload': (...args: any[]) => any; + 'resetPassword': (...args: any[]) => any; + 'roomNameExists': RoomNameExistsMethod; + 'saveCannedResponse': (...args: any[]) => any; + 'saveRoomSettings': SaveRoomSettingsMethod; + 'saveSettings': SaveSettingsMethod; + 'saveUserPreferences': SaveUserPreferencesMethod; + 'saveUserProfile': (...args: any[]) => any; + 'sendConfirmationEmail': (...args: any[]) => any; + 'sendInvitationEmail': (...args: any[]) => any; + 'setAdminStatus': (...args: any[]) => any; + 'setAsset': (...args: any[]) => any; + 'setAvatarFromService': (...args: any[]) => any; + 'setUsername': (...args: any[]) => any; + 'setUserPassword': (...args: any[]) => any; + 'toggleFavorite': (...args: any[]) => any; + 'unblockUser': (...args: any[]) => any; + 'unfollowMessage': UnfollowMessageMethod; + 'unmuteUserInRoom': (...args: any[]) => any; + 'unreadMessages': (...args: any[]) => any; + 'unsetAsset': (...args: any[]) => any; + 'updateIncomingIntegration': (...args: any[]) => any; + 'updateOAuthApp': (...args: any[]) => any; + 'updateOutgoingIntegration': (...args: any[]) => any; + 'uploadCustomSound': (...args: any[]) => any; +}; + +export type ServerMethodName = keyof ServerMethods; + +export type ServerMethodParameters = Parameters; + +export type ServerMethodReturn = ReturnType; + +export type ServerMethodFunction = (...args: ServerMethodParameters) => Promise>; diff --git a/client/contexts/ServerContext/methods/followMessage.ts b/client/contexts/ServerContext/methods/followMessage.ts new file mode 100644 index 000000000000..f6c5e4809698 --- /dev/null +++ b/client/contexts/ServerContext/methods/followMessage.ts @@ -0,0 +1,3 @@ +import { IMessage } from '../../../../definition/IMessage'; + +export type FollowMessageMethod = (options: { mid: IMessage['_id'] }) => false | undefined; diff --git a/client/contexts/ServerContext/methods/roomNameExists.ts b/client/contexts/ServerContext/methods/roomNameExists.ts new file mode 100644 index 000000000000..3cd2aefccb9e --- /dev/null +++ b/client/contexts/ServerContext/methods/roomNameExists.ts @@ -0,0 +1,3 @@ +import { IRoom } from '../../../../definition/IRoom'; + +export type RoomNameExistsMethod = (name: IRoom['name']) => boolean; diff --git a/client/contexts/ServerContext/methods/saveRoomSettings.ts b/client/contexts/ServerContext/methods/saveRoomSettings.ts new file mode 100644 index 000000000000..1b48cd559e54 --- /dev/null +++ b/client/contexts/ServerContext/methods/saveRoomSettings.ts @@ -0,0 +1,32 @@ +import { IRoom } from '../../../../definition/IRoom'; + +type RoomSettings = { + 'roomAvatar': unknown; + 'featured': unknown; + 'roomName': unknown; + 'roomTopic': unknown; + 'roomAnnouncement': unknown; + 'roomCustomFields': unknown; + 'roomDescription': unknown; + 'roomType': unknown; + 'readOnly': unknown; + 'reactWhenReadOnly': unknown; + 'systemMessages': unknown; + 'default': unknown; + 'joinCode': unknown; + 'tokenpass': unknown; + 'streamingOptions': unknown; + 'retentionEnabled': unknown; + 'retentionMaxAge': unknown; + 'retentionExcludePinned': unknown; + 'retentionFilesOnly': unknown; + 'retentionIgnoreThreads': unknown; + 'retentionOverrideGlobal': unknown; + 'encrypted': boolean; + 'favorite': unknown; +}; + +export type SaveRoomSettingsMethod = { + (rid: IRoom['_id'], settings: Partial): { result: true; rid: IRoom['_id'] }; + (rid: IRoom['_id'], setting: RoomSettingName, value: RoomSettings[RoomSettingName]): { result: true; rid: IRoom['_id'] }; +} diff --git a/client/contexts/ServerContext/methods/saveSettings.ts b/client/contexts/ServerContext/methods/saveSettings.ts new file mode 100644 index 000000000000..bf74acd0316c --- /dev/null +++ b/client/contexts/ServerContext/methods/saveSettings.ts @@ -0,0 +1,9 @@ +import { ISetting } from '../../../../definition/ISetting'; + +type SettingChange = { + _id: ISetting['_id']; + value: unknown; + editor?: unknown; +}; + +export type SaveSettingsMethod = (changes: SettingChange[]) => true; diff --git a/client/contexts/ServerContext/methods/saveUserPreferences.ts b/client/contexts/ServerContext/methods/saveUserPreferences.ts new file mode 100644 index 000000000000..e8ddc742f534 --- /dev/null +++ b/client/contexts/ServerContext/methods/saveUserPreferences.ts @@ -0,0 +1,37 @@ +type UserPreferences = { + language: string; + newRoomNotification: string; + newMessageNotification: string; + clockMode: number; + useEmojis: boolean; + convertAsciiEmoji: boolean; + saveMobileBandwidth: boolean; + collapseMediaByDefault: boolean; + autoImageLoad: boolean; + emailNotificationMode: string; + unreadAlert: boolean; + notificationsSoundVolume: number; + desktopNotifications: string; + audioNotifications: string; + mobileNotifications: string; + enableAutoAway: boolean; + highlights: string[]; + messageViewMode: number; + hideUsernames: boolean; + hideRoles: boolean; + hideAvatars: boolean; + hideFlexTab: boolean; + sendOnEnter: string; + idleTimeLimit: number; + sidebarShowFavorites: boolean; + sidebarShowUnread: boolean; + sidebarSortby: string; + sidebarViewMode: string; + sidebarHideAvatar: boolean; + sidebarGroupByType: boolean; + sidebarShowDiscussion: boolean; + muteFocusedConversations: boolean; + dontAskAgainList: { action: string; label: string }[]; +}; + +export type SaveUserPreferencesMethod = (preferences: Partial) => boolean; diff --git a/client/contexts/ServerContext/methods/unfollowMessage.ts b/client/contexts/ServerContext/methods/unfollowMessage.ts new file mode 100644 index 000000000000..2dc4c46c42bc --- /dev/null +++ b/client/contexts/ServerContext/methods/unfollowMessage.ts @@ -0,0 +1,3 @@ +import { IMessage } from '../../../../definition/IMessage'; + +export type UnfollowMessageMethod = (options: { mid: IMessage['_id'] }) => false | undefined; diff --git a/client/contexts/TranslationContext.ts b/client/contexts/TranslationContext.ts index 15bd4c64bbfa..cfcb8afca8f5 100644 --- a/client/contexts/TranslationContext.ts +++ b/client/contexts/TranslationContext.ts @@ -1,18 +1,22 @@ import { createContext, useContext } from 'react'; +import type keys from '../../packages/rocketchat-i18n/i18n/en.i18n.json'; + export type TranslationLanguage = { name: string; en: string; key: string; }; +export type TranslationKey = keyof typeof keys; + export type TranslationContextValue = { languages: TranslationLanguage[]; language: TranslationLanguage['key']; loadLanguage: (language: TranslationLanguage['key']) => Promise; translate: { - (key: string, ...replaces: unknown[]): string; - has: (key: string) => boolean; + (key: TranslationKey, ...replaces: unknown[]): string; + has: (key: TranslationKey) => boolean; }; }; diff --git a/client/hooks/useEndpointData.ts b/client/hooks/useEndpointData.ts index dee970791d9c..5583af98c7c7 100644 --- a/client/hooks/useEndpointData.ts +++ b/client/hooks/useEndpointData.ts @@ -1,17 +1,21 @@ import { useCallback, useEffect } from 'react'; -import { useEndpoint } from '../contexts/ServerContext'; +import { ServerEndpointPath, ServerEndpointRequestPayload, ServerEndpointResponsePayload, ServerEndpoints, useEndpoint } from '../contexts/ServerContext'; import { useToastMessageDispatch } from '../contexts/ToastMessagesContext'; import { AsyncState, useAsyncState } from './useAsyncState'; const defaultParams = {}; -export const useEndpointData = ( - endpoint: string, - params: Record = defaultParams, - initialValue?: T | (() => T), -): AsyncState & { reload: () => void } => { - const { resolve, reject, reset, ...state } = useAsyncState(initialValue); +type ServerGetEndpointPaths = { + [K in ServerEndpointPath]: ServerEndpoints[K] extends { GET: any } ? K : never +}; + +export const useEndpointData = <_T, Path extends ServerGetEndpointPaths[keyof ServerGetEndpointPaths]>( + endpoint: Path, + params: ServerEndpointRequestPayload<'GET', Path> = defaultParams, + initialValue?: ServerEndpointResponsePayload<'GET', Path> | (() => ServerEndpointResponsePayload<'GET', Path>), +): AsyncState> & { reload: () => void } => { + const { resolve, reject, reset, ...state } = useAsyncState>(initialValue); const dispatchToastMessage = useToastMessageDispatch(); const getData = useEndpoint('GET', endpoint); diff --git a/client/hooks/useMethodData.ts b/client/hooks/useMethodData.ts index 3131347d6d04..ec92abc04247 100644 --- a/client/hooks/useMethodData.ts +++ b/client/hooks/useMethodData.ts @@ -1,13 +1,13 @@ import { useCallback, useEffect } from 'react'; -import { useMethod } from '../contexts/ServerContext'; +import { ServerMethods, useMethod } from '../contexts/ServerContext'; import { useToastMessageDispatch } from '../contexts/ToastMessagesContext'; import { AsyncState, useAsyncState } from './useAsyncState'; const defaultArgs: unknown[] = []; export const useMethodData = ( - methodName: string, + methodName: keyof ServerMethods, args: any[] = defaultArgs, initialValue?: T | (() => T), ): AsyncState & { reload: () => void } => { diff --git a/client/hooks/usePolledMethodData.ts b/client/hooks/usePolledMethodData.ts index a6ec0eb9a7ee..d8cdb1615f41 100644 --- a/client/hooks/usePolledMethodData.ts +++ b/client/hooks/usePolledMethodData.ts @@ -1,9 +1,10 @@ import { useEffect } from 'react'; +import { ServerMethods } from '../contexts/ServerContext'; import { AsyncState } from './useAsyncState'; import { useMethodData } from './useMethodData'; -export const usePolledMethodData = (methodName: string, args: any[] = [], intervalMs: number): AsyncState & { reload: () => void } => { +export const usePolledMethodData = (methodName: keyof ServerMethods, args: any[] = [], intervalMs: number): AsyncState & { reload: () => void } => { const { reload, ...state } = useMethodData(methodName, args); useEffect(() => { diff --git a/client/hooks/useRoomIcon.tsx b/client/hooks/useRoomIcon.tsx index 5e7eef708228..549df497af96 100644 --- a/client/hooks/useRoomIcon.tsx +++ b/client/hooks/useRoomIcon.tsx @@ -16,6 +16,10 @@ export const useRoomIcon = (room: IRoom, small = true): JSX.Element | { name: st return { name: 'baloons' }; } + if (room.teamMain) { + return { name: room.t === 'p' ? 'team-lock' : 'team' }; + } + switch (room.t) { case 'p': return { name: 'hashtag-lock' }; diff --git a/client/main.js b/client/main.js index 0557c789eba6..9ee80d00c408 100644 --- a/client/main.js +++ b/client/main.js @@ -21,4 +21,5 @@ import './startup'; import './views/admin'; import './views/login'; import './views/room/adapters'; +import './views/teams'; import './adapters'; diff --git a/client/polyfills/index.js b/client/polyfills/index.js index b871e4139d36..32572a5d3fac 100644 --- a/client/polyfills/index.js +++ b/client/polyfills/index.js @@ -6,6 +6,7 @@ import './cssVars'; Object.fromEntries = Object.fromEntries || function fromEntries(iterable) { return [...iterable].reduce((obj, { 0: key, 1: val }) => Object.assign(obj, { [key]: val }), {}); }; + (function(arr) { arr.forEach(function(item) { if (item.hasOwnProperty('remove')) { diff --git a/client/providers/SettingsProvider.tsx b/client/providers/SettingsProvider.tsx index 4c0c52304c83..6b37beef571b 100644 --- a/client/providers/SettingsProvider.tsx +++ b/client/providers/SettingsProvider.tsx @@ -91,7 +91,9 @@ const SettingsProvider: FunctionComponent = ({ ); const saveSettings = useMethod('saveSettings'); - const dispatch = useCallback((changes) => saveSettings(changes), [saveSettings]); + const dispatch = useCallback(async (changes) => { + await saveSettings(changes); + }, [saveSettings]); const contextValue = useMemo(() => ({ hasPrivateAccess, diff --git a/client/sidebar/header/CreateChannel.js b/client/sidebar/header/CreateChannel.js index e6974a31b9ae..97d8bcac1f09 100644 --- a/client/sidebar/header/CreateChannel.js +++ b/client/sidebar/header/CreateChannel.js @@ -129,6 +129,7 @@ export const CreateChannel = ({ export default memo(({ onClose, + teamId = '', }) => { const createChannel = useEndpointActionExperimental('POST', 'channels.create'); const createPrivateChannel = useEndpointActionExperimental('POST', 'groups.create'); @@ -204,6 +205,7 @@ export default memo(({ name, members: users, readOnly, + ...teamId && { teamId }, extraData: { description, broadcast, @@ -214,14 +216,25 @@ export default memo(({ if (type) { roomData = await createPrivateChannel(params); - goToRoom(roomData.group._id); + !teamId && goToRoom(roomData.group._id); } else { roomData = await createChannel(params); - goToRoom(roomData.channel._id); + !teamId && goToRoom(roomData.channel._id); } onClose(); - }, [broadcast, createChannel, createPrivateChannel, description, encrypted, name, onClose, readOnly, type, users]); + }, [broadcast, + createChannel, + createPrivateChannel, + description, + encrypted, + name, + onClose, + readOnly, + teamId, + type, + users, + ]); return {canCreateChannel && } + {canCreateTeam && } {canCreateDirectMessages && } {discussionEnabled && canCreateDiscussion && } diff --git a/client/sidebar/hooks/useRoomList.ts b/client/sidebar/hooks/useRoomList.ts index 2efe703a46a9..504e905e5b7f 100644 --- a/client/sidebar/hooks/useRoomList.ts +++ b/client/sidebar/hooks/useRoomList.ts @@ -26,6 +26,7 @@ export const useRoomList = (): Array => { useEffect(() => { setRoomList(() => { const favorite = new Set(); + const team = new Set(); const omnichannel = new Set(); const unread = new Set(); const _private = new Set(); @@ -44,6 +45,10 @@ export const useRoomList = (): Array => { return favorite.add(room); } + if (room.teamMain) { + return team.add(room); + } + if (showDiscussion && room.prid) { return discussion.add(room); } @@ -80,6 +85,7 @@ export const useRoomList = (): Array => { showOmnichannel && onHold.size && groups.set('On_Hold_Chats', onHold); sidebarShowUnread && unread.size && groups.set('Unread', unread); favoritesEnabled && favorite.size && groups.set('Favorites', favorite); + team.size && groups.set('Teams', team); showDiscussion && discussion.size && groups.set('Discussions', discussion); sidebarGroupByType && _private.size && groups.set('Private', _private); sidebarGroupByType && _public.size && groups.set('Public', _public); diff --git a/client/types/fuselage.d.ts b/client/types/fuselage.d.ts index ca7c6f6a28b7..f3bac443f9ac 100644 --- a/client/types/fuselage.d.ts +++ b/client/types/fuselage.d.ts @@ -173,6 +173,23 @@ declare module '@rocket.chat/fuselage' { Item: ForwardRefExoticComponent; }; + type AutoCompleteProps = { + value: unknown[]; + filter: string; + setFilter?: (filter: string) => void; + options?: { label: string; value: unknown }[]; + renderItem: ElementType; + renderSelected?: ElementType; + onChange: (value: unknown, action: 'remove' | undefined) => void; + getLabel?: (option: { label: string; value: unknown }) => string; + getValue?: (option: { label: string; value: unknown }) => unknown; + renderEmpty?: ElementType; + placeholder?: string; + error?: boolean; + disabled?: boolean; + }; + export const AutoComplete: FC; + type AvatarProps = Omit & { title?: string; size?: 'x16' | 'x18' | 'x20' | 'x24' | 'x28' | 'x32' | 'x36' | 'x40' | 'x48' | 'x124' | 'x200' | 'x332'; @@ -237,6 +254,7 @@ declare module '@rocket.chat/fuselage' { export const Field: ForwardRefExoticComponent & { Row: ForwardRefExoticComponent; Label: ForwardRefExoticComponent; + Description: ForwardRefExoticComponent; Hint: ForwardRefExoticComponent; Error: ForwardRefExoticComponent; }; @@ -274,6 +292,31 @@ declare module '@rocket.chat/fuselage' { type NumberInputProps = BoxProps; export const NumberInput: ForwardRefExoticComponent; + type OptionsProps = BoxProps & { + multiple?: boolean; + options: [unknown, string, boolean?][]; + cursor: number; + renderItem?: ElementType; + renderEmpty?: ElementType; + onSelect: (option: [unknown, string]) => void; + }; + export const Options: ForwardRefExoticComponent & { + AvatarSize: AvatarProps['size']; + }; + + type OptionProps = { + id?: string; + avatar?: ReactNode; + label?: string; + focus?: boolean; + selected?: boolean; + icon?: string; + className?: BoxProps['className']; + title?: string; + value?: any; + }; + export const Option: ForwardRefExoticComponent; + type PaginationProps = BoxProps & { count: number; current?: number; @@ -371,20 +414,6 @@ declare module '@rocket.chat/fuselage' { export const Divider: ForwardRefExoticComponent; - type OptionProps = { - id?: string; - avatar?: typeof Avatar; - label?: string; - focus?: boolean; - selected?: boolean; - icon?: string; - className?: BoxProps['className']; - title?: string; - value?: any; - }; - - export const Option: ForwardRefExoticComponent; - export type MenuProps = Omit & { icon?: string; options: { diff --git a/client/types/kadira-flow-router.d.ts b/client/types/kadira-flow-router.d.ts index 934e0ba5d36d..0d453049465a 100644 --- a/client/types/kadira-flow-router.d.ts +++ b/client/types/kadira-flow-router.d.ts @@ -138,5 +138,6 @@ declare module 'meteor/kadira:flow-router' { export const FlowRouter: Router & { Route: typeof Route; Router: typeof Router; + goToRoomById: (rid: unknown) => void; }; } diff --git a/client/views/InfoPanel/InfoPanel.stories.js b/client/views/InfoPanel/InfoPanel.stories.js new file mode 100644 index 000000000000..da3477a6d772 --- /dev/null +++ b/client/views/InfoPanel/InfoPanel.stories.js @@ -0,0 +1,67 @@ +import React from 'react'; + +import InfoPanel, { RetentionPolicyCallout } from '.'; + +export default { + title: 'components/InfoPanel', + component: InfoPanel, +}; + +const room = { + fname: 'rocketchat-frontend-team', + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero', + announcement: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero', + topic: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero', +}; + +export const Default = () => + + + + + + + + Description + {room.description} + + + Announcement + {room.announcement} + + + Topic + {room.topic} + + + + + + +; + + +// export const Archived = () => +// +// ; + + +// export const Broadcast = () => +// +// ; diff --git a/client/views/InfoPanel/InfoPanel.tsx b/client/views/InfoPanel/InfoPanel.tsx new file mode 100644 index 000000000000..3e7ad529eb1b --- /dev/null +++ b/client/views/InfoPanel/InfoPanel.tsx @@ -0,0 +1,62 @@ +import React, { FC, ReactNode } from 'react'; +import { Box, Icon, BoxProps, Button, ButtonProps, ButtonGroup, ButtonGroupProps } from '@rocket.chat/fuselage'; +import { css } from '@rocket.chat/css-in-js'; + +type TitleProps = { + title: string; + icon: string | ReactNode; +} + +const wordBreak = css` + word-break: break-word; +`; + +const InfoPanel: FC = ({ children }) => {children}; + +const Section: FC = (props) => ; + +const Title: FC = ({ title, icon }) => + { + typeof icon === 'string' + ? + : icon + } + {title} +; + +const Label: FC = (props) => ; + +const Text: FC = (props) => ; + +const Action: FC = ({ label, icon, ...props }) => ; + +const ActionGroup: FC = (props) =>
; + +const Field: FC = ({ children }) => {children}; + +const Avatar: FC = ({ children }) =>
+ {children} +
; + +Object.assign(InfoPanel, { + Title, + Label, + Text, + Avatar, + Field, + Action, + Section, + ActionGroup, +}); + +export default InfoPanel; diff --git a/client/views/InfoPanel/RetentionPolicyCallout.tsx b/client/views/InfoPanel/RetentionPolicyCallout.tsx new file mode 100644 index 000000000000..b96ac2994cb2 --- /dev/null +++ b/client/views/InfoPanel/RetentionPolicyCallout.tsx @@ -0,0 +1,22 @@ +import React, { FC } from 'react'; +import { Callout } from '@rocket.chat/fuselage'; + +import { useTranslation } from '../../contexts/TranslationContext'; + +type RetentionPolicyCalloutProps = { + filesOnlyDefault: boolean; + excludePinnedDefault: boolean; + maxAgeDefault: number; +} + +const RetentionPolicyCallout: FC = ({ filesOnlyDefault, excludePinnedDefault, maxAgeDefault }) => { + const t = useTranslation(); + return + {filesOnlyDefault && excludePinnedDefault &&

{t('RetentionPolicy_RoomWarning_FilesOnly', { time: maxAgeDefault })}

} + {filesOnlyDefault && !excludePinnedDefault &&

{t('RetentionPolicy_RoomWarning_UnpinnedFilesOnly', { time: maxAgeDefault })}

} + {!filesOnlyDefault && excludePinnedDefault &&

{t('RetentionPolicy_RoomWarning', { time: maxAgeDefault })}

} + {!filesOnlyDefault && !excludePinnedDefault &&

{t('RetentionPolicy_RoomWarning_Unpinned', { time: maxAgeDefault })}

} +
; +}; + +export default RetentionPolicyCallout; diff --git a/client/views/InfoPanel/index.ts b/client/views/InfoPanel/index.ts new file mode 100644 index 000000000000..9a0b6946e83a --- /dev/null +++ b/client/views/InfoPanel/index.ts @@ -0,0 +1,6 @@ +import InfoPanel from './InfoPanel'; +import RetentionPolicyCallout from './RetentionPolicyCallout'; + +export { RetentionPolicyCallout }; + +export default InfoPanel; diff --git a/client/views/admin/cloud/PasteStep.tsx b/client/views/admin/cloud/PasteStep.tsx index dceb888ce510..b0833360fd2f 100644 --- a/client/views/admin/cloud/PasteStep.tsx +++ b/client/views/admin/cloud/PasteStep.tsx @@ -74,7 +74,7 @@ const PasteStep: FC = ({ onBackButtonClick, onFinish }) => { diff --git a/client/views/admin/customEmoji/EditCustomEmoji.tsx b/client/views/admin/customEmoji/EditCustomEmoji.tsx index f110f5fd45db..84e15927ebf1 100644 --- a/client/views/admin/customEmoji/EditCustomEmoji.tsx +++ b/client/views/admin/customEmoji/EditCustomEmoji.tsx @@ -10,13 +10,17 @@ import { useEndpointAction } from '../../../hooks/useEndpointAction'; import VerticalBar from '../../../components/VerticalBar'; import DeleteSuccessModal from '../../../components/DeleteSuccessModal'; import DeleteWarningModal from '../../../components/DeleteWarningModal'; -import { EmojiDescriptor } from './types'; import { useAbsoluteUrl } from '../../../contexts/ServerContext'; type EditCustomEmojiProps = { close: () => void; onChange: () => void; - data: EmojiDescriptor; + data: { + _id: string; + name: string; + aliases: string[]; + extension: string; + }; }; const EditCustomEmoji: FC = ({ close, onChange, data, ...props }) => { diff --git a/client/views/admin/customEmoji/EditCustomEmojiWithData.tsx b/client/views/admin/customEmoji/EditCustomEmojiWithData.tsx index d017b97faf3f..9beae4000294 100644 --- a/client/views/admin/customEmoji/EditCustomEmojiWithData.tsx +++ b/client/views/admin/customEmoji/EditCustomEmojiWithData.tsx @@ -3,7 +3,6 @@ import { Box, Button, ButtonGroup, Skeleton, Throbber, InputBox } from '@rocket. import { useTranslation } from '../../../contexts/TranslationContext'; import EditCustomEmoji from './EditCustomEmoji'; -import { EmojiDescriptor } from './types'; import { useEndpointData } from '../../../hooks/useEndpointData'; import { AsyncStatePhase } from '../../../hooks/useAsyncState'; @@ -26,11 +25,7 @@ const EditCustomEmojiWithData: FC = ({ _id, onChan phase: state, error, reload, - } = useEndpointData<{ - emojis?: { - update: EmojiDescriptor[]; - }; - }>('emoji-custom.list', query); + } = useEndpointData('emoji-custom.list', query); if (state === AsyncStatePhase.LOADING) { return diff --git a/client/views/admin/customEmoji/types.ts b/client/views/admin/customEmoji/types.ts deleted file mode 100644 index d8a0c7c4a361..000000000000 --- a/client/views/admin/customEmoji/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type EmojiDescriptor = { - _id: string; - name: string; - aliases: string[]; - extension: string; -}; diff --git a/client/views/admin/customUserStatus/EditCustomUserStatusWithData.tsx b/client/views/admin/customUserStatus/EditCustomUserStatusWithData.tsx index 40b4510df926..a22d98a48c09 100644 --- a/client/views/admin/customUserStatus/EditCustomUserStatusWithData.tsx +++ b/client/views/admin/customUserStatus/EditCustomUserStatusWithData.tsx @@ -16,9 +16,7 @@ export const EditCustomUserStatusWithData: FC const t = useTranslation(); const query = useMemo(() => ({ query: JSON.stringify({ _id }) }), [_id]); - const { value: data, phase: state, error, reload } = useEndpointData<{ - statuses: unknown[]; - }>('custom-user-status.list', query); + const { value: data, phase: state, error, reload } = useEndpointData('custom-user-status.list', query); if (state === AsyncStatePhase.LOADING) { return diff --git a/client/views/admin/rooms/RoomsTable.js b/client/views/admin/rooms/RoomsTable.js index 9c3fd64c8d06..599980657286 100644 --- a/client/views/admin/rooms/RoomsTable.js +++ b/client/views/admin/rooms/RoomsTable.js @@ -11,7 +11,7 @@ import { useRoute } from '../../../contexts/RouterContext'; const style = { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }; -export const DEFAULT_TYPES = ['d', 'p', 'c']; +export const DEFAULT_TYPES = ['d', 'p', 'c', 'teams']; export const roomTypeI18nMap = { l: 'Omnichannel', @@ -19,11 +19,12 @@ export const roomTypeI18nMap = { d: 'Direct', p: 'Group', discussion: 'Discussion', + team: 'Team', }; const FilterByTypeAndText = ({ setFilter, ...props }) => { const [text, setText] = useState(''); - const [types, setTypes] = useState({ d: false, c: false, p: false, l: false, discussions: false }); + const [types, setTypes] = useState({ d: false, c: false, p: false, l: false, discussions: false, teams: false }); const t = useTranslation(); @@ -43,6 +44,7 @@ const FilterByTypeAndText = ({ setFilter, ...props }) => { const idPrivate = useUniqueId(); const idOmnichannel = useUniqueId(); const idDiscussions = useUniqueId(); + const idTeam = useUniqueId(); return e.preventDefault(), [])} display='flex' flexDirection='column' {...props}> } onChange={handleChange} value={text} /> @@ -69,6 +71,10 @@ const FilterByTypeAndText = ({ setFilter, ...props }) => { handleCheckBox('discussions')}/> {t('Discussions')} + + handleCheckBox('teams')}/> + {t('Teams')} + diff --git a/client/views/admin/sidebar/AdminSidebarSettings.tsx b/client/views/admin/sidebar/AdminSidebarSettings.tsx index ea93ec6ce3af..8e74f2e93f5c 100644 --- a/client/views/admin/sidebar/AdminSidebarSettings.tsx +++ b/client/views/admin/sidebar/AdminSidebarSettings.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useState, useMemo, FC } from 'react'; import { ISetting } from '../../../../definition/ISetting'; import { useSettings } from '../../../contexts/SettingsContext'; -import { useTranslation } from '../../../contexts/TranslationContext'; +import { TranslationKey, useTranslation } from '../../../contexts/TranslationContext'; import Sidebar from '../../../components/Sidebar'; const useSettingsGroups = (filter: string): ISetting[] => { @@ -18,8 +18,8 @@ const useSettingsGroups = (filter: string): ISetting[] => { } const getMatchableStrings = (setting: ISetting): string[] => [ - setting.i18nLabel && t(setting.i18nLabel), - t(setting._id), + setting.i18nLabel && t(setting.i18nLabel as TranslationKey), + t(setting._id as TranslationKey), setting._id, ].filter(Boolean); @@ -48,7 +48,7 @@ const useSettingsGroups = (filter: string): ISetting[] => { return settings .filter(({ type, group, _id }) => type === 'group' && groupIds.includes(group || _id)) - .sort((a, b) => t(a.i18nLabel || a._id).localeCompare(t(b.i18nLabel || b._id))); + .sort((a, b) => t((a.i18nLabel || a._id) as TranslationKey).localeCompare(t((b.i18nLabel || b._id) as TranslationKey))); }, [settings, filterPredicate, t]); }; @@ -78,7 +78,7 @@ const AdminSidebarSettings: FC = ({ currentPath }) => {isLoadingGroups && } {!isLoadingGroups && !!groups.length && ({ - name: t(group.i18nLabel || group._id), + name: t((group.i18nLabel || group._id) as TranslationKey), pathSection: 'admin', pathGroup: group._id, }))} diff --git a/client/views/admin/users/UserInfoActions.js b/client/views/admin/users/UserInfoActions.js index 907de5dffa50..832a5faaa3ae 100644 --- a/client/views/admin/users/UserInfoActions.js +++ b/client/views/admin/users/UserInfoActions.js @@ -1,7 +1,7 @@ import { ButtonGroup, Menu, Option } from '@rocket.chat/fuselage'; import React, { useCallback, useMemo } from 'react'; -import { useUserInfoActionsSpread } from '../../room/hooks/useUserInfoActions'; +import { useActionSpread } from '../../hooks/useActionSpread'; import ConfirmOwnerChangeWarningModal from '../../../components/ConfirmOwnerChangeWarningModal'; import { UserInfo } from '../../room/contextualBar/UserInfo'; import { usePermission } from '../../../contexts/AuthorizationContext'; @@ -234,7 +234,7 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange }) username, ]); - const { actions: actionsDefinition, menu: menuOptions } = useUserInfoActionsSpread(options); + const { actions: actionsDefinition, menu: menuOptions } = useActionSpread(options); const menu = useMemo(() => { if (!menuOptions) { diff --git a/client/views/directory/DirectoryPage.js b/client/views/directory/DirectoryPage.js index 4e6270c3f9fd..5ed6f4173525 100644 --- a/client/views/directory/DirectoryPage.js +++ b/client/views/directory/DirectoryPage.js @@ -5,6 +5,7 @@ import Page from '../../components/Page'; import { useTranslation } from '../../contexts/TranslationContext'; import UserTab from './UserTab'; import ChannelsTab from './ChannelsTab'; +import TeamsTab from './TeamsTab'; import { useRoute, useRouteParameter } from '../../contexts/RouterContext'; import { useSetting } from '../../contexts/SettingsContext'; @@ -31,12 +32,14 @@ function DirectoryPage() { {t('Channels')} {t('Users')} + {t('Teams')} { federationEnabled && {t('External_Users')} } { (tab === 'users' && ) || (tab === 'channels' && ) + || (tab === 'teams' && < TeamsTab/>) || (federationEnabled && tab === 'external' && ) } diff --git a/client/views/directory/TeamsTab.js b/client/views/directory/TeamsTab.js new file mode 100644 index 000000000000..a2334f3cad45 --- /dev/null +++ b/client/views/directory/TeamsTab.js @@ -0,0 +1,109 @@ +import { Box, Margins, Table, Avatar, Tag } from '@rocket.chat/fuselage'; +import { useMediaQuery } from '@rocket.chat/fuselage-hooks'; +import React, { useMemo, useState, useCallback } from 'react'; + +import GenericTable from '../../components/GenericTable'; +import NotAuthorizedPage from '../../components/NotAuthorizedPage'; +import { usePermission } from '../../contexts/AuthorizationContext'; +import { useRoute } from '../../contexts/RouterContext'; +import { useTranslation } from '../../contexts/TranslationContext'; +import { useEndpointData } from '../../hooks/useEndpointData'; +import { useFormatDate } from '../../hooks/useFormatDate'; +import { roomTypes } from '../../../app/utils/client'; +import { useQuery } from './hooks'; + +const style = { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }; + +function RoomTags({ room }) { + const t = useTranslation(); + return + + {room.default && {t('default')}} + {room.featured && {t('featured')}} + + ; +} + +function TeamsTable() { + const t = useTranslation(); + const [sort, setSort] = useState(['name', 'asc']); + const [params, setParams] = useState({ current: 0, itemsPerPage: 25 }); + + const mediaQuery = useMediaQuery('(min-width: 768px)'); + + const onHeaderClick = useCallback((id) => { + const [sortBy, sortDirection] = sort; + + if (sortBy === id) { + setSort([id, sortDirection === 'asc' ? 'desc' : 'asc']); + return; + } + setSort([id, 'asc']); + }, [sort]); + + const header = useMemo(() => [ + {t('Name')}, + {t('Channels')}, + mediaQuery && {t('Created_at')}, + ].filter(Boolean), [sort, onHeaderClick, t, mediaQuery]); + + const channelsRoute = useRoute('channel'); + const groupsRoute = useRoute('group'); + + const query = useQuery(params, sort); + + const { value: data = { result: [] } } = useEndpointData('teams.list', query); + + const onClick = useMemo(() => (name, type) => (e) => { + if (e.type === 'click' || e.key === 'Enter') { + type === 0 ? channelsRoute.push({ name }) : groupsRoute.push({ name }); + } + }, [channelsRoute, groupsRoute]); + + const formatDate = useFormatDate(); + const renderRow = useCallback((team) => { + const { _id, createdAt, name, type, rooms, roomId } = team; + const t = type === 0 ? 'c' : 'p'; + const avatarUrl = roomTypes.getConfig(t).getAvatarPath({ _id: roomId }); + + return + + + + + + + + {name} + + + + + + {rooms} + + { mediaQuery && + {formatDate(createdAt)} + } + ; + } + , [formatDate, mediaQuery, onClick]); + + return ; +} + +export default function TeamsTab(props) { + const canViewPublicRooms = usePermission('view-c-room'); + + if (canViewPublicRooms) { + return ; + } + + return ; +} diff --git a/client/views/hooks/useActionSpread.ts b/client/views/hooks/useActionSpread.ts new file mode 100644 index 000000000000..5e2244af198c --- /dev/null +++ b/client/views/hooks/useActionSpread.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; + +type Action = { + label: string; + icon: string; + action: () => any; +} + +type MenuOption = { + label: { label: string; icon: string}; + action: Function; +} + +const mapOptions = ([key, { action, label, icon }]: [string, Action]): [string, MenuOption] => [ + key, + { + label: { label, icon }, // TODO fuselage + action, + }, +]; + +export const useActionSpread = (actions: Action[], size = 2): { actions: [string, Action][]; menu: { [id: string]: MenuOption } | undefined } => useMemo(() => { + const entries = Object.entries(actions); + + const options = entries.slice(0, size); + const menuOptions = entries.slice(size, entries.length).map(mapOptions); + const menu = menuOptions.length ? Object.fromEntries(menuOptions) : undefined; + + return { actions: options, menu }; +}, [actions, size]); diff --git a/client/views/omnichannel/appearance/AppearancePage.tsx b/client/views/omnichannel/appearance/AppearancePage.tsx index f8d1a101dc13..2a0fdf9aa694 100644 --- a/client/views/omnichannel/appearance/AppearancePage.tsx +++ b/client/views/omnichannel/appearance/AppearancePage.tsx @@ -15,11 +15,6 @@ import { ISetting } from '../../../../definition/ISetting'; import { useEndpointData } from '../../../hooks/useEndpointData'; import { AsyncStatePhase } from '../../../hooks/useAsyncState'; -type LivechatAppearanceEndpointData = { - success: boolean; - appearance: ISetting[]; -}; - type LivechatAppearanceSettings = { Livechat_title: string; Livechat_title_color: string; @@ -44,7 +39,7 @@ type LivechatAppearanceSettings = { type AppearanceSettings = Partial; -const reduceAppearance = (settings: LivechatAppearanceEndpointData['appearance']): AppearanceSettings => +const reduceAppearance = (settings: ISetting[]): AppearanceSettings => settings.reduce>((acc, { _id, value }) => { acc = { ...acc, [_id]: value }; return acc; @@ -53,7 +48,7 @@ const reduceAppearance = (settings: LivechatAppearanceEndpointData['appearance'] const AppearancePageContainer: FC = () => { const t = useTranslation(); - const { value: data, phase: state, error } = useEndpointData('livechat/appearance'); + const { value: data, phase: state, error } = useEndpointData('livechat/appearance'); const canViewAppearance = usePermission('view-livechat-appearance'); @@ -80,7 +75,7 @@ const AppearancePageContainer: FC = () => { }; type AppearancePageProps = { - settings: LivechatAppearanceEndpointData['appearance']; + settings: ISetting[]; }; const AppearancePage: FC = ({ settings }) => { diff --git a/client/views/room/Announcement/Announcement.stories.js b/client/views/room/Announcement/Announcement.stories.js index 84e0acad04a7..d6580017d0fb 100644 --- a/client/views/room/Announcement/Announcement.stories.js +++ b/client/views/room/Announcement/Announcement.stories.js @@ -1,6 +1,6 @@ import React from 'react'; -import { Announcement } from './Announcement'; +import Announcement from '.'; export default { title: 'components/Announcement', @@ -8,4 +8,4 @@ export default { }; export const Default = () => - Lorem Ipsum Indolor; + ; diff --git a/client/views/room/Announcement/Announcement.tsx b/client/views/room/Announcement/Announcement.tsx index 7d9ad98cac76..5775c557c710 100644 --- a/client/views/room/Announcement/Announcement.tsx +++ b/client/views/room/Announcement/Announcement.tsx @@ -18,7 +18,7 @@ type AnnouncementParams = { announcementDetails: () => void; } -export const AnnouncementComponent: FC = ({ children, onClickOpen }) => { +const AnnouncementComponent: FC = ({ children, onClickOpen }) => { const announcementBar = css` background-color: ${ colors.b200 }; background-color: var(--rc-color-announcement-background, ${ colors.b200 }); diff --git a/client/views/room/Header/Header.js b/client/views/room/Header/Header.js index 07eb04056fc6..8fa6bd986bd8 100644 --- a/client/views/room/Header/Header.js +++ b/client/views/room/Header/Header.js @@ -1,9 +1,12 @@ -import React from 'react'; +import React, { useMemo } from 'react'; +import { Meteor } from 'meteor/meteor'; import { Box } from '@rocket.chat/fuselage'; import Header from '../../../components/Header'; import Breadcrumbs from '../../../components/Breadcrumbs'; import { useRoomIcon } from '../../../hooks/useRoomIcon'; +import { useEndpointData } from '../../../hooks/useEndpointData'; +import { AsyncStatePhase } from '../../../hooks/useAsyncState'; import Encrypted from './icons/Encrypted'; import Favorite from './icons/Favorite'; import Translate from './icons/Translate'; @@ -36,25 +39,42 @@ const HeaderIcon = ({ room }) => { return {!icon.name && icon}; }; -const RoomTitle = ({ room }) => { +const RoomTitle = ({ room }) => <> + + {room.name} +; + +const ParentRoom = ({ room }) => { const prevSubscription = useUserSubscription(room.prid); const prevRoomHref = prevSubscription ? roomTypes.getRouteLink(prevSubscription.t, prevSubscription) : null; - - return - {room.prid && prevSubscription && <> - - - {prevSubscription.name} - - - } - - - {room.name} - - ; + return prevSubscription && <> + + + {prevSubscription.name} + + ; }; +const ParentTeam = ({ room }) => { + const query = useMemo(() => ({ teamId: room.teamId }), [room.teamId]); + const userTeamQuery = useMemo(() => ({ userId: Meteor.userId() }), []); + + const { value, phase } = useEndpointData('teams.info', query); + const { value: userTeams, phase: userTeamsPhase } = useEndpointData('users.listTeams', userTeamQuery); + + const teamLoading = phase === AsyncStatePhase.LOADING; + const userTeamsLoading = userTeamsPhase === AsyncStatePhase.LOADING; + const belongsToTeam = userTeams?.teams?.find((team) => team._id === room.teamId); + + const teamMainRoom = useUserSubscription(value?.teamInfo?.roomId); + const teamMainRoomHref = teamMainRoom ? roomTypes.getRouteLink(teamMainRoom.t, teamMainRoom) : null; + const teamIcon = value?.t === 0 ? 'team' : 'team-lock'; + + return teamLoading || userTeamsLoading || room.teamMain ? null : + + {teamMainRoom?.name} + ; +}; const DirectRoomHeader = ({ room }) => { const userId = useUserId(); const directUserId = room.uids.filter((uid) => uid !== userId).shift(); @@ -76,6 +96,7 @@ const RoomHeader = ({ room, topic }) => { + {(room.prid || room.teamId) && ((room.prid && ) || (room.teamId && ))} { showQuickActions && diff --git a/client/views/room/Header/Omnichannel/QuickActions/QuickActions.tsx b/client/views/room/Header/Omnichannel/QuickActions/QuickActions.tsx index d3c1ecf08172..f1d88e0e67be 100644 --- a/client/views/room/Header/Omnichannel/QuickActions/QuickActions.tsx +++ b/client/views/room/Header/Omnichannel/QuickActions/QuickActions.tsx @@ -35,12 +35,14 @@ const QuickActions = ({ room, className }: { room: IRoom; className: BoxProps['c const rid = room._id; const uid = useUserId(); - const getVisitorInfo = useEndpoint('GET', `livechat/visitors.info?visitorId=${ visitorRoomId }`); + const getVisitorInfo = useEndpoint('GET', 'livechat/visitors.info'); const getVisitorEmail = useMutableCallback(async () => { if (!visitorRoomId) { return; } - const { visitor: { visitorEmails } } = await getVisitorInfo(); - setEmail(visitorEmails && visitorEmails.length > 0 && visitorEmails[0].address); + const { visitor: { visitorEmails } } = await getVisitorInfo(visitorRoomId); + if (visitorEmails?.length && visitorEmails[0].address) { + setEmail(visitorEmails[0].address); + } }); useEffect(() => { @@ -200,7 +202,7 @@ const QuickActions = ({ room, className }: { room: IRoom; className: BoxProps['c id, icon, color, - title: t(title), + title: t(title as any), className, tabId: id, index, diff --git a/client/views/room/UserCard/index.js b/client/views/room/UserCard/index.js index ddd2d092084b..015a07f30bdd 100644 --- a/client/views/room/UserCard/index.js +++ b/client/views/room/UserCard/index.js @@ -8,7 +8,8 @@ import UserCard from '../../../components/UserCard'; import { Backdrop } from '../../../components/Backdrop'; import { ReactiveUserStatus } from '../../../components/UserStatus'; import { LocalTime } from '../../../components/UTCClock'; -import { useUserInfoActions, useUserInfoActionsSpread } from '../hooks/useUserInfoActions'; +import { useUserInfoActions } from '../hooks/useUserInfoActions'; +import { useActionSpread } from '../../hooks/useActionSpread'; import { useRolesDescription } from '../../../contexts/AuthorizationContext'; import { AsyncStatePhase } from '../../../hooks/useAsyncState'; import { useEndpointData } from '../../../hooks/useEndpointData'; @@ -69,7 +70,7 @@ const UserCardWithData = ({ username, onClose, target, open, rid }) => { onClose && onClose(); }); - const { actions: actionsDefinition, menu: menuOptions } = useUserInfoActionsSpread(useUserInfoActions(user, rid)); + const { actions: actionsDefinition, menu: menuOptions } = useActionSpread(useUserInfoActions(user, rid)); const menu = useMemo(() => { if (!menuOptions) { diff --git a/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.js b/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.js index bf1581fb5b4c..f76b25419674 100644 --- a/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.js +++ b/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.js @@ -1,11 +1,8 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { Box, Icon, Button, ButtonGroup, Divider, Callout } from '@rocket.chat/fuselage'; -import { css } from '@rocket.chat/css-in-js'; +import { Box, Callout, Menu, Option } from '@rocket.chat/fuselage'; -import RoomAvatar from '../../../../../components/avatar/RoomAvatar'; import { useTranslation } from '../../../../../contexts/TranslationContext'; -import UserCard from '../../../../../components/UserCard'; import VerticalBar from '../../../../../components/VerticalBar'; import { useUserRoom } from '../../../../../contexts/UserContext'; import { useMethod } from '../../../../../contexts/ServerContext'; @@ -19,7 +16,14 @@ import { RoomManager } from '../../../../../../app/ui-utils/client/lib/RoomManag import { usePermission } from '../../../../../contexts/AuthorizationContext'; import WarningModal from '../../../../admin/apps/WarningModal'; import MarkdownText from '../../../../../components/MarkdownText'; +import ChannelToTeamModal from '../../../../teams/modals/ChannelToTeamModal/ChannelToTeamModal'; +import ConvertToTeamModal from '../../../../teams/modals/ConvertToTeamModal'; import { useTabBarClose } from '../../../providers/ToolboxProvider'; +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointAction'; +import InfoPanel, { RetentionPolicyCallout } from '../../../../InfoPanel'; +import RoomAvatar from '../../../../../components/avatar/RoomAvatar'; +import { useActionSpread } from '../../../../hooks/useActionSpread'; + const retentionPolicyMaxAge = { c: 'RetentionPolicy_MaxAge_Channels', @@ -33,19 +37,9 @@ const retentionPolicyAppliesTo = { d: 'RetentionPolicy_AppliesToDMs', }; -const wordBreak = css` - word-break: break-word !important; -`; - -const Label = (props) => ; -const Info = ({ className, ...props }) => ; - -export const RoomInfoIcon = ({ name }) => ; - -export const Title = (props) => ; - export const RoomInfo = function RoomInfo({ - fname: name, + name, + fname, description, archived, broadcast, @@ -60,6 +54,8 @@ export const RoomInfo = function RoomInfo({ onClickLeave, onClickEdit, onClickDelete, + onClickMoveToTeam, + onClickConvertToTeam, }) { const t = useTranslation(); @@ -70,6 +66,64 @@ export const RoomInfo = function RoomInfo({ maxAgeDefault, } = retentionPolicy; + const memoizedActions = useMemo(() => ({ + ...onClickEdit && { edit: { + label: t('Edit'), + icon: 'edit', + action: onClickEdit, + } }, + ...onClickDelete && { delete: { + label: t('Delete'), + icon: 'trash', + action: onClickDelete, + } }, + ...onClickMoveToTeam && { move: { + label: t('Teams_move_channel_to_team'), + icon: 'team', + action: onClickMoveToTeam, + } }, + ...onClickConvertToTeam && { convert: { + label: t('Teams_convert_channel_to_team'), + icon: 'team', + action: onClickConvertToTeam, + } }, + ...onClickHide && { hide: { + label: t('Hide'), + action: onClickHide, + icon: 'eye-off', + } }, + ...onClickLeave && { leave: { + label: t('Leave'), + action: onClickLeave, + icon: 'sign-out', + } }, + }), [onClickEdit, t, onClickDelete, onClickMoveToTeam, onClickConvertToTeam, onClickHide, onClickLeave]); + + const { actions: actionsDefinition, menu: menuOptions } = useActionSpread(memoizedActions); + + const menu = useMemo(() => { + if (!menuOptions) { + return null; + } + + return