diff --git a/keyserver/src/fetchers/thread-fetchers.js b/keyserver/src/fetchers/thread-fetchers.js --- a/keyserver/src/fetchers/thread-fetchers.js +++ b/keyserver/src/fetchers/thread-fetchers.js @@ -36,12 +36,13 @@ `.append(whereClause); const threadsQuery = SQL` - SELECT t.id, t.name, t.parent_thread_id, t.containing_thread_id, - t.community, t.depth, t.color, t.description, t.type, t.creation_time, - t.source_message, t.replies_count, t.avatar, m.user, m.role, m.permissions, - m.subscription, m.last_read_message < m.last_message AS unread, m.sender - FROM threads t - LEFT JOIN memberships m ON m.thread = t.id AND m.role >= 0 + SELECT t.id, t.name, t.parent_thread_id, t.containing_thread_id, + t.community, t.depth, t.color, t.description, t.type, t.creation_time, + t.source_message, t.replies_count, t.avatar, t.pinned_count, m.user, + m.role, m.permissions, m.subscription, + m.last_read_message < m.last_message AS unread, m.sender + FROM threads t + LEFT JOIN memberships m ON m.thread = t.id AND m.role >= 0 ` .append(whereClause) .append(SQL` ORDER BY m.user ASC`); @@ -74,6 +75,7 @@ members: [], roles: {}, repliesCount: threadsRow.replies_count, + pinnedCount: threadsRow.pinned_count, }; if (threadsRow.avatar) { threadInfos[threadID] = { @@ -165,6 +167,10 @@ // native release with thread avatar editing enabled. const filterThreadEditAvatarPermission = true; + const hasCodeVersionBelow209 = !hasMinCodeVersion( + viewer.platformDetails, + 209, + ); const threadInfos = {}; for (const threadID in serverResult.threadInfos) { const serverThreadInfo = serverResult.threadInfos[threadID]; @@ -178,6 +184,7 @@ hideThreadStructure: hasCodeVersionBelow102, filterDetailedThreadEditPermissions: hasCodeVersionBelow104, filterThreadEditAvatarPermission, + excludePinInfo: hasCodeVersionBelow209, }, ); if (threadInfo) { diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -377,6 +377,7 @@ }, repliesCount: 0, sourceMessageID, + pinnedCount: 0, }; const userInfos = {}; @@ -701,6 +702,7 @@ }, +filterDetailedThreadEditPermissions?: boolean, +filterThreadEditAvatarPermission?: boolean, + +excludePinInfo?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, @@ -715,6 +717,7 @@ options?.filterDetailedThreadEditPermissions; const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; + const excludePinInfo = options?.excludePinInfo; const filterThreadPermissions = _omitBy( (v, k) => @@ -728,6 +731,12 @@ threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, + ].includes(k)) || + (excludePinInfo && + [ + threadPermissions.MANAGE_PINS, + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.MANAGE_PINS, ].includes(k)), ); @@ -836,6 +845,12 @@ visibilityRules: rawThreadInfo.type, }; } + if (!excludePinInfo) { + return { + ...rawThreadInfo, + pinnedCount: serverThreadInfo.pinnedCount, + }; + } return rawThreadInfo; } @@ -883,7 +898,7 @@ ...threadInfo, uiName: threadUIName(threadInfo), }; - const { sourceMessageID, avatar } = rawThreadInfo; + const { sourceMessageID, avatar, pinnedCount } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } @@ -899,6 +914,11 @@ avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } + + if (pinnedCount) { + threadInfo = { ...threadInfo, pinnedCount }; + } + return threadInfo; } diff --git a/lib/shared/unshim-utils.js b/lib/shared/unshim-utils.js --- a/lib/shared/unshim-utils.js +++ b/lib/shared/unshim-utils.js @@ -44,6 +44,7 @@ messageTypes.SIDEBAR_SOURCE, messageTypes.MULTIMEDIA, messageTypes.REACTION, + messageTypes.TOGGLE_PIN, ]); function unshimMessageInfos( messageInfos: $ReadOnlyArray, diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -217,6 +217,7 @@ +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, + +pinnedCount?: number, }; export type ThreadInfo = { @@ -236,6 +237,7 @@ +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, + +pinnedCount?: number, }; export type ResolvedThreadInfo = { @@ -255,6 +257,7 @@ +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, + +pinnedCount?: number, }; export type ServerMemberInfo = { @@ -282,6 +285,7 @@ +roles: { [id: string]: RoleInfo }, +sourceMessageID?: string, +repliesCount: number, + +pinnedCount: number, }; export type ThreadStore = { @@ -323,6 +327,7 @@ +currentUser: string, +sourceMessageID?: string, +repliesCount: number, + +pinnedCount?: number, }; export type ClientDBReplaceThreadOperation = { diff --git a/native/cpp/CommonCpp/NativeModules/CommCoreModule.cpp b/native/cpp/CommonCpp/NativeModules/CommCoreModule.cpp --- a/native/cpp/CommonCpp/NativeModules/CommCoreModule.cpp +++ b/native/cpp/CommonCpp/NativeModules/CommCoreModule.cpp @@ -255,6 +255,7 @@ ? jsi::String::createFromUtf8(rt, *thread.source_message_id) : jsi::Value::null()); jsiThread.setProperty(rt, "repliesCount", thread.replies_count); + jsiThread.setProperty(rt, "pinnedCount", thread.pinned_count); if (thread.avatar) { auto avatar = jsi::String::createFromUtf8(rt, *thread.avatar); @@ -643,6 +644,10 @@ ? std::make_unique(maybeAvatar.asString(rt).utf8(rt)) : nullptr; + jsi::Value maybePinnedCount = threadObj.getProperty(rt, "pinnedCount"); + int pinnedCount = maybePinnedCount.isNumber() + ? std::lround(maybePinnedCount.asNumber()) + : 0; Thread thread{ threadID, type, @@ -658,7 +663,8 @@ currentUser, std::move(sourceMessageID), repliesCount, - std::move(avatar)}; + std::move(avatar), + pinnedCount}; threadStoreOps.push_back( std::make_unique(std::move(thread))); diff --git a/native/redux/manage-pins-permission-migration.js b/native/redux/manage-pins-permission-migration.js new file mode 100644 --- /dev/null +++ b/native/redux/manage-pins-permission-migration.js @@ -0,0 +1,89 @@ +// @flow + +import type { + RawThreadInfo, + MemberInfo, + ThreadCurrentUserInfo, + RoleInfo, +} from 'lib/types/thread-types.js'; + +type ThreadStoreThreadInfos = { +[id: string]: RawThreadInfo }; +type TargetMemberInfo = MemberInfo | ThreadCurrentUserInfo; + +const adminRoleName = 'Admins'; + +function addManagePinsThreadPermissionToUser( + threadInfo: RawThreadInfo, + member: TargetMemberInfo, + threadID: string, +): TargetMemberInfo { + const isAdmin = + member.role && threadInfo.roles[member.role].name === adminRoleName; + let newPermissionsForMember; + if (isAdmin) { + newPermissionsForMember = { + ...member.permissions, + manage_pins: { value: true, source: threadID }, + }; + } + + return newPermissionsForMember + ? { + ...member, + permissions: newPermissionsForMember, + } + : member; +} + +function addManagePinsThreadPermissionToRole(role: RoleInfo): RoleInfo { + const isAdminRole = role.name === adminRoleName; + let updatedPermissions; + + if (isAdminRole) { + updatedPermissions = { + ...role.permissions, + manage_pins: true, + descendents_manage_pins: true, + }; + } + + return updatedPermissions + ? { ...role, permissions: updatedPermissions } + : role; +} + +function persistMigrationForManagePinsThreadPermission( + threadInfos: ThreadStoreThreadInfos, +): ThreadStoreThreadInfos { + const newThreadInfos = {}; + for (const threadID in threadInfos) { + const threadInfo: RawThreadInfo = threadInfos[threadID]; + const updatedMembers = threadInfo.members.map(member => + addManagePinsThreadPermissionToUser(threadInfo, member, threadID), + ); + + const updatedCurrentUser = addManagePinsThreadPermissionToUser( + threadInfo, + threadInfo.currentUser, + threadID, + ); + + const updatedRoles = {}; + for (const roleID in threadInfo.roles) { + updatedRoles[roleID] = addManagePinsThreadPermissionToRole( + threadInfo.roles[roleID], + ); + } + + const updatedThreadInfo = { + ...threadInfo, + members: updatedMembers, + currentUser: updatedCurrentUser, + roles: updatedRoles, + }; + newThreadInfos[threadID] = updatedThreadInfo; + } + return newThreadInfos; +} + +export { persistMigrationForManagePinsThreadPermission }; diff --git a/native/redux/persist.js b/native/redux/persist.js --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -13,7 +13,10 @@ getContainingThreadID, getCommunity, } from 'lib/shared/thread-utils.js'; -import { DEPRECATED_unshimMessageStore } from 'lib/shared/unshim-utils.js'; +import { + DEPRECATED_unshimMessageStore, + unshimFunc, +} from 'lib/shared/unshim-utils.js'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { @@ -23,11 +26,23 @@ type ClientDBMessageStoreOperation, } from 'lib/types/message-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; -import { translateRawMessageInfoToClientDBMessageInfo } from 'lib/utils/message-ops-utils.js'; +import type { + ClientDBThreadStoreOperation, + ClientDBThreadInfo, +} from 'lib/types/thread-types.js'; +import { + translateClientDBMessageInfoToRawMessageInfo, + translateRawMessageInfoToClientDBMessageInfo, +} from 'lib/utils/message-ops-utils.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; -import { convertThreadStoreOperationsToClientDBOperations } from 'lib/utils/thread-ops-utils.js'; +import { + convertClientDBThreadInfoToRawThreadInfo, + convertRawThreadInfoToClientDBThreadInfo, + convertThreadStoreOperationsToClientDBOperations, +} from 'lib/utils/thread-ops-utils.js'; import { migrateThreadStoreForEditThreadPermissions } from './edit-thread-permission-migration.js'; +import { persistMigrationForManagePinsThreadPermission } from './manage-pins-permission-migration.js'; import type { AppState } from './state-types.js'; import { unshimClientDB } from './unshim-utils.js'; import { commCoreModule } from '../native-modules.js'; @@ -391,6 +406,104 @@ return stateSansThreadIDsToNotifIDs; }, [35]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), + [36]: (state: AppState) => { + // 1. Get threads and messages from SQLite `threads` and `messages` tables. + const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); + const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); + + // 2. Translate `ClientDBThreadInfo`s to `RawThreadInfo`s and + // `ClientDBMessageInfo`s to `RawMessageInfo`s. + const rawThreadInfos = clientDBThreadInfos.map( + convertClientDBThreadInfoToRawThreadInfo, + ); + const rawMessageInfos = clientDBMessageInfos.map( + translateClientDBMessageInfoToRawMessageInfo, + ); + + // 3. Unshim translated `RawMessageInfos` to get the TOGGLE_PIN messages + const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo => + unshimFunc(messageInfo, new Set([messageTypes.TOGGLE_PIN])), + ); + + // 4. Filter out non-TOGGLE_PIN messages + const filteredRawMessageInfos = unshimmedRawMessageInfos.filter( + messageInfo => messageInfo.type === messageTypes.TOGGLE_PIN, + ); + + // 5. We want only the last TOGGLE_PIN message for each message ID, + // so 'pin', 'unpin', 'pin' don't count as 3 pins, but only 1. + const lastMessageIDToRawMessageInfoMap = new Map(); + for (const messageInfo of filteredRawMessageInfos) { + const { targetMessageID } = messageInfo; + lastMessageIDToRawMessageInfoMap.set(targetMessageID, messageInfo); + } + const lastMessageIDToRawMessageInfos = Array.from( + lastMessageIDToRawMessageInfoMap.values(), + ); + + // 6. Create a Map of threadIDs to pinnedCount + const threadIDsToPinnedCount = new Map(); + for (const messageInfo of lastMessageIDToRawMessageInfos) { + const { threadID, type } = messageInfo; + if (type === messageTypes.TOGGLE_PIN) { + const pinnedCount = threadIDsToPinnedCount.get(threadID) || 0; + threadIDsToPinnedCount.set(threadID, pinnedCount + 1); + } + } + + // 7. Include a pinnedCount for each rawThreadInfo + const rawThreadInfosWithPinnedCount = rawThreadInfos.map(threadInfo => ({ + ...threadInfo, + pinnedCount: threadIDsToPinnedCount.get(threadInfo.id) || 0, + })); + + // 8. Convert rawThreadInfos to a map of threadID to threadInfo + const threadIDToThreadInfo = rawThreadInfosWithPinnedCount.reduce( + (acc, threadInfo) => { + acc[threadInfo.id] = threadInfo; + return acc; + }, + {}, + ); + + // 9. Add threadPermission to each threadInfo + const rawThreadInfosWithThreadPermission = + persistMigrationForManagePinsThreadPermission(threadIDToThreadInfo); + + // 10. Convert the new threadInfos back into an array + const rawThreadInfosWithCountAndPermission = Object.keys( + rawThreadInfosWithThreadPermission, + ).map(id => rawThreadInfosWithThreadPermission[id]); + + // 11. Translate `RawThreadInfo`s to `ClientDBThreadInfo`s. + const convertedClientDBThreadInfos = + rawThreadInfosWithCountAndPermission.map( + convertRawThreadInfoToClientDBThreadInfo, + ); + + // 12. Construct `ClientDBThreadStoreOperation`s to clear SQLite `threads` + // table and repopulate with `ClientDBThreadInfo`s. + const operations: $ReadOnlyArray = [ + { + type: 'remove_all', + }, + ...convertedClientDBThreadInfos.map((thread: ClientDBThreadInfo) => ({ + type: 'replace', + payload: thread, + })), + ]; + + // 13. Try processing `ClientDBThreadStoreOperation`s and log out if + // `processThreadStoreOperationsSync(...)` throws an exception. + try { + commCoreModule.processThreadStoreOperationsSync(operations); + } catch (exception) { + console.log(exception); + return { ...state, cookie: null }; + } + + return state; + }, }; // After migration 31, we'll no longer want to persist `messageStore.messages` @@ -471,7 +584,7 @@ 'storeLoaded', ], debug: __DEV__, - version: 35, + version: 36, transforms: [messageStoreMessagesBlocklistTransform], migrate: (createMigrate(migrations, { debug: __DEV__ }): any), timeout: ((__DEV__ ? 0 : undefined): number | void),