Smarter incoming message handling for filter by unread (#9366)
Co-authored-by: yash-signal <yash@signal.org>
This commit is contained in:
parent
4d7ed49dfd
commit
795edf1987
3 changed files with 185 additions and 17 deletions
|
@ -201,7 +201,6 @@ import { markCallHistoryReadInConversation } from './callHistory';
|
||||||
import type { CapabilitiesType } from '../../textsecure/WebAPI';
|
import type { CapabilitiesType } from '../../textsecure/WebAPI';
|
||||||
import { actions as searchActions } from './search';
|
import { actions as searchActions } from './search';
|
||||||
import type { SearchActionType } from './search';
|
import type { SearchActionType } from './search';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
export type DBConversationType = ReadonlyDeep<{
|
export type DBConversationType = ReadonlyDeep<{
|
||||||
|
@ -2689,14 +2688,8 @@ function conversationsUpdated(
|
||||||
calling.groupMembersChanged(conversation.id);
|
calling.groupMembersChanged(conversation.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { conversationLookup } = getState().conversations;
|
const { conversationLookup: oldConversationLookup } =
|
||||||
|
getState().conversations;
|
||||||
const someConversationsHaveNewMessages = data.some(conversation => {
|
|
||||||
return (
|
|
||||||
conversationLookup[conversation.id]?.lastMessageReceivedAt !==
|
|
||||||
conversation.lastMessageReceivedAt
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'CONVERSATIONS_UPDATED',
|
type: 'CONVERSATIONS_UPDATED',
|
||||||
|
@ -2705,9 +2698,12 @@ function conversationsUpdated(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (someConversationsHaveNewMessages) {
|
dispatch(
|
||||||
dispatch(searchActions.refreshSearch());
|
searchActions.updateSearchResultsOnConversationUpdate(
|
||||||
}
|
oldConversationLookup,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4627,6 +4623,8 @@ function onConversationClosed(
|
||||||
conversationId,
|
conversationId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dispatch(searchActions.maybeRemoveReadConversations([conversationId]));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ import type {
|
||||||
TargetedConversationChangedActionType,
|
TargetedConversationChangedActionType,
|
||||||
ShowArchivedConversationsActionType,
|
ShowArchivedConversationsActionType,
|
||||||
MessageType,
|
MessageType,
|
||||||
|
ConversationLookupType,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
import {
|
import {
|
||||||
getFilterByUnread,
|
getFilterByUnread,
|
||||||
|
@ -45,6 +46,10 @@ import { removeDiacritics } from '../../util/removeDiacritics';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import { searchConversationTitles } from '../../util/searchConversationTitles';
|
import { searchConversationTitles } from '../../util/searchConversationTitles';
|
||||||
import { isDirectConversation } from '../../util/whatTypeOfConversation';
|
import { isDirectConversation } from '../../util/whatTypeOfConversation';
|
||||||
|
import {
|
||||||
|
countConversationUnreadStats,
|
||||||
|
hasUnread,
|
||||||
|
} from '../../util/countUnreadStats';
|
||||||
|
|
||||||
const { searchMessages: dataSearchMessages } = DataReader;
|
const { searchMessages: dataSearchMessages } = DataReader;
|
||||||
|
|
||||||
|
@ -135,6 +140,14 @@ type RefreshSearchActionType = ReadonlyDeep<{
|
||||||
payload: null;
|
payload: null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type MaybeRemoveReadConversationsActionType = ReadonlyDeep<{
|
||||||
|
type: 'MAYBE_REMOVE_READ_CONVERSATIONS';
|
||||||
|
payload: {
|
||||||
|
conversations: Array<ConversationType>;
|
||||||
|
selectedConversationId: string | undefined;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
export type SearchActionType = ReadonlyDeep<
|
export type SearchActionType = ReadonlyDeep<
|
||||||
| SearchMessagesResultsFulfilledActionType
|
| SearchMessagesResultsFulfilledActionType
|
||||||
| SearchDiscussionsResultsFulfilledActionType
|
| SearchDiscussionsResultsFulfilledActionType
|
||||||
|
@ -152,6 +165,7 @@ export type SearchActionType = ReadonlyDeep<
|
||||||
| ConversationUnloadedActionType
|
| ConversationUnloadedActionType
|
||||||
| UpdateFilterByUnreadActionType
|
| UpdateFilterByUnreadActionType
|
||||||
| RefreshSearchActionType
|
| RefreshSearchActionType
|
||||||
|
| MaybeRemoveReadConversationsActionType
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
@ -166,6 +180,8 @@ export const actions = {
|
||||||
updateSearchTerm,
|
updateSearchTerm,
|
||||||
updateFilterByUnread,
|
updateFilterByUnread,
|
||||||
refreshSearch,
|
refreshSearch,
|
||||||
|
maybeRemoveReadConversations,
|
||||||
|
updateSearchResultsOnConversationUpdate,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSearchActions = (): BoundActionCreatorsMapObject<
|
export const useSearchActions = (): BoundActionCreatorsMapObject<
|
||||||
|
@ -248,6 +264,98 @@ function refreshSearch(): ThunkAction<
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSearchResultsOnConversationUpdate(
|
||||||
|
oldConversationLookup: ConversationLookupType,
|
||||||
|
updatedConversations: Array<ConversationType>
|
||||||
|
): ThunkAction<
|
||||||
|
void,
|
||||||
|
RootStateType,
|
||||||
|
unknown,
|
||||||
|
MaybeRemoveReadConversationsActionType
|
||||||
|
> {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
|
||||||
|
if (!getIsActivelySearching(getState())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const someConversationsHaveNewMessages = updatedConversations.some(
|
||||||
|
conversation => {
|
||||||
|
const oldConversation = oldConversationLookup[conversation.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
!oldConversation ||
|
||||||
|
oldConversation.lastMessageReceivedAt !==
|
||||||
|
conversation.lastMessageReceivedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (someConversationsHaveNewMessages) {
|
||||||
|
dispatch(refreshSearch());
|
||||||
|
// A new search will automatically remove read conversations
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'MAYBE_REMOVE_READ_CONVERSATIONS',
|
||||||
|
payload: {
|
||||||
|
conversations: updatedConversations,
|
||||||
|
selectedConversationId: state.conversations.selectedConversationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRemoveConversationFromUnreadList(
|
||||||
|
conversation: ConversationType,
|
||||||
|
selectedConversationId: string | undefined,
|
||||||
|
state: SearchStateType
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
state.filterByUnread &&
|
||||||
|
state.conversationIds.includes(conversation.id) &&
|
||||||
|
conversation &&
|
||||||
|
(selectedConversationId == null ||
|
||||||
|
selectedConversationId !== conversation.id) &&
|
||||||
|
!hasUnread(
|
||||||
|
countConversationUnreadStats(conversation, { includeMuted: true })
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRemoveReadConversations(
|
||||||
|
conversationIds: Array<string>
|
||||||
|
): ThunkAction<
|
||||||
|
void,
|
||||||
|
RootStateType,
|
||||||
|
unknown,
|
||||||
|
MaybeRemoveReadConversationsActionType
|
||||||
|
> {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const {
|
||||||
|
conversations: { selectedConversationId, conversationLookup },
|
||||||
|
} = getState();
|
||||||
|
|
||||||
|
const conversations = conversationIds
|
||||||
|
.map(id => conversationLookup[id])
|
||||||
|
.filter(isNotNil);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'MAYBE_REMOVE_READ_CONVERSATIONS',
|
||||||
|
payload: {
|
||||||
|
conversations,
|
||||||
|
selectedConversationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function updateFilterByUnread(
|
function updateFilterByUnread(
|
||||||
filterByUnread: boolean
|
filterByUnread: boolean
|
||||||
): ThunkAction<void, RootStateType, unknown, UpdateFilterByUnreadActionType> {
|
): ThunkAction<void, RootStateType, unknown, UpdateFilterByUnreadActionType> {
|
||||||
|
@ -308,6 +416,8 @@ const doSearch = debounce(
|
||||||
const ourConversationId = getUserConversationId(state);
|
const ourConversationId = getUserConversationId(state);
|
||||||
const searchConversationId = getSearchConversation(state)?.id;
|
const searchConversationId = getSearchConversation(state)?.id;
|
||||||
|
|
||||||
|
const { selectedConversationId } = state.conversations;
|
||||||
|
|
||||||
strictAssert(ourConversationId, 'doSearch our conversation is missing');
|
strictAssert(ourConversationId, 'doSearch our conversation is missing');
|
||||||
|
|
||||||
// Limit the number of contacts to something reasonable
|
// Limit the number of contacts to something reasonable
|
||||||
|
@ -354,6 +464,10 @@ const doSearch = debounce(
|
||||||
|
|
||||||
if (!searchConversationId) {
|
if (!searchConversationId) {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
const selectedConversation: ConversationType | undefined =
|
||||||
|
selectedConversationId
|
||||||
|
? state.conversations.conversationLookup[selectedConversationId]
|
||||||
|
: undefined;
|
||||||
const { conversationIds, contactIds } =
|
const { conversationIds, contactIds } =
|
||||||
await queryConversationsAndContacts(query, {
|
await queryConversationsAndContacts(query, {
|
||||||
filterByUnread,
|
filterByUnread,
|
||||||
|
@ -361,6 +475,24 @@ const doSearch = debounce(
|
||||||
noteToSelf,
|
noteToSelf,
|
||||||
regionCode,
|
regionCode,
|
||||||
allConversations,
|
allConversations,
|
||||||
|
/**
|
||||||
|
* If filter by unread is enabled, the selected conversation
|
||||||
|
* is read, and it's already in the list, we don't want to remove it
|
||||||
|
* from the list. It will be removed when the user switches to
|
||||||
|
* a different conversation.
|
||||||
|
*/
|
||||||
|
conversationToInject:
|
||||||
|
filterByUnread &&
|
||||||
|
selectedConversationId &&
|
||||||
|
selectedConversation &&
|
||||||
|
state.search.conversationIds.includes(selectedConversationId) &&
|
||||||
|
!hasUnread(
|
||||||
|
countConversationUnreadStats(selectedConversation, {
|
||||||
|
includeMuted: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
? selectedConversation
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
|
@ -416,12 +548,14 @@ async function queryConversationsAndContacts(
|
||||||
noteToSelf: string;
|
noteToSelf: string;
|
||||||
regionCode: string | undefined;
|
regionCode: string | undefined;
|
||||||
allConversations: ReadonlyArray<ConversationType>;
|
allConversations: ReadonlyArray<ConversationType>;
|
||||||
|
conversationToInject?: ConversationType;
|
||||||
}
|
}
|
||||||
): Promise<{
|
): Promise<{
|
||||||
contactIds: Array<string>;
|
contactIds: Array<string>;
|
||||||
conversationIds: Array<string>;
|
conversationIds: Array<string>;
|
||||||
}> {
|
}> {
|
||||||
const {
|
const {
|
||||||
|
conversationToInject,
|
||||||
filterByUnread,
|
filterByUnread,
|
||||||
ourConversationId,
|
ourConversationId,
|
||||||
noteToSelf,
|
noteToSelf,
|
||||||
|
@ -462,7 +596,8 @@ async function queryConversationsAndContacts(
|
||||||
visibleConversations,
|
visibleConversations,
|
||||||
normalizedQuery,
|
normalizedQuery,
|
||||||
regionCode,
|
regionCode,
|
||||||
filterByUnread
|
filterByUnread,
|
||||||
|
conversationToInject
|
||||||
);
|
);
|
||||||
|
|
||||||
// Split into two groups - active conversations and items just from address book
|
// Split into two groups - active conversations and items just from address book
|
||||||
|
@ -479,8 +614,11 @@ async function queryConversationsAndContacts(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject synthetic Note to Self entry if query matches localized 'Note to Self'
|
// If it's a query search and query matches part of localized "Note to Self",
|
||||||
if (noteToSelf.indexOf(query.toLowerCase()) !== -1) {
|
// inject synthetic Note to Self only in the contacts list.
|
||||||
|
// If we're filtering by unread, no contacts are shown anyway, so we show it in the
|
||||||
|
// normal flow of the conversations list.
|
||||||
|
if (!filterByUnread && noteToSelf.indexOf(query.toLowerCase()) !== -1) {
|
||||||
// ensure that we don't have duplicates in our results
|
// ensure that we don't have duplicates in our results
|
||||||
contactIds = contactIds.filter(id => id !== ourConversationId);
|
contactIds = contactIds.filter(id => id !== ourConversationId);
|
||||||
conversationIds = conversationIds.filter(id => id !== ourConversationId);
|
conversationIds = conversationIds.filter(id => id !== ourConversationId);
|
||||||
|
@ -551,6 +689,33 @@ export function reducer(
|
||||||
state: Readonly<SearchStateType> = getEmptyState(),
|
state: Readonly<SearchStateType> = getEmptyState(),
|
||||||
action: Readonly<SearchActionType>
|
action: Readonly<SearchActionType>
|
||||||
): SearchStateType {
|
): SearchStateType {
|
||||||
|
if (action.type === 'MAYBE_REMOVE_READ_CONVERSATIONS') {
|
||||||
|
if (!state.filterByUnread) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const { conversations, selectedConversationId } = action.payload;
|
||||||
|
|
||||||
|
const conversationIdsToRemove = conversations
|
||||||
|
.filter(conversation =>
|
||||||
|
shouldRemoveConversationFromUnreadList(
|
||||||
|
conversation,
|
||||||
|
selectedConversationId,
|
||||||
|
state
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map(conversation => conversation.id);
|
||||||
|
|
||||||
|
if (conversationIdsToRemove.length === 0) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
conversationIds: state.conversationIds.filter(
|
||||||
|
id => !conversationIdsToRemove.includes(id)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
if (action.type === 'FILTER_BY_UNREAD_UPDATE') {
|
if (action.type === 'FILTER_BY_UNREAD_UPDATE') {
|
||||||
return handleSearchUpdate(state, {
|
return handleSearchUpdate(state, {
|
||||||
filterByUnread: action.payload.enabled,
|
filterByUnread: action.payload.enabled,
|
||||||
|
|
|
@ -165,12 +165,17 @@ export function filterAndSortConversations(
|
||||||
conversations: ReadonlyArray<ConversationType>,
|
conversations: ReadonlyArray<ConversationType>,
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
regionCode: string | undefined,
|
regionCode: string | undefined,
|
||||||
filterByUnread: boolean = false
|
filterByUnread: boolean = false,
|
||||||
|
conversationToInject?: ConversationType
|
||||||
): Array<ConversationType> {
|
): Array<ConversationType> {
|
||||||
const filteredConversations = filterByUnread
|
let filteredConversations = filterByUnread
|
||||||
? filterConversationsByUnread(conversations, true)
|
? filterConversationsByUnread(conversations, true)
|
||||||
: conversations;
|
: conversations;
|
||||||
|
|
||||||
|
if (conversationToInject) {
|
||||||
|
filteredConversations = [...filteredConversations, conversationToInject];
|
||||||
|
}
|
||||||
|
|
||||||
if (searchTerm.length) {
|
if (searchTerm.length) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const withoutUnknownAndFiltered = filteredConversations.filter(
|
const withoutUnknownAndFiltered = filteredConversations.filter(
|
||||||
|
|
Loading…
Reference in a new issue