From 6a1c6be395e621d022d9ea1b021b209451970c0e Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Sat, 9 May 2026 05:30:25 +0000 Subject: [PATCH 1/6] =?UTF-8?q?FoldButton:=20=E3=82=AF=E3=83=A9=E3=82=B9?= =?UTF-8?q?=E3=81=8B=E3=82=89hook=E3=83=99=E3=83=BC=E3=82=B9=E3=81=AB?= =?UTF-8?q?=E6=9B=B8=E3=81=8D=E6=8F=9B=E3=81=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PureComponentから関数コンポーネント+memoに変更。handleClickは useCallbackで保持。propsとDOM出力は変更なしで、追加したsnapshot テスト+クリック挙動テスト(handler呼び出し/disabled/preventDefault) が引き続きpassすることを確認している。 --- .../__snapshots__/fold_button-test.jsx.snap | 110 +++++++++++++ .../components/__tests__/fold_button-test.jsx | 44 +++++ .../mastodon/components/fold_button.jsx | 150 +++++++++--------- 3 files changed, 230 insertions(+), 74 deletions(-) create mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/fold_button-test.jsx.snap create mode 100644 app/javascript/mastodon/components/__tests__/fold_button-test.jsx diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/fold_button-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/fold_button-test.jsx.snap new file mode 100644 index 00000000000000..19848a930d8fad --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/fold_button-test.jsx.snap @@ -0,0 +1,110 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > applies transition styles when animate is true 1`] = ` + +`; + +exports[` > renders a button element with default props 1`] = ` + +`; + +exports[` > rotates icon 180deg when active 1`] = ` + +`; diff --git a/app/javascript/mastodon/components/__tests__/fold_button-test.jsx b/app/javascript/mastodon/components/__tests__/fold_button-test.jsx new file mode 100644 index 00000000000000..8b58aa949bbd44 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/fold_button-test.jsx @@ -0,0 +1,44 @@ +import renderer from 'react-test-renderer'; + +import { render, fireEvent, screen } from '@/testing/rendering'; + +import FoldButton from '../fold_button'; + +describe('', () => { + it('renders a button element with default props', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('rotates icon 180deg when active', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('applies transition styles when animate is true', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('calls onClick handler when clicked', () => { + const handler = vi.fn(); + render(); + fireEvent.click(screen.getByTitle('toggle')); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('does not call onClick when disabled', () => { + const handler = vi.fn(); + render(); + fireEvent.click(screen.getByTitle('toggle')); + expect(handler).not.toHaveBeenCalled(); + }); + + it('prevents default on click', () => { + const handler = vi.fn(); + render(); + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + fireEvent(screen.getByTitle('toggle'), event); + expect(event.defaultPrevented).toBe(true); + }); +}); diff --git a/app/javascript/mastodon/components/fold_button.jsx b/app/javascript/mastodon/components/fold_button.jsx index b39dee2b932018..79e7489b04658e 100644 --- a/app/javascript/mastodon/components/fold_button.jsx +++ b/app/javascript/mastodon/components/fold_button.jsx @@ -1,86 +1,88 @@ -import { Icon } from '@/mastodon/components/icon'; -import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react'; +import { memo, useCallback } from 'react'; + import classNames from 'classnames'; import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; -export default class FoldButton extends PureComponent { - - static propTypes = { - active: PropTypes.bool, - activeStyle: PropTypes.object, - animate: PropTypes.bool, - className: PropTypes.string, - disabled: PropTypes.bool, - expanded: PropTypes.bool, - inverted: PropTypes.bool, - onClick: PropTypes.func, - overlay: PropTypes.bool, - pressed: PropTypes.bool, - size: PropTypes.number, - style: PropTypes.object, - tabIndex: PropTypes.number, - title: PropTypes.string, - }; +import { Icon } from '@/mastodon/components/icon'; +import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react'; - handleClick = (e) => { +const FoldButton = ({ + active, + activeStyle, + animate, + className, + disabled, + expanded, + inverted, + onClick, + overlay, + pressed, + size, + style, + tabIndex, + title, +}) => { + const handleClick = useCallback((e) => { e.preventDefault(); - if (!this.props.disabled && this.props.onClick) { - this.props.onClick(e); + if (!disabled && onClick) { + onClick(e); } - }; + }, [disabled, onClick]); - render () { - const style = { - fontSize: `${this.props.size}px`, - width: `${this.props.size * 1.28571429}px`, - height: `${this.props.size * 1.28571429}px`, - lineHeight: `${this.props.size}px`, - ...this.props.style, - ...(this.props.active ? this.props.activeStyle : {}), - }; + const buttonStyle = { + fontSize: `${size}px`, + width: `${size * 1.28571429}px`, + height: `${size * 1.28571429}px`, + lineHeight: `${size}px`, + ...style, + ...(active ? activeStyle : {}), + }; - const { - active, - animate, - className, - disabled, - expanded, - inverted, - overlay, - pressed, - tabIndex, - title, - } = this.props; + const classes = classNames(className, 'icon-button', { + active, + disabled, + inverted, + overlayed: overlay, + }); - const classes = classNames(className, 'icon-button', { - active, - disabled, - inverted, - overlayed: overlay, - }); + const iconStyle = animate ? { + transform: `rotate(${active ? 180 : 0}deg)`, + transition: 'transform 300ms ease-in-out', + } : { + transform: `rotate(${active ? 180 : 0}deg)`, + }; - const iconStyle = animate ? { - transform: `rotate(${active ? 180 : 0}deg)`, - transition: 'transform 300ms ease-in-out' - } : { - transform: `rotate(${active ? 180 : 0}deg)` - }; + return ( + + ); +}; - return ( - - ); - } +FoldButton.propTypes = { + active: PropTypes.bool, + activeStyle: PropTypes.object, + animate: PropTypes.bool, + className: PropTypes.string, + disabled: PropTypes.bool, + expanded: PropTypes.bool, + inverted: PropTypes.bool, + onClick: PropTypes.func, + overlay: PropTypes.bool, + pressed: PropTypes.bool, + size: PropTypes.number, + style: PropTypes.object, + tabIndex: PropTypes.number, + title: PropTypes.string, +}; -} +export default memo(FoldButton); From d27ae53c5eadb0576d1b2d26b18e750ba35a937c Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Sat, 9 May 2026 05:30:33 +0000 Subject: [PATCH 2/6] =?UTF-8?q?FavouriteToggle:=20=E3=82=AF=E3=83=A9?= =?UTF-8?q?=E3=82=B9=E3=81=8B=E3=82=89hook=E3=83=99=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=81=AB=E6=9B=B8=E3=81=8D=E6=8F=9B=E3=81=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PureComponentから関数コンポーネント+memoに変更。injectIntlを useIntlフックに置き換え、各ハンドラはuseCallbackで保持。 props/DOM出力は変更なしで、追加したsnapshot(3パターン)と4種 クリック挙動(add/remove × public/unlisted)テストがpass。 --- .../favourite_toggle-test.jsx.snap | 70 ++++++++++++ .../__tests__/favourite_toggle-test.jsx | 67 +++++++++++ .../components/favourite_toggle.jsx | 106 ++++++++---------- 3 files changed, 183 insertions(+), 60 deletions(-) create mode 100644 app/javascript/mastodon/features/hashtag_timeline/components/__tests__/__snapshots__/favourite_toggle-test.jsx.snap create mode 100644 app/javascript/mastodon/features/hashtag_timeline/components/__tests__/favourite_toggle-test.jsx diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/__tests__/__snapshots__/favourite_toggle-test.jsx.snap b/app/javascript/mastodon/features/hashtag_timeline/components/__tests__/__snapshots__/favourite_toggle-test.jsx.snap new file mode 100644 index 00000000000000..e52805f0a9d530 --- /dev/null +++ b/app/javascript/mastodon/features/hashtag_timeline/components/__tests__/__snapshots__/favourite_toggle-test.jsx.snap @@ -0,0 +1,70 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > publicId/unlistedIdがnullの場合は両方の追加ボタンが表示される 1`] = ` +
+
+ + +
+
+`; + +exports[` > publicId/unlistedIdの両方が指定されている場合は両方の削除ボタンが表示される 1`] = ` +
+
+ + +
+
+`; + +exports[` > publicIdが指定されている場合はpublicに対応する削除ボタンが表示される 1`] = ` +
+
+ + +
+
+`; diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/__tests__/favourite_toggle-test.jsx b/app/javascript/mastodon/features/hashtag_timeline/components/__tests__/favourite_toggle-test.jsx new file mode 100644 index 00000000000000..a377c1b94fc607 --- /dev/null +++ b/app/javascript/mastodon/features/hashtag_timeline/components/__tests__/favourite_toggle-test.jsx @@ -0,0 +1,67 @@ +import { IntlProvider } from 'react-intl'; + +import renderer from 'react-test-renderer'; + +import { render, fireEvent, screen } from '@/testing/rendering'; + +import FavouriteToggle from '../favourite_toggle'; + +const renderWithIntl = (ui) => renderer.create({ui}); + +describe('', () => { + it('publicId/unlistedIdがnullの場合は両方の追加ボタンが表示される', () => { + const noop = vi.fn(); + const tree = renderWithIntl( + + ).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('publicIdが指定されている場合はpublicに対応する削除ボタンが表示される', () => { + const noop = vi.fn(); + const tree = renderWithIntl( + + ).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('publicId/unlistedIdの両方が指定されている場合は両方の削除ボタンが表示される', () => { + const noop = vi.fn(); + const tree = renderWithIntl( + + ).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('publicの追加ボタン押下でaddFavouriteTagsがtagと"public"で呼ばれる', () => { + const addHandler = vi.fn(); + const noop = vi.fn(); + render(); + fireEvent.click(screen.getAllByRole('button')[0]); + expect(addHandler).toHaveBeenCalledWith('test', 'public'); + }); + + it('unlistedの追加ボタン押下でaddFavouriteTagsがtagと"unlisted"で呼ばれる', () => { + const addHandler = vi.fn(); + const noop = vi.fn(); + render(); + fireEvent.click(screen.getAllByRole('button')[1]); + expect(addHandler).toHaveBeenCalledWith('test', 'unlisted'); + }); + + it('publicの削除ボタン押下でremoveFavouriteTagsがpublicIdで呼ばれる', () => { + const removeHandler = vi.fn(); + const noop = vi.fn(); + render(); + fireEvent.click(screen.getAllByRole('button')[0]); + expect(removeHandler).toHaveBeenCalledWith(42); + }); + + it('unlistedの削除ボタン押下でremoveFavouriteTagsがunlistedIdで呼ばれる', () => { + const removeHandler = vi.fn(); + const noop = vi.fn(); + render(); + fireEvent.click(screen.getAllByRole('button')[1]); + expect(removeHandler).toHaveBeenCalledWith(99); + }); +}); diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/favourite_toggle.jsx b/app/javascript/mastodon/features/hashtag_timeline/components/favourite_toggle.jsx index 5f8ae90a70f237..a34abbe749e14e 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/components/favourite_toggle.jsx +++ b/app/javascript/mastodon/features/hashtag_timeline/components/favourite_toggle.jsx @@ -1,11 +1,10 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import { memo, useCallback } from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import { defineMessages, useIntl } from 'react-intl'; import { Button } from '../../../components/button'; - const messages = defineMessages({ add_favourite_tags_public: { id: 'tag.add_favourite.public', defaultMessage: 'add in the favourite tags (Public)' }, add_favourite_tags_unlisted: { id: 'tag.add_favourite.unlisted', defaultMessage: 'add in the favourite tags (Unlisted)' }, @@ -13,60 +12,47 @@ const messages = defineMessages({ remove_favourite_tags_unlisted: { id: 'tag.remove_favourite.unlisted', defaultMessage: 'Remove from the favourite tags (Unlisted)' }, }); -class FavouriteToggle extends React.PureComponent { - - static propTypes = { - tag: PropTypes.string.isRequired, - addFavouriteTags: PropTypes.func.isRequired, - removeFavouriteTags: PropTypes.func.isRequired, - unlistedId: PropTypes.number, - publicId: PropTypes.number, - intl: PropTypes.object.isRequired, - }; - - addFavouriteTags = (visibility) => { - this.props.addFavouriteTags(this.props.tag, visibility); - }; - - addPublic = () => { - this.addFavouriteTags('public'); - }; - - addUnlisted = () => { - this.addFavouriteTags('unlisted'); - }; - - removeFavouriteTags = (id) => { - this.props.removeFavouriteTags(id); - }; - - removePublic = () => { - this.removeFavouriteTags(this.props.publicId); - }; - - removeUnlisted = () => { - this.removeFavouriteTags(this.props.unlistedId); - }; - - render () { - const { intl, unlistedId, publicId } = this.props; - - return ( -
-
- { - publicId != null ?
+const FavouriteToggle = ({ tag, addFavouriteTags, removeFavouriteTags, unlistedId, publicId }) => { + const intl = useIntl(); + + const addPublic = useCallback(() => { + addFavouriteTags(tag, 'public'); + }, [addFavouriteTags, tag]); + + const addUnlisted = useCallback(() => { + addFavouriteTags(tag, 'unlisted'); + }, [addFavouriteTags, tag]); + + const removePublic = useCallback(() => { + removeFavouriteTags(publicId); + }, [removeFavouriteTags, publicId]); + + const removeUnlisted = useCallback(() => { + removeFavouriteTags(unlistedId); + }, [removeFavouriteTags, unlistedId]); + + return ( +
+
+ { + publicId != null ?
- ); - } - -} - -export default injectIntl(FavouriteToggle); +
+ ); +}; + +FavouriteToggle.propTypes = { + tag: PropTypes.string.isRequired, + addFavouriteTags: PropTypes.func.isRequired, + removeFavouriteTags: PropTypes.func.isRequired, + unlistedId: PropTypes.number, + publicId: PropTypes.number, +}; + +export default memo(FavouriteToggle); From 58dc9f5277139d6d6e3b9905b4b926ef6d016405 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Sat, 9 May 2026 05:30:52 +0000 Subject: [PATCH 3/6] =?UTF-8?q?AvatarOverlayIcon:=20hook=E5=8C=96=20+=20up?= =?UTF-8?q?stream=20AvatarOverlay=E3=81=AE=E6=A7=8B=E9=80=A0=E3=81=AB?= =?UTF-8?q?=E6=8F=83=E3=81=88=E3=81=A6=E3=82=B7=E3=83=B3=E3=83=97=E3=83=AB?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PureComponentから関数コンポーネント+memoに変更 - 独自の背景画像レンダリングを廃止し、本家AvatarコンポーネントとAvatarOverlay の.account__avatar-overlay-base/.account__avatar-overlay-overlay構造に 揃えてsizeプロパティをコンテナとアバター画像両方に反映 - これにより通知欄(avatarSize=40)で公開範囲アイコンの位置が アバター画像の右下角からずれるバグも解消 - SVGアイコンをdisplay:blockにすることでwrapperの行高分の余白(line-box) によって縦位置が浮く問題も修正 - imastodon/statuses.scssから重複していた配置関連スタイルを削除し本家 .account__avatar-overlay-overlayの絶対配置に依存させる --- .../avatar_overlay_icon-test.jsx.snap | 209 ++++++++++++++++++ .../__tests__/avatar_overlay_icon-test.jsx | 38 ++++ .../components/avatar_overlay_icon.jsx | 55 ++--- app/javascript/styles/imastodon/statuses.scss | 41 ++-- 4 files changed, 281 insertions(+), 62 deletions(-) create mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay_icon-test.jsx.snap create mode 100644 app/javascript/mastodon/components/__tests__/avatar_overlay_icon-test.jsx diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay_icon-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay_icon-test.jsx.snap new file mode 100644 index 00000000000000..7969cc57921fe7 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay_icon-test.jsx.snap @@ -0,0 +1,209 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > animate=trueでアニメーションavatarが表示される 1`] = ` +
+
+
+ +
+
+
+ + + +
+
+`; + +exports[` > directのときAlternateEmailアイコンと静止画avatarが表示される 1`] = ` +
+
+
+ +
+
+
+ + + +
+
+`; + +exports[` > privateのときLockアイコンが表示される 1`] = ` +
+
+
+ +
+
+
+ + + +
+
+`; + +exports[` > unlistedのときQuietTimeアイコンが表示される 1`] = ` +
+
+
+ +
+
+
+ + + +
+
+`; diff --git a/app/javascript/mastodon/components/__tests__/avatar_overlay_icon-test.jsx b/app/javascript/mastodon/components/__tests__/avatar_overlay_icon-test.jsx new file mode 100644 index 00000000000000..0020aedf4049cc --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/avatar_overlay_icon-test.jsx @@ -0,0 +1,38 @@ +import { Record } from 'immutable'; + +import renderer from 'react-test-renderer'; + +import AvatarOverlayIcon from '../avatar_overlay_icon'; + +const AccountRecord = Record({ + id: '1', + acct: 'alice', + avatar: '/animated/alice.gif', + avatar_static: '/static/alice.jpg', +}); + +describe('', () => { + const account = new AccountRecord(); + + it('directのときAlternateEmailアイコンと静止画avatarが表示される', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + expect(JSON.stringify(tree)).toContain('/static/alice.jpg'); + }); + + it('privateのときLockアイコンが表示される', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('unlistedのときQuietTimeアイコンが表示される', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('animate=trueでアニメーションavatarが表示される', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + expect(JSON.stringify(tree)).toContain('/animated/alice.gif'); + }); +}); diff --git a/app/javascript/mastodon/components/avatar_overlay_icon.jsx b/app/javascript/mastodon/components/avatar_overlay_icon.jsx index 67aa9cb715a59b..bc339e3a7da96f 100644 --- a/app/javascript/mastodon/components/avatar_overlay_icon.jsx +++ b/app/javascript/mastodon/components/avatar_overlay_icon.jsx @@ -1,7 +1,6 @@ -import PropTypes from 'prop-types'; -import React from 'react'; - +import { memo } from 'react'; +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; @@ -9,11 +8,9 @@ import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react'; -import { autoPlayGif } from '../initial_state'; - +import { Avatar } from './avatar'; import { Icon } from './icon'; - const icons = { public: PublicIcon, unlisted: QuietTimeIcon, @@ -21,32 +18,22 @@ const icons = { direct: AlternateEmailIcon, }; -export default class AvatarOverlayIcon extends React.PureComponent { - - static propTypes = { - account: ImmutablePropTypes.map.isRequired, - visibility: PropTypes.string.isRequired, - animate: PropTypes.bool, - }; - - static defaultProps = { - animate: autoPlayGif, - }; - - render() { - const { account, visibility, animate } = this.props; - const icon = icons[visibility]; - - const baseStyle = { - backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`, - }; - - return ( -
-
- -
- ); - } +const AvatarOverlayIcon = ({ account, visibility, animate, size = 46 }) => ( +
+
+ +
+
+ +
+
+); + +AvatarOverlayIcon.propTypes = { + account: ImmutablePropTypes.map.isRequired, + visibility: PropTypes.string.isRequired, + animate: PropTypes.bool, + size: PropTypes.number, +}; -} +export default memo(AvatarOverlayIcon); diff --git a/app/javascript/styles/imastodon/statuses.scss b/app/javascript/styles/imastodon/statuses.scss index e4cc6848dc43a6..e1c5e83adf5bde 100644 --- a/app/javascript/styles/imastodon/statuses.scss +++ b/app/javascript/styles/imastodon/statuses.scss @@ -1,32 +1,17 @@ @use '../mastodon/_variables' as variables; @use 'sass:color'; -.account__avatar-overlay-icon { - &-base { - border-radius: var(--avatar-border-radius); - background: transparent no-repeat; - background-position: 50%; - background-clip: padding-box; - width: 46px; - height: 46px; - background-size: 46px 46px; - } - - &-overlay { - width: 16px; - height: 16px; - padding: 2px; - border-radius: calc(var(--avatar-border-radius) / 2) - calc(var(--avatar-border-radius) / 2) var(--avatar-border-radius) - calc(var(--avatar-border-radius) / 2); - background: transparent no-repeat; - background-size: 16px 16px; - background-position: 50%; - background-clip: padding-box; - background-color: color.adjust(variables.$white, $lightness: -20%); - position: absolute; - bottom: 0; - right: 0; - z-index: 1; - } +.account__avatar-overlay-icon-overlay { + display: block; + width: 16px; + height: 16px; + padding: 2px; + border-radius: calc(var(--avatar-border-radius) / 2) + calc(var(--avatar-border-radius) / 2) var(--avatar-border-radius) + calc(var(--avatar-border-radius) / 2); + background: transparent no-repeat; + background-size: 16px 16px; + background-position: 50%; + background-clip: padding-box; + background-color: color.adjust(variables.$white, $lightness: -20%); } From 062252c67ebb330bdf34a28f29c7561fafcc86b2 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Sat, 9 May 2026 05:31:06 +0000 Subject: [PATCH 4/6] =?UTF-8?q?FavouriteTags:=20hook=E5=8C=96=20+=20?= =?UTF-8?q?=E5=90=8C=E4=B8=80name=E7=95=B0visibility=E3=82=BF=E3=82=B0?= =?UTF-8?q?=E3=81=AE=E4=B8=A1=E6=96=B9=E8=A1=A8=E7=A4=BA=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PureComponentから関数コンポーネント+memoに変更 - state2つ(lockedTag/lockedVisibility)はuseState、componentDidMount とUNSAFE_componentWillUpdateはuseEffectに、injectIntlはuseIntl に置き換え。後者はuseRefで初回スキップを実装 - li要素のkeyにtag.get('name')を使っていたため同一nameで異なる visibilityのfavourite tagが衝突して片方しかレンダリングされない ケースがあった。keyをtag.get('id')に変更し各エントリを一意化 --- .../favourite_tags-test.jsx.snap | 288 ++++++++++++++++++ .../__tests__/favourite_tags-test.jsx | 153 ++++++++++ .../compose/components/favourite_tags.jsx | 178 ++++++----- 3 files changed, 526 insertions(+), 93 deletions(-) create mode 100644 app/javascript/mastodon/features/compose/components/__tests__/__snapshots__/favourite_tags-test.jsx.snap create mode 100644 app/javascript/mastodon/features/compose/components/__tests__/favourite_tags-test.jsx diff --git a/app/javascript/mastodon/features/compose/components/__tests__/__snapshots__/favourite_tags-test.jsx.snap b/app/javascript/mastodon/features/compose/components/__tests__/__snapshots__/favourite_tags-test.jsx.snap new file mode 100644 index 00000000000000..5fbf171706e274 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/__tests__/__snapshots__/favourite_tags-test.jsx.snap @@ -0,0 +1,288 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > tagsが空かつvisible=falseのときの表示 1`] = ` +
+
+
+ + + + + Favourite tags + +
+
+ + + + + +
+ +
+
+
+
+
+
    +
+
+
+`; + +exports[` > tagsに2件・visible=trueのときの表示 1`] = ` +
+
+
+ + + + + Favourite tags + +
+
+ + + + + +
+ +
+
+
+
+
+ +
+
+
+`; diff --git a/app/javascript/mastodon/features/compose/components/__tests__/favourite_tags-test.jsx b/app/javascript/mastodon/features/compose/components/__tests__/favourite_tags-test.jsx new file mode 100644 index 00000000000000..e2090ff8517f80 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/__tests__/favourite_tags-test.jsx @@ -0,0 +1,153 @@ + +import { IntlProvider } from 'react-intl'; + +import { MemoryRouter } from 'react-router'; + +import { fromJS, List as ImmutableList } from 'immutable'; + +import renderer from 'react-test-renderer'; + +import { render, fireEvent, screen, waitFor } from '@/testing/rendering'; + +import FavouriteTags from '../favourite_tags'; + +const renderTree = (ui) => renderer.create( + + {ui} + +); + +describe('', () => { + const sampleTags = fromJS([ + { id: '1', name: 'foo', visibility: 'public' }, + { id: '2', name: 'bar', visibility: 'unlisted' }, + ]); + + it('tagsが空かつvisible=falseのときの表示', () => { + const refreshFavouriteTags = vi.fn(); + const onToggle = vi.fn(); + const onLockTag = vi.fn(); + const tree = renderTree( + + ).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('tagsに2件・visible=trueのときの表示', () => { + const refreshFavouriteTags = vi.fn(); + const onToggle = vi.fn(); + const onLockTag = vi.fn(); + const tree = renderTree( + + ).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('マウント時にrefreshFavouriteTagsが呼ばれる', () => { + const refreshFavouriteTags = vi.fn(); + const onToggle = vi.fn(); + const onLockTag = vi.fn(); + render( + + ); + expect(refreshFavouriteTags).toHaveBeenCalledTimes(1); + }); + + it('FoldButtonのクリックでonToggleが呼ばれる', () => { + const refreshFavouriteTags = vi.fn(); + const onToggle = vi.fn(); + const onLockTag = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTitle('Toggle visibility')); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it('タグのlockボタンクリックでロック追加されonLockTagが呼ばれる', async () => { + const refreshFavouriteTags = vi.fn(); + const onToggle = vi.fn(); + const onLockTag = vi.fn(); + const { container } = render( + + ); + const lockButtons = container.querySelectorAll('button.favourite-tags__lock'); + fireEvent.click(lockButtons[0]); + await waitFor(() => { + expect(onLockTag).toHaveBeenCalledWith('#foo', 'public'); + }); + }); + + it('同一nameで異なるvisibilityのタグが両方リスト表示される', () => { + const dupTags = fromJS([ + { id: '10', name: 'mor', visibility: 'public' }, + { id: '11', name: 'mor', visibility: 'unlisted' }, + ]); + const refreshFavouriteTags = vi.fn(); + const onToggle = vi.fn(); + const onLockTag = vi.fn(); + const { container } = render( + + ); + expect(container.querySelectorAll('.compose__extra__body > li').length).toBe(2); + }); + + it('複数のタグを順にロックすると最後のonLockTag呼び出しに両方含まれる', async () => { + const refreshFavouriteTags = vi.fn(); + const onToggle = vi.fn(); + const onLockTag = vi.fn(); + const { container } = render( + + ); + const lockButtons = container.querySelectorAll('button.favourite-tags__lock'); + fireEvent.click(lockButtons[0]); + fireEvent.click(lockButtons[1]); + await waitFor(() => { + // visibilityIconsをreverseしてfindするため + // public/unlistedの両方含む場合は最も制限の弱い`unlisted`が選ばれる + expect(onLockTag).toHaveBeenLastCalledWith('#foo #bar', 'unlisted'); + }); + }); +}); diff --git a/app/javascript/mastodon/features/compose/components/favourite_tags.jsx b/app/javascript/mastodon/features/compose/components/favourite_tags.jsx index 1b5e6c854ef46f..bfbdf2b469366a 100644 --- a/app/javascript/mastodon/features/compose/components/favourite_tags.jsx +++ b/app/javascript/mastodon/features/compose/components/favourite_tags.jsx @@ -1,9 +1,10 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; -import { injectIntl, defineMessages } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; import { List as ImmutableList } from 'immutable'; + +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Link from 'react-router-dom/Link'; @@ -35,109 +36,100 @@ const lockIcons = { unlock: EditNoteIcon, }; -class FavouriteTags extends React.PureComponent { +const visibilityToIcon = (val) => visibilityIcons.find(icon => icon.key === val).icon; - static propTypes = { - intl: PropTypes.object.isRequired, - visible: PropTypes.bool.isRequired, - tags: ImmutablePropTypes.list.isRequired, - refreshFavouriteTags: PropTypes.func.isRequired, - onToggle: PropTypes.func.isRequired, - onLockTag: PropTypes.func.isRequired, - }; +const FavouriteTags = ({ visible, tags, refreshFavouriteTags, onToggle, onLockTag }) => { + const intl = useIntl(); + const [lockedTag, setLockedTag] = useState(ImmutableList()); + const [lockedVisibility, setLockedVisibility] = useState(ImmutableList()); + const isFirstUpdate = useRef(true); - state = { - lockedTag: ImmutableList(), - lockedVisibility: ImmutableList(), - }; + useEffect(() => { + refreshFavouriteTags(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - componentDidMount () { - this.props.refreshFavouriteTags(); - } - - UNSAFE_componentWillUpdate (nextProps, nextState) { - // タグ操作に変更があった場合 - if (!this.state.lockedTag.equals(nextState.lockedTag)) { - const icon = visibilityIcons.concat().reverse().find(icon => nextState.lockedVisibility.includes(icon.key)); - this.execLockTag( - nextState.lockedTag.join(' '), - typeof icon === 'undefined' ? '' : icon.key, - ); + // UNSAFE_componentWillUpdate相当: lockedTag変化時にonLockTagを呼ぶ + useEffect(() => { + if (isFirstUpdate.current) { + isFirstUpdate.current = false; + return; } - } - - execLockTag (tag, icon) { - this.props.onLockTag(tag, icon); - } + const icon = visibilityIcons.concat().reverse().find(i => lockedVisibility.includes(i.key)); + onLockTag( + lockedTag.join(' '), + typeof icon === 'undefined' ? '' : icon.key, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lockedTag]); - handleLockTag (tag, visibility) { + const handleLockTag = useCallback((tag, visibility) => { const tagName = `#${tag}`; - return ((e) => { + return (e) => { e.preventDefault(); - if (this.state.lockedTag.includes(tagName)) { - this.setState({ lockedTag: this.state.lockedTag.delete(this.state.lockedTag.indexOf(tagName)) }); - this.setState({ lockedVisibility: this.state.lockedVisibility.delete(this.state.lockedTag.indexOf(tagName)) }); + if (lockedTag.includes(tagName)) { + const idx = lockedTag.indexOf(tagName); + setLockedTag(lockedTag.delete(idx)); + setLockedVisibility(lockedVisibility.delete(idx)); } else { - this.setState({ lockedTag: this.state.lockedTag.push(tagName) }); - this.setState({ lockedVisibility: this.state.lockedVisibility.push(visibility) }); + setLockedTag(lockedTag.push(tagName)); + setLockedVisibility(lockedVisibility.push(visibility)); } - }).bind(this); - } - - visibilityToIcon (val) { - return visibilityIcons.find(icon => icon.key === val).icon; - } - - render () { - const { intl, visible, onToggle } = this.props; - - const lockIcon = (tag) => { - const isLocked = this.state.lockedTag.includes(`#${tag.get('name')}`); - const icon = isLocked ? lockIcons.lock : lockIcons.unlock; - return ; }; + }, [lockedTag, lockedVisibility]); - const tags = this.props.tags.map(tag => ( -
  • - - - {`#${tag.get('name')}`} - - -
  • - )); - - return ( -
    -
    -
    - - {intl.formatMessage(messages.favourite_tags)} -
    -
    - - - -
    - -
    + const lockIcon = (tag) => { + const isLocked = lockedTag.includes(`#${tag.get('name')}`); + const icon = isLocked ? lockIcons.lock : lockIcons.unlock; + return ; + }; + + const renderedTags = tags.map(tag => ( +
  • + + + {`#${tag.get('name')}`} + + +
  • + )); + + return ( +
    +
    +
    + + {intl.formatMessage(messages.favourite_tags)} +
    +
    + + + +
    +
    - -
      - {tags} -
    -
    - ); - } + +
      + {renderedTags} +
    +
    +
    + ); +}; -} +FavouriteTags.propTypes = { + visible: PropTypes.bool.isRequired, + tags: ImmutablePropTypes.list.isRequired, + refreshFavouriteTags: PropTypes.func.isRequired, + onToggle: PropTypes.func.isRequired, + onLockTag: PropTypes.func.isRequired, +}; -export default injectIntl(FavouriteTags); +export default memo(FavouriteTags); From 4665686a2ab6edd1052c2a942ec834b453fbfe58 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Sat, 9 May 2026 05:31:23 +0000 Subject: [PATCH 5/6] =?UTF-8?q?Foldable:=20JS=E9=AB=98=E3=81=95=E8=A8=88?= =?UTF-8?q?=E7=AE=97=E3=82=92=E6=92=A4=E5=BB=83=E3=81=97CSS=20Grid?= =?UTF-8?q?=E3=81=AEgrid-template-rows=E3=83=88=E3=83=AA=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=81=AB=E7=A7=BB=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 従来はfullHeight=tags.size*30のような近似値をJSで計算してinline styleで渡していたが、要素ごとの実高さに対応できず内容が増えた時に 正確に折りたたみ/展開できなかった。 CSS Gridのgrid-template-rows: 0fr↔1frをtransitionする手法に変更し、 中身の実高さに自動追従させる。Foldableのfullheight/minHeightプロップ を廃止しisVisibleのみで制御するシンプルなAPIにした。 flex設定を持たせず純粋なgrid containerにすることで、 親フレックス コンテナ内でも 1fr が中身分のサイズに正しく解決され、 親が高さ干渉 でshrinkすると Foldable も追従して shrink、foldable__inner の overflow-y: auto で内部スクロールに切り替わる。 --- .../mastodon/components/foldable.jsx | 24 ++++++------------- app/javascript/styles/application.scss | 1 + app/javascript/styles/imastodon/foldable.scss | 16 +++++++++++++ 3 files changed, 24 insertions(+), 17 deletions(-) create mode 100644 app/javascript/styles/imastodon/foldable.scss diff --git a/app/javascript/mastodon/components/foldable.jsx b/app/javascript/mastodon/components/foldable.jsx index b54fab0a197598..83ac0939fff749 100644 --- a/app/javascript/mastodon/components/foldable.jsx +++ b/app/javascript/mastodon/components/foldable.jsx @@ -1,27 +1,17 @@ +import classNames from 'classnames'; import PropTypes from 'prop-types'; -const Foldable = ({ fullHeight, minHeight, isVisible, children }) => { - const height = isVisible ? fullHeight : minHeight; - - return ( -
    +const Foldable = ({ isVisible, className, children }) => ( +
    +
    {children}
    - ); -}; +
    +); Foldable.propTypes = { - fullHeight: PropTypes.number.isRequired, - minHeight: PropTypes.number.isRequired, isVisible: PropTypes.bool.isRequired, + className: PropTypes.string, children: PropTypes.node.isRequired, }; diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss index e42f184b59eaae..ad9dc51f675d68 100644 --- a/app/javascript/styles/application.scss +++ b/app/javascript/styles/application.scss @@ -29,4 +29,5 @@ @use 'imastodon/logo'; @use 'imastodon/compose_common'; @use 'imastodon/favourite_tags'; +@use 'imastodon/foldable'; @use 'imastodon/statuses'; diff --git a/app/javascript/styles/imastodon/foldable.scss b/app/javascript/styles/imastodon/foldable.scss new file mode 100644 index 00000000000000..98e90e4f75a9d0 --- /dev/null +++ b/app/javascript/styles/imastodon/foldable.scss @@ -0,0 +1,16 @@ +.foldable { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + transition: grid-template-rows 300ms ease-in-out; + + &--visible { + grid-template-rows: 1fr; + } + + &__inner { + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + } +} From 0f391e983ab274bb935125ad5bea90ef660fd0cd Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki Date: Sat, 9 May 2026 05:31:37 +0000 Subject: [PATCH 6/6] =?UTF-8?q?compose-panel:=20=E3=81=8A=E6=B0=97?= =?UTF-8?q?=E3=81=AB=E5=85=A5=E3=82=8A=E3=82=BF=E3=82=B0=E3=82=92=E4=B8=8B?= =?UTF-8?q?=E8=A9=B0=E3=82=81+=E5=B9=B2=E6=B8=89=E6=99=82=E3=81=AE?= =?UTF-8?q?=E3=81=BFshrink=E3=81=99=E3=82=8B=E3=83=AC=E3=82=A4=E3=82=A2?= =?UTF-8?q?=E3=82=A6=E3=83=88=E3=81=AB=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compose-form: flex 0 0 auto に上書きし中身分のサイズで固定 (grow/shrink共に禁止)。compose-form__highlightable も flex-shrink: 0 で内部も縮ませない - compose__extra: margin-top: auto + flex 0 1 auto + min-height: 0 でサイドバー余白を上に集約してお気に入りタグ自身を下詰め配置。 flex-shrink: 1 により高さ干渉時はお気に入りタグだけがshrinkする これにより compose-form の表示領域は維持されたまま、お気に入り タグ一覧が中身全部入りきらない場合でも内部スクロールで確認できる。 --- .../styles/imastodon/compose_common.scss | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/javascript/styles/imastodon/compose_common.scss b/app/javascript/styles/imastodon/compose_common.scss index abb34c07aa0baa..ab966d910183ab 100644 --- a/app/javascript/styles/imastodon/compose_common.scss +++ b/app/javascript/styles/imastodon/compose_common.scss @@ -18,9 +18,28 @@ } } +// compose-form系は中身分のサイズで固定し、余ったスペースをお気に入りタグ +// (.compose__extra)の上にmargin-top:autoで集約することで下詰めを実現。 +// 高さが足りない時はお気に入りタグだけがshrinkして内部スクロールに切り替わる。 +.compose-panel { + .compose-form { + flex: 0 0 auto; + } + + .compose-form__highlightable { + flex-shrink: 0; + } +} + .compose__extra { background: variables.$ui-base-color; position: relative; + margin-top: auto; + flex: 0 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; } .compose__extra__header {