{
const appURLFacts = getAppURLFactsFromRequestURL(req.originalUrl);
const { basePath, baseDomain } = appURLFacts;
const baseURL = basePath.replace(/\/$/, '');
const baseHref = baseDomain + baseURL;
const loadingPromise = getWebpackCompiledRootComponentForSSR();
const hasNotAcknowledgedPoliciesPromise = hasAnyNotAcknowledgedPolicies(
viewer.id,
baseLegalPolicies,
);
let initialNavInfo;
try {
initialNavInfo = navInfoFromURL(req.url, {
now: currentDateInTimeZone(viewer.timeZone),
});
} catch (e) {
throw new ServerError(e.message);
}
const calendarQuery = {
startDate: initialNavInfo.startDate,
endDate: initialNavInfo.endDate,
filters: defaultCalendarFilters,
};
const messageSelectionCriteria = { joinedThreads: true };
const initialTime = Date.now();
const assetInfoPromise = getAssetInfo();
const threadInfoPromise = fetchThreadInfos(viewer);
const messageInfoPromise = fetchMessageInfos(
viewer,
messageSelectionCriteria,
defaultNumberPerThread,
);
const entryInfoPromise = fetchEntryInfos(viewer, [calendarQuery]);
const currentUserInfoPromise = fetchCurrentUserInfo(viewer);
const userInfoPromise = fetchKnownUserInfos(viewer);
const sessionIDPromise = (async () => {
if (viewer.loggedIn) {
await setNewSession(viewer, calendarQuery, initialTime);
}
return viewer.sessionID;
})();
const threadStorePromise = (async () => {
const [{ threadInfos }, hasNotAcknowledgedPolicies] = await Promise.all([
threadInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
return { threadInfos: hasNotAcknowledgedPolicies ? {} : threadInfos };
})();
const messageStorePromise = (async () => {
const [
{ threadInfos },
{ rawMessageInfos, truncationStatuses },
hasNotAcknowledgedPolicies,
] = await Promise.all([
threadInfoPromise,
messageInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
if (hasNotAcknowledgedPolicies) {
return {
messages: {},
threads: {},
local: {},
currentAsOf: 0,
};
}
const { messageStore: freshStore } = freshMessageStore(
rawMessageInfos,
truncationStatuses,
mostRecentMessageTimestamp(rawMessageInfos, initialTime),
threadInfos,
);
return freshStore;
})();
const entryStorePromise = (async () => {
const [{ rawEntryInfos }, hasNotAcknowledgedPolicies] = await Promise.all([
entryInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
if (hasNotAcknowledgedPolicies) {
return {
entryInfos: {},
daysToEntries: {},
lastUserInteractionCalendar: 0,
};
}
return {
entryInfos: _keyBy('id')(rawEntryInfos),
daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos),
lastUserInteractionCalendar: initialTime,
};
})();
const userStorePromise = (async () => {
const [userInfos, hasNotAcknowledgedPolicies] = await Promise.all([
userInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
return {
userInfos: hasNotAcknowledgedPolicies ? {} : userInfos,
inconsistencyReports: [],
};
})();
const navInfoPromise = (async () => {
const [{ threadInfos }, messageStore, currentUserInfo, userStore] =
await Promise.all([
threadInfoPromise,
messageStorePromise,
currentUserInfoPromise,
userStorePromise,
]);
const finalNavInfo = initialNavInfo;
const requestedActiveChatThreadID = finalNavInfo.activeChatThreadID;
if (
requestedActiveChatThreadID &&
!threadIsPending(requestedActiveChatThreadID) &&
!threadHasPermission(
threadInfos[requestedActiveChatThreadID],
threadPermissions.VISIBLE,
)
) {
finalNavInfo.activeChatThreadID = null;
}
if (!finalNavInfo.activeChatThreadID) {
const mostRecentThread = mostRecentlyReadThread(
messageStore,
threadInfos,
);
if (mostRecentThread) {
finalNavInfo.activeChatThreadID = mostRecentThread;
}
}
if (
finalNavInfo.activeChatThreadID &&
threadIsPending(finalNavInfo.activeChatThreadID) &&
finalNavInfo.pendingThread?.id !== finalNavInfo.activeChatThreadID
) {
const pendingThreadData = parsePendingThreadID(
finalNavInfo.activeChatThreadID,
);
if (
pendingThreadData &&
pendingThreadData.threadType !== threadTypes.SIDEBAR &&
currentUserInfo.id
) {
const { userInfos } = userStore;
- const members = pendingThreadData.memberIDs
- .map(id => userInfos[id])
+ const members = [...pendingThreadData.memberIDs, currentUserInfo.id]
+ .map(id => {
+ const userInfo = userInfos[id];
+ if (!userInfo || !userInfo.username) {
+ return undefined;
+ }
+ const { username } = userInfo;
+ return { id, username };
+ })
.filter(Boolean);
const newPendingThread = createPendingThread({
viewerID: currentUserInfo.id,
threadType: pendingThreadData.threadType,
members,
});
finalNavInfo.activeChatThreadID = newPendingThread.id;
finalNavInfo.pendingThread = newPendingThread;
}
}
return finalNavInfo;
})();
const currentAsOfPromise = (async () => {
const hasNotAcknowledgedPolicies = await hasNotAcknowledgedPoliciesPromise;
return hasNotAcknowledgedPolicies ? 0 : initialTime;
})();
const { jsURL, fontsURL, cssInclude } = await assetInfoPromise;
// prettier-ignore
res.write(html`
${getTitle(0)}
${cssInclude}
`);
const Loading = await loadingPromise;
const reactStream = renderToNodeStream();
reactStream.pipe(res, { end: false });
await waitForStream(reactStream);
res.write(html`
`);
}
export { websiteResponder };
diff --git a/lib/reducers/message-reducer.test.js b/lib/reducers/message-reducer.test.js
index 75f119e09..b989ebaef 100644
--- a/lib/reducers/message-reducer.test.js
+++ b/lib/reducers/message-reducer.test.js
@@ -1,314 +1,315 @@
// @flow
import invariant from 'invariant';
import { reduceMessageStore } from './message-reducer.js';
import { createPendingThread } from '../shared/thread-utils.js';
import type { MessageStore } from '../types/message-types.js';
import { messageTypes } from '../types/message-types.js';
import { threadTypes } from '../types/thread-types.js';
const messageStoreBeforeMediaUpdate: MessageStore = {
messages: {
local1: {
type: 14,
threadID: '91140',
creatorID: '91097',
time: 1639522317443,
media: [
{
id: 'localUpload2',
uri: 'assets-library://asset/asset.HEIC?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=HEIC',
type: 'photo',
dimensions: { height: 3024, width: 4032 },
localMediaSelection: {
step: 'photo_library',
dimensions: { height: 3024, width: 4032 },
uri: 'assets-library://asset/asset.HEIC?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=HEIC',
filename: 'IMG_0006.HEIC',
mediaNativeID: 'CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001',
selectTime: 1639522317349,
sendTime: 1639522317349,
retries: 0,
},
},
],
localID: 'local1',
},
},
threads: {
'91140': {
messageIDs: ['local1'],
startReached: true,
lastNavigatedTo: 1639522314170,
lastPruned: 1639522292271,
},
},
local: {},
currentAsOf: 1639522292174,
};
describe('UPDATE_MULTIMEDIA_MESSAGE_MEDIA', () => {
const updateMultiMediaMessageMediaAction = {
type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA',
payload: {
messageID: 'local1',
currentMediaID: 'localUpload2',
mediaUpdate: {
id: '91172',
type: 'photo',
uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde',
dimensions: { height: 1440, width: 1920 },
localMediaSelection: undefined,
},
},
};
const { messageStore: updatedMessageStore } = reduceMessageStore(
messageStoreBeforeMediaUpdate,
updateMultiMediaMessageMediaAction,
{},
);
test('replace local media with uploaded media', () => {
expect(
updatedMessageStore.messages[
updateMultiMediaMessageMediaAction.payload.messageID
],
).toStrictEqual({
type: 14,
threadID: '91140',
creatorID: '91097',
time: 1639522317443,
media: [
{
id: '91172',
type: 'photo',
uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde',
dimensions: { height: 1440, width: 1920 },
},
],
localID: 'local1',
});
});
test('localMediaSelection is unset when undefined in update', () => {
const msg =
updatedMessageStore.messages[
updateMultiMediaMessageMediaAction.payload.messageID
];
expect(msg.type).toEqual(messageTypes.IMAGES);
invariant(msg.type === messageTypes.IMAGES, 'message is of type IMAGES');
expect(msg.media[0]).not.toHaveProperty('localMediaSelection');
});
test('localMediaSelection is unchanged when missing in update', () => {
const actionWithoutLocalMediaSelectionUpdate = {
type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA',
payload: {
messageID: 'local1',
currentMediaID: 'localUpload2',
mediaUpdate: {
id: '91172',
type: 'photo',
uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde',
dimensions: { height: 1440, width: 1920 },
},
},
};
const { messageStore: storeWithoutLocalMediaSelectionUpdate } =
reduceMessageStore(
messageStoreBeforeMediaUpdate,
actionWithoutLocalMediaSelectionUpdate,
{},
);
const prevMsg =
messageStoreBeforeMediaUpdate.messages[
actionWithoutLocalMediaSelectionUpdate.payload.messageID
];
const updatedMsg =
storeWithoutLocalMediaSelectionUpdate.messages[
actionWithoutLocalMediaSelectionUpdate.payload.messageID
];
expect(updatedMsg.type).toEqual(messageTypes.IMAGES);
expect(prevMsg.type).toEqual(messageTypes.IMAGES);
invariant(
updatedMsg.type === messageTypes.IMAGES &&
prevMsg.type === messageTypes.IMAGES,
'message is of type IMAGES',
);
expect(updatedMsg.media[0].localMediaSelection).toStrictEqual(
prevMsg.media[0].localMediaSelection,
);
});
test('localMediaSelection is updated when included in update', () => {
const updateMultiMediaMessageMediaActionWithReplacement = {
type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA',
payload: {
messageID: 'local1',
currentMediaID: 'localUpload2',
mediaUpdate: {
id: '91172',
type: 'photo',
uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde',
dimensions: { height: 1440, width: 1920 },
localMediaSelection: {
step: 'photo_library',
dimensions: { height: 10, width: 10 },
uri: 'assets-library://asset/new/path',
filename: 'NEWNAME.PNG',
mediaNativeID: 'CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001',
selectTime: 1639522317349,
sendTime: 1639522317349,
retries: 1,
},
},
},
};
const { messageStore: updatedMessageStoreWithReplacement } =
reduceMessageStore(
messageStoreBeforeMediaUpdate,
updateMultiMediaMessageMediaActionWithReplacement,
{},
);
const updatedMsg =
updatedMessageStoreWithReplacement.messages[
updateMultiMediaMessageMediaActionWithReplacement.payload.messageID
];
expect(updatedMsg.type).toEqual(messageTypes.IMAGES);
invariant(
updatedMsg.type === messageTypes.IMAGES,
'message is of type IMAGES',
);
expect(updatedMsg.media[0].localMediaSelection).toStrictEqual({
step: 'photo_library',
dimensions: { height: 10, width: 10 },
uri: 'assets-library://asset/new/path',
filename: 'NEWNAME.PNG',
mediaNativeID: 'CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001',
selectTime: 1639522317349,
sendTime: 1639522317349,
retries: 1,
});
});
});
describe('SET_MESSAGE_STORE_MESSAGES', () => {
const clientDBMessages = [
{
id: '103502',
local_id: null,
thread: '88471',
user: '83809',
type: '14',
future_type: null,
content: '[103501]',
time: '1658168455316',
media_infos: [
{
id: '103501',
uri: 'http://localhost/comm/upload/103501/425db25471f3acd5',
type: 'photo',
extras: '{"dimensions":{"width":1920,"height":1440},"loop":false}',
},
],
},
{
id: 'local10',
local_id: 'local10',
thread: '88471',
user: '83809',
type: '14',
future_type: null,
content: '[null]',
time: '1658172650495',
media_infos: [
{
id: 'localUpload0',
uri: 'assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic',
type: 'photo',
extras:
'{"dimensions":{"height":3024,"width":4032},"loop":false,"local_media_selection":{"step":"photo_library","dimensions":{"height":3024,"width":4032},"uri":"assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic","filename":"IMG_0006.HEIC","mediaNativeID":"CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001","selectTime":1658172650370,"sendTime":1658172650370,"retries":0}}',
},
],
},
{
id: 'local11',
local_id: 'local11',
thread: '88471',
user: '83809',
type: '14',
future_type: null,
content: '[null,null]',
time: '1658172656976',
media_infos: [
{
id: 'localUpload2',
uri: 'assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic',
type: 'photo',
extras:
'{"dimensions":{"height":3024,"width":4032},"loop":false,"local_media_selection":{"step":"photo_library","dimensions":{"height":3024,"width":4032},"uri":"assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic","filename":"IMG_0006.HEIC","mediaNativeID":"CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001","selectTime":1658172656826,"sendTime":1658172656826,"retries":0}}',
},
{
id: 'localUpload4',
uri: 'assets-library://asset/asset.jpg?id=ED7AC36B-A150-4C38-BB8C-B6D696F4F2ED&ext=jpg',
type: 'photo',
extras:
'{"dimensions":{"height":2002,"width":3000},"loop":false,"local_media_selection":{"step":"photo_library","dimensions":{"height":2002,"width":3000},"uri":"assets-library://asset/asset.jpg?id=ED7AC36B-A150-4C38-BB8C-B6D696F4F2ED&ext=jpg","filename":"IMG_0005.JPG","mediaNativeID":"ED7AC36B-A150-4C38-BB8C-B6D696F4F2ED/L0/001","selectTime":1658172656826,"sendTime":1658172656826,"retries":0}}',
},
],
},
];
const threads = {
'88471': {
messageIDs: ['local11', 'local10', '103502'],
startReached: false,
lastNavigatedTo: 1658172614602,
lastPruned: 1658169913623,
},
};
const { messageStore: updatedMessageStore } = reduceMessageStore(
{
messages: {},
threads,
local: {},
currentAsOf: 1234567890123,
},
{
type: 'SET_CLIENT_DB_STORE',
payload: {
currentUserID: '',
drafts: [],
threadStore: {
threadInfos: {},
},
messages: clientDBMessages,
},
},
{
[88471]: createPendingThread({
viewerID: '',
threadType: threadTypes.LOCAL,
+ members: [{ id: '', username: '' }],
}),
},
);
test('removes local media when constructing messageStore.messages', () => {
expect(updatedMessageStore.messages).toHaveProperty('103502');
expect(updatedMessageStore.messages).not.toHaveProperty('local10');
expect(updatedMessageStore.messages).not.toHaveProperty('local11');
});
test('removes local media when constructing messageStore.threads', () => {
expect(updatedMessageStore).toBeDefined();
expect(updatedMessageStore.threads).toBeDefined();
expect(updatedMessageStore.threads['88471']).toBeDefined();
expect(updatedMessageStore.threads['88471'].messageIDs).toBeDefined();
expect(updatedMessageStore.threads['88471'].messageIDs).toEqual(['103502']);
});
});
diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js
index 5ec50c3c8..ad7c450c6 100644
--- a/lib/shared/thread-utils.js
+++ b/lib/shared/thread-utils.js
@@ -1,1545 +1,1546 @@
// @flow
import invariant from 'invariant';
import _find from 'lodash/fp/find.js';
import * as React from 'react';
import stringHash from 'string-hash';
import tinycolor from 'tinycolor2';
import { type ParserRules } from './markdown.js';
import { getMessageTitle } from './message-utils.js';
import { relationshipBlockedInEitherDirection } from './relationship-utils.js';
import threadWatcher from './thread-watcher.js';
import {
fetchMostRecentMessagesActionTypes,
fetchMostRecentMessages,
} from '../actions/message-actions.js';
import {
changeThreadMemberRolesActionTypes,
newThreadActionTypes,
removeUsersFromThreadActionTypes,
} from '../actions/thread-actions.js';
import { searchUsers as searchUserCall } from '../actions/user-actions.js';
import ashoat from '../facts/ashoat.js';
import genesis from '../facts/genesis.js';
import { useLoggedInUserInfo } from '../hooks/account-hooks.js';
import {
permissionLookup,
getAllThreadPermissions,
makePermissionsBlob,
} from '../permissions/thread-permissions.js';
import type {
ChatThreadItem,
ChatMessageInfoItem,
} from '../selectors/chat-selectors.js';
import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js';
import {
threadInfoSelector,
pendingToRealizedThreadIDsSelector,
} from '../selectors/thread-selectors.js';
import {
getRelativeMemberInfos,
usersWithPersonalThreadSelector,
} from '../selectors/user-selectors.js';
import type { CalendarQuery } from '../types/entry-types.js';
import type {
RobotextMessageInfo,
ComposableMessageInfo,
} from '../types/message-types.js';
import { userRelationshipStatus } from '../types/relationship-types.js';
import {
type RawThreadInfo,
type ThreadInfo,
type ThreadPermission,
type MemberInfo,
type ServerThreadInfo,
type RelativeMemberInfo,
type ThreadCurrentUserInfo,
type RoleInfo,
type ServerMemberInfo,
type ThreadPermissionsInfo,
type ThreadType,
type ClientNewThreadRequest,
type NewThreadResult,
type ChangeThreadSettingsPayload,
threadTypes,
threadPermissions,
threadTypeIsCommunityRoot,
assertThreadType,
} from '../types/thread-types.js';
import { type ClientUpdateInfo, updateTypes } from '../types/update-types.js';
import type {
GlobalAccountUserInfo,
UserInfos,
- UserInfo,
AccountUserInfo,
LoggedInUserInfo,
} from '../types/user-types.js';
import {
useDispatchActionPromise,
useServerCall,
} from '../utils/action-utils.js';
import type { DispatchActionPromise } from '../utils/action-utils.js';
import type { GetENSNames } from '../utils/ens-helpers.js';
import {
ET,
entityTextToRawString,
getEntityTextAsString,
type ThreadEntity,
} from '../utils/entity-text.js';
import { values } from '../utils/objects.js';
import { useSelector } from '../utils/redux-utils.js';
import { firstLine } from '../utils/string-utils.js';
import { trimText } from '../utils/text-utils.js';
const chatNameMaxLength = 191;
const chatNameMinLength = 0;
const secondCharRange = `{${chatNameMinLength},${chatNameMaxLength}}`;
const validChatNameRegexString = `^.${secondCharRange}$`;
const validChatNameRegex: RegExp = new RegExp(validChatNameRegexString);
function colorIsDark(color: string): boolean {
return tinycolor(`#${color}`).isDark();
}
const selectedThreadColorsObj = Object.freeze({
a: '4b87aa',
b: '5c9f5f',
c: 'b8753d',
d: 'aa4b4b',
e: '6d49ab',
f: 'c85000',
g: '008f83',
h: '648caa',
i: '57697f',
j: '575757',
});
const selectedThreadColors: $ReadOnlyArray = values(
selectedThreadColorsObj,
);
export type SelectedThreadColors = $Values;
function generateRandomColor(): string {
return selectedThreadColors[
Math.floor(Math.random() * selectedThreadColors.length)
];
}
function generatePendingThreadColor(userIDs: $ReadOnlyArray): string {
const ids = [...userIDs].sort().join('#');
const colorIdx = stringHash(ids) % selectedThreadColors.length;
return selectedThreadColors[colorIdx];
}
function threadHasPermission(
threadInfo: ?(ThreadInfo | RawThreadInfo),
permission: ThreadPermission,
): boolean {
if (!threadInfo) {
return false;
}
invariant(
!permissionsDisabledByBlock.has(permission) || threadInfo?.uiName,
`${permission} can be disabled by a block, but threadHasPermission can't ` +
'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!',
);
if (!threadInfo.currentUser.permissions[permission]) {
return false;
}
return threadInfo.currentUser.permissions[permission].value;
}
function viewerIsMember(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean {
return !!(
threadInfo &&
threadInfo.currentUser.role !== null &&
threadInfo.currentUser.role !== undefined
);
}
function threadIsInHome(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean {
return !!(threadInfo && threadInfo.currentUser.subscription.home);
}
// Can have messages
function threadInChatList(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean {
return (
viewerIsMember(threadInfo) &&
threadHasPermission(threadInfo, threadPermissions.VISIBLE)
);
}
function threadIsTopLevel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean {
return threadInChatList(threadInfo) && threadIsChannel(threadInfo);
}
function threadIsChannel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean {
return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR);
}
function threadIsSidebar(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean {
return threadInfo?.type === threadTypes.SIDEBAR;
}
function threadInBackgroundChatList(
threadInfo: ?(ThreadInfo | RawThreadInfo),
): boolean {
return threadInChatList(threadInfo) && !threadIsInHome(threadInfo);
}
function threadInHomeChatList(
threadInfo: ?(ThreadInfo | RawThreadInfo),
): boolean {
return threadInChatList(threadInfo) && threadIsInHome(threadInfo);
}
// Can have Calendar entries,
// does appear as a top-level entity in the thread list
function threadInFilterList(
threadInfo: ?(ThreadInfo | RawThreadInfo),
): boolean {
return (
threadInChatList(threadInfo) &&
!!threadInfo &&
threadInfo.type !== threadTypes.SIDEBAR
);
}
function userIsMember(
threadInfo: ?(ThreadInfo | RawThreadInfo),
userID: string,
): boolean {
if (!threadInfo) {
return false;
}
if (threadInfo.id === genesis.id) {
return true;
}
return threadInfo.members.some(member => member.id === userID && member.role);
}
function threadActualMembers(
memberInfos: $ReadOnlyArray,
): $ReadOnlyArray {
return memberInfos
.filter(memberInfo => memberInfo.role)
.map(memberInfo => memberInfo.id);
}
function threadOtherMembers(
memberInfos: $ReadOnlyArray,
viewerID: ?string,
): $ReadOnlyArray {
return memberInfos.filter(
memberInfo => memberInfo.role && memberInfo.id !== viewerID,
);
}
function threadMembersWithoutAddedAshoat(
threadInfo: T,
): $PropertyType {
if (threadInfo.community !== genesis.id) {
return threadInfo.members;
}
return threadInfo.members.filter(
member => member.id !== ashoat.id || member.role,
);
}
function threadIsGroupChat(threadInfo: ThreadInfo | RawThreadInfo): boolean {
return (
threadMembersWithoutAddedAshoat(threadInfo).filter(
member =>
member.role || member.permissions[threadPermissions.VOICED]?.value,
).length > 2
);
}
function threadOrParentThreadIsGroupChat(
threadInfo: RawThreadInfo | ThreadInfo,
) {
return threadMembersWithoutAddedAshoat(threadInfo).length > 2;
}
function threadIsPending(threadID: ?string): boolean {
return !!threadID?.startsWith('pending');
}
function getSingleOtherUser(
threadInfo: ThreadInfo | RawThreadInfo,
viewerID: ?string,
): ?string {
if (!viewerID) {
return undefined;
}
const otherMembers = threadOtherMembers(threadInfo.members, viewerID);
if (otherMembers.length !== 1) {
return undefined;
}
return otherMembers[0].id;
}
function getPendingThreadID(
threadType: ThreadType,
memberIDs: $ReadOnlyArray,
sourceMessageID: ?string,
): string {
const pendingThreadKey = sourceMessageID
? `sidebar/${sourceMessageID}`
: [...memberIDs].sort().join('+');
const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`;
return `pending/${pendingThreadTypeString}${pendingThreadKey}`;
}
const pendingThreadIDRegex =
'pending/(type[0-9]+/[0-9]+(\\+[0-9]+)*|sidebar/[0-9]+)';
type PendingThreadIDContents = {
+threadType: ThreadType,
+memberIDs: $ReadOnlyArray,
+sourceMessageID: ?string,
};
function parsePendingThreadID(
pendingThreadID: string,
): ?PendingThreadIDContents {
const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`);
const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID);
if (!pendingThreadIDMatches) {
return null;
}
const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/');
const threadType =
threadTypeString === 'sidebar'
? threadTypes.SIDEBAR
: assertThreadType(Number(threadTypeString.replace('type', '')));
const memberIDs = threadTypeString === 'sidebar' ? [] : threadKey.split('+');
const sourceMessageID = threadTypeString === 'sidebar' ? threadKey : null;
return {
threadType,
memberIDs,
sourceMessageID,
};
}
+type UserIDAndUsername = {
+ +id: string,
+ +username: string,
+ ...
+};
type CreatePendingThreadArgs = {
+viewerID: string,
+threadType: ThreadType,
- +members?: $ReadOnlyArray,
+ +members: $ReadOnlyArray,
+parentThreadInfo?: ?ThreadInfo,
+threadColor?: ?string,
+name?: ?string,
+sourceMessageID?: string,
};
function createPendingThread({
viewerID,
threadType,
members,
parentThreadInfo,
threadColor,
name,
sourceMessageID,
}: CreatePendingThreadArgs): ThreadInfo {
const now = Date.now();
- const nonViewerMembers = members
- ? members.filter(member => member.id !== viewerID)
- : [];
- const nonViewerMemberIDs = nonViewerMembers.map(member => member.id);
- const memberIDs = [...nonViewerMemberIDs, viewerID];
+ if (!members.some(member => member.id === viewerID)) {
+ throw new Error(
+ 'createPendingThread should be called with the viewer as a member',
+ );
+ }
+
+ const memberIDs = members.map(member => member.id);
const threadID = getPendingThreadID(threadType, memberIDs, sourceMessageID);
const permissions = {
[threadPermissions.KNOW_OF]: true,
[threadPermissions.VISIBLE]: true,
[threadPermissions.VOICED]: true,
};
const membershipPermissions = getAllThreadPermissions(
makePermissionsBlob(permissions, null, threadID, threadType),
threadID,
);
const role = {
id: `${threadID}/role`,
name: 'Members',
permissions,
isDefault: true,
};
const rawThreadInfo = {
id: threadID,
type: threadType,
name: name ?? null,
description: null,
color: threadColor ?? generatePendingThreadColor(memberIDs),
creationTime: now,
parentThreadID: parentThreadInfo?.id ?? null,
containingThreadID: getContainingThreadID(parentThreadInfo, threadType),
community: getCommunity(parentThreadInfo),
- members: [
- {
- id: viewerID,
- role: role.id,
- permissions: membershipPermissions,
- isSender: false,
- },
- ...nonViewerMembers.map(member => ({
- id: member.id,
- role: role.id,
- permissions: membershipPermissions,
- isSender: false,
- })),
- ],
+ members: members.map(member => ({
+ id: member.id,
+ role: role.id,
+ permissions: membershipPermissions,
+ isSender: false,
+ })),
roles: {
[role.id]: role,
},
currentUser: {
role: role.id,
permissions: membershipPermissions,
subscription: {
pushNotifs: false,
home: false,
},
unread: false,
},
repliesCount: 0,
sourceMessageID,
};
const userInfos = {};
- nonViewerMembers.forEach(member => (userInfos[member.id] = member));
+ for (const member of members) {
+ const { id, username } = member;
+ userInfos[id] = { id, username };
+ }
return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos);
}
function createPendingThreadItem(
loggedInUserInfo: LoggedInUserInfo,
- user: GlobalAccountUserInfo,
+ user: UserIDAndUsername,
): ChatThreadItem {
const threadInfo = createPendingThread({
viewerID: loggedInUserInfo.id,
threadType: threadTypes.PERSONAL,
- members: [user],
+ members: [loggedInUserInfo, user],
});
return {
type: 'chatThreadItem',
threadInfo,
mostRecentMessageInfo: null,
mostRecentNonLocalMessage: null,
lastUpdatedTime: threadInfo.creationTime,
lastUpdatedTimeIncludingSidebars: threadInfo.creationTime,
sidebars: [],
pendingPersonalThreadUserInfo: {
id: user.id,
username: user.username,
},
};
}
type SharedCreatePendingSidebarInput = {
+sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo,
+parentThreadInfo: ThreadInfo,
+loggedInUserInfo: LoggedInUserInfo,
};
type BaseCreatePendingSidebarInput = {
...SharedCreatePendingSidebarInput,
+messageTitle: string,
};
function baseCreatePendingSidebar(
input: BaseCreatePendingSidebarInput,
): ThreadInfo {
const {
sourceMessageInfo,
parentThreadInfo,
loggedInUserInfo,
messageTitle,
} = input;
const { color, type: parentThreadType } = parentThreadInfo;
const threadName = trimText(messageTitle, 30);
const initialMembers = new Map();
const { id: viewerID, username } = loggedInUserInfo;
- initialMembers.set(viewerID, ({ id: viewerID, username }: UserInfo));
+ initialMembers.set(viewerID, { id: viewerID, username });
if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) {
const { id: sourceAuthorID, username: sourceAuthorUsername } =
sourceMessageInfo.creator;
invariant(
sourceAuthorUsername,
'sourceAuthorUsername should be set in createPendingSidebar',
);
- const initialMemberUserInfo: GlobalAccountUserInfo = {
+ const initialMemberUserInfo = {
id: sourceAuthorID,
username: sourceAuthorUsername,
};
initialMembers.set(sourceAuthorID, initialMemberUserInfo);
}
const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID);
if (parentThreadType === threadTypes.PERSONAL && singleOtherUser) {
const singleOtherUsername = parentThreadInfo.members.find(
member => member.id === singleOtherUser,
)?.username;
invariant(
singleOtherUsername,
'singleOtherUsername should be set in createPendingSidebar',
);
- const singleOtherUserInfo: GlobalAccountUserInfo = {
+ const singleOtherUserInfo = {
id: singleOtherUser,
username: singleOtherUsername,
};
initialMembers.set(singleOtherUser, singleOtherUserInfo);
}
return createPendingThread({
viewerID,
threadType: threadTypes.SIDEBAR,
members: [...initialMembers.values()],
parentThreadInfo,
threadColor: color,
name: threadName,
sourceMessageID: sourceMessageInfo.id,
});
}
// The message title here may have ETH addresses that aren't resolved to ENS
// names. This function should only be used in cases where we're sure that we
// don't care about the thread title. We should prefer createPendingSidebar
// wherever possible
type CreateUnresolvedPendingSidebarInput = {
...SharedCreatePendingSidebarInput,
+markdownRules: ParserRules,
};
function createUnresolvedPendingSidebar(
input: CreateUnresolvedPendingSidebarInput,
): ThreadInfo {
const {
sourceMessageInfo,
parentThreadInfo,
loggedInUserInfo,
markdownRules,
} = input;
const messageTitleEntityText = getMessageTitle(
sourceMessageInfo,
parentThreadInfo,
markdownRules,
);
const messageTitle = entityTextToRawString(messageTitleEntityText, {
ignoreViewer: true,
});
return baseCreatePendingSidebar({
sourceMessageInfo,
parentThreadInfo,
messageTitle,
loggedInUserInfo,
});
}
type CreatePendingSidebarInput = {
...SharedCreatePendingSidebarInput,
+markdownRules: ParserRules,
+getENSNames: ?GetENSNames,
};
async function createPendingSidebar(
input: CreatePendingSidebarInput,
): Promise {
const {
sourceMessageInfo,
parentThreadInfo,
loggedInUserInfo,
markdownRules,
getENSNames,
} = input;
const messageTitleEntityText = getMessageTitle(
sourceMessageInfo,
parentThreadInfo,
markdownRules,
);
const messageTitle = await getEntityTextAsString(
messageTitleEntityText,
getENSNames,
{ ignoreViewer: true },
);
invariant(
messageTitle !== null && messageTitle !== undefined,
'getEntityTextAsString only returns falsey when passed falsey',
);
return baseCreatePendingSidebar({
sourceMessageInfo,
parentThreadInfo,
messageTitle,
loggedInUserInfo,
});
}
function pendingThreadType(numberOfOtherMembers: number): 4 | 6 | 7 {
if (numberOfOtherMembers === 0) {
return threadTypes.PRIVATE;
} else if (numberOfOtherMembers === 1) {
return threadTypes.PERSONAL;
} else {
return threadTypes.LOCAL;
}
}
function threadTypeCanBePending(threadType: ThreadType): boolean {
return (
threadType === threadTypes.PERSONAL ||
threadType === threadTypes.LOCAL ||
threadType === threadTypes.SIDEBAR ||
threadType === threadTypes.PRIVATE
);
}
type CreateRealThreadParameters = {
+threadInfo: ThreadInfo,
+dispatchActionPromise: DispatchActionPromise,
+createNewThread: ClientNewThreadRequest => Promise,
+sourceMessageID: ?string,
+viewerID: ?string,
+handleError?: () => mixed,
+calendarQuery: CalendarQuery,
};
async function createRealThreadFromPendingThread({
threadInfo,
dispatchActionPromise,
createNewThread,
sourceMessageID,
viewerID,
calendarQuery,
}: CreateRealThreadParameters): Promise {
if (!threadIsPending(threadInfo.id)) {
return threadInfo.id;
}
const otherMemberIDs = threadOtherMembers(threadInfo.members, viewerID).map(
member => member.id,
);
let resultPromise;
if (threadInfo.type !== threadTypes.SIDEBAR) {
invariant(
otherMemberIDs.length > 0,
'otherMemberIDs should not be empty for threads',
);
resultPromise = createNewThread({
type: pendingThreadType(otherMemberIDs.length),
initialMemberIDs: otherMemberIDs,
color: threadInfo.color,
calendarQuery,
});
} else {
invariant(
sourceMessageID,
'sourceMessageID should be set when creating a sidebar',
);
resultPromise = createNewThread({
type: threadTypes.SIDEBAR,
initialMemberIDs: otherMemberIDs,
color: threadInfo.color,
sourceMessageID,
parentThreadID: threadInfo.parentThreadID,
name: threadInfo.name,
calendarQuery,
});
}
dispatchActionPromise(newThreadActionTypes, resultPromise);
const { newThreadID } = await resultPromise;
return newThreadID;
}
type RawThreadInfoOptions = {
+includeVisibilityRules?: ?boolean,
+filterMemberList?: ?boolean,
+hideThreadStructure?: ?boolean,
+shimThreadTypes?: ?{
+[inType: ThreadType]: ThreadType,
},
+filterDetailedThreadEditPermissions?: boolean,
};
function rawThreadInfoFromServerThreadInfo(
serverThreadInfo: ServerThreadInfo,
viewerID: string,
options?: RawThreadInfoOptions,
): ?RawThreadInfo {
const includeVisibilityRules = options?.includeVisibilityRules;
const filterMemberList = options?.filterMemberList;
const hideThreadStructure = options?.hideThreadStructure;
const shimThreadTypes = options?.shimThreadTypes;
const filterDetailedThreadEditPermissions =
options?.filterDetailedThreadEditPermissions;
const members = [];
let currentUser;
for (const serverMember of serverThreadInfo.members) {
if (
filterMemberList &&
serverMember.id !== viewerID &&
!serverMember.role &&
!memberHasAdminPowers(serverMember)
) {
continue;
}
if (
serverThreadInfo.id === genesis.id &&
serverMember.id !== viewerID &&
serverMember.id !== ashoat.id
) {
continue;
}
const memberPermissions = filterThreadEditDetailedPermissions(
serverMember.permissions,
filterDetailedThreadEditPermissions,
);
members.push({
id: serverMember.id,
role: serverMember.role,
permissions: memberPermissions,
isSender: serverMember.isSender,
});
if (serverMember.id === viewerID) {
currentUser = {
role: serverMember.role,
permissions: memberPermissions,
subscription: serverMember.subscription,
unread: serverMember.unread,
};
}
}
let currentUserPermissions;
if (currentUser) {
currentUserPermissions = currentUser.permissions;
} else {
currentUserPermissions = filterThreadEditDetailedPermissions(
getAllThreadPermissions(null, serverThreadInfo.id),
filterDetailedThreadEditPermissions,
);
currentUser = {
role: null,
permissions: currentUserPermissions,
subscription: {
home: false,
pushNotifs: false,
},
unread: null,
};
}
if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) {
return null;
}
let { type } = serverThreadInfo;
if (
shimThreadTypes &&
shimThreadTypes[type] !== null &&
shimThreadTypes[type] !== undefined
) {
type = shimThreadTypes[type];
}
let rawThreadInfo: any = {
id: serverThreadInfo.id,
type,
name: serverThreadInfo.name,
description: serverThreadInfo.description,
color: serverThreadInfo.color,
creationTime: serverThreadInfo.creationTime,
parentThreadID: serverThreadInfo.parentThreadID,
members,
roles: serverThreadInfo.roles,
currentUser,
repliesCount: serverThreadInfo.repliesCount,
};
if (!hideThreadStructure) {
rawThreadInfo = {
...rawThreadInfo,
containingThreadID: serverThreadInfo.containingThreadID,
community: serverThreadInfo.community,
};
}
const sourceMessageID = serverThreadInfo.sourceMessageID;
if (sourceMessageID) {
rawThreadInfo = { ...rawThreadInfo, sourceMessageID };
}
if (includeVisibilityRules) {
return {
...rawThreadInfo,
visibilityRules: rawThreadInfo.type,
};
}
return rawThreadInfo;
}
function filterThreadEditDetailedPermissions(
permissions: ThreadPermissionsInfo,
shouldFilter: ?boolean,
): ThreadPermissionsInfo {
if (!shouldFilter) {
return permissions;
}
const { edit_thread_color, edit_thread_description, ...newPermissions } =
permissions;
return newPermissions;
}
function threadUIName(threadInfo: ThreadInfo): string | ThreadEntity {
if (threadInfo.name) {
return firstLine(threadInfo.name);
}
const threadMembers = threadInfo.members.filter(
memberInfo => memberInfo.role,
);
const memberEntities = threadMembers.map(member =>
ET.user({ userInfo: member }),
);
return {
type: 'thread',
id: threadInfo.id,
name: threadInfo.name,
display: 'uiName',
uiName: memberEntities,
};
}
function threadInfoFromRawThreadInfo(
rawThreadInfo: RawThreadInfo,
viewerID: ?string,
userInfos: UserInfos,
): ThreadInfo {
let threadInfo: ThreadInfo = {
id: rawThreadInfo.id,
type: rawThreadInfo.type,
name: rawThreadInfo.name,
uiName: '',
description: rawThreadInfo.description,
color: rawThreadInfo.color,
creationTime: rawThreadInfo.creationTime,
parentThreadID: rawThreadInfo.parentThreadID,
containingThreadID: rawThreadInfo.containingThreadID,
community: rawThreadInfo.community,
members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos),
roles: rawThreadInfo.roles,
currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos),
repliesCount: rawThreadInfo.repliesCount,
};
threadInfo = {
...threadInfo,
uiName: threadUIName(threadInfo),
};
const { sourceMessageID } = rawThreadInfo;
if (sourceMessageID) {
threadInfo = { ...threadInfo, sourceMessageID };
}
return threadInfo;
}
function getCurrentUser(
threadInfo: RawThreadInfo | ThreadInfo,
viewerID: ?string,
userInfos: UserInfos,
): ThreadCurrentUserInfo {
if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) {
return threadInfo.currentUser;
}
return {
...threadInfo.currentUser,
permissions: {
...threadInfo.currentUser.permissions,
...disabledPermissions,
},
};
}
function threadIsWithBlockedUserOnly(
threadInfo: RawThreadInfo | ThreadInfo,
viewerID: ?string,
userInfos: UserInfos,
checkOnlyViewerBlock?: boolean,
): boolean {
if (
threadOrParentThreadIsGroupChat(threadInfo) ||
threadOrParentThreadHasAdminRole(threadInfo)
) {
return false;
}
const otherUserID = getSingleOtherUser(threadInfo, viewerID);
if (!otherUserID) {
return false;
}
const otherUserRelationshipStatus =
userInfos[otherUserID]?.relationshipStatus;
if (checkOnlyViewerBlock) {
return (
otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER
);
}
return (
!!otherUserRelationshipStatus &&
relationshipBlockedInEitherDirection(otherUserRelationshipStatus)
);
}
function threadFrozenDueToBlock(
threadInfo: RawThreadInfo | ThreadInfo,
viewerID: ?string,
userInfos: UserInfos,
): boolean {
return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos);
}
function threadFrozenDueToViewerBlock(
threadInfo: RawThreadInfo | ThreadInfo,
viewerID: ?string,
userInfos: UserInfos,
): boolean {
return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true);
}
function rawThreadInfoFromThreadInfo(threadInfo: ThreadInfo): RawThreadInfo {
let rawThreadInfo: RawThreadInfo = {
id: threadInfo.id,
type: threadInfo.type,
name: threadInfo.name,
description: threadInfo.description,
color: threadInfo.color,
creationTime: threadInfo.creationTime,
parentThreadID: threadInfo.parentThreadID,
containingThreadID: threadInfo.containingThreadID,
community: threadInfo.community,
members: threadInfo.members.map(relativeMemberInfo => ({
id: relativeMemberInfo.id,
role: relativeMemberInfo.role,
permissions: relativeMemberInfo.permissions,
isSender: relativeMemberInfo.isSender,
})),
roles: threadInfo.roles,
currentUser: threadInfo.currentUser,
repliesCount: threadInfo.repliesCount,
};
const { sourceMessageID } = threadInfo;
if (sourceMessageID) {
rawThreadInfo = { ...rawThreadInfo, sourceMessageID };
}
return rawThreadInfo;
}
const threadTypeDescriptions: { [ThreadType]: string } = {
[threadTypes.COMMUNITY_OPEN_SUBTHREAD]:
'Anybody in the parent channel can see an open subchannel.',
[threadTypes.COMMUNITY_SECRET_SUBTHREAD]:
'Only visible to its members and admins of ancestor channels.',
};
function usersInThreadInfo(threadInfo: RawThreadInfo | ThreadInfo): string[] {
const userIDs = new Set();
for (const member of threadInfo.members) {
userIDs.add(member.id);
}
return [...userIDs];
}
function memberIsAdmin(
memberInfo: RelativeMemberInfo | MemberInfo,
threadInfo: ThreadInfo | RawThreadInfo,
): boolean {
return !!(
memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role])
);
}
// Since we don't have access to all of the ancestor ThreadInfos, we approximate
// "parent admin" as anybody with CHANGE_ROLE permissions.
function memberHasAdminPowers(
memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo,
): boolean {
return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value;
}
function roleIsAdminRole(roleInfo: ?RoleInfo): boolean {
return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins');
}
function threadHasAdminRole(
threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo),
): boolean {
if (!threadInfo) {
return false;
}
return !!_find({ name: 'Admins' })(threadInfo.roles);
}
function threadOrParentThreadHasAdminRole(
threadInfo: RawThreadInfo | ThreadInfo,
) {
return (
threadMembersWithoutAddedAshoat(threadInfo).filter(member =>
memberHasAdminPowers(member),
).length > 0
);
}
function identifyInvalidatedThreads(
updateInfos: $ReadOnlyArray,
): Set {
const invalidated = new Set();
for (const updateInfo of updateInfos) {
if (updateInfo.type === updateTypes.DELETE_THREAD) {
invalidated.add(updateInfo.threadID);
}
}
return invalidated;
}
const permissionsDisabledByBlockArray = [
threadPermissions.VOICED,
threadPermissions.EDIT_ENTRIES,
threadPermissions.EDIT_THREAD_NAME,
threadPermissions.EDIT_THREAD_COLOR,
threadPermissions.EDIT_THREAD_DESCRIPTION,
threadPermissions.CREATE_SUBCHANNELS,
threadPermissions.CREATE_SIDEBARS,
threadPermissions.JOIN_THREAD,
threadPermissions.EDIT_PERMISSIONS,
threadPermissions.ADD_MEMBERS,
threadPermissions.REMOVE_MEMBERS,
];
const permissionsDisabledByBlock: Set = new Set(
permissionsDisabledByBlockArray,
);
const disabledPermissions: ThreadPermissionsInfo =
permissionsDisabledByBlockArray.reduce(
(permissions: ThreadPermissionsInfo, permission: string) => ({
...permissions,
[permission]: { value: false, source: null },
}),
{},
);
// Consider updating itemHeight in native/chat/chat-thread-list.react.js
// if you change this
const emptyItemText: string =
`Background chats are just like normal chats, except they don't ` +
`contribute to your unread count.\n\n` +
`To move a chat over here, switch the “Background” option in its settings.`;
function threadNoun(threadType: ThreadType): string {
if (threadType === threadTypes.SIDEBAR) {
return 'thread';
} else if (
threadType === threadTypes.COMMUNITY_ROOT ||
threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT ||
threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD ||
threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ||
threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD ||
threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD ||
threadType === threadTypes.GENESIS
) {
return 'channel';
} else {
return 'chat';
}
}
function threadLabel(threadType: ThreadType): string {
if (
threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD ||
threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD
) {
return 'Open';
} else if (threadType === threadTypes.PERSONAL) {
return 'Personal';
} else if (threadType === threadTypes.SIDEBAR) {
return 'Thread';
} else if (threadType === threadTypes.PRIVATE) {
return 'Private';
} else if (
threadType === threadTypes.COMMUNITY_ROOT ||
threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT ||
threadType === threadTypes.GENESIS
) {
return 'Community';
} else {
return 'Secret';
}
}
function useWatchThread(threadInfo: ?ThreadInfo) {
const dispatchActionPromise = useDispatchActionPromise();
const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages);
const threadID = threadInfo?.id;
const threadNotInChatList = !threadInChatList(threadInfo);
React.useEffect(() => {
if (threadID && threadNotInChatList) {
threadWatcher.watchID(threadID);
dispatchActionPromise(
fetchMostRecentMessagesActionTypes,
callFetchMostRecentMessages(threadID),
);
}
return () => {
if (threadID && threadNotInChatList) {
threadWatcher.removeID(threadID);
}
};
}, [
callFetchMostRecentMessages,
dispatchActionPromise,
threadNotInChatList,
threadID,
]);
}
type ExistingThreadInfoFinderParams = {
+searching: boolean,
+userInfoInputArray: $ReadOnlyArray,
};
type ExistingThreadInfoFinder = (
params: ExistingThreadInfoFinderParams,
) => ?ThreadInfo;
function useExistingThreadInfoFinder(
baseThreadInfo: ?ThreadInfo,
): ExistingThreadInfoFinder {
const threadInfos = useSelector(threadInfoSelector);
const loggedInUserInfo = useLoggedInUserInfo();
const userInfos = useSelector(state => state.userStore.userInfos);
const pendingToRealizedThreadIDs = useSelector(state =>
pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos),
);
return React.useCallback(
(params: ExistingThreadInfoFinderParams): ?ThreadInfo => {
if (!baseThreadInfo) {
return null;
}
const realizedThreadInfo = threadInfos[baseThreadInfo.id];
if (realizedThreadInfo) {
return realizedThreadInfo;
}
if (!loggedInUserInfo || !threadIsPending(baseThreadInfo.id)) {
return baseThreadInfo;
}
const viewerID = loggedInUserInfo?.id;
invariant(
threadTypeCanBePending(baseThreadInfo.type),
`ThreadInfo has pending ID ${baseThreadInfo.id}, but type that ` +
`should not be pending ${baseThreadInfo.type}`,
);
const { searching, userInfoInputArray } = params;
const { sourceMessageID } = baseThreadInfo;
const pendingThreadID = searching
? getPendingThreadID(
pendingThreadType(userInfoInputArray.length),
[...userInfoInputArray.map(user => user.id), viewerID],
sourceMessageID,
)
: getPendingThreadID(
baseThreadInfo.type,
baseThreadInfo.members.map(member => member.id),
sourceMessageID,
);
const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID);
if (realizedThreadID && threadInfos[realizedThreadID]) {
return threadInfos[realizedThreadID];
}
const updatedThread = searching
? createPendingThread({
viewerID,
threadType: pendingThreadType(userInfoInputArray.length),
- members: userInfoInputArray,
+ members: [loggedInUserInfo, ...userInfoInputArray],
})
: baseThreadInfo;
return {
...updatedThread,
currentUser: getCurrentUser(updatedThread, viewerID, userInfos),
};
},
[
baseThreadInfo,
threadInfos,
loggedInUserInfo,
pendingToRealizedThreadIDs,
userInfos,
],
);
}
type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled';
function getThreadTypeParentRequirement(
threadType: ThreadType,
): ThreadTypeParentRequirement {
if (
threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD ||
threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ||
//threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD ||
threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD ||
threadType === threadTypes.SIDEBAR
) {
return 'required';
} else if (
threadType === threadTypes.COMMUNITY_ROOT ||
threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT ||
threadType === threadTypes.GENESIS ||
threadType === threadTypes.PERSONAL ||
threadType === threadTypes.PRIVATE
) {
return 'disabled';
} else {
return 'optional';
}
}
function threadMemberHasPermission(
threadInfo: ServerThreadInfo | RawThreadInfo | ThreadInfo,
memberID: string,
permission: ThreadPermission,
): boolean {
for (const member of threadInfo.members) {
if (member.id !== memberID) {
continue;
}
return permissionLookup(member.permissions, permission);
}
return false;
}
function useCanCreateSidebarFromMessage(
threadInfo: ThreadInfo,
messageInfo: ComposableMessageInfo | RobotextMessageInfo,
): boolean {
const messageCreatorUserInfo = useSelector(
state => state.userStore.userInfos[messageInfo.creator.id],
);
if (!messageInfo.id || threadInfo.sourceMessageID === messageInfo.id) {
return false;
}
const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus;
const creatorRelationshipHasBlock =
messageCreatorRelationship &&
relationshipBlockedInEitherDirection(messageCreatorRelationship);
const hasPermission = threadHasPermission(
threadInfo,
threadPermissions.CREATE_SIDEBARS,
);
return hasPermission && !creatorRelationshipHasBlock;
}
function useSidebarExistsOrCanBeCreated(
threadInfo: ThreadInfo,
messageItem: ChatMessageInfoItem,
): boolean {
const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage(
threadInfo,
messageItem.messageInfo,
);
return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage;
}
function checkIfDefaultMembersAreVoiced(threadInfo: ThreadInfo): boolean {
const defaultRoleID = Object.keys(threadInfo.roles).find(
roleID => threadInfo.roles[roleID].isDefault,
);
invariant(
defaultRoleID !== undefined,
'all threads should have a default role',
);
const defaultRole = threadInfo.roles[defaultRoleID];
return !!defaultRole.permissions[threadPermissions.VOICED];
}
function draftKeyFromThreadID(threadID: string): string {
return `${threadID}/message_composer`;
}
function getContainingThreadID(
parentThreadInfo: ?ServerThreadInfo | RawThreadInfo | ThreadInfo,
threadType: ThreadType,
): ?string {
if (!parentThreadInfo) {
return null;
}
if (threadType === threadTypes.SIDEBAR) {
return parentThreadInfo.id;
}
if (!parentThreadInfo.containingThreadID) {
return parentThreadInfo.id;
}
return parentThreadInfo.containingThreadID;
}
function getCommunity(
parentThreadInfo: ?ServerThreadInfo | RawThreadInfo | ThreadInfo,
): ?string {
if (!parentThreadInfo) {
return null;
}
const { id, community, type } = parentThreadInfo;
if (community !== null && community !== undefined) {
return community;
}
if (threadTypeIsCommunityRoot(type)) {
return id;
}
return null;
}
function getThreadListSearchResults(
chatListData: $ReadOnlyArray,
searchText: string,
threadFilter: ThreadInfo => boolean,
threadSearchResults: $ReadOnlySet,
usersSearchResults: $ReadOnlyArray,
loggedInUserInfo: ?LoggedInUserInfo,
): $ReadOnlyArray {
if (!searchText) {
return chatListData.filter(
item =>
threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo),
);
}
const privateThreads = [];
const personalThreads = [];
const otherThreads = [];
for (const item of chatListData) {
if (!threadSearchResults.has(item.threadInfo.id)) {
continue;
}
if (item.threadInfo.type === threadTypes.PRIVATE) {
privateThreads.push({ ...item, sidebars: [] });
} else if (item.threadInfo.type === threadTypes.PERSONAL) {
personalThreads.push({ ...item, sidebars: [] });
} else {
otherThreads.push({ ...item, sidebars: [] });
}
}
const chatItems = [...privateThreads, ...personalThreads, ...otherThreads];
if (loggedInUserInfo) {
chatItems.push(
...usersSearchResults.map(user =>
createPendingThreadItem(loggedInUserInfo, user),
),
);
}
return chatItems;
}
type ThreadListSearchResult = {
+threadSearchResults: $ReadOnlySet,
+usersSearchResults: $ReadOnlyArray,
};
function useThreadListSearch(
chatListData: $ReadOnlyArray,
searchText: string,
viewerID: ?string,
): ThreadListSearchResult {
const callSearchUsers = useServerCall(searchUserCall);
const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector);
const searchUsers = React.useCallback(
async (usernamePrefix: string) => {
if (usernamePrefix.length === 0) {
return [];
}
const { userInfos } = await callSearchUsers(usernamePrefix);
return userInfos.filter(
info => !usersWithPersonalThread.has(info.id) && info.id !== viewerID,
);
},
[callSearchUsers, usersWithPersonalThread, viewerID],
);
const [threadSearchResults, setThreadSearchResults] = React.useState(
new Set(),
);
const [usersSearchResults, setUsersSearchResults] = React.useState([]);
const threadSearchIndex = useGlobalThreadSearchIndex();
React.useEffect(() => {
(async () => {
const results = threadSearchIndex.getSearchResults(searchText);
setThreadSearchResults(new Set(results));
const usersResults = await searchUsers(searchText);
setUsersSearchResults(usersResults);
})();
}, [searchText, chatListData, threadSearchIndex, searchUsers]);
return { threadSearchResults, usersSearchResults };
}
function removeMemberFromThread(
threadInfo: ThreadInfo,
memberInfo: RelativeMemberInfo,
dispatchActionPromise: DispatchActionPromise,
removeUserFromThreadServerCall: (
threadID: string,
memberIDs: $ReadOnlyArray,
) => Promise,
) {
const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`;
dispatchActionPromise(
removeUsersFromThreadActionTypes,
removeUserFromThreadServerCall(threadInfo.id, [memberInfo.id]),
{ customKeyName },
);
}
function switchMemberAdminRoleInThread(
threadInfo: ThreadInfo,
memberInfo: RelativeMemberInfo,
isCurrentlyAdmin: boolean,
dispatchActionPromise: DispatchActionPromise,
changeUserRoleServerCall: (
threadID: string,
memberIDs: $ReadOnlyArray,
newRole: string,
) => Promise,
) {
let newRole = null;
for (const roleID in threadInfo.roles) {
const role = threadInfo.roles[roleID];
if (isCurrentlyAdmin && role.isDefault) {
newRole = role.id;
break;
} else if (!isCurrentlyAdmin && roleIsAdminRole(role)) {
newRole = role.id;
break;
}
}
invariant(newRole !== null, 'Could not find new role');
const customKeyName = `${changeThreadMemberRolesActionTypes.started}:${memberInfo.id}`;
dispatchActionPromise(
changeThreadMemberRolesActionTypes,
changeUserRoleServerCall(threadInfo.id, [memberInfo.id], newRole),
{ customKeyName },
);
}
function getAvailableThreadMemberActions(
memberInfo: RelativeMemberInfo,
threadInfo: ThreadInfo,
canEdit: ?boolean = true,
): $ReadOnlyArray<'remove_user' | 'remove_admin' | 'make_admin'> {
const role = memberInfo.role;
if (!canEdit || !role) {
return [];
}
const canRemoveMembers = threadHasPermission(
threadInfo,
threadPermissions.REMOVE_MEMBERS,
);
const canChangeRoles = threadHasPermission(
threadInfo,
threadPermissions.CHANGE_ROLE,
);
const result = [];
if (
canRemoveMembers &&
!memberInfo.isViewer &&
(canChangeRoles || threadInfo.roles[role]?.isDefault)
) {
result.push('remove_user');
}
if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) {
result.push(
memberIsAdmin(memberInfo, threadInfo) ? 'remove_admin' : 'make_admin',
);
}
return result;
}
export {
colorIsDark,
generateRandomColor,
generatePendingThreadColor,
threadHasPermission,
viewerIsMember,
threadInChatList,
threadIsTopLevel,
threadIsChannel,
threadIsSidebar,
threadInBackgroundChatList,
threadInHomeChatList,
threadIsInHome,
threadInFilterList,
userIsMember,
threadActualMembers,
threadOtherMembers,
threadIsGroupChat,
threadIsPending,
getSingleOtherUser,
getPendingThreadID,
pendingThreadIDRegex,
parsePendingThreadID,
createPendingThread,
createUnresolvedPendingSidebar,
createPendingSidebar,
pendingThreadType,
createRealThreadFromPendingThread,
getCurrentUser,
threadFrozenDueToBlock,
threadFrozenDueToViewerBlock,
rawThreadInfoFromServerThreadInfo,
filterThreadEditDetailedPermissions,
threadUIName,
threadInfoFromRawThreadInfo,
rawThreadInfoFromThreadInfo,
threadTypeDescriptions,
usersInThreadInfo,
memberIsAdmin,
memberHasAdminPowers,
roleIsAdminRole,
threadHasAdminRole,
identifyInvalidatedThreads,
permissionsDisabledByBlock,
emptyItemText,
threadNoun,
threadLabel,
useWatchThread,
useExistingThreadInfoFinder,
getThreadTypeParentRequirement,
threadMemberHasPermission,
useCanCreateSidebarFromMessage,
useSidebarExistsOrCanBeCreated,
checkIfDefaultMembersAreVoiced,
draftKeyFromThreadID,
threadTypeCanBePending,
getContainingThreadID,
getCommunity,
getThreadListSearchResults,
useThreadListSearch,
removeMemberFromThread,
switchMemberAdminRoleInThread,
getAvailableThreadMemberActions,
selectedThreadColors,
threadMembersWithoutAddedAshoat,
validChatNameRegex,
chatNameMaxLength,
};
diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js
index cf810f6b2..60d602850 100644
--- a/native/chat/chat-thread-list.react.js
+++ b/native/chat/chat-thread-list.react.js
@@ -1,646 +1,647 @@
// @flow
import IonIcon from '@expo/vector-icons/Ionicons.js';
import invariant from 'invariant';
import _sum from 'lodash/fp/sum.js';
import * as React from 'react';
import {
View,
FlatList,
Platform,
TextInput,
TouchableWithoutFeedback,
BackHandler,
} from 'react-native';
import { FloatingAction } from 'react-native-floating-action';
import Animated from 'react-native-reanimated';
import { createSelector } from 'reselect';
import { searchUsers } from 'lib/actions/user-actions.js';
import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js';
import {
type ChatThreadItem,
useFlattenedChatListData,
} from 'lib/selectors/chat-selectors.js';
import { useGlobalThreadSearchIndex } from 'lib/selectors/nav-selectors.js';
import { usersWithPersonalThreadSelector } from 'lib/selectors/user-selectors.js';
import SearchIndex from 'lib/shared/search-index.js';
import {
createPendingThread,
getThreadListSearchResults,
} from 'lib/shared/thread-utils.js';
import type { UserSearchResult } from 'lib/types/search-types.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import { threadTypes } from 'lib/types/thread-types.js';
import type {
GlobalAccountUserInfo,
UserInfo,
LoggedInUserInfo,
} from 'lib/types/user-types.js';
import { useServerCall } from 'lib/utils/action-utils.js';
import {
ChatThreadListItem,
chatThreadListItemHeight,
spacerHeight,
} from './chat-thread-list-item.react.js';
import type {
ChatTopTabsNavigationProp,
ChatNavigationProp,
} from './chat.react.js';
import {
type MessageListParams,
useNavigateToThread,
} from './message-list-types.js';
import { sidebarHeight } from './sidebar-item.react.js';
import Button from '../components/button.react.js';
import Search from '../components/search.react.js';
import {
SidebarListModalRouteName,
HomeChatThreadListRouteName,
BackgroundChatThreadListRouteName,
type NavigationRoute,
} from '../navigation/route-names.js';
import type { TabNavigationProp } from '../navigation/tab-navigator.react.js';
import { useSelector } from '../redux/redux-utils.js';
import {
type IndicatorStyle,
indicatorStyleSelector,
useStyles,
} from '../themes/colors.js';
import type { ScrollEvent } from '../types/react-native.js';
import { AnimatedView, type AnimatedStyleObj } from '../types/styles.js';
import { animateTowards } from '../utils/animation-utils.js';
const floatingActions = [
{
text: 'Compose',
icon: ,
name: 'compose',
position: 1,
},
];
/* eslint-disable import/no-named-as-default-member */
const { Value, Node, interpolateNode } = Animated;
/* eslint-enable import/no-named-as-default-member */
type Item =
| ChatThreadItem
| { +type: 'search', +searchText: string }
| { +type: 'empty', +emptyItem: React.ComponentType<{}> };
type BaseProps = {
+navigation:
| ChatTopTabsNavigationProp<'HomeChatThreadList'>
| ChatTopTabsNavigationProp<'BackgroundChatThreadList'>,
+route:
| NavigationRoute<'HomeChatThreadList'>
| NavigationRoute<'BackgroundChatThreadList'>,
+filterThreads: (threadItem: ThreadInfo) => boolean,
+emptyItem?: React.ComponentType<{}>,
};
type Props = {
...BaseProps,
// Redux state
+chatListData: $ReadOnlyArray,
+loggedInUserInfo: ?LoggedInUserInfo,
+threadSearchIndex: SearchIndex,
+styles: typeof unboundStyles,
+indicatorStyle: IndicatorStyle,
+usersWithPersonalThread: $ReadOnlySet,
+navigateToThread: (params: MessageListParams) => void,
// async functions that hit server APIs
+searchUsers: (usernamePrefix: string) => Promise,
};
type SearchStatus = 'inactive' | 'activating' | 'active';
type State = {
+searchStatus: SearchStatus,
+searchText: string,
+threadsSearchResults: Set,
+usersSearchResults: $ReadOnlyArray,
+openedSwipeableId: string,
+numItemsToDisplay: number,
};
type PropsAndState = { ...Props, ...State };
class ChatThreadList extends React.PureComponent {
state: State = {
searchStatus: 'inactive',
searchText: '',
threadsSearchResults: new Set(),
usersSearchResults: [],
openedSwipeableId: '',
numItemsToDisplay: 25,
};
searchInput: ?React.ElementRef;
flatList: ?FlatList- ;
scrollPos = 0;
clearNavigationBlurListener: ?() => mixed;
searchCancelButtonOpen: Value = new Value(0);
searchCancelButtonProgress: Node;
searchCancelButtonOffset: Node;
constructor(props: Props) {
super(props);
this.searchCancelButtonProgress = animateTowards(
this.searchCancelButtonOpen,
100,
);
this.searchCancelButtonOffset = interpolateNode(
this.searchCancelButtonProgress,
{ inputRange: [0, 1], outputRange: [0, 56] },
);
}
componentDidMount() {
this.clearNavigationBlurListener = this.props.navigation.addListener(
'blur',
() => {
this.setState({ numItemsToDisplay: 25 });
},
);
const chatNavigation: ?ChatNavigationProp<'ChatThreadList'> =
this.props.navigation.getParent();
invariant(chatNavigation, 'ChatNavigator should be within TabNavigator');
const tabNavigation: ?TabNavigationProp<'Chat'> =
chatNavigation.getParent();
invariant(tabNavigation, 'ChatNavigator should be within TabNavigator');
tabNavigation.addListener('tabPress', this.onTabPress);
BackHandler.addEventListener('hardwareBackPress', this.hardwareBack);
}
componentWillUnmount() {
this.clearNavigationBlurListener && this.clearNavigationBlurListener();
const chatNavigation: ?ChatNavigationProp<'ChatThreadList'> =
this.props.navigation.getParent();
invariant(chatNavigation, 'ChatNavigator should be within TabNavigator');
const tabNavigation: ?TabNavigationProp<'Chat'> =
chatNavigation.getParent();
invariant(tabNavigation, 'ChatNavigator should be within TabNavigator');
tabNavigation.removeListener('tabPress', this.onTabPress);
BackHandler.removeEventListener('hardwareBackPress', this.hardwareBack);
}
hardwareBack = () => {
if (!this.props.navigation.isFocused()) {
return false;
}
const { searchStatus } = this.state;
const isActiveOrActivating =
searchStatus === 'active' || searchStatus === 'activating';
if (!isActiveOrActivating) {
return false;
}
this.onSearchCancel();
return true;
};
componentDidUpdate(prevProps: Props, prevState: State) {
const { searchStatus } = this.state;
const prevSearchStatus = prevState.searchStatus;
const isActiveOrActivating =
searchStatus === 'active' || searchStatus === 'activating';
const wasActiveOrActivating =
prevSearchStatus === 'active' || prevSearchStatus === 'activating';
if (isActiveOrActivating && !wasActiveOrActivating) {
this.searchCancelButtonOpen.setValue(1);
} else if (!isActiveOrActivating && wasActiveOrActivating) {
this.searchCancelButtonOpen.setValue(0);
}
const { flatList } = this;
if (!flatList) {
return;
}
if (this.state.searchText !== prevState.searchText) {
flatList.scrollToOffset({ offset: 0, animated: false });
return;
}
if (searchStatus === 'activating' && prevSearchStatus === 'inactive') {
flatList.scrollToOffset({ offset: 0, animated: true });
}
}
onTabPress = () => {
if (!this.props.navigation.isFocused()) {
return;
}
if (this.scrollPos > 0 && this.flatList) {
this.flatList.scrollToOffset({ offset: 0, animated: true });
} else if (this.props.route.name === BackgroundChatThreadListRouteName) {
this.props.navigation.navigate({ name: HomeChatThreadListRouteName });
}
};
onSearchFocus = () => {
if (this.state.searchStatus !== 'inactive') {
return;
}
if (this.scrollPos === 0) {
this.setState({ searchStatus: 'active' });
} else {
this.setState({ searchStatus: 'activating' });
}
};
clearSearch() {
const { flatList } = this;
flatList && flatList.scrollToOffset({ offset: 0, animated: false });
this.setState({ searchStatus: 'inactive' });
}
onSearchBlur = () => {
if (this.state.searchStatus !== 'active') {
return;
}
this.clearSearch();
};
onSearchCancel = () => {
this.onChangeSearchText('');
this.clearSearch();
};
renderSearch(additionalProps?: $Shape>) {
const animatedSearchBoxStyle: AnimatedStyleObj = {
marginRight: this.searchCancelButtonOffset,
};
const searchBoxStyle = [
this.props.styles.searchBox,
animatedSearchBoxStyle,
];
const buttonStyle = [
this.props.styles.cancelSearchButtonText,
{ opacity: this.searchCancelButtonProgress },
];
return (
);
}
searchInputRef = (searchInput: ?React.ElementRef) => {
this.searchInput = searchInput;
};
renderItem = (row: { item: Item, ... }) => {
const item = row.item;
if (item.type === 'search') {
return (
{this.renderSearch({ active: false })}
);
}
if (item.type === 'empty') {
const EmptyItem = item.emptyItem;
return ;
}
return (
);
};
static keyExtractor = (item: Item) => {
if (item.type === 'chatThreadItem') {
return item.threadInfo.id;
} else if (item.type === 'empty') {
return 'empty';
} else {
return 'search';
}
};
static getItemLayout = (data: ?$ReadOnlyArray
- , index: number) => {
if (!data) {
return { length: 0, offset: 0, index };
}
const offset = ChatThreadList.heightOfItems(
data.filter((_, i) => i < index),
);
const item = data[index];
const length = item ? ChatThreadList.itemHeight(item) : 0;
return { length, offset, index };
};
static itemHeight = (item: Item) => {
if (item.type === 'search') {
return Platform.OS === 'ios' ? 54.5 : 55;
}
// itemHeight for emptyItem might be wrong because of line wrapping
// but we don't care because we'll only ever be rendering this item
// by itself and it should always be on-screen
if (item.type === 'empty') {
return 123;
}
let height = chatThreadListItemHeight;
height += item.sidebars.length * sidebarHeight;
if (item.sidebars.length > 0) {
height += spacerHeight;
}
return height;
};
static heightOfItems(data: $ReadOnlyArray
- ): number {
return _sum(data.map(ChatThreadList.itemHeight));
}
listDataSelector = createSelector(
(propsAndState: PropsAndState) => propsAndState.chatListData,
(propsAndState: PropsAndState) => propsAndState.searchStatus,
(propsAndState: PropsAndState) => propsAndState.searchText,
(propsAndState: PropsAndState) => propsAndState.threadsSearchResults,
(propsAndState: PropsAndState) => propsAndState.emptyItem,
(propsAndState: PropsAndState) => propsAndState.usersSearchResults,
(propsAndState: PropsAndState) => propsAndState.filterThreads,
(propsAndState: PropsAndState) => propsAndState.loggedInUserInfo,
(
reduxChatListData: $ReadOnlyArray,
searchStatus: SearchStatus,
searchText: string,
threadsSearchResults: Set,
emptyItem?: React.ComponentType<{}>,
usersSearchResults: $ReadOnlyArray,
filterThreads: ThreadInfo => boolean,
loggedInUserInfo: ?LoggedInUserInfo,
): $ReadOnlyArray
- => {
const chatThreadItems = getThreadListSearchResults(
reduxChatListData,
searchText,
filterThreads,
threadsSearchResults,
usersSearchResults,
loggedInUserInfo,
);
const chatItems: Item[] = [...chatThreadItems];
if (emptyItem && chatItems.length === 0) {
chatItems.push({ type: 'empty', emptyItem });
}
if (searchStatus === 'inactive' || searchStatus === 'activating') {
chatItems.unshift({ type: 'search', searchText });
}
return chatItems;
},
);
partialListDataSelector = createSelector(
this.listDataSelector,
(propsAndState: PropsAndState) => propsAndState.numItemsToDisplay,
(items: $ReadOnlyArray
- , numItemsToDisplay: number) =>
items.slice(0, numItemsToDisplay),
);
get fullListData() {
return this.listDataSelector({ ...this.props, ...this.state });
}
get listData() {
return this.partialListDataSelector({ ...this.props, ...this.state });
}
onEndReached = () => {
if (this.listData.length === this.fullListData.length) {
return;
}
this.setState(prevState => ({
numItemsToDisplay: prevState.numItemsToDisplay + 25,
}));
};
render() {
let floatingAction;
if (Platform.OS === 'android') {
floatingAction = (
);
}
let fixedSearch;
const { searchStatus } = this.state;
if (searchStatus === 'active') {
fixedSearch = this.renderSearch({ autoFocus: true });
}
const scrollEnabled =
searchStatus === 'inactive' || searchStatus === 'active';
// viewerID is in extraData since it's used by MessagePreview
// within ChatThreadListItem
const viewerID = this.props.loggedInUserInfo?.id;
const extraData = `${viewerID || ''} ${this.state.openedSwipeableId}`;
return (
{fixedSearch}
{floatingAction}
);
}
flatListRef = (flatList: ?FlatList
- ) => {
this.flatList = flatList;
};
onScroll = (event: ScrollEvent) => {
const oldScrollPos = this.scrollPos;
this.scrollPos = event.nativeEvent.contentOffset.y;
if (this.scrollPos !== 0 || oldScrollPos === 0) {
return;
}
if (this.state.searchStatus === 'activating') {
this.setState({ searchStatus: 'active' });
}
};
async searchUsers(usernamePrefix: string) {
if (usernamePrefix.length === 0) {
return [];
}
const { userInfos } = await this.props.searchUsers(usernamePrefix);
return userInfos.filter(
info =>
!this.props.usersWithPersonalThread.has(info.id) &&
info.id !== this.props.loggedInUserInfo?.id,
);
}
onChangeSearchText = async (searchText: string) => {
const results = this.props.threadSearchIndex.getSearchResults(searchText);
this.setState({
searchText,
threadsSearchResults: new Set(results),
numItemsToDisplay: 25,
});
const usersSearchResults = await this.searchUsers(searchText);
this.setState({ usersSearchResults });
};
onPressItem = (
threadInfo: ThreadInfo,
pendingPersonalThreadUserInfo?: UserInfo,
) => {
this.onChangeSearchText('');
if (this.searchInput) {
this.searchInput.blur();
}
this.props.navigateToThread({ threadInfo, pendingPersonalThreadUserInfo });
};
onPressSeeMoreSidebars = (threadInfo: ThreadInfo) => {
this.onChangeSearchText('');
if (this.searchInput) {
this.searchInput.blur();
}
this.props.navigation.navigate<'SidebarListModal'>({
name: SidebarListModalRouteName,
params: { threadInfo },
});
};
onSwipeableWillOpen = (threadInfo: ThreadInfo) => {
this.setState(state => ({ ...state, openedSwipeableId: threadInfo.id }));
};
composeThread = () => {
const { loggedInUserInfo } = this.props;
if (!loggedInUserInfo) {
return;
}
const threadInfo = createPendingThread({
viewerID: loggedInUserInfo.id,
threadType: threadTypes.PRIVATE,
+ members: [loggedInUserInfo],
});
this.props.navigateToThread({ threadInfo, searching: true });
};
}
const unboundStyles = {
icon: {
fontSize: 28,
},
container: {
flex: 1,
},
searchContainer: {
backgroundColor: 'listBackground',
display: 'flex',
justifyContent: 'center',
flexDirection: 'row',
},
searchBox: {
flex: 1,
},
search: {
marginBottom: 8,
marginHorizontal: 18,
marginTop: 16,
},
cancelSearchButton: {
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
display: 'flex',
justifyContent: 'center',
},
cancelSearchButtonText: {
color: 'link',
fontSize: 16,
paddingHorizontal: 16,
paddingTop: 8,
},
flatList: {
flex: 1,
backgroundColor: 'listBackground',
},
};
const ConnectedChatThreadList: React.ComponentType =
React.memo(function ConnectedChatThreadList(props: BaseProps) {
const boundChatListData = useFlattenedChatListData();
const loggedInUserInfo = useLoggedInUserInfo();
const threadSearchIndex = useGlobalThreadSearchIndex();
const styles = useStyles(unboundStyles);
const indicatorStyle = useSelector(indicatorStyleSelector);
const callSearchUsers = useServerCall(searchUsers);
const usersWithPersonalThread = useSelector(
usersWithPersonalThreadSelector,
);
const navigateToThread = useNavigateToThread();
return (
);
});
export default ConnectedChatThreadList;
diff --git a/native/chat/compose-thread-button.react.js b/native/chat/compose-thread-button.react.js
index b7871431a..5243a5b62 100644
--- a/native/chat/compose-thread-button.react.js
+++ b/native/chat/compose-thread-button.react.js
@@ -1,62 +1,61 @@
// @flow
import * as React from 'react';
import { StyleSheet } from 'react-native';
+import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js';
import { createPendingThread } from 'lib/shared/thread-utils.js';
import { threadTypes } from 'lib/types/thread-types.js';
import type { ChatNavigationProp } from './chat.react.js';
import Button from '../components/button.react.js';
import SWMansionIcon from '../components/swmansion-icon.react.js';
import { MessageListRouteName } from '../navigation/route-names.js';
-import { useSelector } from '../redux/redux-utils.js';
import { useColors } from '../themes/colors.js';
type Props = {
+navigate: $PropertyType, 'navigate'>,
};
function ComposeThreadButton(props: Props) {
const { navigate } = props;
- const viewerID = useSelector(
- state => state.currentUserInfo && state.currentUserInfo.id,
- );
+ const loggedInUserInfo = useLoggedInUserInfo();
const onPress = React.useCallback(() => {
- if (!viewerID) {
+ if (!loggedInUserInfo) {
return;
}
navigate<'MessageList'>({
name: MessageListRouteName,
params: {
threadInfo: createPendingThread({
- viewerID,
+ viewerID: loggedInUserInfo.id,
threadType: threadTypes.PRIVATE,
+ members: [loggedInUserInfo],
}),
searching: true,
},
});
- }, [navigate, viewerID]);
+ }, [navigate, loggedInUserInfo]);
const { listForegroundSecondaryLabel } = useColors();
return (
);
}
const styles = StyleSheet.create({
composeButton: {
marginRight: 16,
},
});
const MemoizedComposeThreadButton: React.ComponentType =
React.memo(ComposeThreadButton);
export default MemoizedComposeThreadButton;
diff --git a/web/chat/chat-message-list-container.react.js b/web/chat/chat-message-list-container.react.js
index bc5e4b865..d725afe7f 100644
--- a/web/chat/chat-message-list-container.react.js
+++ b/web/chat/chat-message-list-container.react.js
@@ -1,260 +1,264 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import _isEqual from 'lodash/fp/isEqual.js';
import * as React from 'react';
import { useDrop } from 'react-dnd';
import { NativeTypes } from 'react-dnd-html5-backend';
import { useDispatch } from 'react-redux';
+import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js';
import {
useWatchThread,
useExistingThreadInfoFinder,
createPendingThread,
threadIsPending,
} from 'lib/shared/thread-utils.js';
import { threadTypes } from 'lib/types/thread-types.js';
import type { AccountUserInfo } from 'lib/types/user-types.js';
import ChatInputBar from './chat-input-bar.react.js';
import css from './chat-message-list-container.css';
import ChatMessageList from './chat-message-list.react.js';
import ChatThreadComposer from './chat-thread-composer.react.js';
import ThreadTopBar from './thread-top-bar.react.js';
import { InputStateContext } from '../input/input-state.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
type Props = {
+activeChatThreadID: string,
};
function ChatMessageListContainer(props: Props): React.Node {
const { activeChatThreadID } = props;
const isChatCreation =
useSelector(state => state.navInfo.chatMode) === 'create';
const selectedUserIDs = useSelector(state => state.navInfo.selectedUserList);
const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers);
const userInfoInputArray: $ReadOnlyArray = React.useMemo(
() => selectedUserIDs?.map(id => otherUserInfos[id]).filter(Boolean) ?? [],
[otherUserInfos, selectedUserIDs],
);
- const viewerID = useSelector(state => state.currentUserInfo?.id);
- invariant(viewerID, 'should be set');
+
+ const loggedInUserInfo = useLoggedInUserInfo();
+ invariant(loggedInUserInfo, 'loggedInUserInfo should be set');
const pendingPrivateThread = React.useRef(
createPendingThread({
- viewerID,
+ viewerID: loggedInUserInfo.id,
threadType: threadTypes.PRIVATE,
+ members: [loggedInUserInfo],
}),
);
const newThreadID = 'pending/new_thread';
const pendingNewThread = React.useMemo(
() => ({
...createPendingThread({
- viewerID,
+ viewerID: loggedInUserInfo.id,
threadType: threadTypes.PRIVATE,
+ members: [loggedInUserInfo],
name: 'New thread',
}),
id: newThreadID,
}),
- [viewerID],
+ [loggedInUserInfo],
);
const existingThreadInfoFinderForCreatingThread = useExistingThreadInfoFinder(
pendingPrivateThread.current,
);
const baseThreadInfo = useSelector(state => {
if (!activeChatThreadID) {
return null;
}
return (
threadInfoSelector(state)[activeChatThreadID] ??
state.navInfo.pendingThread
);
});
const existingThreadInfoFinder = useExistingThreadInfoFinder(baseThreadInfo);
const threadInfo = React.useMemo(() => {
if (isChatCreation) {
if (userInfoInputArray.length === 0) {
return pendingNewThread;
}
return existingThreadInfoFinderForCreatingThread({
searching: true,
userInfoInputArray,
});
}
return existingThreadInfoFinder({
searching: false,
userInfoInputArray: [],
});
}, [
existingThreadInfoFinder,
existingThreadInfoFinderForCreatingThread,
isChatCreation,
userInfoInputArray,
pendingNewThread,
]);
invariant(threadInfo, 'ThreadInfo should be set');
const dispatch = useDispatch();
// The effect removes members from list in navInfo
// if some of the user IDs don't exist in redux store
React.useEffect(() => {
if (!isChatCreation) {
return;
}
const existingSelectedUsersSet = new Set(
userInfoInputArray.map(userInfo => userInfo.id),
);
if (
selectedUserIDs?.length !== existingSelectedUsersSet.size ||
!_isEqual(new Set(selectedUserIDs), existingSelectedUsersSet)
) {
dispatch({
type: updateNavInfoActionType,
payload: {
selectedUserList: Array.from(existingSelectedUsersSet),
},
});
}
}, [
dispatch,
isChatCreation,
otherUserInfos,
selectedUserIDs,
userInfoInputArray,
]);
React.useEffect(() => {
if (isChatCreation && activeChatThreadID !== threadInfo?.id) {
let payload = {
activeChatThreadID: threadInfo?.id,
};
if (threadIsPending(threadInfo?.id)) {
payload = {
...payload,
pendingThread: threadInfo,
};
}
dispatch({
type: updateNavInfoActionType,
payload,
});
}
}, [activeChatThreadID, dispatch, isChatCreation, threadInfo]);
const inputState = React.useContext(InputStateContext);
invariant(inputState, 'InputState should be set');
const [{ isActive }, connectDropTarget] = useDrop({
accept: NativeTypes.FILE,
drop: item => {
const { files } = item;
if (inputState && files.length > 0) {
inputState.appendFiles(files);
}
},
collect: monitor => ({
isActive: monitor.isOver() && monitor.canDrop(),
}),
});
useWatchThread(threadInfo);
const containerStyle = classNames({
[css.container]: true,
[css.activeContainer]: isActive,
});
const containerRef = React.useRef();
const onPaste = React.useCallback(
(e: ClipboardEvent) => {
if (!inputState) {
return;
}
const { clipboardData } = e;
if (!clipboardData) {
return;
}
const { files } = clipboardData;
if (files.length === 0) {
return;
}
e.preventDefault();
inputState.appendFiles([...files]);
},
[inputState],
);
React.useEffect(() => {
const currentContainerRef = containerRef.current;
if (!currentContainerRef) {
return;
}
currentContainerRef.addEventListener('paste', onPaste);
return () => {
currentContainerRef.removeEventListener('paste', onPaste);
};
}, [onPaste]);
const content = React.useMemo(() => {
const topBar = ;
const messageListAndInput = (
<>
>
);
if (!isChatCreation) {
return (
<>
{topBar}
{messageListAndInput}
>
);
}
const chatUserSelection = (
);
if (!userInfoInputArray.length) {
return chatUserSelection;
}
return (
<>
{topBar}
{chatUserSelection}
{messageListAndInput}
>
);
}, [
inputState,
isChatCreation,
otherUserInfos,
threadInfo,
userInfoInputArray,
]);
return connectDropTarget(
{content}
,
);
}
export default ChatMessageListContainer;