feat: init
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import Api from '../../global/framework/api-service';
|
||||
import { ChannelType } from 'app/features/channels/types/channel';
|
||||
import { TwakeService } from '../../global/framework/registry-decorator-service';
|
||||
import { delayRequest } from 'app/features/global/utils/managedSearchRequest';
|
||||
import { removeBadgesNow } from 'app/features/users/hooks/use-notifications';
|
||||
|
||||
import Workspace from 'app/deprecated/workspaces/workspaces';
|
||||
import Logger from 'features/global/framework/logger-service';
|
||||
const PREFIX = '/internal/services/channels/v1/companies';
|
||||
|
||||
export type SearchOptions = {
|
||||
company_id: string;
|
||||
page_token?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
@TwakeService('ChannelAPIClientService')
|
||||
class ChannelAPIClientService {
|
||||
private logger = Logger.getLogger('ChannelAPIClientService');
|
||||
|
||||
async getDirect(companyId: string, membersId: string[]) {
|
||||
return Api.post<{ options: { members: string[] }; resource: unknown }, { resource: ChannelType }>(
|
||||
`${PREFIX}/${companyId}/workspaces/direct/channels`,
|
||||
{ options: { members: membersId }, resource: {} },
|
||||
).then(result => result.resource);
|
||||
}
|
||||
|
||||
async get(companyId: string, workspaceId: string, channelId: string): Promise<ChannelType> {
|
||||
return Api.get<{ resource: ChannelType }>(
|
||||
`${PREFIX}/${companyId}/workspaces/${workspaceId}/channels/${channelId}`,
|
||||
).then(result => result?.resource);
|
||||
}
|
||||
|
||||
async save(
|
||||
channel: ChannelType,
|
||||
context: { companyId: string; workspaceId: string; channelId?: string },
|
||||
) {
|
||||
return Api.post<{ resource: ChannelType }, { resource: ChannelType }>(
|
||||
`${PREFIX}/${context.companyId}/workspaces/${context.workspaceId}/channels${
|
||||
context?.channelId ? `/${context.channelId}` : ''
|
||||
}`,
|
||||
{
|
||||
resource: channel,
|
||||
},
|
||||
).then(result => result.resource);
|
||||
}
|
||||
|
||||
async read(
|
||||
companyId: string,
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
{ status = true, requireFocus = false, now = false },
|
||||
): Promise<void> {
|
||||
if (requireFocus && !document.hasFocus()) return;
|
||||
|
||||
if (status) removeBadgesNow('channel', channelId);
|
||||
|
||||
delayRequest(
|
||||
'reach-end-read-channel-' + channelId,
|
||||
async () =>
|
||||
await Api.post<{ value: boolean }, void>(
|
||||
`${PREFIX}/${companyId}/workspaces/${workspaceId}/channels/${channelId}/read`,
|
||||
{
|
||||
value: status,
|
||||
},
|
||||
),
|
||||
{ doInitialCall: now, timeout: 2000 },
|
||||
);
|
||||
}
|
||||
|
||||
async recent(companyId: string, limit: number): Promise<ChannelType[]> {
|
||||
try {
|
||||
const res = await Api.get<{ resources: ChannelType[] }>(
|
||||
`${PREFIX}/${companyId}/channels/recent?limit=${limit}`,
|
||||
);
|
||||
|
||||
return res.resources;
|
||||
} catch (e) {
|
||||
console.error("Can't retrieve channels", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async search(
|
||||
searchString: string | null,
|
||||
options: SearchOptions,
|
||||
): Promise<{ resources: ChannelType[] }> {
|
||||
if (!searchString) {
|
||||
return { resources: await this.recent(options.company_id, options?.limit || 100) };
|
||||
}
|
||||
|
||||
const companyId = options?.company_id || Workspace.currentGroupId;
|
||||
const query = `/internal/services/channels/v1/companies/${companyId}/search?q=${searchString}`;
|
||||
const res = await Api.getWithParams<{ resources: ChannelType[] }>(query, options);
|
||||
this.logger.debug(
|
||||
`Search by name "${searchString}" with options`,
|
||||
options,
|
||||
'. Found',
|
||||
res.resources.length,
|
||||
'channels',
|
||||
);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
const ChannelAPIClient = new ChannelAPIClientService();
|
||||
export default ChannelAPIClient;
|
||||
@@ -0,0 +1,98 @@
|
||||
import Api from '../../global/framework/api-service';
|
||||
import { ChannelType } from 'app/features/channels/types/channel';
|
||||
import { TwakeService } from '../../global/framework/registry-decorator-service';
|
||||
import { WebsocketRoom } from '../../global/types/websocket-types';
|
||||
|
||||
type ChannelsMineGetResponse = { resources: ChannelType[]; websockets: WebsocketRoom[] };
|
||||
type ChannelsMineDeleteBaseResponse = {
|
||||
statusCode: number;
|
||||
error: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
@TwakeService('ChannelsMineAPIClientService')
|
||||
class ChannelsMineAPIClient {
|
||||
private readonly prefix = '/internal/services/channels/v1/companies';
|
||||
private readonly realtime: Map<string, WebsocketRoom[]> = new Map();
|
||||
|
||||
websockets(companyId: string, workspaceId: string): WebsocketRoom[] {
|
||||
return this.realtime.get(this.getRealtimeKey(companyId, workspaceId)) || [];
|
||||
}
|
||||
|
||||
getRealtimeKey(companyId: string, workspaceId: string) {
|
||||
return `/companies/${companyId}/workspaces/${workspaceId}/channels`;
|
||||
}
|
||||
|
||||
async save(
|
||||
channel: ChannelType,
|
||||
context: { companyId: string; workspaceId: string; channelId?: string },
|
||||
) {
|
||||
return Api.post<{ resource: ChannelType }, { resource: ChannelType }>(
|
||||
`${this.prefix}/${context.companyId}/workspaces/${context.workspaceId}/channels${
|
||||
context?.channelId ? `/${context.channelId}` : ''
|
||||
}`,
|
||||
{
|
||||
resource: channel,
|
||||
},
|
||||
).then(result => result.resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param companyId
|
||||
* @param workspaceId
|
||||
* @return channels that user is already a member
|
||||
*/
|
||||
async get(context: { companyId: string; workspaceId?: string }): Promise<ChannelType[]> {
|
||||
context.workspaceId = context.workspaceId || 'direct';
|
||||
|
||||
return Api.get<ChannelsMineGetResponse>(
|
||||
`${this.prefix}/${context.companyId}/workspaces/${
|
||||
context.workspaceId
|
||||
}/channels?mine=1&websockets=1${context.workspaceId == 'direct' ? '&include_users=1' : ''}`,
|
||||
).then(result => {
|
||||
this.realtime.set(
|
||||
this.getRealtimeKey(context.companyId, context.workspaceId as string),
|
||||
result.websockets,
|
||||
);
|
||||
return result.resources;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user from a channel.
|
||||
* Every user in the channel (except guests) can remove an user.
|
||||
* A system message will be sent.
|
||||
* We cannot call this route for direct channels.
|
||||
* @param userId string
|
||||
* @param context companyId - workspaceId - channelId
|
||||
*/
|
||||
async removeUser(
|
||||
userId: string,
|
||||
context: { companyId: string; workspaceId: string; channelId: string },
|
||||
): Promise<ChannelsMineDeleteBaseResponse> {
|
||||
return Api.delete<ChannelsMineDeleteBaseResponse>(
|
||||
`${this.prefix}/${context.companyId}/workspaces/${context.workspaceId}/channels/${context.channelId}/members/${userId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a channel, this action is not reversible.
|
||||
* Direct channels can not be removed.
|
||||
* Only administrators and channel owner can remove a channel.
|
||||
* @param companyId string
|
||||
* @param workspaceId string
|
||||
* @param channelId string
|
||||
*
|
||||
*/
|
||||
removeChannel(
|
||||
companyId: string,
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
): Promise<ChannelsMineDeleteBaseResponse> {
|
||||
return Api.delete<ChannelsMineDeleteBaseResponse>(
|
||||
`${this.prefix}/${companyId}/workspaces/${workspaceId}/channels/${channelId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChannelsMineAPIClient();
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
ChannelsReachableGetResponse,
|
||||
ChannelsReachableInviteUserRequest,
|
||||
ChannelsReachableInviteUserResponse,
|
||||
} from 'app/features/channels/types/channels-reachable-types';
|
||||
import Api from '../../global/framework/api-service';
|
||||
import { ChannelType } from 'app/features/channels/types/channel';
|
||||
import { TwakeService } from '../../global/framework/registry-decorator-service';
|
||||
import { ChannelMemberType } from 'app/features/channel-members/types/channel-member-types';
|
||||
|
||||
@TwakeService('ChannelsReachableAPIClientService')
|
||||
class ChannelsReachableAPIClientService {
|
||||
private readonly prefix = '/internal/services/channels/v1/companies';
|
||||
|
||||
/**
|
||||
* @param companyId
|
||||
* @param workspaceId
|
||||
* @return channels that user is not a member but could join
|
||||
*/
|
||||
async get(companyId: string, workspaceId: string): Promise<ChannelType[]> {
|
||||
return Api.get<ChannelsReachableGetResponse>(
|
||||
`${this.prefix}/${companyId}/workspaces/${workspaceId}/channels`,
|
||||
).then(result => result.resources);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add user to a channel.
|
||||
* Every user in the channel (except guests) can invite or remove someone.
|
||||
* A system message will be sent on invitations.
|
||||
* @param companyId string
|
||||
* @param workspaceId string
|
||||
* @param userId string
|
||||
*
|
||||
*/
|
||||
async inviteUser(
|
||||
companyId: string,
|
||||
workspaceId: string,
|
||||
channelId: string,
|
||||
userId: string,
|
||||
): Promise<ChannelMemberType> {
|
||||
return Api.post<ChannelsReachableInviteUserRequest, ChannelsReachableInviteUserResponse>(
|
||||
`${this.prefix}/${companyId}/workspaces/${workspaceId}/channels/${channelId}/members`,
|
||||
{
|
||||
resource: {
|
||||
user_id: userId,
|
||||
},
|
||||
},
|
||||
).then(result => result.resource);
|
||||
}
|
||||
}
|
||||
const ChannelsReachableAPIClient = new ChannelsReachableAPIClientService();
|
||||
export default ChannelsReachableAPIClient;
|
||||
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useEffect } from 'react';
|
||||
import useRouterWorkspace from 'app/features/router/hooks/use-router-workspace';
|
||||
import useRouterChannel from 'app/features/router/hooks/use-router-channel';
|
||||
import RouterServices from 'app/features/router/services/router-service';
|
||||
import { useDirectChannels } from './use-direct-channels';
|
||||
import { usePublicOrPrivateChannels } from './use-public-or-private-channels';
|
||||
import LocalStorage from 'app/features/global/framework/local-storage-service';
|
||||
|
||||
const fromLocalStorage = LocalStorage.getItem('default_channel_id_per_workspace');
|
||||
const activeChannelPerWorkspace: { [key: string]: string } =
|
||||
(typeof fromLocalStorage === 'object' ? (fromLocalStorage as any) : {}) || {};
|
||||
|
||||
export function useAutoSelectChannel() {
|
||||
const workspaceId = useRouterWorkspace();
|
||||
const channelId = useRouterChannel();
|
||||
const { directChannels } = useDirectChannels();
|
||||
const { publicChannels, privateChannels } = usePublicOrPrivateChannels();
|
||||
const channels = [...directChannels, ...publicChannels, ...privateChannels];
|
||||
|
||||
useEffect(() => {
|
||||
if (channelId) {
|
||||
activeChannelPerWorkspace[workspaceId] = channelId;
|
||||
LocalStorage.setItem('default_channel_id_per_workspace', activeChannelPerWorkspace);
|
||||
}
|
||||
}, [channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!channelId && channels.length > 0) {
|
||||
let preferedChannelId = activeChannelPerWorkspace[workspaceId];
|
||||
|
||||
if (!preferedChannelId || !channels.find(c => c.id === preferedChannelId)) {
|
||||
preferedChannelId =
|
||||
channels.sort((a, b) => (b.last_activity || 0) - (a.last_activity || 0))[0]?.id ||
|
||||
preferedChannelId;
|
||||
}
|
||||
|
||||
if (preferedChannelId) {
|
||||
const url = RouterServices.generateRouteFromState({
|
||||
channelId: preferedChannelId,
|
||||
});
|
||||
RouterServices.replace(url);
|
||||
}
|
||||
}
|
||||
}, [workspaceId, channels.length > 0]);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
import useRouterChannel from 'app/features/router/hooks/use-router-channel';
|
||||
import useRouterCompany from 'app/features/router/hooks/use-router-company';
|
||||
import useRouterWorkspace from 'app/features/router/hooks/use-router-workspace';
|
||||
import { LoadingState } from 'app/features/global/state/atoms/Loading';
|
||||
import messageApiClient from 'app/features/messages/api/message-api-client';
|
||||
import { useGlobalEffect } from 'app/features/global/hooks/use-global-effect';
|
||||
import {
|
||||
channelAttachmentFileState,
|
||||
channelAttachmentListState,
|
||||
channelAttachmentMediaState,
|
||||
} from '../state/channel-attachment-list';
|
||||
import _ from 'lodash';
|
||||
|
||||
export const useChannelAttachmentList = (type: 'file' | 'media') => {
|
||||
const limit = 25;
|
||||
const companyId = useRouterCompany();
|
||||
const workspaceId = useRouterWorkspace();
|
||||
const channelId = useRouterChannel();
|
||||
const [loading, setLoading] = useRecoilState(
|
||||
LoadingState(`useChannelAttachmentList-${type}-${companyId}-${workspaceId}-${channelId}`),
|
||||
);
|
||||
const [isOpen] = useRecoilState(channelAttachmentListState);
|
||||
const [, setchannelFiles] = useRecoilState(channelAttachmentFileState(companyId));
|
||||
const [, setchannelMedia] = useRecoilState(channelAttachmentMediaState(companyId));
|
||||
|
||||
const [result, setResult] = useRecoilState(
|
||||
type === 'media'
|
||||
? channelAttachmentMediaState(companyId)
|
||||
: channelAttachmentFileState(companyId),
|
||||
);
|
||||
|
||||
const options = _.omitBy(
|
||||
{
|
||||
limit,
|
||||
is_file: type === 'file' || undefined,
|
||||
is_media: type === 'media' || undefined,
|
||||
workspace_id: workspaceId,
|
||||
channel_id: channelId,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
|
||||
const loadItems = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const response = await messageApiClient.searchFile(null, options);
|
||||
const results = (response.resources || []).sort(
|
||||
(a, b) => (b?.message?.created_at || 0) - (a?.message?.created_at || 0),
|
||||
);
|
||||
|
||||
const update = {
|
||||
results,
|
||||
nextPage: response.next_page_token || null,
|
||||
};
|
||||
|
||||
setResult(update);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (result.nextPage && result.results.length % limit === 0) {
|
||||
const response = await messageApiClient.searchFile(null, { ...options, next_page_token: result.nextPage });
|
||||
const results = (response.resources || []).sort(
|
||||
(a, b) => (b?.message?.created_at || 0) - (a?.message?.created_at || 0),
|
||||
);
|
||||
|
||||
const update = {
|
||||
results: _.uniqBy([...result.results, ...results] || [], 'id'),
|
||||
nextPage: response.next_page_token || null,
|
||||
};
|
||||
|
||||
setResult(update);
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
const update = {
|
||||
results: [],
|
||||
nextPage: null,
|
||||
}
|
||||
|
||||
setchannelFiles(update);
|
||||
setchannelMedia(update);
|
||||
}
|
||||
|
||||
useGlobalEffect(
|
||||
`useChannelAttachmentList${type}`,
|
||||
() => {
|
||||
if (!isOpen) {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
[channelId, workspaceId, isOpen],
|
||||
);
|
||||
|
||||
return {
|
||||
loading,
|
||||
result: result.results,
|
||||
loadItems,
|
||||
loadMore,
|
||||
};
|
||||
};
|
||||
|
||||
export const useChannelMediaList = () => {
|
||||
return useChannelAttachmentList('media');
|
||||
};
|
||||
|
||||
export const useChannelFileList = () => {
|
||||
return useChannelAttachmentList('file');
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useRealtimeRoom } from 'app/features/global/hooks/use-realtime';
|
||||
import WorkspaceAPIClient from 'app/features/workspaces/api/workspace-api-client';
|
||||
import { useRecoilCallback, useRecoilValue } from 'recoil';
|
||||
import {
|
||||
ChannelWritingActivityState,
|
||||
ChannelWritingActivityType,
|
||||
} from '../state/channel-writing-activity';
|
||||
|
||||
import { ThreadWritingActivitySelector } from '../../messages/state/selectors/thread-writing-activity';
|
||||
import UserService from 'app/features/users/services/current-user-service';
|
||||
import useRouterCompany from '../../router/hooks/use-router-company';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCurrentUser } from '../../users/hooks/use-current-user';
|
||||
|
||||
const MAX_DELAY_BETWEEN_KEYDOWN = 500;
|
||||
const MIN_DELAY_BETWEEN_EMIT = 8000;
|
||||
const MAX_DELAY_AFTER_LAST_WRITE_EVENT = 10000;
|
||||
|
||||
export type WritingEvent = {
|
||||
type: 'writing';
|
||||
event: {
|
||||
channel_id: string;
|
||||
thread_id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
is_writing: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChannelWritingActivityTypeEmit = {
|
||||
iAmWriting: (writing: boolean) => void;
|
||||
};
|
||||
|
||||
export function useChannelWritingActivityState(
|
||||
channelId: string,
|
||||
threadId?: string | null,
|
||||
): ChannelWritingActivityType[] {
|
||||
const threadIdSelector = useRecoilValue(
|
||||
ThreadWritingActivitySelector({ channelId: channelId, threadId: threadId || '' }),
|
||||
);
|
||||
return threadIdSelector;
|
||||
}
|
||||
|
||||
const receivedWritingTimeout = new Map<string, number>();
|
||||
|
||||
export default function useChannelWritingActivity() {
|
||||
const companyId = useRouterCompany();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const setChannelWritingActivityState = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
async (event: WritingEvent['event']) => {
|
||||
let currentList: ChannelWritingActivityType[] = await snapshot.getPromise(
|
||||
ChannelWritingActivityState(event.channel_id),
|
||||
);
|
||||
const newEvent: ChannelWritingActivityType = {
|
||||
threadId: event.thread_id,
|
||||
userId: event.user_id,
|
||||
name: event.name,
|
||||
};
|
||||
currentList = currentList.filter(elem => elem.userId !== newEvent.userId);
|
||||
if (event.is_writing) {
|
||||
currentList = [...currentList, newEvent];
|
||||
}
|
||||
currentList = currentList.filter(elem => elem.userId !== user?.id);
|
||||
set(ChannelWritingActivityState(event.channel_id), currentList);
|
||||
|
||||
//Fallback stop is_writing in case of lost connection
|
||||
if (receivedWritingTimeout.has(event.user_id))
|
||||
clearTimeout(receivedWritingTimeout.get(event.user_id));
|
||||
if (event.is_writing) {
|
||||
receivedWritingTimeout.set(
|
||||
event.user_id,
|
||||
window.setTimeout(() => {
|
||||
setChannelWritingActivityState({ ...event, is_writing: false });
|
||||
}, MAX_DELAY_AFTER_LAST_WRITE_EVENT),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
useRealtimeRoom<WritingEvent>(
|
||||
WorkspaceAPIClient.websockets(companyId)[0],
|
||||
'useChannelWritingActivity',
|
||||
(action, resource) => {
|
||||
if (action === 'event' && resource.type === 'writing') {
|
||||
setChannelWritingActivityState(resource.event);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useChannelWritingActivityEmit(
|
||||
channelId: string,
|
||||
threadId: string | null,
|
||||
): ChannelWritingActivityTypeEmit {
|
||||
const companyId = useRouterCompany();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const { send } = useRealtimeRoom<WritingEvent>(
|
||||
WorkspaceAPIClient.websockets(companyId)[0],
|
||||
'useChannelWritingActivityEmit',
|
||||
() => undefined,
|
||||
);
|
||||
(window as any).send = send;
|
||||
|
||||
const iAmWriting = useCallback(
|
||||
async (writing: boolean) => {
|
||||
if (user)
|
||||
send({
|
||||
type: 'writing',
|
||||
event: {
|
||||
channel_id: channelId,
|
||||
thread_id: threadId,
|
||||
user_id: user.id,
|
||||
name: UserService.getFullName(user),
|
||||
is_writing: writing,
|
||||
},
|
||||
} as WritingEvent);
|
||||
},
|
||||
[send],
|
||||
);
|
||||
return { iAmWriting };
|
||||
}
|
||||
|
||||
/** Keyboard typeing detection helper */
|
||||
let writeTimeout = setTimeout(() => {}, 0);
|
||||
export const useWritingDetector = () => {
|
||||
const lastEmit = useRef(new Date().getTime());
|
||||
|
||||
const onKeydown = useCallback((emit: (value: boolean) => unknown) => {
|
||||
const now = new Date().getTime();
|
||||
if (now - lastEmit.current > MIN_DELAY_BETWEEN_EMIT) {
|
||||
lastEmit.current = now;
|
||||
emit(true);
|
||||
}
|
||||
|
||||
if (writeTimeout) {
|
||||
clearTimeout(writeTimeout);
|
||||
}
|
||||
writeTimeout = setTimeout(() => {
|
||||
emit(false);
|
||||
lastEmit.current = 0;
|
||||
}, MAX_DELAY_BETWEEN_KEYDOWN);
|
||||
}, []);
|
||||
|
||||
return { onKeydown };
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import { ChannelType } from 'app/features/channels/types/channel';
|
||||
import { ChannelsState, ChannelSelector } from '../state/channels';
|
||||
import ChannelAPIClient from '../api/channel-api-client';
|
||||
import { useGlobalEffect } from 'app/features/global/hooks/use-global-effect';
|
||||
import useRouterCompany from 'app/features/router/hooks/use-router-company';
|
||||
import useRouterWorkspace from 'app/features/router/hooks/use-router-workspace';
|
||||
import { LoadingState } from 'app/features/global/state/atoms/Loading';
|
||||
|
||||
//Keep the channels in a easy to use variable
|
||||
let channelsKeeper: ChannelType[] = [];
|
||||
|
||||
export function useChannel(
|
||||
channelId: string,
|
||||
options?: { companyId: string; workspaceId: string },
|
||||
) {
|
||||
const companyId = options?.companyId || useRouterCompany();
|
||||
const workspaceId = options?.workspaceId || useRouterWorkspace();
|
||||
|
||||
const hookId = 'useChannel-' + companyId + '-' + workspaceId + '-' + channelId;
|
||||
const [loading, setLoading] = useRecoilState(LoadingState(hookId));
|
||||
|
||||
const channel = useRecoilValue(ChannelSelector(channelId)) as ChannelType;
|
||||
const { set } = useSetChannel();
|
||||
|
||||
const save = async (channel: ChannelType) => {
|
||||
setLoading(true);
|
||||
await ChannelAPIClient.save(channel, {
|
||||
companyId: channel.company_id || '',
|
||||
workspaceId: channel.workspace_id || '',
|
||||
channelId: channel.id,
|
||||
});
|
||||
set(channel);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
const ch = await ChannelAPIClient.get(companyId, workspaceId, channelId);
|
||||
if (ch && ch?.id) {
|
||||
set(ch);
|
||||
} else {
|
||||
set({
|
||||
id: channelId,
|
||||
name: '',
|
||||
visibility: 'private',
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useGlobalEffect(
|
||||
hookId,
|
||||
async () => {
|
||||
if (!channel) refresh();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { channel, save, loading, refresh };
|
||||
}
|
||||
|
||||
export const useIsChannelMember = (channelId: string) => {
|
||||
return !!useChannel(channelId)?.channel?.user_member?.user_id;
|
||||
};
|
||||
|
||||
export const useIsReadOnlyChannel = (channelId: string) => {
|
||||
return useChannel(channelId)?.channel?.is_readonly;
|
||||
};
|
||||
|
||||
export function getChannel(channelId: string) {
|
||||
return channelsKeeper.find(ch => ch.id === channelId);
|
||||
}
|
||||
|
||||
export function getAllChannelsCache() {
|
||||
return channelsKeeper;
|
||||
}
|
||||
|
||||
export function useSetChannel() {
|
||||
const set = useRecoilCallback(({ set }) => (channel: ChannelType) => {
|
||||
if (channel.id) {
|
||||
channelsKeeper = channelsKeeper.filter(c => c.id !== channel.id);
|
||||
channelsKeeper.push(channel);
|
||||
set(ChannelsState, channelsKeeper);
|
||||
}
|
||||
});
|
||||
return { set };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { LoadingState } from 'app/features/global/state/atoms/Loading';
|
||||
|
||||
export const useChannelsBarLoader = ({
|
||||
companyId,
|
||||
workspaceId,
|
||||
}: {
|
||||
companyId: string;
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
const publicOrPrivateContext = useRecoilValue(
|
||||
LoadingState(`channels-${companyId}-${workspaceId}`),
|
||||
);
|
||||
const applicationContext = useRecoilValue(LoadingState(`applications-${companyId}`));
|
||||
const directContext = useRecoilValue(LoadingState(`channels-direct-${companyId}`));
|
||||
|
||||
return { loading: publicOrPrivateContext || applicationContext || directContext };
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { ChannelType } from 'app/features/channels/types/channel';
|
||||
import { DirectChannelsState } from '../state/channels';
|
||||
import useRouterCompany from 'app/features/router/hooks/use-router-company';
|
||||
import ChannelsMineAPIClient from 'app/features/channels/api/channels-mine-api-client';
|
||||
import { useRealtimeRoom } from 'app/features/global/hooks/use-realtime';
|
||||
import { LoadingState } from 'app/features/global/state/atoms/Loading';
|
||||
import { useGlobalEffect } from 'app/features/global/hooks/use-global-effect';
|
||||
import { useSetUserList } from 'app/features/users/hooks/use-user-list';
|
||||
import { UserType } from 'app/features/users/types/user';
|
||||
import { getChannel, useSetChannel } from './use-channel';
|
||||
import ChannelAPIClient from '../api/channel-api-client';
|
||||
import MenusManager from 'app/components/menus/menus-manager.jsx';
|
||||
import RouterService from 'app/features/router/services/router-service';
|
||||
|
||||
export function useRefreshDirectChannels(): {
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const companyId = useRouterCompany();
|
||||
const { set: setUserList } = useSetUserList('useRefreshDirectChannels');
|
||||
const { set } = useSetChannel();
|
||||
|
||||
const _setDirectChannels = useSetRecoilState(DirectChannelsState(companyId));
|
||||
|
||||
const refresh = async () => {
|
||||
const directChannels = await ChannelsMineAPIClient.get({ companyId, workspaceId: 'direct' });
|
||||
|
||||
if (directChannels) _setDirectChannels(directChannels);
|
||||
|
||||
const users: UserType[] = [];
|
||||
|
||||
directChannels.forEach(c => {
|
||||
if (c.users) users.push(...c.users);
|
||||
});
|
||||
|
||||
directChannels.forEach(c => set(c));
|
||||
|
||||
if (users) setUserList(users);
|
||||
};
|
||||
|
||||
return { refresh };
|
||||
}
|
||||
|
||||
export function useDirectChannelsSetup() {
|
||||
const companyId = useRouterCompany();
|
||||
const [, setLoading] = useRecoilState(LoadingState(`channels-direct-${companyId}`));
|
||||
const [didLoad, setDidLoad] = useRecoilState(
|
||||
LoadingState(`channels-direct-did-load-${companyId}`),
|
||||
);
|
||||
const { refresh } = useRefreshDirectChannels();
|
||||
const { set } = useSetChannel();
|
||||
|
||||
useGlobalEffect(
|
||||
'useDirectChannels',
|
||||
async () => {
|
||||
if (!didLoad) setLoading(true);
|
||||
await refresh();
|
||||
setLoading(false);
|
||||
setDidLoad(true);
|
||||
},
|
||||
[companyId],
|
||||
);
|
||||
|
||||
useRealtimeRoom<ChannelType & { _type: string }>(
|
||||
ChannelsMineAPIClient.websockets(companyId, 'direct')[0],
|
||||
'useDirectChannels',
|
||||
(_action, event) => {
|
||||
//TODO replace this to avoid calling backend every time
|
||||
if (_action === 'saved') refresh();
|
||||
if (_action === 'updated' && event._type === 'channel_activity') {
|
||||
if (event.id)
|
||||
set({ ...getChannel(event.id), stats: event.stats, last_message: event.last_message });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useDirectChannels(): {
|
||||
directChannels: ChannelType[];
|
||||
refresh: () => Promise<void>;
|
||||
openDiscussion: (membersId: string[]) => Promise<void>;
|
||||
} {
|
||||
const companyId = useRouterCompany();
|
||||
const [directChannels] = useRecoilState(DirectChannelsState(companyId));
|
||||
const { refresh } = useRefreshDirectChannels();
|
||||
|
||||
const openDiscussion = async (membersIds: string[]) => {
|
||||
const channel = await ChannelAPIClient.getDirect(companyId, membersIds);
|
||||
await refresh();
|
||||
if (channel) {
|
||||
RouterService.push(
|
||||
RouterService.generateRouteFromState({
|
||||
channelId: channel.id,
|
||||
companyId: channel.company_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
MenusManager.closeMenu();
|
||||
};
|
||||
|
||||
return {
|
||||
refresh,
|
||||
directChannels,
|
||||
openDiscussion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ChannelType } from 'app/features/channels/types/channel';
|
||||
import { useDirectChannels, useRefreshDirectChannels } from './use-direct-channels';
|
||||
import {
|
||||
usePublicOrPrivateChannels,
|
||||
useRefreshPublicOrPrivateChannels,
|
||||
} from './use-public-or-private-channels';
|
||||
|
||||
export function useRefreshFavoriteChannels(): {
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const { refresh: refreshPublicOrPrivateChannels } = useRefreshPublicOrPrivateChannels();
|
||||
const { refresh: refreshDirectChannels } = useRefreshDirectChannels();
|
||||
|
||||
const refresh = async () => {
|
||||
await refreshPublicOrPrivateChannels();
|
||||
await refreshDirectChannels();
|
||||
};
|
||||
|
||||
return { refresh };
|
||||
}
|
||||
|
||||
export function useFavoriteChannels(): {
|
||||
favoriteChannels: ChannelType[];
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const { publicChannels, privateChannels } = usePublicOrPrivateChannels();
|
||||
const { directChannels } = useDirectChannels();
|
||||
|
||||
const { refresh } = useRefreshFavoriteChannels();
|
||||
|
||||
return {
|
||||
favoriteChannels: [...publicChannels, ...privateChannels, ...directChannels].filter(
|
||||
c => c.user_member?.favorite,
|
||||
),
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { ChannelType } from 'app/features/channels/types/channel';
|
||||
import { MineChannelsState } from '../state/channels';
|
||||
import useRouterCompany from 'app/features/router/hooks/use-router-company';
|
||||
import ChannelsMineAPIClient from 'app/features/channels/api/channels-mine-api-client';
|
||||
import useRouterWorkspace from 'app/features/router/hooks/use-router-workspace';
|
||||
import { isPrivateChannel, isPublicChannel } from 'app/features/channels/utils/utils';
|
||||
import { useRealtimeRoom } from 'app/features/global/hooks/use-realtime';
|
||||
import { LoadingState } from 'app/features/global/state/atoms/Loading';
|
||||
import { useGlobalEffect } from 'app/features/global/hooks/use-global-effect';
|
||||
import { getChannel, useSetChannel } from './use-channel';
|
||||
|
||||
export function useRefreshPublicOrPrivateChannels(): {
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const companyId = useRouterCompany();
|
||||
const workspaceId = useRouterWorkspace();
|
||||
const _setMineChannels = useSetRecoilState(MineChannelsState({ companyId, workspaceId }));
|
||||
const { set } = useSetChannel();
|
||||
|
||||
const refresh = async () => {
|
||||
const res = await ChannelsMineAPIClient.get({ companyId, workspaceId });
|
||||
res.forEach(c => set(c));
|
||||
if (res) _setMineChannels(res);
|
||||
};
|
||||
|
||||
return { refresh };
|
||||
}
|
||||
|
||||
export function usePublicOrPrivateChannelsSetup() {
|
||||
const companyId = useRouterCompany();
|
||||
const workspaceId = useRouterWorkspace();
|
||||
const [, setLoading] = useRecoilState(LoadingState(`channels-${companyId}-${workspaceId}`));
|
||||
const [didLoad, setDidLoad] = useRecoilState(
|
||||
LoadingState(`channels-did-load-${companyId}-${workspaceId}`),
|
||||
);
|
||||
const { refresh } = useRefreshPublicOrPrivateChannels();
|
||||
const { set } = useSetChannel();
|
||||
|
||||
useGlobalEffect(
|
||||
'usePublicOrPrivateChannels',
|
||||
async () => {
|
||||
if (!didLoad) setLoading(true);
|
||||
await refresh();
|
||||
setLoading(false);
|
||||
setDidLoad(true);
|
||||
},
|
||||
[companyId, workspaceId],
|
||||
);
|
||||
|
||||
//Public channels
|
||||
useRealtimeRoom<ChannelType & { _type: string }>(
|
||||
ChannelsMineAPIClient.websockets(companyId, workspaceId)[0],
|
||||
'usePublicOrPrivateChannelsPublic',
|
||||
(_action, event) => {
|
||||
//TODO replace this to avoid calling backend every time
|
||||
if (_action === 'saved') refresh();
|
||||
if (_action === 'updated' && event._type === 'channel_activity') {
|
||||
if (event.id)
|
||||
set({ ...getChannel(event.id), stats: event.stats, last_message: event.last_message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
//Private channels
|
||||
useRealtimeRoom<ChannelType & { _type: string }>(
|
||||
ChannelsMineAPIClient.websockets(companyId, workspaceId)[1],
|
||||
'usePublicOrPrivateChannelsPrivate',
|
||||
(_action, event) => {
|
||||
//TODO replace this to avoid calling backend every time
|
||||
if (_action === 'saved') refresh();
|
||||
if (_action === 'updated' && event._type === 'channel_activity') {
|
||||
if (event.id)
|
||||
set({ ...getChannel(event.id), stats: event.stats, last_message: event.last_message });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function usePublicOrPrivateChannels(): {
|
||||
privateChannels: ChannelType[];
|
||||
publicChannels: ChannelType[];
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const companyId = useRouterCompany();
|
||||
const workspaceId = useRouterWorkspace();
|
||||
const [mineChannels] = useRecoilState(
|
||||
MineChannelsState({ companyId, workspaceId }),
|
||||
);
|
||||
|
||||
const { refresh } = useRefreshPublicOrPrivateChannels();
|
||||
|
||||
return {
|
||||
refresh: refresh,
|
||||
privateChannels: (mineChannels || [])?.filter(
|
||||
c => c.visibility && isPrivateChannel(c.visibility),
|
||||
),
|
||||
publicChannels: (mineChannels || [])?.filter(
|
||||
c => c.visibility && isPublicChannel(c.visibility),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { ChannelType } from 'app/features/channels/types/channel';
|
||||
import { ReachableChannelsState } from '../state/channels';
|
||||
import useRouterCompany from 'app/features/router/hooks/use-router-company';
|
||||
import useRouterWorkspace from 'app/features/router/hooks/use-router-workspace';
|
||||
import ChannelsReachableAPIClient from 'app/features/channels/api/channels-reachable-api-client';
|
||||
import { useSetChannel } from './use-channel';
|
||||
|
||||
export function useReachableChannels(): {
|
||||
reachableChannels: ChannelType[];
|
||||
refresh: () => void;
|
||||
} {
|
||||
const companyId = useRouterCompany();
|
||||
const workspaceId = useRouterWorkspace();
|
||||
const [reachableChannels, _setReachableChannels] = useRecoilState(
|
||||
ReachableChannelsState({ companyId, workspaceId }),
|
||||
);
|
||||
const { set } = useSetChannel();
|
||||
|
||||
const refresh = async () => {
|
||||
const channels = await ChannelsReachableAPIClient.get(companyId, workspaceId);
|
||||
|
||||
channels.forEach(channel => {
|
||||
set(channel);
|
||||
});
|
||||
|
||||
if (channels) _setReachableChannels(channels);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
companyId.length > 1 && workspaceId.length > 1 && refresh();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [companyId, workspaceId]);
|
||||
|
||||
return {
|
||||
refresh,
|
||||
reachableChannels,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Observable from 'app/deprecated/Observable/Observable';
|
||||
import LocalStorage from 'app/features/global/framework/local-storage-service';
|
||||
|
||||
class ChannelsBarService extends Observable {
|
||||
private callbacks = new Map<string, (state: boolean) => void>();
|
||||
private ready = new Map<string, boolean>();
|
||||
|
||||
collectionIsReady(companyId: string, workspaceId: string, suffix?: string[]): void {
|
||||
const callbackId = this.getCallbackId(companyId, workspaceId, suffix);
|
||||
|
||||
if (!this.ready.has(callbackId)) {
|
||||
this.ready.set(callbackId, true);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
isReady(companyId = '', workspaceId = '', suffix?: string[]): boolean {
|
||||
return !!this.ready.get(this.getCallbackId(companyId, workspaceId, suffix));
|
||||
}
|
||||
|
||||
updateCurrentChannelId(
|
||||
companyId = '',
|
||||
workspaceId = '',
|
||||
channelId = '',
|
||||
): void {
|
||||
LocalStorage.setItem(this.getLocalStorageKey(companyId, workspaceId), channelId);
|
||||
}
|
||||
|
||||
private getLocalStorageKey(companyId: string, workspaceId: string): string {
|
||||
return `${companyId}:${workspaceId}:channel`;
|
||||
}
|
||||
|
||||
private getCallbackId(companyId: string, workspaceId: string, suffix?: string[]): string {
|
||||
let key = `${companyId}+${workspaceId}`;
|
||||
|
||||
if (suffix && suffix.length) {
|
||||
key = [key, ...suffix].join('+');
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChannelsBarService();
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
import { MessageFileType } from 'app/features/messages/types/message';
|
||||
import { atom, atomFamily } from 'recoil';
|
||||
|
||||
export type ChannelAttachment = {
|
||||
results: MessageFileType[];
|
||||
nextPage: string | null;
|
||||
};
|
||||
|
||||
export const activeChannelAttachementListTabState = atom<number>({
|
||||
key: 'activeChannelAttachementListTabState',
|
||||
default: 0,
|
||||
});
|
||||
|
||||
export const channelAttachmentListState = atom<boolean>({
|
||||
key: 'channelAttachmentListState',
|
||||
default: false,
|
||||
});
|
||||
|
||||
export const channelAttachmentMediaState = atomFamily<ChannelAttachment, string>({
|
||||
key: 'channelAttachmentMediaState',
|
||||
default: () => ({ results: [], nextPage: '' }),
|
||||
});
|
||||
|
||||
export const channelAttachmentFileState = atomFamily<ChannelAttachment, string>({
|
||||
key: 'channelAttachmentFileState',
|
||||
default: () => ({ results: [], nextPage: '' }),
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export type ChannelWritingActivityType = {
|
||||
threadId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const ChannelWritingActivityState = atomFamily<ChannelWritingActivityType[], string>({
|
||||
key: 'ChannelWritingActivityState',
|
||||
default: [],
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { atom, atomFamily, selectorFamily } from 'recoil';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { ChannelType } from 'app/features/channels/types/channel';
|
||||
|
||||
type ChannelsListContextType = { companyId: string; workspaceId: string };
|
||||
|
||||
export const ChannelsState = atom<ChannelType[]>({
|
||||
key: 'ChannelsState',
|
||||
default: [],
|
||||
});
|
||||
|
||||
export const ChannelSelector = selectorFamily<ChannelType | undefined, string>({
|
||||
key: 'ChannelSelector',
|
||||
get:
|
||||
channelId =>
|
||||
({ get }) => {
|
||||
const channels = get(ChannelsState);
|
||||
return _.find(channels, { id: channelId });
|
||||
},
|
||||
});
|
||||
|
||||
export const MineChannelsState = atomFamily<ChannelType[], ChannelsListContextType>({
|
||||
key: 'MineChannelsState',
|
||||
default: [],
|
||||
});
|
||||
|
||||
export const ReachableChannelsState = atomFamily<ChannelType[], ChannelsListContextType>({
|
||||
key: 'ReachableChannelsState',
|
||||
default: [],
|
||||
});
|
||||
|
||||
export const DirectChannelsState = atomFamily<ChannelType[], string>({
|
||||
key: 'DirectChannelsState',
|
||||
default: [],
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ChannelMemberType } from 'app/features/channel-members/types/channel-member-types';
|
||||
import { UserType } from 'app/features/users/types/user';
|
||||
import _ from 'lodash';
|
||||
|
||||
export type ChannelType = {
|
||||
company_id?: string;
|
||||
workspace_id?: string | null; //Null for direct messages
|
||||
type?: string;
|
||||
id?: string;
|
||||
icon?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
channel_group?: string;
|
||||
visibility?: 'public' | 'direct' | 'private';
|
||||
is_default?: boolean;
|
||||
members?: string[];
|
||||
owner?: string;
|
||||
members_count?: number;
|
||||
guests_count?: number;
|
||||
messages_count?: number;
|
||||
archived?: false | true;
|
||||
archivation_date?: number; //Timestamp
|
||||
user_member?: ChannelMemberType;
|
||||
connectors?: string[];
|
||||
last_activity?: number;
|
||||
last_message?: {
|
||||
date: number;
|
||||
sender: string;
|
||||
title: string;
|
||||
text: string;
|
||||
};
|
||||
stats?: {
|
||||
members: number;
|
||||
messages: number;
|
||||
};
|
||||
users?: UserType[];
|
||||
is_readonly?: boolean;
|
||||
};
|
||||
|
||||
export const createDirectChannelFromUsers = (companyId: string, users: UserType[]): ChannelType => {
|
||||
users = _.uniqBy(users, 'id');
|
||||
const id = users.map(u => u.id).join('_') + '_frontend';
|
||||
return {
|
||||
company_id: companyId,
|
||||
workspace_id: 'direct',
|
||||
visibility: 'direct',
|
||||
id: id,
|
||||
members: users.map(u => u.id).filter(a => a) as string[],
|
||||
owner: users[0].id,
|
||||
members_count: users.length,
|
||||
guests_count: 0,
|
||||
messages_count: 0,
|
||||
archived: false,
|
||||
user_member: {
|
||||
user_id: users[0].id,
|
||||
channel_id: id,
|
||||
},
|
||||
connectors: [],
|
||||
users: users,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ChannelMemberType } from 'app/features/channel-members/types/channel-member-types';
|
||||
import { ChannelType } from './channel';
|
||||
|
||||
export type ChannelsReachableGetResponse = { resources: ChannelType[] };
|
||||
export type ChannelsReachableInviteUserResponse = { resource: ChannelMemberType };
|
||||
export type ChannelsReachableInviteUserRequest = {
|
||||
resource: {
|
||||
user_id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChannelsReachableRemoveUserResponse = {
|
||||
status: 'success' | 'error';
|
||||
errors: string[]; //List of errors
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
// Channel visibility utils
|
||||
export function isPublicChannel(visibility: string): boolean {
|
||||
return visibility === 'public' ? true : false;
|
||||
}
|
||||
|
||||
export function isPrivateChannel(visibility: string): boolean {
|
||||
return visibility === 'private' ? true : false;
|
||||
}
|
||||
|
||||
export function isDirectChannel(visibility: string): boolean {
|
||||
return visibility === 'direct' ? true : false;
|
||||
}
|
||||
Reference in New Issue
Block a user