[Glitch] Profile redesign: Featured tags
Port 9079a75574426e1e4712d5c9bba9af09af2e85a7 to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
parent
d62a7775b5
commit
56100254b7
@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import type { FC, Key, MouseEventHandler } from 'react';
|
import type { FC, Key, MouseEventHandler } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { useOverflow } from '@/flavours/glitch/hooks/useOverflow';
|
||||||
|
|
||||||
import { MiniCard } from '.';
|
import { MiniCard } from '.';
|
||||||
import type { MiniCardProps } from '.';
|
import type { MiniCardProps } from '.';
|
||||||
import classes from './styles.module.css';
|
import classes from './styles.module.css';
|
||||||
@ -66,156 +67,3 @@ export const MiniCardList: FC<MiniCardListProps> = ({
|
|||||||
</div>
|
</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);
|
border: 1px solid var(--color-border-primary);
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 8px;
|
padding: 6px 8px;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useIntl } from 'react-intl';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { OmitUnion } from '@/flavours/glitch/utils/types';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
|
||||||
import type { IconProp } from '../icon';
|
import type { IconProp } from '../icon';
|
||||||
@ -23,7 +24,7 @@ export interface TagProps {
|
|||||||
|
|
||||||
export const Tag = forwardRef<
|
export const Tag = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
TagProps & ComponentPropsWithoutRef<'button'>
|
OmitUnion<ComponentPropsWithoutRef<'button'>, TagProps>
|
||||||
>(({ name, active, icon, className, children, ...props }, ref) => {
|
>(({ name, active, icon, className, children, ...props }, ref) => {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return null;
|
return null;
|
||||||
@ -34,6 +35,7 @@ export const Tag = forwardRef<
|
|||||||
type='button'
|
type='button'
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(className, classes.tag, active && classes.active)}
|
className={classNames(className, classes.tag, active && classes.active)}
|
||||||
|
aria-pressed={active}
|
||||||
>
|
>
|
||||||
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
||||||
{typeof name === 'string' ? `#${name}` : name}
|
{typeof name === 'string' ? `#${name}` : name}
|
||||||
@ -45,10 +47,13 @@ Tag.displayName = 'Tag';
|
|||||||
|
|
||||||
export const EditableTag = forwardRef<
|
export const EditableTag = forwardRef<
|
||||||
HTMLSpanElement,
|
HTMLSpanElement,
|
||||||
TagProps & {
|
OmitUnion<
|
||||||
onRemove: () => void;
|
ComponentPropsWithoutRef<'span'>,
|
||||||
removeIcon?: IconProp;
|
TagProps & {
|
||||||
} & ComponentPropsWithoutRef<'span'>
|
onRemove: () => void;
|
||||||
|
removeIcon?: IconProp;
|
||||||
|
}
|
||||||
|
>
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useCallback } from 'react';
|
import { forwardRef, useCallback } from 'react';
|
||||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import classes from './style.module.css';
|
import classes from './style.module.css';
|
||||||
import { EditableTag, Tag } from './tag';
|
import { EditableTag, Tag } from './tag';
|
||||||
import type { TagProps } from './tag';
|
import type { TagProps } from './tag';
|
||||||
@ -17,31 +19,39 @@ export type TagsProps = {
|
|||||||
| ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>)
|
| ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Tags: FC<TagsProps> = ({ tags, active, onRemove, ...props }) => {
|
export const Tags = forwardRef<HTMLDivElement, TagsProps>(
|
||||||
if (onRemove) {
|
({ 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 (
|
return (
|
||||||
<div className={classes.tagsWrapper}>
|
<div className={classNames(classes.tagsWrapper, className)} ref={ref}>
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<MappedTag
|
<Tag
|
||||||
key={tag.name}
|
key={tag.name}
|
||||||
active={tag.name === active}
|
active={tag.name === active}
|
||||||
onRemove={onRemove}
|
|
||||||
{...tag}
|
{...tag}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
return (
|
Tags.displayName = 'Tags';
|
||||||
<div className={classes.tagsWrapper}>
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<Tag key={tag.name} active={tag.name === active} {...tag} {...props} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
|
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
|
||||||
onRemove,
|
onRemove,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { FC } from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import type { NavLinkProps } from 'react-router-dom';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { isRedesignEnabled } from '../common';
|
import { isRedesignEnabled } from '../common';
|
||||||
@ -12,7 +13,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
|||||||
if (isRedesignEnabled()) {
|
if (isRedesignEnabled()) {
|
||||||
return (
|
return (
|
||||||
<div className={classes.tabs}>
|
<div className={classes.tabs}>
|
||||||
<NavLink exact to={`/@${acct}`}>
|
<NavLink isActive={isActive} to={`/@${acct}`}>
|
||||||
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink exact to={`/@${acct}/media`}>
|
<NavLink exact to={`/@${acct}/media`}>
|
||||||
@ -44,3 +45,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
|||||||
</div>
|
</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 '@/flavours/glitch/actions/featured_tags';
|
||||||
|
import { useAppHistory } from '@/flavours/glitch/components/router';
|
||||||
|
import { Tag } from '@/flavours/glitch/components/tags/tag';
|
||||||
|
import { useOverflow } from '@/flavours/glitch/hooks/useOverflow';
|
||||||
|
import { selectAccountFeaturedTags } from '@/flavours/glitch/selectors/accounts';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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 { LimitedAccountHint } from '../components/limited_account_hint';
|
||||||
import { useFilters } from '../hooks/useFilters';
|
import { useFilters } from '../hooks/useFilters';
|
||||||
|
|
||||||
|
import { FeaturedTags } from './featured_tags';
|
||||||
import { AccountFilters } from './filters';
|
import { AccountFilters } from './filters';
|
||||||
|
|
||||||
const emptyList = ImmutableList<string>();
|
const emptyList = ImmutableList<string>();
|
||||||
@ -135,6 +136,7 @@ const Prepend: FC<{
|
|||||||
<>
|
<>
|
||||||
<AccountHeader accountId={accountId} hideTabs />
|
<AccountHeader accountId={accountId} hideTabs />
|
||||||
<AccountFilters />
|
<AccountFilters />
|
||||||
|
<FeaturedTags accountId={accountId} />
|
||||||
<FeaturedCarousel accountId={accountId} tagged={tagged} />
|
<FeaturedCarousel accountId={accountId} tagged={tagged} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -35,3 +35,25 @@
|
|||||||
align-items: center;
|
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/flavours/glitch/hooks/useOverflow.ts
Normal file
172
app/javascript/flavours/glitch/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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,12 +1,15 @@
|
|||||||
import { createSelector } from '@reduxjs/toolkit';
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
import { Record as ImmutableRecord } from 'immutable';
|
import { Record as ImmutableRecord, List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
import { accountDefaultValues } from 'flavours/glitch/models/account';
|
import { accountDefaultValues } from 'flavours/glitch/models/account';
|
||||||
import type { Account, AccountShape } from 'flavours/glitch/models/account';
|
import type { Account, AccountShape } from 'flavours/glitch/models/account';
|
||||||
import type { Relationship } from 'flavours/glitch/models/relationship';
|
import type { Relationship } from 'flavours/glitch/models/relationship';
|
||||||
|
import { createAppSelector } from 'flavours/glitch/store';
|
||||||
import type { RootState } from 'flavours/glitch/store';
|
import type { RootState } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
import type { ApiHashtagJSON } from '../api_types/tags';
|
||||||
|
|
||||||
const getAccountBase = (state: RootState, id: string) =>
|
const getAccountBase = (state: RootState, id: string) =>
|
||||||
state.accounts.get(id, null);
|
state.accounts.get(id, null);
|
||||||
|
|
||||||
@ -33,7 +36,7 @@ const FullAccountFactory = ImmutableRecord<FullAccountShape>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function makeGetAccount() {
|
export function makeGetAccount() {
|
||||||
return createSelector(
|
return createAppSelector(
|
||||||
[getAccountBase, getAccountRelationship, getAccountMoved],
|
[getAccountBase, getAccountRelationship, getAccountMoved],
|
||||||
(base, relationship, moved) => {
|
(base, relationship, moved) => {
|
||||||
if (base === null) {
|
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, id: string) => state.accounts.get(id)?.hidden,
|
||||||
(state: RootState, id: string) =>
|
(state, id: string) =>
|
||||||
state.relationships.get(id)?.following ||
|
state.relationships.get(id)?.following ||
|
||||||
state.relationships.get(id)?.requested,
|
state.relationships.get(id)?.requested,
|
||||||
(state: RootState, id: string) => id === me,
|
(_, id: string) => id === me,
|
||||||
],
|
],
|
||||||
(hidden, followingOrRequested, isSelf) => {
|
(hidden, followingOrRequested, isSelf) => {
|
||||||
return hidden && !(isSelf || followingOrRequested);
|
return hidden && !(isSelf || followingOrRequested);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getAccountFamiliarFollowers = createSelector(
|
export const getAccountFamiliarFollowers = createAppSelector(
|
||||||
[
|
[
|
||||||
(state: RootState) => state.accounts,
|
(state) => state.accounts,
|
||||||
(state: RootState, id: string) => state.accounts_familiar_followers[id],
|
(state, id: string) => state.accounts_familiar_followers[id],
|
||||||
],
|
],
|
||||||
(accounts, accounts_familiar_followers) => {
|
(accounts, accounts_familiar_followers) => {
|
||||||
if (!accounts_familiar_followers) return null;
|
if (!accounts_familiar_followers) return null;
|
||||||
@ -72,3 +75,36 @@ export const getAccountFamiliarFollowers = createSelector(
|
|||||||
.filter((f) => !!f);
|
.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 AnyFunction = (...args: never) => unknown;
|
||||||
|
|
||||||
|
export type OmitUnion<TUnion, TBase> = TBase & Omit<TUnion, keyof TBase>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user