Keyboard shortcuts and accessibility

This commit is contained in:
Scott Nonnenberg 2019-11-07 13:36:16 -08:00
parent 8590a047c7
commit 20a892247f
87 changed files with 3652 additions and 711 deletions

View file

@ -130,7 +130,7 @@ type ConversationRemovedActionType = {
id: string;
};
};
type ConversationUnloadedActionType = {
export type ConversationUnloadedActionType = {
type: 'CONVERSATION_UNLOADED';
payload: {
id: string;
@ -140,6 +140,13 @@ export type RemoveAllConversationsActionType = {
type: 'CONVERSATIONS_REMOVE_ALL';
payload: null;
};
export type MessageSelectedActionType = {
type: 'MESSAGE_SELECTED';
payload: {
messageId: string;
conversationId: string;
};
};
export type MessageChangedActionType = {
type: 'MESSAGE_CHANGED';
payload: {
@ -228,7 +235,7 @@ type ShowInboxActionType = {
type: 'SHOW_INBOX';
payload: null;
};
type ShowArchivedConversationsActionType = {
export type ShowArchivedConversationsActionType = {
type: 'SHOW_ARCHIVED_CONVERSATIONS';
payload: null;
};
@ -239,6 +246,7 @@ export type ConversationActionType =
| ConversationRemovedActionType
| ConversationUnloadedActionType
| RemoveAllConversationsActionType
| MessageSelectedActionType
| MessageChangedActionType
| MessageDeletedActionType
| MessagesAddedActionType
@ -264,6 +272,7 @@ export const actions = {
conversationRemoved,
conversationUnloaded,
removeAllConversations,
selectMessage,
messageDeleted,
messageChanged,
messagesAdded,
@ -328,6 +337,16 @@ function removeAllConversations(): RemoveAllConversationsActionType {
};
}
function selectMessage(messageId: string, conversationId: string) {
return {
type: 'MESSAGE_SELECTED',
payload: {
messageId,
conversationId,
},
};
}
function messageChanged(
id: string,
conversationId: string,
@ -632,9 +651,14 @@ export function reducer(
}
const { messageIds } = existingConversation;
const selectedConversation =
state.selectedConversation !== id
? state.selectedConversation
: undefined;
return {
...state,
selectedConversation,
messagesLookup: omit(state.messagesLookup, messageIds),
messagesByConversation: omit(state.messagesByConversation, [id]),
};
@ -642,6 +666,19 @@ export function reducer(
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
return getEmptyState();
}
if (action.type === 'MESSAGE_SELECTED') {
const { messageId, conversationId } = action.payload;
if (state.selectedConversation !== conversationId) {
return state;
}
return {
...state,
selectedMessage: messageId,
selectedMessageCounter: state.selectedMessageCounter + 1,
};
}
if (action.type === 'MESSAGE_CHANGED') {
const { id, conversationId, data } = action.payload;
const existingConversation = state.messagesByConversation[conversationId];
@ -712,7 +749,9 @@ export function reducer(
[conversationId]: {
isLoadingMessages: false,
scrollToMessageId,
scrollToMessageCounter: 0,
scrollToMessageCounter: existingConversation
? existingConversation.scrollToMessageCounter + 1
: 0,
messageIds,
metrics,
resetCounter,

View file

@ -12,10 +12,12 @@ import { makeLookup } from '../../util/makeLookup';
import {
ConversationType,
ConversationUnloadedActionType,
MessageDeletedActionType,
MessageType,
RemoveAllConversationsActionType,
SelectedConversationChangedActionType,
ShowArchivedConversationsActionType,
} from './conversations';
// State
@ -29,6 +31,7 @@ export type MessageSearchResultLookupType = {
};
export type SearchStateType = {
startSearchCounter: number;
searchConversationId?: string;
searchConversationName?: string;
// We store just ids of conversations, since that data is always cached in memory
@ -81,6 +84,10 @@ type UpdateSearchTermActionType = {
query: string;
};
};
type StartSearchActionType = {
type: 'SEARCH_START';
payload: null;
};
type ClearSearchActionType = {
type: 'SEARCH_CLEAR';
payload: null;
@ -103,18 +110,22 @@ export type SEARCH_TYPES =
| SearchMessagesResultsFulfilledActionType
| SearchDiscussionsResultsFulfilledActionType
| UpdateSearchTermActionType
| StartSearchActionType
| ClearSearchActionType
| ClearConversationSearchActionType
| SearchInConversationActionType
| MessageDeletedActionType
| RemoveAllConversationsActionType
| SelectedConversationChangedActionType;
| SelectedConversationChangedActionType
| ShowArchivedConversationsActionType
| ConversationUnloadedActionType;
// Action Creators
export const actions = {
searchMessages,
searchDiscussions,
startSearch,
clearSearch,
clearConversationSearch,
searchInConversation,
@ -188,7 +199,12 @@ async function doSearchDiscussions(
query,
};
}
function startSearch(): StartSearchActionType {
return {
type: 'SEARCH_START',
payload: null,
};
}
function clearSearch(): ClearSearchActionType {
return {
type: 'SEARCH_CLEAR',
@ -294,6 +310,7 @@ async function queryConversationsAndContacts(
function getEmptyState(): SearchStateType {
return {
startSearchCounter: 0,
query: '',
messageIds: [],
messageLookup: {},
@ -304,11 +321,24 @@ function getEmptyState(): SearchStateType {
};
}
// tslint:disable-next-line max-func-body-length
// tslint:disable-next-line cyclomatic-complexity max-func-body-length
export function reducer(
state: SearchStateType = getEmptyState(),
action: SEARCH_TYPES
): SearchStateType {
if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') {
return getEmptyState();
}
if (action.type === 'SEARCH_START') {
return {
...state,
searchConversationId: undefined,
searchConversationName: undefined,
startSearchCounter: state.startSearchCounter + 1,
};
}
if (action.type === 'SEARCH_CLEAR') {
return getEmptyState();
}
@ -341,13 +371,17 @@ export function reducer(
const { searchConversationId, searchConversationName } = payload;
if (searchConversationId === state.searchConversationId) {
return state;
return {
...state,
startSearchCounter: state.startSearchCounter + 1,
};
}
return {
...getEmptyState(),
searchConversationId,
searchConversationName,
startSearchCounter: state.startSearchCounter + 1,
};
}
if (action.type === 'CLEAR_CONVERSATION_SEARCH') {
@ -412,6 +446,18 @@ export function reducer(
};
}
if (action.type === 'CONVERSATION_UNLOADED') {
const { payload } = action;
const { id } = payload;
const { searchConversationId } = state;
if (searchConversationId && searchConversationId === id) {
return getEmptyState();
}
return state;
}
if (action.type === 'MESSAGE_DELETED') {
const { messageIds, messageLookup } = state;
if (!messageIds || messageIds.length < 1) {

View file

@ -8,6 +8,7 @@ export type UserStateType = {
stickersPath: string;
tempPath: string;
ourNumber: string;
platform: string;
regionCode: string;
i18n: LocalizerType;
};
@ -49,6 +50,7 @@ function getEmptyState(): UserStateType {
tempPath: 'missing',
ourNumber: 'missing',
regionCode: 'missing',
platform: 'missing',
i18n: () => 'missing',
};
}

View file

@ -0,0 +1,16 @@
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { SmartShortcutGuideModal } from '../smart/ShortcutGuideModal';
// Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
const FilteredShortcutGuideModal = SmartShortcutGuideModal as any;
export const createShortcutGuideModal = (store: Store, props: Object) => (
<Provider store={store}>
<FilteredShortcutGuideModal {...props} />
</Provider>
);

View file

@ -50,6 +50,11 @@ export const getSearchConversationName = createSelector(
(state: SearchStateType): string | undefined => state.searchConversationName
);
export const getStartSearchCounter = createSelector(
getSearch,
(state: SearchStateType): number => state.startSearchCounter
);
export const isSearching = createSelector(
getSearch,
(state: SearchStateType) => {
@ -64,12 +69,19 @@ export const getMessageSearchResultLookup = createSelector(
(state: SearchStateType) => state.messageLookup
);
export const getSearchResults = createSelector(
[getSearch, getRegionCode, getConversationLookup, getSelectedConversation],
[
getSearch,
getRegionCode,
getConversationLookup,
getSelectedConversation,
getSelectedMessage,
],
(
state: SearchStateType,
regionCode: string,
lookup: ConversationLookupType,
selectedConversation?: string
selectedConversationId?: string,
selectedMessageId?: string
// tslint:disable-next-line max-func-body-length
): SearchResultsPropsType | undefined => {
const {
@ -115,7 +127,7 @@ export const getSearchResults = createSelector(
type: 'conversation',
data: {
...data,
isSelected: Boolean(data && id === selectedConversation),
isSelected: Boolean(data && id === selectedConversationId),
},
});
});
@ -141,7 +153,7 @@ export const getSearchResults = createSelector(
type: 'contact',
data: {
...data,
isSelected: Boolean(data && id === selectedConversation),
isSelected: Boolean(data && id === selectedConversationId),
},
});
});
@ -177,6 +189,8 @@ export const getSearchResults = createSelector(
regionCode: regionCode,
searchConversationName,
searchTerm: state.query,
selectedConversationId,
selectedMessageId,
};
}
);

View file

@ -32,6 +32,11 @@ export const getStickersPath = createSelector(
(state: UserStateType): string => state.stickersPath
);
export const getPlatform = createSelector(
getUser,
(state: UserStateType): string => state.platform
);
export const getTempPath = createSelector(
getUser,
(state: UserStateType): string => state.tempPath

View file

@ -6,7 +6,11 @@ import { StateType } from '../reducer';
import { getSearchResults, isSearching } from '../selectors/search';
import { getIntl } from '../selectors/user';
import { getLeftPaneLists, getShowArchived } from '../selectors/conversations';
import {
getLeftPaneLists,
getSelectedConversation,
getShowArchived,
} from '../selectors/conversations';
import { SmartMainHeader } from './MainHeader';
import { SmartMessageSearchResult } from './MessageSearchResult';
@ -28,10 +32,12 @@ const mapStateToProps = (state: StateType) => {
const lists = showSearch ? undefined : getLeftPaneLists(state);
const searchResults = showSearch ? getSearchResults(state) : undefined;
const selectedConversationId = getSelectedConversation(state);
return {
...lists,
searchResults,
selectedConversationId,
showArchived: getShowArchived(state),
i18n: getIntl(state),
renderMainHeader,

View file

@ -8,6 +8,7 @@ import {
getQuery,
getSearchConversationId,
getSearchConversationName,
getStartSearchCounter,
} from '../selectors/search';
import { getIntl, getRegionCode, getUserNumber } from '../selectors/user';
import { getMe } from '../selectors/conversations';
@ -17,6 +18,7 @@ const mapStateToProps = (state: StateType) => {
searchTerm: getQuery(state),
searchConversationId: getSearchConversationId(state),
searchConversationName: getSearchConversationName(state),
startSearchCounter: getStartSearchCounter(state),
regionCode: getRegionCode(state),
ourNumber: getUserNumber(state),
...getMe(state),

View file

@ -0,0 +1,47 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { ShortcutGuideModal } from '../../components/ShortcutGuideModal';
import { StateType } from '../reducer';
import { countStickers } from '../../components/stickers/lib';
import { getIntl, getPlatform } from '../selectors/user';
import {
getBlessedStickerPacks,
getInstalledStickerPacks,
getKnownStickerPacks,
getReceivedStickerPacks,
} from '../selectors/stickers';
type ExternalProps = {
close: () => unknown;
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { close } = props;
const blessedPacks = getBlessedStickerPacks(state);
const installedPacks = getInstalledStickerPacks(state);
const knownPacks = getKnownStickerPacks(state);
const receivedPacks = getReceivedStickerPacks(state);
const hasInstalledStickers =
countStickers({
knownPacks,
blessedPacks,
installedPacks,
receivedPacks,
}) === 0;
const platform = getPlatform(state);
return {
close,
hasInstalledStickers,
platform,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartShortcutGuideModal = smart(ShortcutGuideModal);

View file

@ -9,6 +9,7 @@ import { getIntl } from '../selectors/user';
import {
getConversationMessagesSelector,
getConversationSelector,
getSelectedMessage,
} from '../selectors/conversations';
import { SmartTimelineItem } from './TimelineItem';
@ -30,8 +31,18 @@ type ExternalProps = {
// are provided by ConversationView in setupTimeline().
};
function renderItem(messageId: string, actionProps: Object): JSX.Element {
return <FilteredSmartTimelineItem {...actionProps} id={messageId} />;
function renderItem(
messageId: string,
conversationId: string,
actionProps: Object
): JSX.Element {
return (
<FilteredSmartTimelineItem
{...actionProps}
conversationId={conversationId}
id={messageId}
/>
);
}
function renderLastSeenIndicator(id: string): JSX.Element {
return <FilteredSmartLastSeenIndicator id={id} />;
@ -48,11 +59,13 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const conversation = getConversationSelector(state)(id);
const conversationMessages = getConversationMessagesSelector(state)(id);
const selectedMessage = getSelectedMessage(state);
return {
id,
...pick(conversation, ['unreadCount', 'typingContact']),
...conversationMessages,
selectedMessageId: selectedMessage ? selectedMessage.id : undefined,
i18n: getIntl(state),
renderItem,
renderLastSeenIndicator,

View file

@ -5,20 +5,30 @@ import { StateType } from '../reducer';
import { TimelineItem } from '../../components/conversation/TimelineItem';
import { getIntl } from '../selectors/user';
import { getMessageSelector } from '../selectors/conversations';
import {
getMessageSelector,
getSelectedMessage,
} from '../selectors/conversations';
type ExternalProps = {
id: string;
conversationId: string;
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
const { id, conversationId } = props;
const messageSelector = getMessageSelector(state);
const item = messageSelector(id);
const selectedMessage = getSelectedMessage(state);
const isSelected = Boolean(selectedMessage && id === selectedMessage.id);
return {
item,
id,
conversationId,
isSelected,
i18n: getIntl(state),
};
};