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`] = `
+
+const Foldable = ({ isVisible, className, 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/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)}
+
+
-
-
-
- );
- }
+
+
+
+
+ );
+};
-}
+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);
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 ?
- :
- }
- {
- unlistedId != 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 ?
+ :
+ }
+ {
+ unlistedId != 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);
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/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 {
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;
+ }
+}
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%);
}