diff --git a/app/actions/local/notification.ts b/app/actions/local/notification.ts index 66518ad22..1e83be557 100644 --- a/app/actions/local/notification.ts +++ b/app/actions/local/notification.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import DatabaseManager from '@database/manager'; -import {getPostById, queryPostsInChannel} from '@queries/servers/post'; +import {getPostById, queryPostsInChannel, queryPostsInThread} from '@queries/servers/post'; export const updatePostSinceCache = async (serverUrl: string, notification: NotificationWithData) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -32,3 +32,30 @@ export const updatePostSinceCache = async (serverUrl: string, notification: Noti } }; +export const updatePostsInThreadsSinceCache = async (serverUrl: string, notification: NotificationWithData) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + if (notification.payload?.root_id) { + const {database} = operator; + const chunks = await queryPostsInThread(database, notification.payload.root_id).fetch(); + if (chunks.length) { + const recent = chunks[0]; + const lastPost = await getPostById(database, notification.payload.post_id); + if (lastPost) { + await operator.database.write(async () => { + await recent.update(() => { + recent.latest = lastPost.createAt; + }); + }); + } + } + } + return {}; + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/local/thread.ts b/app/actions/local/thread.ts index edcf9623c..fde3b4b03 100644 --- a/app/actions/local/thread.ts +++ b/app/actions/local/thread.ts @@ -3,19 +3,21 @@ import {DeviceEventEmitter} from 'react-native'; -import {ActionType, General, Navigation, Screens} from '@constants'; +import {ActionType, General, Navigation, Preferences, Screens} from '@constants'; +import {getDefaultThemeByAppearance} from '@context/theme'; import DatabaseManager from '@database/manager'; import {getTranslations, t} from '@i18n'; import {getChannelById} from '@queries/servers/channel'; import {getPostById} from '@queries/servers/post'; -import {getCurrentTeamId, getCurrentUserId, setCurrentTeamAndChannelId} from '@queries/servers/system'; -import {addChannelToTeamHistory} from '@queries/servers/team'; +import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; +import {getCommonSystemValues, getCurrentTeamId, getCurrentUserId, prepareCommonSystemValues, PrepareCommonSystemValuesArgs, setCurrentTeamAndChannelId} from '@queries/servers/system'; +import {addChannelToTeamHistory, addTeamToTeamHistory} from '@queries/servers/team'; import {getIsCRTEnabled, getThreadById, prepareThreadsFromReceivedPosts, queryThreadsInTeam} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; -import {goToScreen} from '@screens/navigation'; +import {dismissAllModalsAndPopToRoot, goToScreen} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import {isTablet} from '@utils/helpers'; -import {changeOpacity} from '@utils/theme'; +import {changeOpacity, setThemeDefaults, updateThemeIfNeeded} from '@utils/theme'; import type Model from '@nozbe/watermelondb/Model'; @@ -59,12 +61,14 @@ export const switchToGlobalThreads = async (serverUrl: string, teamId?: string, return {models}; }; -export const switchToThread = async (serverUrl: string, rootId: string) => { - const database = DatabaseManager.serverDatabases[serverUrl]?.database; - if (!database) { +export const switchToThread = async (serverUrl: string, rootId: string, isFromNotification = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { return {error: `${serverUrl} database not found`}; } + const {database} = operator; + try { const user = await getCurrentUser(database); if (!user) { @@ -80,9 +84,37 @@ export const switchToThread = async (serverUrl: string, rootId: string) => { return {error: 'Channel not found'}; } - const theme = EphemeralStore.theme; + const system = await getCommonSystemValues(database); + const isTabletDevice = await isTablet(); + const teamId = channel.teamId || system.currentTeamId; + + let switchingTeams = false; + if (system.currentTeamId !== teamId) { + const modelPromises: Array> = []; + switchingTeams = true; + modelPromises.push(addTeamToTeamHistory(operator, teamId, true)); + const commonValues: PrepareCommonSystemValuesArgs = { + currentTeamId: teamId, + }; + modelPromises.push(prepareCommonSystemValues(operator, commonValues)); + const models = (await Promise.all(modelPromises)).flat(); + if (models.length) { + await operator.batchRecords(models); + } + } + + let theme = EphemeralStore.theme; if (!theme) { - return {error: 'Theme not found'}; + theme = getDefaultThemeByAppearance(); + + // When opening the app from a push notification the theme may not be set in the EphemeralStore + // causing the goToScreen to use the Appearance theme instead and that causes the screen background color to potentially + // not match the theme + const themes = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_THEME, teamId).fetch(); + if (themes.length) { + theme = setThemeDefaults(JSON.parse(themes[0].value) as Theme); + } + updateThemeIfNeeded(theme!, true); } // Modal right buttons @@ -120,13 +152,21 @@ export const switchToThread = async (serverUrl: string, rootId: string) => { } EphemeralStore.setLastViewedThreadId(rootId); + + if (isFromNotification) { + await dismissAllModalsAndPopToRoot(); + await EphemeralStore.waitUntilScreenIsTop(Screens.HOME); + if (switchingTeams && isTabletDevice) { + DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_THREADS); + } + } goToScreen(Screens.THREAD, '', {rootId}, { topBar: { title: { text: title, }, subtitle: { - color: changeOpacity(theme.sidebarHeaderTextColor, 0.72), + color: changeOpacity(theme!.sidebarHeaderTextColor, 0.72), text: subtitle, }, noBorder: true, @@ -136,6 +176,7 @@ export const switchToThread = async (serverUrl: string, rootId: string) => { rightButtons, }, }); + return {}; } catch (error) { return {error}; diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 9d6d28101..517378761 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -490,6 +490,7 @@ export async function fetchMissingDirectChannelsInfo(serverUrl: string, directCh const ownDirectChannel = dms.find((dm) => dm.name === getDirectChannelName(currentUserId, currentUserId)); if (ownDirectChannel) { ownDirectChannel.display_name = displayUsername(currentUser, locale, teammateDisplayNameSetting, false); + ownDirectChannel.fake = true; updatedChannels.add(ownDirectChannel); } } diff --git a/app/actions/remote/entry/notification.ts b/app/actions/remote/entry/notification.ts index 29672f6d1..e66d1ee17 100644 --- a/app/actions/remote/entry/notification.ts +++ b/app/actions/remote/entry/notification.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {switchToChannelById} from '@actions/remote/channel'; +import {fetchAndSwitchToThread} from '@actions/remote/thread'; import {Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {getMyChannel} from '@queries/servers/channel'; @@ -25,6 +26,7 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not // We only reach this point if we have a channel Id in the notification payload const channelId = notification.payload!.channel_id!; + const rootId = notification.payload!.root_id!; const {database} = operator; const currentTeamId = await getCurrentTeamId(database); const lastDisconnectedAt = await getWebSocketLastDisconnected(database); @@ -45,14 +47,22 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not // To make the switch faster we determine if we already have the team & channel const myChannel = await getMyChannel(database, channelId); const myTeam = await getMyTeamById(database, teamId); + const isCRTEnabled = await getIsCRTEnabled(database); + const isThreadNotification = isCRTEnabled && Boolean(rootId); await EphemeralStore.waitUntilScreenHasLoaded(Screens.HOME); - let switchedToChannel = isCRTEnabled; - if (myChannel && myTeam && !switchedToChannel) { - await switchToChannelById(serverUrl, channelId, teamId); - switchedToChannel = true; + let switchedToScreen = false; + let switchedToChannel = false; + if (myChannel && myTeam) { + if (isThreadNotification) { + await fetchAndSwitchToThread(serverUrl, rootId, true); + } else { + switchedToChannel = true; + await switchToChannelById(serverUrl, channelId, teamId); + } + switchedToScreen = true; } const entryData = await entry(serverUrl, teamId, channelId); @@ -76,11 +86,16 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not } } - if (!switchedToChannel) { + if (!switchedToScreen) { if (isTabletDevice || (selectedChannelId === channelId)) { // Make switch again to get the missing data and make sure the team is the correct one - switchedToChannel = true; - switchToChannelById(serverUrl, selectedChannelId, selectedTeamId); + switchedToScreen = true; + if (isThreadNotification) { + fetchAndSwitchToThread(serverUrl, rootId, true); + } else { + switchedToChannel = true; + switchToChannelById(serverUrl, selectedChannelId, selectedTeamId); + } } else if (selectedTeamId !== teamId || selectedChannelId !== channelId) { // If in the end the selected team or channel is different than the one from the notification // we switch again diff --git a/app/actions/remote/notifications.ts b/app/actions/remote/notifications.ts index 0d7bac29d..ebdfed663 100644 --- a/app/actions/remote/notifications.ts +++ b/app/actions/remote/notifications.ts @@ -3,10 +3,11 @@ import {Platform} from 'react-native'; -import {updatePostSinceCache} from '@actions/local/notification'; +import {updatePostSinceCache, updatePostsInThreadsSinceCache} from '@actions/local/notification'; import {fetchDirectChannelsInfo, fetchMyChannel, switchToChannelById} from '@actions/remote/channel'; import {forceLogoutIfNecessary} from '@actions/remote/session'; import {fetchMyTeam} from '@actions/remote/team'; +import {fetchAndSwitchToThread} from '@actions/remote/thread'; import DatabaseManager from '@database/manager'; import {getMyChannel, getChannelById} from '@queries/servers/channel'; import {getCommonSystemValues, getWebSocketLastDisconnected} from '@queries/servers/system'; @@ -85,15 +86,16 @@ export const backgroundNotification = async (serverUrl: string, notification: No return; } - const isCRTEnabled = await getIsCRTEnabled(database); - if (isCRTEnabled && notification.payload?.root_id) { - return; - } - const lastDisconnectedAt = await getWebSocketLastDisconnected(database); if (lastDisconnectedAt) { if (Platform.OS === 'ios') { - updatePostSinceCache(serverUrl, notification); + const isCRTEnabled = await getIsCRTEnabled(database); + const isThreadNotification = isCRTEnabled && Boolean(notification.payload?.root_id); + if (isThreadNotification) { + updatePostsInThreadsSinceCache(serverUrl, notification); + } else { + updatePostSinceCache(serverUrl, notification); + } } await fetchNotificationData(serverUrl, notification, true); @@ -109,10 +111,11 @@ export const openNotification = async (serverUrl: string, notification: Notifica try { const {database} = operator; const channelId = notification.payload!.channel_id!; + const rootId = notification.payload!.root_id!; + const isCRTEnabled = await getIsCRTEnabled(database); - if (isCRTEnabled && notification.payload?.root_id) { - return {error: 'Opening CRT notifications not implemented yet'}; - } + const isThreadNotification = isCRTEnabled && Boolean(rootId); + const system = await getCommonSystemValues(database); const currentServerUrl = await DatabaseManager.getActiveServerUrl(); let teamId = notification.payload?.team_id; @@ -131,6 +134,9 @@ export const openNotification = async (serverUrl: string, notification: Notifica const myTeam = await getMyTeamById(database, teamId); if (myChannel && myTeam) { + if (isThreadNotification) { + return fetchAndSwitchToThread(serverUrl, rootId, true); + } return switchToChannelById(serverUrl, channelId, teamId); } @@ -139,6 +145,9 @@ export const openNotification = async (serverUrl: string, notification: Notifica return {error: result.error}; } + if (isThreadNotification) { + return fetchAndSwitchToThread(serverUrl, rootId, true); + } return switchToChannelById(serverUrl, channelId, teamId); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts index b1d87aa76..630cfa538 100644 --- a/app/actions/remote/thread.ts +++ b/app/actions/remote/thread.ts @@ -33,7 +33,7 @@ type FetchThreadsOptions = { totalsOnly?: boolean; }; -export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string) => { +export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string, isFromNotification = false) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; @@ -49,7 +49,7 @@ export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string) const post = await getPostById(database, rootId); if (post) { const thread = await getThreadById(database, rootId); - if (thread?.unreadReplies || thread?.unreadMentions) { + if (thread) { const channel = await getChannelById(database, post.channelId); if (channel) { markThreadAsRead(serverUrl, channel.teamId, thread.id); @@ -58,7 +58,7 @@ export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string) } } - switchToThread(serverUrl, rootId); + switchToThread(serverUrl, rootId, isFromNotification); return {}; }; diff --git a/app/actions/websocket/posts.ts b/app/actions/websocket/posts.ts index b073b4495..f883f638e 100644 --- a/app/actions/websocket/posts.ts +++ b/app/actions/websocket/posts.ts @@ -65,10 +65,7 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag const isCRTEnabled = await getIsCRTEnabled(database); if (isCRTEnabled) { - const {models: threadModels} = await createThreadFromNewPost(serverUrl, post, true); - if (threadModels?.length) { - models.push(...threadModels); - } + await createThreadFromNewPost(serverUrl, post, false); } // Ensure the channel membership diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index 6ce90d489..92a74fac8 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -122,6 +122,22 @@ class EphemeralStore { } }; + /** + * Waits until a passed screen is the top screen + * Use this function only if you know what you are doing + * this function will run until the screen is in the top + * @param componentId string + */ + waitUntilScreenIsTop = async (componentId: string) => { + let found = false; + while (!found) { + // eslint-disable-next-line no-await-in-loop + await (new Promise((r) => requestAnimationFrame(r))); + + found = this.getNavigationTopComponentId() === componentId; + } + }; + /** * Waits until a screen has been removed as part of the stack. * Use this function only if you know what you are doing diff --git a/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift b/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift index 981d5dd4e..e93c95b66 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift @@ -163,7 +163,7 @@ extension Network { let userId = participant.id if (userId != currentUserId) { threadParticipantUserIds.insert(userId) - if (threadParticipantUsers[userId] != nil) { + if (threadParticipantUsers[userId] == nil) { threadParticipantUsers[userId] = participant } }