Add badges to avatars in group dialogs
This commit is contained in:
parent
7bb37dc63b
commit
e490d91cc4
11 changed files with 121 additions and 39 deletions
|
@ -4,8 +4,9 @@
|
|||
import type { ReactChild, ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import { ModalHost } from './ModalHost';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
|
@ -92,20 +93,29 @@ GroupDialog.Paragraph = ({
|
|||
|
||||
type ContactsPropsType = {
|
||||
contacts: Array<ConversationType>;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
i18n: LocalizerType;
|
||||
theme: ThemeType;
|
||||
};
|
||||
|
||||
GroupDialog.Contacts = ({ contacts, i18n }: Readonly<ContactsPropsType>) => (
|
||||
GroupDialog.Contacts = ({
|
||||
contacts,
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
theme,
|
||||
}: Readonly<ContactsPropsType>) => (
|
||||
<ul className="module-GroupDialog__contacts">
|
||||
{contacts.map(contact => (
|
||||
<li key={contact.id} className="module-GroupDialog__contacts__contact">
|
||||
<Avatar
|
||||
acceptedMessageRequest={contact.acceptedMessageRequest}
|
||||
avatarPath={contact.avatarPath}
|
||||
badge={getPreferredBadge(contact.badges)}
|
||||
color={contact.color}
|
||||
conversationType={contact.type}
|
||||
isMe={contact.isMe}
|
||||
noteToSelf={contact.isMe}
|
||||
theme={theme}
|
||||
title={contact.title}
|
||||
unblurredAvatarPath={contact.unblurredAvatarPath}
|
||||
sharedGroupNames={contact.sharedGroupNames}
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { ConversationType } from '../state/ducks/conversations';
|
|||
import { setupI18n } from '../util/setupI18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { ThemeType } from '../types/Util';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -44,6 +45,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
booleanOr(overrideProps.areWeInvited, false)
|
||||
),
|
||||
droppedMembers: overrideProps.droppedMembers || [contact3, contact1],
|
||||
getPreferredBadge: () => undefined,
|
||||
hasMigrated: boolean(
|
||||
'hasMigrated',
|
||||
booleanOr(overrideProps.hasMigrated, false)
|
||||
|
@ -52,6 +54,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
invitedMembers: overrideProps.invitedMembers || [contact2],
|
||||
migrate: action('migrate'),
|
||||
onClose: action('onClose'),
|
||||
theme: ThemeType.light,
|
||||
});
|
||||
|
||||
const stories = storiesOf('Components/GroupV1MigrationDialog', module);
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
// Copyright 2019-2020 Signal Messenger, LLC
|
||||
// Copyright 2019-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import { GroupDialog } from './GroupDialog';
|
||||
import { sortByTitle } from '../util/sortByTitle';
|
||||
|
||||
|
@ -19,7 +20,9 @@ export type DataPropsType = {
|
|||
};
|
||||
|
||||
export type HousekeepingPropsType = {
|
||||
readonly getPreferredBadge: PreferredBadgeSelectorType;
|
||||
readonly i18n: LocalizerType;
|
||||
readonly theme: ThemeType;
|
||||
};
|
||||
|
||||
export type PropsType = DataPropsType & HousekeepingPropsType;
|
||||
|
@ -29,11 +32,13 @@ export const GroupV1MigrationDialog: React.FunctionComponent<PropsType> =
|
|||
const {
|
||||
areWeInvited,
|
||||
droppedMembers,
|
||||
getPreferredBadge,
|
||||
hasMigrated,
|
||||
i18n,
|
||||
invitedMembers,
|
||||
migrate,
|
||||
onClose,
|
||||
theme,
|
||||
} = props;
|
||||
|
||||
const title = hasMigrated
|
||||
|
@ -84,23 +89,39 @@ export const GroupV1MigrationDialog: React.FunctionComponent<PropsType> =
|
|||
</GroupDialog.Paragraph>
|
||||
) : (
|
||||
<>
|
||||
{renderMembers(
|
||||
invitedMembers,
|
||||
'GroupV1--Migration--info--invited',
|
||||
i18n
|
||||
)}
|
||||
{renderMembers(droppedMembers, droppedMembersKey, i18n)}
|
||||
{renderMembers({
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
members: invitedMembers,
|
||||
prefix: 'GroupV1--Migration--info--invited',
|
||||
theme,
|
||||
})}
|
||||
{renderMembers({
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
members: droppedMembers,
|
||||
prefix: droppedMembersKey,
|
||||
theme,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</GroupDialog>
|
||||
);
|
||||
});
|
||||
|
||||
function renderMembers(
|
||||
members: Array<ConversationType>,
|
||||
prefix: string,
|
||||
i18n: LocalizerType
|
||||
): React.ReactNode {
|
||||
function renderMembers({
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
members,
|
||||
prefix,
|
||||
theme,
|
||||
}: Readonly<{
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
i18n: LocalizerType;
|
||||
members: Array<ConversationType>;
|
||||
prefix: string;
|
||||
theme: ThemeType;
|
||||
}>): React.ReactNode {
|
||||
if (!members.length) {
|
||||
return null;
|
||||
}
|
||||
|
@ -111,7 +132,12 @@ function renderMembers(
|
|||
return (
|
||||
<>
|
||||
<GroupDialog.Paragraph>{i18n(key)}</GroupDialog.Paragraph>
|
||||
<GroupDialog.Contacts contacts={sortByTitle(members)} i18n={i18n} />
|
||||
<GroupDialog.Contacts
|
||||
contacts={sortByTitle(members)}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { setupI18n } from '../util/setupI18n';
|
|||
import enMessages from '../../_locales/en/messages.json';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { ThemeType } from '../types/Util';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -27,15 +28,19 @@ const story = storiesOf(
|
|||
story.add('One contact', () => (
|
||||
<NewlyCreatedGroupInvitedContactsDialog
|
||||
contacts={[conversations[0]]}
|
||||
getPreferredBadge={() => undefined}
|
||||
i18n={i18n}
|
||||
onClose={action('onClose')}
|
||||
theme={ThemeType.light}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Two contacts', () => (
|
||||
<NewlyCreatedGroupInvitedContactsDialog
|
||||
contacts={conversations}
|
||||
getPreferredBadge={() => undefined}
|
||||
i18n={i18n}
|
||||
onClose={action('onClose')}
|
||||
theme={ThemeType.light}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -4,8 +4,9 @@
|
|||
import type { FunctionComponent, ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import { Intl } from './Intl';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
import { GroupDialog } from './GroupDialog';
|
||||
|
@ -13,12 +14,14 @@ import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
|
|||
|
||||
type PropsType = {
|
||||
contacts: Array<ConversationType>;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
i18n: LocalizerType;
|
||||
onClose: () => void;
|
||||
theme: ThemeType;
|
||||
};
|
||||
|
||||
export const NewlyCreatedGroupInvitedContactsDialog: FunctionComponent<PropsType> =
|
||||
({ contacts, i18n, onClose }) => {
|
||||
({ contacts, getPreferredBadge, i18n, onClose, theme }) => {
|
||||
let title: string;
|
||||
let body: ReactNode;
|
||||
if (contacts.length === 1) {
|
||||
|
@ -57,7 +60,12 @@ export const NewlyCreatedGroupInvitedContactsDialog: FunctionComponent<PropsType
|
|||
'NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph'
|
||||
)}
|
||||
</GroupDialog.Paragraph>
|
||||
<GroupDialog.Contacts contacts={contacts} i18n={i18n} />
|
||||
<GroupDialog.Contacts
|
||||
contacts={contacts}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import { setupI18n } from '../../util/setupI18n';
|
|||
import enMessages from '../../../_locales/en/messages.json';
|
||||
import type { PropsType } from './GroupV1Migration';
|
||||
import { GroupV1Migration } from './GroupV1Migration';
|
||||
import { ThemeType } from '../../types/Util';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -33,8 +34,10 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
isBoolean(overrideProps.areWeInvited) ? overrideProps.areWeInvited : false
|
||||
),
|
||||
droppedMembers: overrideProps.droppedMembers || [contact1],
|
||||
getPreferredBadge: () => undefined,
|
||||
i18n,
|
||||
invitedMembers: overrideProps.invitedMembers || [contact2],
|
||||
theme: ThemeType.light,
|
||||
});
|
||||
|
||||
const stories = storiesOf('Components/Conversation/GroupV1Migration', module);
|
||||
|
|
|
@ -5,8 +5,9 @@ import * as React from 'react';
|
|||
|
||||
import { Button, ButtonSize, ButtonVariant } from '../Button';
|
||||
import { SystemMessage } from './SystemMessage';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||
import type { ConversationType } from '../../state/ducks/conversations';
|
||||
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
|
||||
import { Intl } from '../Intl';
|
||||
import { ContactName } from './ContactName';
|
||||
import { GroupV1MigrationDialog } from '../GroupV1MigrationDialog';
|
||||
|
@ -19,13 +20,22 @@ export type PropsDataType = {
|
|||
};
|
||||
|
||||
export type PropsHousekeepingType = {
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
i18n: LocalizerType;
|
||||
theme: ThemeType;
|
||||
};
|
||||
|
||||
export type PropsType = PropsDataType & PropsHousekeepingType;
|
||||
|
||||
export function GroupV1Migration(props: PropsType): React.ReactElement {
|
||||
const { areWeInvited, droppedMembers, i18n, invitedMembers } = props;
|
||||
const {
|
||||
areWeInvited,
|
||||
droppedMembers,
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
invitedMembers,
|
||||
theme,
|
||||
} = props;
|
||||
const [showingDialog, setShowingDialog] = React.useState(false);
|
||||
|
||||
const showDialog = React.useCallback(() => {
|
||||
|
@ -77,11 +87,13 @@ export function GroupV1Migration(props: PropsType): React.ReactElement {
|
|||
<GroupV1MigrationDialog
|
||||
areWeInvited={areWeInvited}
|
||||
droppedMembers={droppedMembers}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
hasMigrated
|
||||
i18n={i18n}
|
||||
invitedMembers={invitedMembers}
|
||||
migrate={() => log.warn('GroupV1Migration: Modal called migrate()')}
|
||||
onClose={dismissDialog}
|
||||
theme={theme}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
@ -457,8 +457,10 @@ const renderTypingBubble = () => (
|
|||
/>
|
||||
);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
getPreferredBadge: () => undefined,
|
||||
i18n,
|
||||
theme: React.useContext(StorybookThemeContext),
|
||||
|
||||
haveNewest: boolean('haveNewest', overrideProps.haveNewest !== false),
|
||||
haveOldest: boolean('haveOldest', overrideProps.haveOldest !== false),
|
||||
|
@ -494,13 +496,13 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
});
|
||||
|
||||
story.add('Oldest and Newest', () => {
|
||||
const props = createProps();
|
||||
const props = useProps();
|
||||
|
||||
return <Timeline {...props} />;
|
||||
});
|
||||
|
||||
story.add('With active message request', () => {
|
||||
const props = createProps({
|
||||
const props = useProps({
|
||||
isIncomingMessageRequest: true,
|
||||
});
|
||||
|
||||
|
@ -508,7 +510,7 @@ story.add('With active message request', () => {
|
|||
});
|
||||
|
||||
story.add('Without Newest Message', () => {
|
||||
const props = createProps({
|
||||
const props = useProps({
|
||||
haveNewest: false,
|
||||
});
|
||||
|
||||
|
@ -516,7 +518,7 @@ story.add('Without Newest Message', () => {
|
|||
});
|
||||
|
||||
story.add('Without newest message, active message request', () => {
|
||||
const props = createProps({
|
||||
const props = useProps({
|
||||
haveOldest: false,
|
||||
isIncomingMessageRequest: true,
|
||||
});
|
||||
|
@ -525,7 +527,7 @@ story.add('Without newest message, active message request', () => {
|
|||
});
|
||||
|
||||
story.add('Without Oldest Message', () => {
|
||||
const props = createProps({
|
||||
const props = useProps({
|
||||
haveOldest: false,
|
||||
scrollToIndex: -1,
|
||||
});
|
||||
|
@ -534,7 +536,7 @@ story.add('Without Oldest Message', () => {
|
|||
});
|
||||
|
||||
story.add('Empty (just hero)', () => {
|
||||
const props = createProps({
|
||||
const props = useProps({
|
||||
items: [],
|
||||
});
|
||||
|
||||
|
@ -542,7 +544,7 @@ story.add('Empty (just hero)', () => {
|
|||
});
|
||||
|
||||
story.add('Last Seen', () => {
|
||||
const props = createProps({
|
||||
const props = useProps({
|
||||
oldestUnreadIndex: 13,
|
||||
totalUnread: 2,
|
||||
});
|
||||
|
@ -551,7 +553,7 @@ story.add('Last Seen', () => {
|
|||
});
|
||||
|
||||
story.add('Target Index to Top', () => {
|
||||
const props = createProps({
|
||||
const props = useProps({
|
||||
scrollToIndex: 0,
|
||||
});
|
||||
|
||||
|
@ -559,7 +561,7 @@ story.add('Target Index to Top', () => {
|
|||
});
|
||||
|
||||
story.add('Typing Indicator', () => {
|
||||
const props = createProps({
|
||||
const props = useProps({
|
||||
typingContactId: UUID.generate().toString(),
|
||||
});
|
||||
|
||||
|
@ -567,7 +569,7 @@ story.add('Typing Indicator', () => {
|
|||
});
|
||||
|
||||
story.add('With invited contacts for a newly-created group', () => {
|
||||
const props = createProps({
|
||||
const props = useProps({
|
||||
invitedContactsForNewlyCreatedGroup: [
|
||||
getDefaultConversation({
|
||||
id: 'abc123',
|
||||
|
@ -584,7 +586,7 @@ story.add('With invited contacts for a newly-created group', () => {
|
|||
});
|
||||
|
||||
story.add('With "same name in direct conversation" warning', () => {
|
||||
const props = createProps({
|
||||
const props = useProps({
|
||||
warning: {
|
||||
type: ContactSpoofingType.DirectConversationWithSameTitle,
|
||||
safeConversation: getDefaultConversation(),
|
||||
|
@ -596,7 +598,7 @@ story.add('With "same name in direct conversation" warning', () => {
|
|||
});
|
||||
|
||||
story.add('With "same name in group conversation" warning', () => {
|
||||
const props = createProps({
|
||||
const props = useProps({
|
||||
warning: {
|
||||
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
|
||||
acknowledgedGroupNameCollisions: {},
|
||||
|
|
|
@ -17,8 +17,9 @@ import Measure from 'react-measure';
|
|||
|
||||
import { ScrollDownButton } from './ScrollDownButton';
|
||||
|
||||
import type { AssertProps, LocalizerType } from '../../types/Util';
|
||||
import type { AssertProps, LocalizerType, ThemeType } from '../../types/Util';
|
||||
import type { ConversationType } from '../../state/ducks/conversations';
|
||||
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
|
||||
import { assert } from '../../util/assert';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { createRefMerger } from '../../util/refMerger';
|
||||
|
@ -102,7 +103,9 @@ type PropsHousekeepingType = {
|
|||
warning?: WarningType;
|
||||
contactSpoofingReview?: ContactSpoofingReviewPropType;
|
||||
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
i18n: LocalizerType;
|
||||
theme: ThemeType;
|
||||
|
||||
renderItem: (props: {
|
||||
actionProps: PropsActionsType;
|
||||
|
@ -1312,6 +1315,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
clearInvitedUuidsForNewlyCreatedGroup,
|
||||
closeContactSpoofingReview,
|
||||
contactSpoofingReview,
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
id,
|
||||
invitedContactsForNewlyCreatedGroup,
|
||||
|
@ -1325,6 +1329,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
removeMember,
|
||||
reviewGroupMemberNameCollision,
|
||||
reviewMessageRequestNameCollision,
|
||||
theme,
|
||||
} = this.props;
|
||||
const {
|
||||
shouldShowScrollDownButton,
|
||||
|
@ -1561,8 +1566,10 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
{Boolean(invitedContactsForNewlyCreatedGroup.length) && (
|
||||
<NewlyCreatedGroupInvitedContactsDialog
|
||||
contacts={invitedContactsForNewlyCreatedGroup}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
onClose={clearInvitedUuidsForNewlyCreatedGroup}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
@ -7,9 +7,10 @@ import type { PropsType as GroupV1MigrationDialogPropsType } from '../../compone
|
|||
import { GroupV1MigrationDialog } from '../../components/GroupV1MigrationDialog';
|
||||
import type { ConversationType } from '../ducks/conversations';
|
||||
import type { StateType } from '../reducer';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
import { getConversationSelector } from '../selectors/conversations';
|
||||
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getIntl, getTheme } from '../selectors/user';
|
||||
import * as log from '../../logging/log';
|
||||
|
||||
export type PropsType = {
|
||||
|
@ -17,7 +18,7 @@ export type PropsType = {
|
|||
readonly invitedMemberIds: Array<string>;
|
||||
} & Omit<
|
||||
GroupV1MigrationDialogPropsType,
|
||||
'i18n' | 'droppedMembers' | 'invitedMembers'
|
||||
'i18n' | 'droppedMembers' | 'invitedMembers' | 'theme' | 'getPreferredBadge'
|
||||
>;
|
||||
|
||||
const mapStateToProps = (
|
||||
|
@ -44,8 +45,10 @@ const mapStateToProps = (
|
|||
return {
|
||||
...props,
|
||||
droppedMembers,
|
||||
getPreferredBadge: getPreferredBadgeSelector(state),
|
||||
invitedMembers,
|
||||
i18n: getIntl(state),
|
||||
theme: getTheme(state),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ import { Timeline } from '../../components/conversation/Timeline';
|
|||
import type { StateType } from '../reducer';
|
||||
import type { ConversationType } from '../ducks/conversations';
|
||||
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getIntl, getTheme } from '../selectors/user';
|
||||
import {
|
||||
getConversationByUuidSelector,
|
||||
getConversationMessagesSelector,
|
||||
|
@ -48,6 +48,7 @@ import {
|
|||
} from '../../util/groupMemberNameCollisions';
|
||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||
import type { WidthBreakpoint } from '../../components/_util';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
|
||||
type ExternalProps = {
|
||||
id: string;
|
||||
|
@ -313,7 +314,9 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
warning: getWarning(conversation, state),
|
||||
contactSpoofingReview: getContactSpoofingReview(id, state),
|
||||
|
||||
getPreferredBadge: getPreferredBadgeSelector(state),
|
||||
i18n: getIntl(state),
|
||||
theme: getTheme(state),
|
||||
renderItem,
|
||||
renderLastSeenIndicator,
|
||||
renderHeroRow,
|
||||
|
|
Loading…
Add table
Reference in a new issue