[Glitch] Adds collection notification UI

Port df4b4f1620413e279f1be69177ed8029396bff9f to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion 2026-04-10 14:11:11 +02:00 committed by Claire
parent 4c38bcce5d
commit 04b3f0fc32
12 changed files with 302 additions and 202 deletions

View File

@ -8,9 +8,9 @@
.menuButton {
position: absolute;
inset-inline-end: 0;
top: 0;
padding: 9px;
inset-inline-end: 1px;
top: 1px;
padding: 7px;
&::before {
// Subtle divider line separating the button from the input field

View File

@ -8,7 +8,7 @@
.copyButton {
position: absolute;
inset-inline-end: 0;
top: 0;
padding: 9px;
inset-inline-end: 1px;
top: 1px;
padding: 7px;
}

View File

@ -9,67 +9,6 @@
}
}
.content {
position: relative;
flex-grow: 1;
display: flex;
align-items: center;
column-gap: 12px;
}
.avatarGrid {
position: relative;
display: grid;
grid-template-columns: repeat(2, min-content);
gap: 2px;
&.avatarGridSensitive {
.avatar {
filter: blur(4px);
}
}
}
.avatar {
background: var(--color-bg-brand-softest);
}
.avatarSensitiveBadge {
position: absolute;
inset: 0;
margin: auto;
padding: 3px;
width: 18px;
height: 18px;
border-radius: 8px;
fill: var(--color-text-primary);
background: var(--color-bg-warning-softest);
}
.link {
display: block;
font-size: 15px;
font-weight: 500;
text-decoration: none;
color: var(--color-text-primary);
&:hover {
color: var(--color-text-brand);
}
&::after {
// Increase clickable area by extending link across parent
content: '';
position: absolute;
inset: 0;
}
}
.info {
font-size: 13px;
color: var(--color-text-secondary);
}
.menuButton {
padding: 4px;
margin-top: -2px;

View File

@ -1,69 +1,30 @@
import { useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { CollectionMenu } from '@/flavours/glitch/features/collections/components/collection_menu';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { AvatarById } from 'flavours/glitch/components/avatar';
import { useAccountHandle } from 'flavours/glitch/components/display_name/default';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import { Article } from 'flavours/glitch/components/scrollable_list/components';
import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { domain } from 'flavours/glitch/initial_state';
import type { CollectionLockupProps } from 'flavours/glitch/features/collections/components/collection_lockup';
import { CollectionLockup } from 'flavours/glitch/features/collections/components/collection_lockup';
import { CollectionMenu } from 'flavours/glitch/features/collections/components/collection_menu';
import classes from './collection_list_item.module.scss';
export const AvatarGrid: React.FC<{
accountIds: (string | undefined)[];
sensitive?: boolean;
}> = ({ accountIds: ids, sensitive }) => {
const avatarIds = [ids[0], ids[1], ids[2], ids[3]];
return (
<div
className={classNames(
classes.avatarGrid,
sensitive ? classes.avatarGridSensitive : null,
)}
>
{avatarIds.map((id) => (
<AvatarById
animate={false}
key={id}
accountId={id}
className={classes.avatar}
size={25}
/>
))}
{sensitive && <WarningIcon className={classes.avatarSensitiveBadge} />}
</div>
);
};
export const CollectionListItem: React.FC<{
collection: ApiCollectionJSON;
interface CollectionListItemProps extends CollectionLockupProps {
withoutBorder?: boolean;
withAuthorHandle?: boolean;
withTimestamp?: boolean;
positionInList: number;
listSize: number;
}> = ({
}
export const CollectionListItem: React.FC<CollectionListItemProps> = ({
collection,
withoutBorder,
withAuthorHandle = true,
withTimestamp,
positionInList,
listSize,
...otherProps
}) => {
const { id, name } = collection;
const uniqueId = useId();
const linkId = `${uniqueId}-link`;
const infoId = `${uniqueId}-info`;
const authorAccount = useAccount(collection.account_id);
const authorHandle = useAccountHandle(authorAccount, domain);
return (
<Article
@ -77,57 +38,7 @@ export const CollectionListItem: React.FC<{
aria-posinset={positionInList}
aria-setsize={listSize}
>
<div className={classes.content}>
<AvatarGrid
accountIds={collection.items.map((item) => item.account_id)}
sensitive={collection.sensitive}
/>
<div>
<h2 id={linkId}>
<Link to={`/collections/${id}`} className={classes.link}>
{name}
</Link>
</h2>
<ul className={classes.info} id={infoId}>
{collection.sensitive && (
<li className='sr-only'>
<FormattedMessage
id='collections.sensitive'
defaultMessage='Sensitive'
/>
</li>
)}
{withAuthorHandle && authorAccount && (
<FormattedMessage
id='collections.by_account'
defaultMessage='by {account_handle}'
values={{
account_handle: authorHandle,
}}
tagName='li'
/>
)}
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
tagName='li'
/>
{withTimestamp && (
<FormattedMessage
id='collections.last_updated_at'
defaultMessage='Last updated: {date}'
values={{
date: (
<RelativeTimestamp timestamp={collection.updated_at} long />
),
}}
tagName='li'
/>
)}
</ul>
</div>
</div>
<CollectionLockup collection={collection} {...otherProps} />
<CollectionMenu
context='list'

View File

@ -0,0 +1,62 @@
.content {
position: relative;
flex-grow: 1;
display: flex;
align-items: center;
column-gap: 12px;
color: var(--color-text-primary);
line-height: 1.3;
}
.avatarGrid {
position: relative;
display: grid;
grid-template-columns: repeat(2, min-content);
gap: 2px;
&.avatarGridSensitive {
.avatar {
filter: blur(4px);
}
}
}
.avatar {
background: var(--color-bg-brand-softest);
}
.avatarSensitiveBadge {
position: absolute;
inset: 0;
margin: auto;
padding: 3px;
width: 18px;
height: 18px;
border-radius: 8px;
fill: var(--color-text-primary);
background: var(--color-bg-warning-softest);
}
.link {
display: block;
font-size: 15px;
font-weight: 500;
text-decoration: none;
color: var(--color-text-primary);
&:hover {
color: var(--color-text-brand);
}
&::after {
// Increase clickable area by extending link across parent
content: '';
position: absolute;
inset: 0;
}
}
.info {
font-size: 13px;
color: var(--color-text-secondary);
}

View File

@ -0,0 +1,115 @@
import { useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { AvatarById } from 'flavours/glitch/components/avatar';
import { useAccountHandle } from 'flavours/glitch/components/display_name/default';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { domain } from 'flavours/glitch/initial_state';
import classes from './collection_lockup.module.scss';
export const AvatarGrid: React.FC<{
accountIds: (string | undefined)[];
sensitive?: boolean;
}> = ({ accountIds: ids, sensitive }) => {
const avatarIds = [ids[0], ids[1], ids[2], ids[3]];
return (
<div
className={classNames(
classes.avatarGrid,
sensitive ? classes.avatarGridSensitive : null,
)}
>
{avatarIds.map((id) => (
<AvatarById
animate={false}
key={id}
accountId={id}
className={classes.avatar}
size={25}
/>
))}
{sensitive && <WarningIcon className={classes.avatarSensitiveBadge} />}
</div>
);
};
export interface CollectionLockupProps {
collection: ApiCollectionJSON;
withAuthorHandle?: boolean;
withTimestamp?: boolean;
}
export const CollectionLockup: React.FC<CollectionLockupProps> = ({
collection,
withAuthorHandle = true,
withTimestamp,
}) => {
const { id, name } = collection;
const uniqueId = useId();
const linkId = `${uniqueId}-link`;
const infoId = `${uniqueId}-info`;
const authorAccount = useAccount(collection.account_id);
const authorHandle = useAccountHandle(authorAccount, domain);
return (
<div className={classes.content}>
<AvatarGrid
accountIds={collection.items.map((item) => item.account_id)}
sensitive={collection.sensitive}
/>
<div>
<h2 id={linkId}>
<Link to={`/collections/${id}`} className={classes.link}>
{name}
</Link>
</h2>
<ul className={classes.info} id={infoId}>
{collection.sensitive && (
<li className='sr-only'>
<FormattedMessage
id='collections.sensitive'
defaultMessage='Sensitive'
/>
</li>
)}
{withAuthorHandle && authorAccount && (
<FormattedMessage
id='collections.by_account'
defaultMessage='by {account_handle}'
values={{
account_handle: authorHandle,
}}
tagName='li'
/>
)}
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
tagName='li'
/>
{withTimestamp && (
<FormattedMessage
id='collections.last_updated_at'
defaultMessage='Last updated: {date}'
values={{
date: (
<RelativeTimestamp timestamp={collection.updated_at} long />
),
}}
tagName='li'
/>
)}
</ul>
</div>
</div>
);
};

View File

@ -0,0 +1,8 @@
.wrapper {
display: flex;
align-items: start;
padding: 12px;
gap: 12px;
border-radius: 12px;
border: 1px solid var(--color-border-primary);
}

View File

@ -0,0 +1,20 @@
import type { CollectionLockupProps } from 'flavours/glitch/features/collections/components/collection_lockup';
import { CollectionLockup } from 'flavours/glitch/features/collections/components/collection_lockup';
import classes from './collection_preview_card.module.scss';
interface CollectionPreviewCardProps extends CollectionLockupProps {
onRemove?: () => void;
}
export const CollectionPreviewCard: React.FC<CollectionPreviewCardProps> = ({
collection,
onRemove,
...otherProps
}) => {
return (
<div className={classes.wrapper}>
<CollectionLockup collection={collection} {...otherProps} />
</div>
);
};

View File

@ -5,27 +5,7 @@
}
.preview {
display: flex;
flex-wrap: wrap-reverse;
align-items: start;
justify-content: space-between;
gap: 8px;
padding: 16px;
margin-bottom: 16px;
border-radius: 8px;
color: var(--color-text-primary);
background: linear-gradient(
145deg,
var(--color-bg-brand-soft),
var(--color-bg-primary)
);
border: 1px solid var(--color-bg-brand-base);
}
.previewHeading {
font-size: 22px;
line-height: 1.3;
margin-bottom: 4px;
}
.actions {

View File

@ -8,8 +8,6 @@ import { me } from '@/flavours/glitch/initial_state';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { changeCompose, focusCompose } from 'flavours/glitch/actions/compose';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { AvatarById } from 'flavours/glitch/components/avatar';
import { AvatarGroup } from 'flavours/glitch/components/avatar_group';
import { Button } from 'flavours/glitch/components/button';
import { CopyLinkField } from 'flavours/glitch/components/form_fields';
import { IconButton } from 'flavours/glitch/components/icon_button';
@ -20,8 +18,7 @@ import {
} from 'flavours/glitch/components/modal_shell';
import { useAppDispatch } from 'flavours/glitch/store';
import { AuthorNote } from '../detail';
import { CollectionPreviewCard } from './collection_preview_card';
import classes from './share_modal.module.scss';
const messages = defineMessages({
@ -96,24 +93,13 @@ export const CollectionShareModal: React.FC<{
/>
<div className={classes.preview}>
<div>
<h2 className={classes.previewHeading}>{collection.name}</h2>
<AuthorNote id={collection.account_id} />
</div>
<AvatarGroup>
{collection.items.slice(0, 5).map(({ account_id }) => {
if (!account_id) return;
return (
<AvatarById key={account_id} accountId={account_id} size={28} />
);
})}
</AvatarGroup>
<CollectionPreviewCard collection={collection} />
</div>
<CopyLinkField
label={intl.formatMessage({
id: 'collection.share_modal.share_link_label',
defaultMessage: 'Invite share link',
defaultMessage: 'Share link',
})}
value={collectionLink}
/>

View File

@ -0,0 +1,69 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { DisplayNameSimple } from '@/flavours/glitch/components/display_name/simple';
import { Icon } from '@/flavours/glitch/components/icon';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import CollectionsFilledIcon from '@/material-icons/400-24px/category-fill.svg?react';
import type {
NotificationGroupAddedToCollection,
NotificationGroupCollectionUpdate,
} from 'flavours/glitch/models/notification_group';
import { CollectionPreviewCard } from '../../collections/components/collection_preview_card';
export const NotificationCollection: React.FC<{
notification:
| NotificationGroupAddedToCollection
| NotificationGroupCollectionUpdate;
unread: boolean;
}> = ({ notification, unread }) => {
const { collection, type } = notification;
const collectionCreatorAccount = useAccount(collection.account_id);
return (
<div
className={classNames(
'notification-group',
`notification-group--${type}`,
{ 'notification-group--unread': unread },
)}
>
<div className='notification-group__icon'>
<Icon id='collection' icon={CollectionsFilledIcon} />
</div>
<div className='notification-group__main'>
<div className='notification-group__main__header'>
<div className='notification-group__main__header__label'>
{type === 'added_to_collection' && (
<FormattedMessage
id='notification.added_to_collection'
defaultMessage='{name} added you to a collection'
values={{
name: (
<DisplayNameSimple account={collectionCreatorAccount} />
),
}}
/>
)}
{type === 'collection_update' && (
<FormattedMessage
id='notification.collection_update'
defaultMessage='{name} edited a collection youre in'
values={{
name: (
<DisplayNameSimple account={collectionCreatorAccount} />
),
}}
/>
)}
</div>
</div>
<CollectionPreviewCard collection={collection} />
</div>
</div>
);
};

View File

@ -9,6 +9,7 @@ import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { NotificationAdminReport } from './notification_admin_report';
import { NotificationAdminSignUp } from './notification_admin_sign_up';
import { NotificationAnnualReport } from './notification_annual_report';
import { NotificationCollection } from './notification_collection';
import { NotificationFavourite } from './notification_favourite';
import { NotificationFollow } from './notification_follow';
import { NotificationFollowRequest } from './notification_follow_request';
@ -156,6 +157,15 @@ export const NotificationGroup: React.FC<{
/>
);
break;
case 'added_to_collection':
case 'collection_update':
content = (
<NotificationCollection
unread={unread}
notification={notificationGroup}
/>
);
break;
default:
return null;
}