diff --git a/CHANGELOG.md b/CHANGELOG.md index a39c6605c..11b6afec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Experimental +## [1.134.3] - 2024-07-29 +### Added +- The autocomplete menu now has a links to the found users/groups pages. These + links opens in a new tab for the most cases. Links opens in the current tab + when the user enters the "@username" in the search bar at the beginning of the + query. +### Changed +- The 'Expandable' component was rewritten using the modern browser APIs. It now + properly handles the content updates and does not require the content to be + fully inline. + ## [1.134.2] - 2024-07-20 ### Fixed - Style issues for code blocks. diff --git a/package.json b/package.json index c688f6e4c..b6f7e35eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactive-pepyatka", - "version": "1.134.2", + "version": "1.134.3", "description": "", "main": "index.js", "dependencies": { @@ -12,7 +12,7 @@ "classnames": "~2.5.1", "custom-event": "~1.0.1", "date-fns": "~3.6.0", - "debug": "~4.3.5", + "debug": "~4.3.6", "events": "~3.3.0", "filesize": "~10.1.4", "final-form": "~4.20.10", @@ -55,12 +55,12 @@ "whatwg-fetch": "~3.6.20" }, "devDependencies": { - "@babel/core": "~7.24.8", - "@babel/eslint-parser": "~7.24.8", + "@babel/core": "~7.24.9", + "@babel/eslint-parser": "~7.25.1", "@babel/preset-react": "~7.24.7", "@gfx/zopfli": "~1.0.15", - "@testing-library/dom": "~10.3.1", - "@testing-library/jest-dom": "~6.4.6", + "@testing-library/dom": "~10.4.0", + "@testing-library/jest-dom": "~6.4.8", "@testing-library/react": "~16.0.0", "@testing-library/react-hooks": "~8.0.1", "@testing-library/user-event": "~14.5.2", @@ -73,14 +73,14 @@ "eslint-plugin-babel": "~5.3.1", "eslint-plugin-import": "~2.29.1", "eslint-plugin-lodash": "~8.0.0", - "eslint-plugin-prettier": "~5.1.3", + "eslint-plugin-prettier": "~5.2.1", "eslint-plugin-promise": "~6.4.0", - "eslint-plugin-react": "~7.34.4", + "eslint-plugin-react": "~7.35.0", "eslint-plugin-react-hooks": "~4.6.2", "eslint-plugin-unicorn": "~54.0.0", "eslint-plugin-you-dont-need-lodash-underscore": "~6.14.0", "husky": "~8.0.3", - "jsdom": "~24.1.0", + "jsdom": "~24.1.1", "lint-staged": "~15.2.7", "node-html-parser": "~6.1.13", "npm-run-all": "~4.1.5", @@ -93,17 +93,17 @@ "stylelint": "~16.7.0", "stylelint-config-prettier": "~9.0.5", "stylelint-config-standard-scss": "~13.1.0", - "stylelint-prettier": "~5.0.1", + "stylelint-prettier": "~5.0.2", "stylelint-scss": "~6.4.1", - "terser": "~5.31.2", + "terser": "~5.31.3", "unexpected": "~13.2.1", "unexpected-react": "~6.0.2", "unexpected-sinon": "~11.1.0", - "url": "~0.11.3", - "vite": "~5.3.3", + "url": "~0.11.4", + "vite": "~5.3.5", "vite-plugin-compression": "~0.5.1", - "vite-plugin-generate-file": "~0.1.1", - "vitest": "~2.0.2" + "vite-plugin-generate-file": "~0.2.0", + "vitest": "~2.0.4" }, "scripts": { "start": "vite", diff --git a/src/components/autocomplete/autocomplete.jsx b/src/components/autocomplete/autocomplete.jsx index 749929aca..aeb5197ae 100644 --- a/src/components/autocomplete/autocomplete.jsx +++ b/src/components/autocomplete/autocomplete.jsx @@ -12,6 +12,9 @@ const defaultAnchor = /(^|[^a-z\d])@/gi; export function Autocomplete({ inputRef, context, anchor = defaultAnchor }) { const [query, setQuery] = useState(/** @type {string|null}*/ null); + // Special case for the "@username" in the search bar + const [atStart, setAtStart] = useState(false); + const events = useMemo(() => new EventEmitter(), []); const keyHandler = useEvent((/** @type {KeyboardEvent}*/ e) => { @@ -39,6 +42,7 @@ export function Autocomplete({ inputRef, context, anchor = defaultAnchor }) { } const matchPos = getQueryPosition(input, anchor); setQuery(matchPos ? input.value.slice(matchPos[0], matchPos[1]) : null); + setAtStart(context === 'search' && matchPos?.[0] === 1 && input.value.charAt(0) === '@'); }; // Clears the query after 100ms of no focus. This delay allows to click on @@ -63,14 +67,20 @@ export function Autocomplete({ inputRef, context, anchor = defaultAnchor }) { document.removeEventListener('selectionchange', inputHandler); input.removeEventListener('keydown', keyHandler, { capture: true }); }; - }, [anchor, inputRef, keyHandler]); + }, [anchor, context, inputRef, keyHandler]); const onSelectHandler = useEvent((text) => replaceQuery(inputRef.current, text, anchor)); if (query) { return (
- +
); } diff --git a/src/components/autocomplete/autocomplete.module.scss b/src/components/autocomplete/autocomplete.module.scss index d959e2ce8..84d1aa606 100644 --- a/src/components/autocomplete/autocomplete.module.scss +++ b/src/components/autocomplete/autocomplete.module.scss @@ -65,8 +65,30 @@ flex: none; } +.itemText { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemLink { + flex: none; + color: currentColor; + opacity: 0.5; + font-size: 0.8em; + width: 1.5em; + display: flex; + align-items: center; + justify-content: center; +} + +.itemLinkIconLocal { + transform: rotate(45deg); +} + .screenName { - margin-left: 1em; + margin-left: 0.5em; color: #999; } diff --git a/src/components/autocomplete/selector.jsx b/src/components/autocomplete/selector.jsx index 95ff65535..43f5d5113 100644 --- a/src/components/autocomplete/selector.jsx +++ b/src/components/autocomplete/selector.jsx @@ -1,8 +1,9 @@ import { useDispatch, useSelector, useStore } from 'react-redux'; +import { Link } from 'react-router'; import cn from 'classnames'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useEvent } from 'react-use-event-hook'; -import { faUserFriends } from '@fortawesome/free-solid-svg-icons'; +import { faExternalLinkAlt, faUserFriends } from '@fortawesome/free-solid-svg-icons'; import { Finder } from '../../utils/sparse-match'; import { UserPicture } from '../user-picture'; import { Icon } from '../fontawesome-icons'; @@ -20,7 +21,7 @@ import { getRankedNames, } from './ranked-names'; -export function Selector({ query, events, onSelect, context }) { +export function Selector({ query, events, onSelect, context, localLinks = false }) { const dispatch = useDispatch(); const [usernames, accountsMap, compare] = useAccountsMap({ context }); @@ -77,6 +78,7 @@ export function Selector({ query, events, onSelect, context }) { account={accountsMap.get(match.text)} match={match} isCurrent={idx === cursor} + localLink={localLinks} onClick={onSelect} /> ))} @@ -85,8 +87,9 @@ export function Selector({ query, events, onSelect, context }) { ); } -function Item({ account, match, isCurrent, onClick }) { +function Item({ account, match, isCurrent, onClick, localLink }) { const clk = useEvent(() => onClick(match.text)); + const linkClk = useEvent((e) => e.stopPropagation()); return (
  • @@ -100,6 +103,23 @@ function Item({ account, match, isCurrent, onClick }) { {account.screenName} )} + {localLink ? ( + + + + ) : ( + + + + )}
  • ); } diff --git a/src/components/drafts-page.jsx b/src/components/drafts-page.jsx index 24b8ddb7e..8f382a5df 100644 --- a/src/components/drafts-page.jsx +++ b/src/components/drafts-page.jsx @@ -4,7 +4,6 @@ import { faComment, faEdit } from '@fortawesome/free-regular-svg-icons'; import { useSelector } from 'react-redux'; import { deleteDraft, getAllDrafts, subscribeToDraftChanges } from '../services/drafts'; import { pluralForm } from '../utils'; -import { postReadmoreConfig } from '../utils/readmore-config'; import { READMORE_STYLE_COMPACT } from '../utils/frontend-preferences-options'; import ErrorBoundary from './error-boundary'; import TimeDisplay from './time-display'; @@ -13,7 +12,7 @@ import { Icon } from './fontawesome-icons'; import { useBool } from './hooks/bool'; import { UserPicture } from './user-picture'; import { faCommentPlus } from './fontawesome-custom-icons'; -import Expandable from './expandable'; +import { Expandable } from './expandable'; export default function DraftsPage() { const allDrafts = useSyncExternalStore(subscribeToDraftChanges, getAllDrafts); @@ -118,10 +117,7 @@ function DraftEntry({ draftKey, data }) {
    {data.text ? ( - + ) : ( diff --git a/src/components/expandable.jsx b/src/components/expandable.jsx index 7a8b46ebc..139d13363 100644 --- a/src/components/expandable.jsx +++ b/src/components/expandable.jsx @@ -1,129 +1,168 @@ -import { Component, createRef } from 'react'; -import classnames from 'classnames'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import cn from 'classnames'; import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; +import { useEvent } from 'react-use-event-hook'; +import { useSelector } from 'react-redux'; +import { ButtonLink } from './button-link'; import { Icon } from './fontawesome-icons'; +import style from './expandable.module.scss'; -import ErrorBoundary from './error-boundary'; -import { ButtonLink } from './button-link'; +// Pixel areas (before the UI scale applied) of the folded text rectangles for +// posts and comments. The Expandable component reduces the height of the text +// to approximately match this area. -const DEFAULT_MAX_LINES = 8; -const DEFAULT_ABOVE_FOLD_LINES = 5; -const DEFAULT_KEY = 'default'; - -export default class Expandable extends Component { - root = createRef(null); - - constructor(props) { - super(props); - this.state = { - expanded: false, - userExpanded: false, - maxHeight: 5000, - }; - this.userExpand = this.userExpand.bind(this); - this.rewrap = this.rewrap.bind(this); - } +const foldedAreas = { + post: 600 * 130, + comment: 500 * 110, + postAnonymous: 860 * 130, // No sidebar, so we need more pixels + commentAnonymous: 700 * 110, +}; - componentDidMount() { - this.rewrap(); - window.addEventListener('resize', this.rewrap); - } +const isSafari = /^((?!chrome|android).)*safari/i.test(navigator?.userAgent ?? ''); - componentWillUnmount() { - window.removeEventListener('resize', this.rewrap); - } +export function Expandable({ + children, + expanded: givenExpanded = false, + tail = null, + panelClass = null, + contentType = 'post', // or 'comment' +}) { + const authenticated = useSelector((state) => state.authenticated); + const uiScale = useSelector((state) => state.uiScale ?? 100) / 100; - render() { - const expanded = this.state.expanded || this.state.userExpanded || this.props.expanded; - const cn = classnames(['expandable', { expanded, folded: !expanded }]); - const style = { maxHeight: expanded ? '' : `${this.state.maxHeight}px` }; - return ( -
    - - {this.props.children} - {!expanded && ( -
    -
    - - Read more - {' '} - {this.props.bonusInfo} -
    -
    - )} -
    -
    - ); + if (contentType !== 'comment' && contentType !== 'post') { + throw new Error('Unsupported content type'); } - userExpand() { - this.setState({ userExpanded: true }); - } + const foldedArea = foldedAreas[contentType + (authenticated ? '' : 'Anonymous')]; + const scaledFoldedArea = foldedArea * uiScale * uiScale; + // Don't fold content that is smaller than this + const scaledMaxUnfoldedArea = scaledFoldedArea * 1.5; - rewrap() { - const { maxLines, aboveFoldLines } = chooseLineCounts(this.props.config, window.innerWidth); - const node = this.root.current; - const lines = gatherContentLines(node, '.Linkify', '.p-break'); - const shouldExpand = lines.length <= (maxLines || DEFAULT_MAX_LINES); - const [readMorePanel] = node.querySelectorAll('.expand-panel'); - const readMorePanelHeight = readMorePanel ? readMorePanel.clientHeight : 0; - const foldedLines = aboveFoldLines || maxLines || DEFAULT_ABOVE_FOLD_LINES; - const maxHeight = shouldExpand ? '5000' : lines[foldedLines - 1].bottom + readMorePanelHeight; - this.setState({ expanded: shouldExpand, maxHeight }); - } -} + const content = useRef(null); + // Null means content doesn't need to be expandable + const [maxHeight, setMaxHeight] = useState(null); -function gatherContentLines(node, contentSelector, breakSelector) { - const [content] = node.querySelectorAll(contentSelector || '.wrapper'); - if (!content) { - return []; - } - const breaks = [...content.querySelectorAll(breakSelector || '.text')]; - const rects = [...content.getClientRects()]; - const breakRects = breaks.map((br) => br.getBoundingClientRect()); - const breakTops = new Set(breakRects.map((br) => br.top)); - - const lines = rects.filter((rect) => { - const isRectABreak = breakTops.has(rect.top); - return !isRectABreak; - }); + const [expandedByUser, setExpandedByUser] = useState(false); + const expand = useEvent(() => setExpandedByUser(true)); + + const expanded = expandedByUser || givenExpanded; + const clipped = maxHeight !== null && !expanded; - const lineRects = lines.reduce((res, { top, bottom, left, right }) => { - if (res.length === 0) { - res.push({ top, bottom, left, right }); - } else if (res[res.length - 1].top !== top) { - res.push({ top, bottom, left, right }); + // Update the maxHeight when the content dimensions changes + const update = useEvent(({ width, height }) => { + if (width * height < scaledMaxUnfoldedArea) { + setMaxHeight(null); } else { - const last = res[res.length - 1]; - res[res.length - 1].bottom = last.bottom > bottom ? last.bottom : bottom; - res[res.length - 1].left = last.left < left ? last.left : left; - res[res.length - 1].right = last.right > right ? last.right : right; + let targetHeight = scaledFoldedArea / width; + targetHeight = align(content.current, targetHeight); + if (isSafari) { + // Safari has extremely large string heights, so we need to slightly + // reduce the clipping. + targetHeight -= 4; + } + setMaxHeight(`${targetHeight}px`); } - return res; - }, []); - - const nodeClientRect = node.getBoundingClientRect(); - - return lineRects.map(({ top, bottom, left, right }) => { - return { - top: top - nodeClientRect.top, - bottom: bottom - nodeClientRect.top, - left: left - nodeClientRect.left, - right: nodeClientRect.right - right, - }; }); -} -function chooseLineCounts(config = {}, windowWidth) { - const breakpoints = Object.keys(config) - .filter((key) => key !== DEFAULT_KEY) - .map(Number) - .sort((a, b) => a - b); - const breakpointToUse = breakpoints.find((b) => b >= windowWidth) || DEFAULT_KEY; - return ( - config[breakpointToUse] || { - maxLines: DEFAULT_MAX_LINES, - aboveFoldLines: DEFAULT_ABOVE_FOLD_LINES, + // We use the layout effect just once, to set the initial height without + // flickering. + useLayoutEffect( + () => { + if (!expanded) { + update(content.current.getBoundingClientRect()); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + // Update the maxHeight when the content resizes + useEffect(() => { + if (!expanded) { + return observeResizeOf(content.current, ({ contentRect }) => update(contentRect)); } + }, [expanded, update]); + + return ( + <> +
    +
    {children}
    +
    + {clipped && ( +
    + + Read more + {' '} + {tail} +
    + )} + ); } + +/** + * Align the given offset to the bottom of the closest text string + * + * @param {Element} rootElement + * @param {number} targetOffset + * @returns {number} + */ +function align(rootElement, targetOffset) { + const { top } = rootElement.getBoundingClientRect(); + + // Iterate over all the text nodes + const nodeIterator = document.createNodeIterator(rootElement, NodeFilter.SHOW_TEXT); + const range = document.createRange(); + let node; + let prev = null; + let current = null; + mainLoop: while ((node = nodeIterator.nextNode())) { + range.selectNode(node); + // In every text node we check all the rects + for (const { bottom } of range.getClientRects()) { + prev = current; + current = bottom - top; + if (current >= targetOffset) { + break mainLoop; + } + } + } + if (!prev || !current) { + return targetOffset; + } + return Math.abs(current - targetOffset) < Math.abs(prev - targetOffset) ? current : prev; +} + +let resizeObserver = null; +const resizeHandlers = new Map(); + +/** + * Subscribe to resize of the given element + * + * @param {Element} element + * @param {(entry: ResizeObserverEntry) => void} callback + * @returns {() => void} unsubscribe function + */ +function observeResizeOf(element, callback) { + if (process.env.NODE_ENV === 'test') { + return; + } + if (!resizeObserver) { + resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + resizeHandlers.get(entry.target)?.(entry); + } + }); + } + + resizeHandlers.set(element, callback); + resizeObserver.observe(element); + return () => { + resizeObserver.unobserve(element); + resizeHandlers.delete(element); + }; +} diff --git a/src/components/expandable.module.scss b/src/components/expandable.module.scss new file mode 100644 index 000000000..2b92c5c6b --- /dev/null +++ b/src/components/expandable.module.scss @@ -0,0 +1,18 @@ +@import '../styles/helvetica/dark-vars.scss'; + +.clippedContent { + overflow-y: hidden; /* fallback for old Safari */ + overflow-y: clip; +} + +.button { + color: #555599; + + :global(.dark-theme) & { + color: $link-color-dim; + } +} + +.icon { + opacity: 0.6; +} diff --git a/src/components/footer.jsx b/src/components/footer.jsx index 7f886b693..2105a9e78 100644 --- a/src/components/footer.jsx +++ b/src/components/footer.jsx @@ -7,7 +7,7 @@ export default function Footer({ short }) { return (
    {content ? ( - + ) : contentSource ? ( diff --git a/src/components/post/post-comment-preview.jsx b/src/components/post/post-comment-preview.jsx index 1ad3ffaea..8dcfbfff8 100644 --- a/src/components/post/post-comment-preview.jsx +++ b/src/components/post/post-comment-preview.jsx @@ -11,13 +11,12 @@ import { READMORE_STYLE_COMPACT, } from '../../utils/frontend-preferences-options'; -import { commentReadmoreConfig } from '../../utils/readmore-config'; -import Expandable from '../expandable'; import PieceOfText from '../piece-of-text'; import { Separated } from '../separated'; import TimeDisplay from '../time-display'; import UserName from '../user-name'; +import { Expandable } from '../expandable'; import styles from './post-comment-preview.module.scss'; import { CommentProvider } from './post-comment-provider'; @@ -158,11 +157,12 @@ export function PostCommentPreview({ ) : ( diff --git a/src/components/post/post-comment.jsx b/src/components/post/post-comment.jsx index 4cc1d5955..2f58a09a5 100644 --- a/src/components/post/post-comment.jsx +++ b/src/components/post/post-comment.jsx @@ -9,12 +9,10 @@ import { HIDDEN_AUTHOR_BANNED, READMORE_STYLE_COMPACT, } from '../../utils/frontend-preferences-options'; -import { commentReadmoreConfig } from '../../utils/readmore-config'; import { defaultCommentState } from '../../redux/reducers/comment-edit'; import { intentToScroll } from '../../services/unscroll'; import PieceOfText from '../piece-of-text'; -import Expandable from '../expandable'; import UserName from '../user-name'; import TimeDisplay from '../time-display'; import CommentIcon, { JustCommentIcon } from '../comment-icon'; @@ -26,6 +24,7 @@ import { TranslatedText } from '../translated-text'; import { initialAsyncState } from '../../redux/async-helpers'; import { existingCommentURI, newCommentURI } from '../../services/drafts'; import { UnlockedHiddenComment } from '../unlocked-hidden-comment'; +import { Expandable } from '../expandable'; import { PostCommentMore } from './post-comment-more'; import { PostCommentPreview } from './post-comment-preview'; import { CommentProvider } from './post-comment-provider'; @@ -316,8 +315,8 @@ class PostComment extends Component { this.props.isExpanded || !this.props.translateStatus.initial } - bonusInfo={commentTail} - config={commentReadmoreConfig} + tail={commentTail} + contentType="comment" >
    Renders post comments and doesn't blow up 1`] = `
    -
    @@ -148,38 +148,12 @@ exports[`PostComments > Renders post comments and doesn't blow up 1`] = `
    -
    @@ -272,68 +272,68 @@ exports[`PostComments > Renders post comments and doesn't blow up 1`] = `
    -
    diff --git a/test/jest/__snapshots__/post.test.jsx.snap b/test/jest/__snapshots__/post.test.jsx.snap index 6855b81bf..e72bea563 100644 --- a/test/jest/__snapshots__/post.test.jsx.snap +++ b/test/jest/__snapshots__/post.test.jsx.snap @@ -8,58 +8,58 @@ exports[`Post > Renders a post and doesn't blow up 1`] = ` data-author="author" role="article" > -