Merge branch 'virtuoso-notifications' into 'next'

Next: Notifications improvements 2

See merge request soapbox-pub/soapbox-fe!1242
This commit is contained in:
Alex Gleason 2022-04-19 18:15:17 +00:00
commit 55c4e3a00b
15 changed files with 219 additions and 287 deletions

View File

@ -150,7 +150,6 @@ module.exports = {
'react/jsx-wrap-multilines': 'error', 'react/jsx-wrap-multilines': 'error',
'react/no-multi-comp': 'off', 'react/no-multi-comp': 'off',
'react/no-string-refs': 'error', 'react/no-string-refs': 'error',
'react/prop-types': 'error',
'react/self-closing-comp': 'error', 'react/self-closing-comp': 'error',
'jsx-a11y/accessible-emoji': 'warn', 'jsx-a11y/accessible-emoji': 'warn',
@ -264,7 +263,6 @@ module.exports = {
files: ['**/*.ts', '**/*.tsx'], files: ['**/*.ts', '**/*.tsx'],
rules: { rules: {
'no-undef': 'off', // https://stackoverflow.com/a/69155899 'no-undef': 'off', // https://stackoverflow.com/a/69155899
'react/prop-types': 'off',
}, },
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
}, },

View File

@ -1035,7 +1035,7 @@ export function accountLookup(acct, cancelToken) {
}; };
} }
export function fetchBirthdayReminders(day, month) { export function fetchBirthdayReminders(month, day) {
return (dispatch, getState) => { return (dispatch, getState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;

View File

@ -5,7 +5,7 @@ import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
import VerificationBadge from 'soapbox/components/verification_badge'; import VerificationBadge from 'soapbox/components/verification_badge';
import ActionButton from 'soapbox/features/ui/components/action_button'; import ActionButton from 'soapbox/features/ui/components/action_button';
import { useAppSelector, useOnScreen } from 'soapbox/hooks'; import { useAppSelector, useOnScreen } from 'soapbox/hooks';
import { getAcct, getDomain } from 'soapbox/utils/accounts'; import { getAcct } from 'soapbox/utils/accounts';
import { displayFqn } from 'soapbox/utils/state'; import { displayFqn } from 'soapbox/utils/state';
import RelativeTimestamp from './relative_timestamp'; import RelativeTimestamp from './relative_timestamp';
@ -91,7 +91,7 @@ const Account = ({
); );
} }
if (account.get('id') !== me && account.get('relationship', null) !== null) { if (account.id !== me) {
return <ActionButton account={account} />; return <ActionButton account={account} />;
} }
@ -118,8 +118,8 @@ const Account = ({
if (hidden) { if (hidden) {
return ( return (
<> <>
{account.get('display_name')} {account.display_name}
{account.get('username')} {account.username}
</> </>
); );
} }
@ -128,34 +128,31 @@ const Account = ({
const LinkEl: any = showProfileHoverCard ? Link : 'div'; const LinkEl: any = showProfileHoverCard ? Link : 'div';
const favicon = account.pleroma.get('favicon');
const domain = getDomain(account);
return ( return (
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}> <div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'> <HStack alignItems={actionAlignment} justifyContent='between'>
<HStack alignItems='center' space={3} grow> <HStack alignItems='center' space={3} grow>
<ProfilePopper <ProfilePopper
condition={showProfileHoverCard} condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper accountId={account.get('id')} inline>{children}</HoverRefWrapper>} wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
> >
<LinkEl <LinkEl
to={`/@${account.get('acct')}`} to={`/@${account.acct}`}
title={account.get('acct')} title={account.acct}
onClick={(event: React.MouseEvent) => event.stopPropagation()} onClick={(event: React.MouseEvent) => event.stopPropagation()}
> >
<Avatar src={account.get('avatar')} size={avatarSize} /> <Avatar src={account.avatar} size={avatarSize} />
</LinkEl> </LinkEl>
</ProfilePopper> </ProfilePopper>
<div className='flex-grow'> <div className='flex-grow'>
<ProfilePopper <ProfilePopper
condition={showProfileHoverCard} condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper accountId={account.get('id')} inline>{children}</HoverRefWrapper>} wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
> >
<LinkEl <LinkEl
to={`/@${account.get('acct')}`} to={`/@${account.acct}`}
title={account.get('acct')} title={account.acct}
onClick={(event: React.MouseEvent) => event.stopPropagation()} onClick={(event: React.MouseEvent) => event.stopPropagation()}
> >
<div className='flex items-center space-x-1 flex-grow' style={style}> <div className='flex items-center space-x-1 flex-grow' style={style}>
@ -163,10 +160,10 @@ const Account = ({
size='sm' size='sm'
weight='semibold' weight='semibold'
truncate truncate
dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/> />
{account.get('verified') && <VerificationBadge />} {account.verified && <VerificationBadge />}
</div> </div>
</LinkEl> </LinkEl>
</ProfilePopper> </ProfilePopper>
@ -174,9 +171,9 @@ const Account = ({
<HStack alignItems='center' space={1} style={style}> <HStack alignItems='center' space={1} style={style}>
<Text theme='muted' size='sm' truncate>@{username}</Text> <Text theme='muted' size='sm' truncate>@{username}</Text>
{favicon && ( {account.favicon && (
<Link to={`/timeline/${domain}`} className='w-4 h-4'> <Link to={`/timeline/${account.domain}`} className='w-4 h-4 flex-none' onClick={e => e.stopPropagation()}>
<img src={favicon} alt='' title={domain} className='w-full max-h-full' /> <img src={account.favicon} alt='' title={account.domain} className='w-full max-h-full' />
</Link> </Link>
)} )}

View File

@ -0,0 +1,47 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { fetchBirthdayReminders } from 'soapbox/actions/accounts';
import { Widget } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { useAppSelector } from 'soapbox/hooks';
interface IBirthdayPanel {
limit: number
}
const BirthdayPanel = ({ limit }: IBirthdayPanel) => {
const dispatch = useDispatch();
const birthdays: ImmutableOrderedSet<string> = useAppSelector(state => state.user_lists.getIn(['birthday_reminders', state.me], ImmutableOrderedSet()));
const birthdaysToRender = birthdays.slice(0, limit);
React.useEffect(() => {
const date = new Date();
const day = date.getDate();
const month = date.getMonth() + 1;
dispatch(fetchBirthdayReminders(month, day));
}, []);
if (birthdaysToRender.isEmpty()) {
return null;
}
return (
<Widget title={<FormattedMessage id='birthday_panel.title' defaultMessage='Birthdays' />}>
{birthdaysToRender.map(accountId => (
<AccountContainer
key={accountId}
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={accountId}
/>
))}
</Widget>
);
};
export default BirthdayPanel;

View File

@ -1,161 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { HotKeys } from 'react-hotkeys';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { fetchBirthdayReminders } from 'soapbox/actions/accounts';
import { openModal } from 'soapbox/actions/modals';
import Icon from 'soapbox/components/icon';
import { HStack, Text } from 'soapbox/components/ui';
import { makeGetAccount } from 'soapbox/selectors';
const mapStateToProps = (state, props) => {
const me = state.get('me');
const getAccount = makeGetAccount();
const birthdays = state.getIn(['user_lists', 'birthday_reminders', me]);
if (birthdays?.size > 0) {
return {
birthdays,
account: getAccount(state, birthdays.first()),
};
}
return {
birthdays,
};
};
export default @connect(mapStateToProps)
@injectIntl
class BirthdayReminders extends ImmutablePureComponent {
static propTypes = {
birthdays: ImmutablePropTypes.orderedSet,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
onMoveDown: PropTypes.func,
};
componentDidMount() {
const { dispatch } = this.props;
const date = new Date();
const day = date.getDate();
const month = date.getMonth() + 1;
dispatch(fetchBirthdayReminders(day, month));
}
getHandlers() {
return {
open: this.handleOpenBirthdaysModal,
moveDown: this.props.onMoveDown,
};
}
handleOpenBirthdaysModal = () => {
const { dispatch } = this.props;
dispatch(openModal('BIRTHDAYS'));
}
renderMessage() {
const { birthdays, account } = this.props;
const link = (
<bdi>
<Link
className='text-gray-800 dark:text-gray-200 font-bold hover:underline'
title={account.get('acct')}
to={`/@${account.get('acct')}`}
dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }}
/>
</bdi>
);
if (birthdays.size === 1) {
return <FormattedMessage id='notification.birthday' defaultMessage='{name} has a birthday today' values={{ name: link }} />;
}
return (
<FormattedMessage
id='notification.birthday_plural'
defaultMessage='{name} and {more} have birthday today'
values={{
name: link,
more: (
<span type='button' role='presentation' onClick={this.handleOpenBirthdaysModal}>
<FormattedMessage
id='notification.birthday.more'
defaultMessage='{count} more {count, plural, one {friend} other {friends}}'
values={{ count: birthdays.size - 1 }}
/>
</span>
),
}}
/>
);
}
renderMessageForScreenReader = () => {
const { intl, birthdays, account } = this.props;
if (birthdays.size === 1) {
return intl.formatMessage({ id: 'notification.birthday', defaultMessage: '{name} has a birthday today' }, { name: account.get('display_name') });
}
return intl.formatMessage(
{
id: 'notification.birthday_plural',
defaultMessage: '{name} and {more} have birthday today',
},
{
name: account.get('display_name'),
more: intl.formatMessage(
{
id: 'notification.birthday.more',
defaultMessage: '{count} more {count, plural, one {friend} other {friends}}',
},
{ count: birthdays.size - 1 },
),
},
);
}
render() {
const { birthdays } = this.props;
if (!birthdays || birthdays.size === 0) return null;
return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-birthday focusable' tabIndex='0' title={this.renderMessageForScreenReader()}>
<div className='p-4 focusable'>
<HStack alignItems='center' space={1.5}>
<Icon
src={require('@tabler/icons/icons/ballon.svg')}
className='text-primary-600'
/>
<Text
theme='muted'
size='sm'
>
{this.renderMessage()}
</Text>
</HStack>
</div>
</div>
</HotKeys>
);
}
}

View File

@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import { Stack, Text } from 'soapbox/components/ui'; import { Text, IconButton } from 'soapbox/components/ui';
import HStack from 'soapbox/components/ui/hstack/hstack';
import Stack from 'soapbox/components/ui/stack/stack';
interface IWidgetTitle { interface IWidgetTitle {
title: string | React.ReactNode title: string | React.ReactNode,
} }
const WidgetTitle = ({ title }: IWidgetTitle): JSX.Element => ( const WidgetTitle = ({ title }: IWidgetTitle): JSX.Element => (
@ -16,12 +18,31 @@ const WidgetBody: React.FC = ({ children }): JSX.Element => (
interface IWidget { interface IWidget {
title: string | React.ReactNode, title: string | React.ReactNode,
onActionClick?: () => void,
actionIcon?: string,
actionTitle?: string,
} }
const Widget: React.FC<IWidget> = ({ title, children }): JSX.Element => { const Widget: React.FC<IWidget> = ({
title,
children,
onActionClick,
actionIcon = require('@tabler/icons/icons/arrow-right.svg'),
actionTitle,
}): JSX.Element => {
return ( return (
<Stack space={2}> <Stack space={2}>
<WidgetTitle title={title} /> <HStack alignItems='center'>
<WidgetTitle title={title} />
{onActionClick && (
<IconButton
className='w-6 h-6 ml-2 text-black dark:text-white'
src={actionIcon}
onClick={onActionClick}
title={actionTitle}
/>
)}
</HStack>
<WidgetBody>{children}</WidgetBody> <WidgetBody>{children}</WidgetBody>
</Stack> </Stack>
); );

View File

@ -78,9 +78,7 @@ const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false })
Footer: () => hasMore ? <PlaceholderChat /> : null, Footer: () => hasMore ? <PlaceholderChat /> : null,
EmptyPlaceholder: () => { EmptyPlaceholder: () => {
if (isLoading) { if (isLoading) {
return ( return <PlaceholderChat />;
<>{Array(20).fill(<PlaceholderChat />)}</>
);
} else { } else {
return <Text>{intl.formatMessage(messages.emptyMessage)}</Text>; return <Text>{intl.formatMessage(messages.emptyMessage)}</Text>;
} }

View File

@ -1,17 +1,24 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { Text, Widget } from 'soapbox/components/ui'; import { Text, Widget } from 'soapbox/components/ui';
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks'; import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
import SiteWallet from './site_wallet'; import SiteWallet from './site_wallet';
const messages = defineMessages({
actionTitle: { id: 'crypto_donate_panel.actions.view', defaultMessage: 'Click to see {count} {count, plural, one {wallet} other {wallets}}' },
});
interface ICryptoDonatePanel { interface ICryptoDonatePanel {
limit: number, limit: number,
} }
const CryptoDonatePanel: React.FC<ICryptoDonatePanel> = ({ limit = 3 }): JSX.Element | null => { const CryptoDonatePanel: React.FC<ICryptoDonatePanel> = ({ limit = 3 }): JSX.Element | null => {
const intl = useIntl();
const history = useHistory();
const addresses = useSoapboxConfig().get('cryptoAddresses'); const addresses = useSoapboxConfig().get('cryptoAddresses');
const siteTitle = useAppSelector((state) => state.instance.title); const siteTitle = useAppSelector((state) => state.instance.title);
@ -19,11 +26,16 @@ const CryptoDonatePanel: React.FC<ICryptoDonatePanel> = ({ limit = 3 }): JSX.Ele
return null; return null;
} }
const more = addresses.size - limit; const handleAction = () => {
const hasMore = more > 0; history.push('/donate/crypto');
};
return ( return (
<Widget title={<FormattedMessage id='crypto_donate_panel.heading' defaultMessage='Donate Cryptocurrency' />}> <Widget
title={<FormattedMessage id='crypto_donate_panel.heading' defaultMessage='Donate Cryptocurrency' />}
onActionClick={handleAction}
actionTitle={intl.formatMessage(messages.actionTitle, { count: addresses.size })}
>
<Text> <Text>
<FormattedMessage <FormattedMessage
id='crypto_donate_panel.intro.message' id='crypto_donate_panel.intro.message'
@ -33,18 +45,6 @@ const CryptoDonatePanel: React.FC<ICryptoDonatePanel> = ({ limit = 3 }): JSX.Ele
</Text> </Text>
<SiteWallet limit={limit} /> <SiteWallet limit={limit} />
{hasMore && (
<Link className='wtf-panel__expand-btn' to='/donate/crypto'>
<Text>
<FormattedMessage
id='crypto_donate_panel.actions.more'
defaultMessage='Click to see {count} more {count, plural, one {wallet} other {wallets}}'
values={{ count: more }}
/>
</Text>
</Link>
)}
</Widget> </Widget>
); );
}; };

View File

@ -150,6 +150,7 @@ class EditProfile extends ImmutablePureComponent {
display_name: state.display_name, display_name: state.display_name,
website: state.website, website: state.website,
location: state.location, location: state.location,
birthday: state.birthday,
note: state.note, note: state.note,
avatar: state.avatar_file, avatar: state.avatar_file,
header: state.header_file, header: state.header_file,
@ -263,6 +264,18 @@ class EditProfile extends ImmutablePureComponent {
/> />
</FormGroup> </FormGroup>
{features.birthdays && (
<FormGroup
labelText={<FormattedMessage id='edit_profile.fields.birthday_label' defaultMessage='Birthday' />}
>
<Input
name='birthday'
value={this.state.birthday}
onChange={this.handleTextChange}
/>
</FormGroup>
)}
{features.accountLocation && ( {features.accountLocation && (
<FormGroup <FormGroup
labelText={<FormattedMessage id='edit_profile.fields.location_label' defaultMessage='Location' />} labelText={<FormattedMessage id='edit_profile.fields.location_label' defaultMessage='Location' />}

View File

@ -1,4 +1,3 @@
import classNames from 'classnames';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@ -6,22 +5,20 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Virtuoso } from 'react-virtuoso';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { getSettings } from 'soapbox/actions/settings'; import { getSettings } from 'soapbox/actions/settings';
import BirthdayReminders from 'soapbox/components/birthday_reminders'; import PullToRefresh from 'soapbox/components/pull-to-refresh';
import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder_notification'; import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder_notification';
import { getFeatures } from 'soapbox/utils/features';
import { import {
expandNotifications, expandNotifications,
scrollTopNotifications, scrollTopNotifications,
dequeueNotifications, dequeueNotifications,
} from '../../actions/notifications'; } from '../../actions/notifications';
import LoadGap from '../../components/load_gap';
import ScrollableList from '../../components/scrollable_list';
import TimelineQueueButtonHeader from '../../components/timeline_queue_button_header'; import TimelineQueueButtonHeader from '../../components/timeline_queue_button_header';
import { Column } from '../../components/ui'; import { Column, Text } from '../../components/ui';
import FilterBarContainer from './containers/filter_bar_container'; import FilterBarContainer from './containers/filter_bar_container';
import NotificationContainer from './containers/notification_container'; import NotificationContainer from './containers/notification_container';
@ -31,6 +28,24 @@ const messages = defineMessages({
queue: { id: 'notifications.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {notification} other {notifications}}' }, queue: { id: 'notifications.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {notification} other {notifications}}' },
}); });
const Footer = ({ context }) => (
context.hasMore ? (
<PlaceholderNotification />
) : null
);
const EmptyPlaceholder = ({ context }) => {
if (context.isLoading) {
return <PlaceholderNotification />;
} else {
return <Text>{context.emptyMessage}</Text>;
}
};
const Item = ({ context, ...rest }) => (
<div className='border-solid border-b border-gray-200 dark:border-gray-600' {...rest} />
);
const getNotifications = createSelector([ const getNotifications = createSelector([
state => getSettings(state).getIn(['notifications', 'quickFilter', 'show']), state => getSettings(state).getIn(['notifications', 'quickFilter', 'show']),
state => getSettings(state).getIn(['notifications', 'quickFilter', 'active']), state => getSettings(state).getIn(['notifications', 'quickFilter', 'active']),
@ -48,10 +63,6 @@ const getNotifications = createSelector([
const mapStateToProps = state => { const mapStateToProps = state => {
const settings = getSettings(state); const settings = getSettings(state);
const instance = state.get('instance');
const features = getFeatures(instance);
const showBirthdayReminders = settings.getIn(['notifications', 'birthdays', 'show']) && settings.getIn(['notifications', 'quickFilter', 'active']) === 'all' && features.birthdays;
const birthdays = showBirthdayReminders && state.getIn(['user_lists', 'birthday_reminders', state.get('me')]);
return { return {
showFilterBar: settings.getIn(['notifications', 'quickFilter', 'show']), showFilterBar: settings.getIn(['notifications', 'quickFilter', 'show']),
@ -60,8 +71,6 @@ const mapStateToProps = state => {
isUnread: state.getIn(['notifications', 'unread']) > 0, isUnread: state.getIn(['notifications', 'unread']) > 0,
hasMore: state.getIn(['notifications', 'hasMore']), hasMore: state.getIn(['notifications', 'hasMore']),
totalQueuedNotificationsCount: state.getIn(['notifications', 'totalQueuedNotificationsCount'], 0), totalQueuedNotificationsCount: state.getIn(['notifications', 'totalQueuedNotificationsCount'], 0),
showBirthdayReminders,
hasBirthdays: !!birthdays,
}; };
}; };
@ -79,8 +88,6 @@ class Notifications extends React.PureComponent {
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
dequeueNotifications: PropTypes.func, dequeueNotifications: PropTypes.func,
totalQueuedNotificationsCount: PropTypes.number, totalQueuedNotificationsCount: PropTypes.number,
showBirthdayReminders: PropTypes.bool,
hasBirthdays: PropTypes.bool,
}; };
componentWillUnmount() { componentWillUnmount() {
@ -117,25 +124,15 @@ class Notifications extends React.PureComponent {
} }
handleMoveUp = id => { handleMoveUp = id => {
const { hasBirthdays } = this.props; const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
let elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
if (hasBirthdays) elementIndex++;
this._selectChild(elementIndex, true); this._selectChild(elementIndex, true);
} }
handleMoveDown = id => { handleMoveDown = id => {
const { hasBirthdays } = this.props; const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
let elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
if (hasBirthdays) elementIndex++;
this._selectChild(elementIndex, false); this._selectChild(elementIndex, false);
} }
handleMoveBelowBirthdays = () => {
this._selectChild(1, false);
}
_selectChild(index, align_top) { _selectChild(index, align_top) {
const container = this.column; const container = this.column;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
@ -160,66 +157,42 @@ class Notifications extends React.PureComponent {
} }
render() { render() {
const { intl, notifications, isLoading, hasMore, showFilterBar, totalQueuedNotificationsCount, showBirthdayReminders } = this.props; const { intl, notifications, isLoading, hasMore, showFilterBar, totalQueuedNotificationsCount } = this.props;
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
let scrollableContent = null;
const filterBarContainer = showFilterBar const filterBarContainer = showFilterBar
? (<FilterBarContainer />) ? (<FilterBarContainer />)
: null; : null;
if (isLoading && this.scrollableContent) { const scrollContainer = (
scrollableContent = this.scrollableContent; <PullToRefresh onRefresh={this.handleRefresh}>
} else if (notifications.size > 0 || hasMore) { <Virtuoso
scrollableContent = notifications.map((item, index) => item === null ? ( useWindowScroll
<LoadGap data={notifications.toArray()}
key={'gap:' + notifications.getIn([index + 1, 'id'])} startReached={this.handleScrollToTop}
disabled={isLoading} endReached={this.handleLoadOlder}
maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null} isScrolling={isScrolling => isScrolling && this.handleScroll}
onClick={this.handleLoadGap} itemContent={(_index, notification) => (
<NotificationContainer
key={notification.id}
notification={notification}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
)}
context={{
hasMore,
isLoading,
emptyMessage,
}}
components={{
ScrollSeekPlaceholder: PlaceholderNotification,
Footer,
EmptyPlaceholder,
Item,
}}
/> />
) : ( </PullToRefresh>
<NotificationContainer
key={item.get('id')}
notification={item}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
));
if (showBirthdayReminders) scrollableContent = scrollableContent.unshift(
<BirthdayReminders
key='birthdays'
onMoveDown={this.handleMoveBelowBirthdays}
/>,
);
} else {
scrollableContent = null;
}
this.scrollableContent = scrollableContent;
const scrollContainer = (
<ScrollableList
scrollKey='notifications'
isLoading={isLoading}
showLoading={isLoading && notifications.size === 0}
hasMore={hasMore}
emptyMessage={emptyMessage}
placeholderComponent={PlaceholderNotification}
placeholderCount={20}
onLoadMore={this.handleLoadOlder}
onRefresh={this.handleRefresh}
onScrollToTop={this.handleScrollToTop}
onScroll={this.handleScroll}
className={classNames({
'divide-y divide-gray-200 dark:divide-gray-600 divide-solid': notifications.size > 0,
'space-y-2': notifications.size === 0,
})}
>
{scrollableContent}
</ScrollableList>
); );
return ( return (

View File

@ -36,8 +36,16 @@ const WhoToFollowPanel = ({ limit }: IWhoToFollowPanel) => {
return null; return null;
} }
// FIXME: This page actually doesn't look good right now
// const handleAction = () => {
// history.push('/suggestions');
// };
return ( return (
<Widget title={<FormattedMessage id='who_to_follow.title' defaultMessage='People To Follow' />}> <Widget
title={<FormattedMessage id='who_to_follow.title' defaultMessage='People To Follow' />}
// onAction={handleAction}
>
{suggestionsToRender.map((suggestion: ImmutableMap<string, any>) => ( {suggestionsToRender.map((suggestion: ImmutableMap<string, any>) => (
<AccountContainer <AccountContainer
key={suggestion.get('account')} key={suggestion.get('account')}

View File

@ -222,6 +222,10 @@ export function BirthdaysModal() {
return import(/* webpackChunkName: "features/ui" */'../components/birthdays_modal'); return import(/* webpackChunkName: "features/ui" */'../components/birthdays_modal');
} }
export function BirthdayPanel() {
return import(/* webpackChunkName: "features/ui" */'../../../components/birthday-panel');
}
export function AccountNoteModal() { export function AccountNoteModal() {
return import(/* webpackChunkName: "features/ui" */'../components/account_note_modal'); return import(/* webpackChunkName: "features/ui" */'../components/account_note_modal');
} }

View File

@ -177,4 +177,18 @@ describe('normalizeAccount()', () => {
expect(result.staff).toBe(true); expect(result.staff).toBe(true);
expect(result.moderator).toBe(false); expect(result.moderator).toBe(false);
}); });
it('normalizes Pleroma favicon', () => {
const account = require('soapbox/__fixtures__/pleroma-account.json');
const result = normalizeAccount(account);
expect(result.favicon).toEqual('https://gleasonator.com/favicon.png');
});
it('adds account domain', () => {
const account = require('soapbox/__fixtures__/pleroma-account.json');
const result = normalizeAccount(account);
expect(result.domain).toEqual('gleasonator.com');
});
}); });

View File

@ -28,6 +28,7 @@ export const AccountRecord = ImmutableRecord({
created_at: new Date(), created_at: new Date(),
display_name: '', display_name: '',
emojis: ImmutableList<Emoji>(), emojis: ImmutableList<Emoji>(),
favicon: '',
fields: ImmutableList<Field>(), fields: ImmutableList<Field>(),
followers_count: 0, followers_count: 0,
following_count: 0, following_count: 0,
@ -52,6 +53,7 @@ export const AccountRecord = ImmutableRecord({
// Internal fields // Internal fields
admin: false, admin: false,
display_name_html: '', display_name_html: '',
domain: '',
moderator: false, moderator: false,
note_emojified: '', note_emojified: '',
note_plain: '', note_plain: '',
@ -224,6 +226,16 @@ const normalizeFqn = (account: ImmutableMap<string, any>) => {
return account.set('fqn', fqn); return account.set('fqn', fqn);
}; };
const normalizeFavicon = (account: ImmutableMap<string, any>) => {
const favicon = account.getIn(['pleroma', 'favicon']) || '';
return account.set('favicon', favicon);
};
const addDomain = (account: ImmutableMap<string, any>) => {
const domain = account.get('fqn', '').split('@')[1] || '';
return account.set('domain', domain);
};
const addStaffFields = (account: ImmutableMap<string, any>) => { const addStaffFields = (account: ImmutableMap<string, any>) => {
const admin = account.getIn(['pleroma', 'is_admin']) === true; const admin = account.getIn(['pleroma', 'is_admin']) === true;
const moderator = account.getIn(['pleroma', 'is_moderator']) === true; const moderator = account.getIn(['pleroma', 'is_moderator']) === true;
@ -248,6 +260,8 @@ export const normalizeAccount = (account: Record<string, any>) => {
normalizeBirthday(account); normalizeBirthday(account);
normalizeLocation(account); normalizeLocation(account);
normalizeFqn(account); normalizeFqn(account);
normalizeFavicon(account);
addDomain(account);
addStaffFields(account); addStaffFields(account);
fixUsername(account); fixUsername(account);
fixDisplayName(account); fixDisplayName(account);

View File

@ -11,6 +11,7 @@ import {
TrendsPanel, TrendsPanel,
SignUpPanel, SignUpPanel,
CryptoDonatePanel, CryptoDonatePanel,
BirthdayPanel,
} from 'soapbox/features/ui/util/async-components'; } from 'soapbox/features/ui/util/async-components';
// import GroupSidebarPanel from '../features/groups/sidebar_panel'; // import GroupSidebarPanel from '../features/groups/sidebar_panel';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features';
@ -93,6 +94,11 @@ class HomePage extends ImmutablePureComponent {
{Component => <Component limit={cryptoLimit} />} {Component => <Component limit={cryptoLimit} />}
</BundleContainer> </BundleContainer>
)} )}
{features.birthdays && (
<BundleContainer fetchComponent={BirthdayPanel}>
{Component => <Component limit={10} />}
</BundleContainer>
)}
{features.suggestions && ( {features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}> <BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={5} />} {Component => <Component limit={5} />}