Profile redesign: Featured tags (#37645)
This commit is contained in:
parent
8efcdc04eb
commit
9079a75574
@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { FC, Key, MouseEventHandler } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useOverflow } from '@/mastodon/hooks/useOverflow';
|
||||
|
||||
import { MiniCard } from '.';
|
||||
import type { MiniCardProps } from '.';
|
||||
import classes from './styles.module.css';
|
||||
@ -66,156 +67,3 @@ export const MiniCardList: FC<MiniCardListProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useOverflow() {
|
||||
const [hiddenIndex, setHiddenIndex] = useState(-1);
|
||||
const [hiddenCount, setHiddenCount] = useState(0);
|
||||
const [maxWidth, setMaxWidth] = useState<number | 'none'>('none');
|
||||
|
||||
// This is the item container element.
|
||||
const listRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// The main recalculation function.
|
||||
const handleRecalculate = useCallback(() => {
|
||||
const listEle = listRef.current;
|
||||
if (!listEle) return;
|
||||
|
||||
const reset = () => {
|
||||
setHiddenIndex(-1);
|
||||
setHiddenCount(0);
|
||||
setMaxWidth('none');
|
||||
};
|
||||
|
||||
// Calculate the width via the parent element, minus the more button, minus the padding.
|
||||
const maxWidth =
|
||||
(listEle.parentElement?.offsetWidth ?? 0) -
|
||||
(listEle.nextElementSibling?.scrollWidth ?? 0) -
|
||||
4;
|
||||
if (maxWidth <= 0) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Iterate through children until we exceed max width.
|
||||
let visible = 0;
|
||||
let index = 0;
|
||||
let totalWidth = 0;
|
||||
for (const child of listEle.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
const rightOffset = child.offsetLeft + child.offsetWidth;
|
||||
if (rightOffset <= maxWidth) {
|
||||
visible += 1;
|
||||
totalWidth = rightOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
// All are visible, so remove max-width restriction.
|
||||
if (visible === listEle.children.length) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the width to avoid wrapping, and set hidden count.
|
||||
setHiddenIndex(index);
|
||||
setHiddenCount(listEle.children.length - visible);
|
||||
setMaxWidth(totalWidth);
|
||||
}, []);
|
||||
|
||||
// Set up observers to watch for size and content changes.
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const mutationObserverRef = useRef<MutationObserver | null>(null);
|
||||
|
||||
// Helper to get or create the resize observer.
|
||||
const resizeObserver = useCallback(() => {
|
||||
const observer = (resizeObserverRef.current ??= new ResizeObserver(
|
||||
handleRecalculate,
|
||||
));
|
||||
return observer;
|
||||
}, [handleRecalculate]);
|
||||
|
||||
// Iterate through children and observe them for size changes.
|
||||
const handleChildrenChange = useCallback(() => {
|
||||
const listEle = listRef.current;
|
||||
const observer = resizeObserver();
|
||||
|
||||
if (listEle) {
|
||||
for (const child of listEle.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
observer.observe(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleRecalculate();
|
||||
}, [handleRecalculate, resizeObserver]);
|
||||
|
||||
// Helper to get or create the mutation observer.
|
||||
const mutationObserver = useCallback(() => {
|
||||
const observer = (mutationObserverRef.current ??= new MutationObserver(
|
||||
handleChildrenChange,
|
||||
));
|
||||
return observer;
|
||||
}, [handleChildrenChange]);
|
||||
|
||||
// Set up observers.
|
||||
const handleObserve = useCallback(() => {
|
||||
if (wrapperRef.current) {
|
||||
resizeObserver().observe(wrapperRef.current);
|
||||
}
|
||||
if (listRef.current) {
|
||||
mutationObserver().observe(listRef.current, { childList: true });
|
||||
handleChildrenChange();
|
||||
}
|
||||
}, [handleChildrenChange, mutationObserver, resizeObserver]);
|
||||
|
||||
// Watch the wrapper for size changes, and recalculate when it resizes.
|
||||
const wrapperRef = useRef<HTMLElement | null>(null);
|
||||
const wrapperRefCallback = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
wrapperRef.current = node;
|
||||
handleObserve();
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
);
|
||||
|
||||
// If there are changes to the children, recalculate which are visible.
|
||||
const listRefCallback = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
listRef.current = node;
|
||||
handleObserve();
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleObserve();
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
if (mutationObserverRef.current) {
|
||||
mutationObserverRef.current.disconnect();
|
||||
mutationObserverRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [handleObserve]);
|
||||
|
||||
return {
|
||||
hiddenCount,
|
||||
hasOverflow: hiddenCount > 0,
|
||||
wrapperRef: wrapperRefCallback,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
listRef: listRefCallback,
|
||||
recalculate: handleRecalculate,
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
border: 1px solid var(--color-border-primary);
|
||||
appearance: none;
|
||||
background: none;
|
||||
padding: 8px;
|
||||
padding: 6px 8px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
color: var(--color-text-primary);
|
||||
display: inline-flex;
|
||||
|
||||
@ -5,6 +5,7 @@ import { useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { OmitUnion } from '@/mastodon/utils/types';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import type { IconProp } from '../icon';
|
||||
@ -23,7 +24,7 @@ export interface TagProps {
|
||||
|
||||
export const Tag = forwardRef<
|
||||
HTMLButtonElement,
|
||||
TagProps & ComponentPropsWithoutRef<'button'>
|
||||
OmitUnion<ComponentPropsWithoutRef<'button'>, TagProps>
|
||||
>(({ name, active, icon, className, children, ...props }, ref) => {
|
||||
if (!name) {
|
||||
return null;
|
||||
@ -34,6 +35,7 @@ export const Tag = forwardRef<
|
||||
type='button'
|
||||
ref={ref}
|
||||
className={classNames(className, classes.tag, active && classes.active)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
||||
{typeof name === 'string' ? `#${name}` : name}
|
||||
@ -45,10 +47,13 @@ Tag.displayName = 'Tag';
|
||||
|
||||
export const EditableTag = forwardRef<
|
||||
HTMLSpanElement,
|
||||
TagProps & {
|
||||
onRemove: () => void;
|
||||
removeIcon?: IconProp;
|
||||
} & ComponentPropsWithoutRef<'span'>
|
||||
OmitUnion<
|
||||
ComponentPropsWithoutRef<'span'>,
|
||||
TagProps & {
|
||||
onRemove: () => void;
|
||||
removeIcon?: IconProp;
|
||||
}
|
||||
>
|
||||
>(
|
||||
(
|
||||
{
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import classes from './style.module.css';
|
||||
import { EditableTag, Tag } from './tag';
|
||||
import type { TagProps } from './tag';
|
||||
@ -17,31 +19,39 @@ export type TagsProps = {
|
||||
| ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>)
|
||||
);
|
||||
|
||||
export const Tags: FC<TagsProps> = ({ tags, active, onRemove, ...props }) => {
|
||||
if (onRemove) {
|
||||
export const Tags = forwardRef<HTMLDivElement, TagsProps>(
|
||||
({ tags, active, onRemove, className, ...props }, ref) => {
|
||||
if (onRemove) {
|
||||
return (
|
||||
<div className={classNames(classes.tagsWrapper, className)}>
|
||||
{tags.map((tag) => (
|
||||
<MappedTag
|
||||
key={tag.name}
|
||||
active={tag.name === active}
|
||||
onRemove={onRemove}
|
||||
{...tag}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.tagsWrapper}>
|
||||
<div className={classNames(classes.tagsWrapper, className)} ref={ref}>
|
||||
{tags.map((tag) => (
|
||||
<MappedTag
|
||||
<Tag
|
||||
key={tag.name}
|
||||
active={tag.name === active}
|
||||
onRemove={onRemove}
|
||||
{...tag}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.tagsWrapper}>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag.name} active={tag.name === active} {...tag} {...props} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
Tags.displayName = 'Tags';
|
||||
|
||||
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
|
||||
onRemove,
|
||||
|
||||
@ -2,6 +2,7 @@ import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { NavLinkProps } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
@ -12,7 +13,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
||||
if (isRedesignEnabled()) {
|
||||
return (
|
||||
<div className={classes.tabs}>
|
||||
<NavLink exact to={`/@${acct}`}>
|
||||
<NavLink isActive={isActive} to={`/@${acct}`}>
|
||||
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/media`}>
|
||||
@ -44,3 +45,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isActive: Required<NavLinkProps>['isActive'] = (match, location) =>
|
||||
match?.url === location.pathname ||
|
||||
(!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`));
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { FC, MouseEventHandler } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags';
|
||||
import { useAppHistory } from '@/mastodon/components/router';
|
||||
import { Tag } from '@/mastodon/components/tags/tag';
|
||||
import { useOverflow } from '@/mastodon/hooks/useOverflow';
|
||||
import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { useFilters } from '../hooks/useFilters';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
// Fetch tags.
|
||||
const featuredTags = useAppSelector((state) =>
|
||||
selectAccountFeaturedTags(state, accountId),
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
void dispatch(fetchFeaturedTags({ accountId }));
|
||||
}, [accountId, dispatch]);
|
||||
|
||||
// Get list of tags with overflow handling.
|
||||
const [showOverflow, setShowOverflow] = useState(false);
|
||||
const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } =
|
||||
useOverflow();
|
||||
|
||||
// Handle whether to show all tags.
|
||||
const handleOverflowClick: MouseEventHandler = useCallback(() => {
|
||||
setShowOverflow(true);
|
||||
}, []);
|
||||
|
||||
const { onClick, currentTag } = useTagNavigate();
|
||||
|
||||
if (featuredTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.tagsWrapper} ref={wrapperRef}>
|
||||
<div
|
||||
className={classNames(
|
||||
classes.tagsList,
|
||||
showOverflow && classes.tagsListShowAll,
|
||||
)}
|
||||
style={{ maxWidth }}
|
||||
ref={listRef}
|
||||
>
|
||||
{featuredTags.map(({ id, name }, index) => (
|
||||
<Tag
|
||||
name={name}
|
||||
key={id}
|
||||
inert={hiddenIndex > 0 && index >= hiddenIndex ? '' : undefined}
|
||||
onClick={onClick}
|
||||
active={currentTag === name}
|
||||
data-name={name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!showOverflow && hiddenCount > 0 && (
|
||||
<Tag
|
||||
onClick={handleOverflowClick}
|
||||
name={
|
||||
<FormattedMessage
|
||||
id='featured_tags.more_items'
|
||||
defaultMessage='+{count}'
|
||||
values={{ count: hiddenCount }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useTagNavigate() {
|
||||
// Get current account, tag, and filters.
|
||||
const { acct, tagged } = useParams<{ acct: string; tagged?: string }>();
|
||||
const { boosts, replies } = useFilters();
|
||||
|
||||
const history = useAppHistory();
|
||||
|
||||
const handleTagClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||
(event) => {
|
||||
const name = event.currentTarget.getAttribute('data-name');
|
||||
if (!name || !acct) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine whether to navigate to or from the tag.
|
||||
let url = `/@${acct}/tagged/${encodeURIComponent(name)}`;
|
||||
if (name === tagged) {
|
||||
url = `/@${acct}`;
|
||||
}
|
||||
|
||||
// Append filters.
|
||||
const params = new URLSearchParams();
|
||||
if (boosts) {
|
||||
params.append('boosts', '1');
|
||||
}
|
||||
if (replies) {
|
||||
params.append('replies', '1');
|
||||
}
|
||||
|
||||
history.push({
|
||||
pathname: url,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[acct, tagged, boosts, replies, history],
|
||||
);
|
||||
|
||||
return {
|
||||
onClick: handleTagClick,
|
||||
currentTag: tagged,
|
||||
};
|
||||
}
|
||||
@ -27,6 +27,7 @@ import { AccountHeader } from '../components/account_header';
|
||||
import { LimitedAccountHint } from '../components/limited_account_hint';
|
||||
import { useFilters } from '../hooks/useFilters';
|
||||
|
||||
import { FeaturedTags } from './featured_tags';
|
||||
import { AccountFilters } from './filters';
|
||||
|
||||
const emptyList = ImmutableList<string>();
|
||||
@ -135,6 +136,7 @@ const Prepend: FC<{
|
||||
<>
|
||||
<AccountHeader accountId={accountId} hideTabs />
|
||||
<AccountFilters />
|
||||
<FeaturedTags accountId={accountId} />
|
||||
<FeaturedCarousel accountId={accountId} tagged={tagged} />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -35,3 +35,25 @@
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.tagsWrapper {
|
||||
margin: 0 24px 8px;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tagsList {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tagsListShowAll {
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
172
app/javascript/mastodon/hooks/useOverflow.ts
Normal file
172
app/javascript/mastodon/hooks/useOverflow.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Calculate and manage overflow of child elements within a container.
|
||||
*
|
||||
* To use, wire up the `wrapperRef` to the container element, and the `listRef` to the
|
||||
* child element that contains the items to be measured. If autoResize is true,
|
||||
* the list element will have its max-width set to prevent wrapping. The listRef element
|
||||
* requires both position:relative and overflow:hidden styles to work correctly.
|
||||
*/
|
||||
export function useOverflow({
|
||||
autoResize,
|
||||
padding = 4,
|
||||
}: { autoResize?: boolean; padding?: number } = {}) {
|
||||
const [hiddenIndex, setHiddenIndex] = useState(-1);
|
||||
const [hiddenCount, setHiddenCount] = useState(0);
|
||||
const [maxWidth, setMaxWidth] = useState<number | 'none'>('none');
|
||||
|
||||
// This is the item container element.
|
||||
const listRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// The main recalculation function.
|
||||
const handleRecalculate = useCallback(() => {
|
||||
const listEle = listRef.current;
|
||||
if (!listEle) return;
|
||||
|
||||
const reset = () => {
|
||||
setHiddenIndex(-1);
|
||||
setHiddenCount(0);
|
||||
setMaxWidth('none');
|
||||
};
|
||||
|
||||
// Calculate the width via the parent element, minus the more button, minus the padding.
|
||||
const maxWidth =
|
||||
(listEle.parentElement?.offsetWidth ?? 0) -
|
||||
(listEle.nextElementSibling?.scrollWidth ?? 0) -
|
||||
padding;
|
||||
if (maxWidth <= 0) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Iterate through children until we exceed max width.
|
||||
let visible = 0;
|
||||
let index = 0;
|
||||
let totalWidth = 0;
|
||||
for (const child of listEle.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
const rightOffset = child.offsetLeft + child.offsetWidth;
|
||||
if (rightOffset <= maxWidth) {
|
||||
visible += 1;
|
||||
totalWidth = rightOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
// All are visible, so remove max-width restriction.
|
||||
if (visible === listEle.children.length) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the width to avoid wrapping, and set hidden count.
|
||||
setHiddenIndex(index);
|
||||
setHiddenCount(listEle.children.length - visible);
|
||||
setMaxWidth(totalWidth);
|
||||
}, [padding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current && autoResize) {
|
||||
listRef.current.style.maxWidth =
|
||||
typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth;
|
||||
}
|
||||
}, [autoResize, maxWidth]);
|
||||
|
||||
// Set up observers to watch for size and content changes.
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const mutationObserverRef = useRef<MutationObserver | null>(null);
|
||||
|
||||
// Helper to get or create the resize observer.
|
||||
const resizeObserver = useCallback(() => {
|
||||
const observer = (resizeObserverRef.current ??= new ResizeObserver(
|
||||
handleRecalculate,
|
||||
));
|
||||
return observer;
|
||||
}, [handleRecalculate]);
|
||||
|
||||
// Iterate through children and observe them for size changes.
|
||||
const handleChildrenChange = useCallback(() => {
|
||||
const listEle = listRef.current;
|
||||
const observer = resizeObserver();
|
||||
|
||||
if (listEle) {
|
||||
for (const child of listEle.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
observer.observe(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleRecalculate();
|
||||
}, [handleRecalculate, resizeObserver]);
|
||||
|
||||
// Helper to get or create the mutation observer.
|
||||
const mutationObserver = useCallback(() => {
|
||||
const observer = (mutationObserverRef.current ??= new MutationObserver(
|
||||
handleChildrenChange,
|
||||
));
|
||||
return observer;
|
||||
}, [handleChildrenChange]);
|
||||
|
||||
// Set up observers.
|
||||
const handleObserve = useCallback(() => {
|
||||
if (wrapperRef.current) {
|
||||
resizeObserver().observe(wrapperRef.current);
|
||||
}
|
||||
if (listRef.current) {
|
||||
mutationObserver().observe(listRef.current, { childList: true });
|
||||
handleChildrenChange();
|
||||
}
|
||||
}, [handleChildrenChange, mutationObserver, resizeObserver]);
|
||||
|
||||
// Watch the wrapper for size changes, and recalculate when it resizes.
|
||||
const wrapperRef = useRef<HTMLElement | null>(null);
|
||||
const wrapperRefCallback = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
wrapperRef.current = node;
|
||||
handleObserve();
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
);
|
||||
|
||||
// If there are changes to the children, recalculate which are visible.
|
||||
const listRefCallback = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
listRef.current = node;
|
||||
handleObserve();
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleObserve();
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
if (mutationObserverRef.current) {
|
||||
mutationObserverRef.current.disconnect();
|
||||
mutationObserverRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [handleObserve]);
|
||||
|
||||
return {
|
||||
hiddenCount,
|
||||
hasOverflow: hiddenCount > 0,
|
||||
wrapperRef: wrapperRefCallback,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
listRef: listRefCallback,
|
||||
recalculate: handleRecalculate,
|
||||
};
|
||||
}
|
||||
@ -427,6 +427,7 @@
|
||||
"featured_carousel.current": "<sr>Post</sr> {current, number} / {max, number}",
|
||||
"featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}",
|
||||
"featured_carousel.slide": "Post {current, number} of {max, number}",
|
||||
"featured_tags.more_items": "+{count}",
|
||||
"filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.",
|
||||
"filter_modal.added.context_mismatch_title": "Context mismatch!",
|
||||
"filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.",
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { Record as ImmutableRecord } from 'immutable';
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import { Record as ImmutableRecord, List as ImmutableList } from 'immutable';
|
||||
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { accountDefaultValues } from 'mastodon/models/account';
|
||||
import type { Account, AccountShape } from 'mastodon/models/account';
|
||||
import type { Relationship } from 'mastodon/models/relationship';
|
||||
import { createAppSelector } from 'mastodon/store';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
|
||||
import type { ApiHashtagJSON } from '../api_types/tags';
|
||||
|
||||
const getAccountBase = (state: RootState, id: string) =>
|
||||
state.accounts.get(id, null);
|
||||
|
||||
@ -33,7 +36,7 @@ const FullAccountFactory = ImmutableRecord<FullAccountShape>({
|
||||
});
|
||||
|
||||
export function makeGetAccount() {
|
||||
return createSelector(
|
||||
return createAppSelector(
|
||||
[getAccountBase, getAccountRelationship, getAccountMoved],
|
||||
(base, relationship, moved) => {
|
||||
if (base === null) {
|
||||
@ -47,23 +50,23 @@ export function makeGetAccount() {
|
||||
);
|
||||
}
|
||||
|
||||
export const getAccountHidden = createSelector(
|
||||
export const getAccountHidden = createAppSelector(
|
||||
[
|
||||
(state: RootState, id: string) => state.accounts.get(id)?.hidden,
|
||||
(state: RootState, id: string) =>
|
||||
(state, id: string) => state.accounts.get(id)?.hidden,
|
||||
(state, id: string) =>
|
||||
state.relationships.get(id)?.following ||
|
||||
state.relationships.get(id)?.requested,
|
||||
(state: RootState, id: string) => id === me,
|
||||
(_, id: string) => id === me,
|
||||
],
|
||||
(hidden, followingOrRequested, isSelf) => {
|
||||
return hidden && !(isSelf || followingOrRequested);
|
||||
},
|
||||
);
|
||||
|
||||
export const getAccountFamiliarFollowers = createSelector(
|
||||
export const getAccountFamiliarFollowers = createAppSelector(
|
||||
[
|
||||
(state: RootState) => state.accounts,
|
||||
(state: RootState, id: string) => state.accounts_familiar_followers[id],
|
||||
(state) => state.accounts,
|
||||
(state, id: string) => state.accounts_familiar_followers[id],
|
||||
],
|
||||
(accounts, accounts_familiar_followers) => {
|
||||
if (!accounts_familiar_followers) return null;
|
||||
@ -72,3 +75,36 @@ export const getAccountFamiliarFollowers = createSelector(
|
||||
.filter((f) => !!f);
|
||||
},
|
||||
);
|
||||
|
||||
export type TagType = Omit<
|
||||
ApiHashtagJSON,
|
||||
'history' | 'following' | 'featured'
|
||||
> & {
|
||||
accountId: string;
|
||||
statuses_count: number;
|
||||
last_status_at: string;
|
||||
};
|
||||
|
||||
export const selectAccountFeaturedTags = createAppSelector(
|
||||
[(state) => state.user_lists, (_, accountId: string) => accountId],
|
||||
(user_lists, accountId) => {
|
||||
const list = user_lists.getIn(
|
||||
['featured_tags', accountId, 'items'],
|
||||
ImmutableList(),
|
||||
) as ImmutableList<ImmutableMap<string, string | null>>;
|
||||
return list.toArray().map(
|
||||
(tag) =>
|
||||
({
|
||||
id: tag.get('id') as string,
|
||||
name: tag.get('name') as string,
|
||||
url: tag.get('url') as string,
|
||||
accountId: tag.get('accountId') as string,
|
||||
statuses_count: Number.parseInt(
|
||||
tag.get('statuses_count') as string,
|
||||
10,
|
||||
),
|
||||
last_status_at: tag.get('last_status_at') as string,
|
||||
}) satisfies TagType,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -22,3 +22,5 @@ export type OmitValueType<T, V> = {
|
||||
};
|
||||
|
||||
export type AnyFunction = (...args: never) => unknown;
|
||||
|
||||
export type OmitUnion<TUnion, TBase> = TBase & Omit<TUnion, keyof TBase>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user