From f0ea22f87514e0741cc311bd599e462183d92772 Mon Sep 17 00:00:00 2001 From: Yury Shevchenko Date: Thu, 6 Jan 2022 21:17:53 -0800 Subject: [PATCH 01/55] Take Save out of "more" menu --- src/components/post/post-more-link.jsx | 16 +--- src/components/post/post-more-menu.jsx | 39 +-------- src/components/post/post.jsx | 18 ++++ styles/shared/post.scss | 4 + .../post-more-menu.test.jsx.snap | 87 ------------------- test/jest/__snapshots__/post.test.js.snap | 10 +++ 6 files changed, 36 insertions(+), 138 deletions(-) diff --git a/src/components/post/post-more-link.jsx b/src/components/post/post-more-link.jsx index 10b94bc56..bee06eae8 100644 --- a/src/components/post/post-more-link.jsx +++ b/src/components/post/post-more-link.jsx @@ -9,7 +9,6 @@ import { useDropDownKbd } from '../hooks/drop-down-kbd'; import { useMediaQuery } from '../hooks/media-query'; import { useServerInfo } from '../hooks/server-info'; import { MoreWithTriangle } from '../more-with-triangle'; -import { TimedMessage } from '../timed-message'; import { ButtonLink } from '../button-link'; import { PostMoreMenu } from './post-more-menu'; @@ -34,19 +33,6 @@ export default function PostMoreLink({ post, user, ...props }) { const canonicalPostURI = canonicalURI(post); - const { isSaved, savePostStatus } = post; - - let label = 'More'; - if (savePostStatus.loading) { - label = isSaved ? 'Un-saving...' : 'Saving...'; - } - if (savePostStatus.success) { - label = More; - } - if (savePostStatus.error) { - label = More; - } - useEffect(() => { if (fixedMenu && opened) { // Fix scroll position @@ -71,7 +57,7 @@ export default function PostMoreLink({ post, user, ...props }) { aria-haspopup aria-expanded={opened} > - {label} + More {opened && ( diff --git a/src/components/post/post-more-menu.jsx b/src/components/post/post-more-menu.jsx index ba4caa437..a9323358a 100644 --- a/src/components/post/post-more-menu.jsx +++ b/src/components/post/post-more-menu.jsx @@ -1,19 +1,8 @@ import { forwardRef, useLayoutEffect, useState, useMemo, useCallback } from 'react'; import { Link } from 'react-router'; import cn from 'classnames'; -import { - faExclamationTriangle, - faLink, - faEdit, - faBookmark as faBookmarkSolid, - faSignOutAlt, -} from '@fortawesome/free-solid-svg-icons'; -import { - faClock, - faCommentDots, - faTrashAlt, - faBookmark, -} from '@fortawesome/free-regular-svg-icons'; +import { faLink, faEdit, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'; +import { faClock, faCommentDots, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; import { noop } from 'lodash'; import { useDispatch } from 'react-redux'; @@ -21,7 +10,6 @@ import { andJoin } from '../../utils/and-join'; import { copyURL } from '../../utils/copy-url'; import { leaveDirect } from '../../redux/action-creators'; import { ButtonLink } from '../button-link'; -import { Throbber } from '../throbber'; import { Icon } from '../fontawesome-icons'; import TimeDisplay from '../time-display'; @@ -40,8 +28,6 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu( commentsDisabled = false, createdAt, updatedAt, - isSaved = false, - savePostStatus = {}, createdBy: postCreatedBy, isDirect = false, }, @@ -53,7 +39,6 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu( perGroupDeleteEnabled = false, doAndClose, permalink, - toggleSave, fixed = false, }, ref, @@ -132,25 +117,7 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu( )), - [ - amIAuthenticated && ( -
- - - {isSaved ? 'Un-save' : 'Save'} post - {savePostStatus.loading && } - {savePostStatus.error && ( - - )} - - -
- ), - ], + [ isDirect && !isOwnPost && (
diff --git a/src/components/post/post.jsx b/src/components/post/post.jsx index 74680bbf5..10e5a9e0e 100644 --- a/src/components/post/post.jsx +++ b/src/components/post/post.jsx @@ -410,6 +410,23 @@ class Post extends Component { false ); + const { isSaved, savePostStatus } = this.props; + const saveLink = amIAuthenticated && ( + <> + + {isSaved ? 'Un-save' : 'Save'} + + {savePostStatus.loading && } + {savePostStatus.error && ( + + )} + + ); + // "More" menu const moreLink = ( {commentLink} {likeLink} + {saveLink} {props.hideEnabled && ( {this.renderHideLink()} diff --git a/styles/shared/post.scss b/styles/shared/post.scss index 5ef5ef856..d0f7db6f8 100644 --- a/styles/shared/post.scss +++ b/styles/shared/post.scss @@ -146,6 +146,10 @@ $post-line-height: rem(20px); margin-left: -1em; margin-right: 1em; max-width: 100%; + + @media (max-width: 500px) { + display: block; + } } .post-footer-item { diff --git a/test/jest/__snapshots__/post-more-menu.test.jsx.snap b/test/jest/__snapshots__/post-more-menu.test.jsx.snap index 088b3f0aa..abae6a94f 100644 --- a/test/jest/__snapshots__/post-more-menu.test.jsx.snap +++ b/test/jest/__snapshots__/post-more-menu.test.jsx.snap @@ -114,35 +114,6 @@ exports[`PostMoreMenu Renders a More menu for a group moderator 1`] = `
-
@@ -236,35 +207,6 @@ exports[`PostMoreMenu Renders a More menu for a logged-in reader 1`] = ` class="list focusList" style="min-width: 18em;" > -
@@ -534,35 +476,6 @@ exports[`PostMoreMenu Renders a More menu for a post owner 1`] = `
-
diff --git a/test/jest/__snapshots__/post.test.js.snap b/test/jest/__snapshots__/post.test.js.snap index aa1abd385..59701f98b 100644 --- a/test/jest/__snapshots__/post.test.js.snap +++ b/test/jest/__snapshots__/post.test.js.snap @@ -145,6 +145,16 @@ exports[`Post Renders a post and doesn't blow up 1`] = ` Like +
- -
- - - - sunrise.mp4 (117.7 MiB) - - -
+ sunrise.mp4 (117.7 MiB) + +
-
-
Date: Sun, 26 Feb 2023 17:08:45 +0300 Subject: [PATCH 08/55] Fix test snapshots --- test/jest/__snapshots__/list-editor.test.jsx.snap | 8 ++++++++ test/jest/__snapshots__/post.test.js.snap | 3 ++- test/jest/__snapshots__/subs-list.test.js.snap | 2 ++ .../__snapshots__/user-profile-head.test.js.snap | 12 ++++++++---- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/test/jest/__snapshots__/list-editor.test.jsx.snap b/test/jest/__snapshots__/list-editor.test.jsx.snap index 338352f44..ec5e99ff1 100644 --- a/test/jest/__snapshots__/list-editor.test.jsx.snap +++ b/test/jest/__snapshots__/list-editor.test.jsx.snap @@ -89,6 +89,7 @@ exports[`ListEditor Renders a list and doesn't blow up 1`] = ` >
Profile picture of group
Profile picture of homeless
Profile picture of in-list-1
Profile picture of in-list-2
Profile picture of not-in-list
Profile picture of in-list-1
Profile picture of in-list-2
Profile picture of not-in-list Profile picture of author Profile picture of user1 Profile picture of user2 Profile picture of freefeed
+_+
@@ -250,7 +252,8 @@ exports[`UserProfileHead Renders my own header and doesn't blow up 1`] = ` class="avatar" >
Profile picture of freefeed
...
From ce86349843319249daeb291f3c566bb60e5fc10b Mon Sep 17 00:00:00 2001 From: Ivan Dyachkov Date: Mon, 27 Mar 2023 20:49:26 +0200 Subject: [PATCH 09/55] ci: push images to ghcr.io --- .github/workflows/docker.yml | 55 +++++++++++++++++++++++++---------- .github/workflows/preview.yml | 2 +- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ce680d607..5b73b6cfd 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -5,6 +5,16 @@ on: - beta tags: - '**' + workflow_dispatch: + inputs: + ref: + required: false + push: + requried: false + default: true + deploy: + required: false + default: true name: Build and push docker image @@ -14,48 +24,63 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v1 - - - name: Build and push docker image - uses: docker/build-push-action@v1 + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.inputs.ref }} # when input is not given, the event tag is used + - uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - uses: docker/setup-buildx-action@v2 + - uses: docker/setup-qemu-action@v2 + - uses: docker/metadata-action@v4 + id: meta with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - repository: freefeed/freefeed-react-client - tag_with_ref: true + images: ghcr.io/freefeed/freefeed-react-client + tags: | + type=ref,event=branch + type=ref,event=pr + type=match,pattern=freefeed_release_(.*),group=1 + - uses: docker/build-push-action@v4 + with: + push: ${{ github.event.inputs.push == 'true' }} + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} deploy: needs: build name: Trigger deploy runs-on: ubuntu-latest + if: github.event.inputs.deploy == 'true' steps: - name: Deploy stable if: github.ref == 'refs/heads/stable' - uses: satak/webrequest-action@v1.2.3 + uses: satak/webrequest-action@v1.2.4 with: url: "https://webhook.freefeed.net/${{ secrets.WEBHOOK_SECRET }}/react-client/stable?version=stable" method: GET - name: Get release version if: startsWith(github.ref, 'refs/tags/') - shell: bash - run: echo "::set-output name=tag::${GITHUB_REF#refs/tags/}" id: version + shell: bash + run: echo "version=${GITHUB_REF#refs/tags/freefeed_release_}" >> $GITHUB_OUTPUT env: GITHUB_REF: ${{ github.ref }} - name: Deploy release if: startsWith(github.ref, 'refs/tags/') - uses: satak/webrequest-action@v1.2.3 + uses: satak/webrequest-action@v1.2.4 with: - url: "https://webhook.freefeed.net/${{ secrets.WEBHOOK_SECRET }}/react-client/release?version=${{ steps.version.outputs.tag }}" + url: "https://webhook.freefeed.net/${{ secrets.WEBHOOK_SECRET }}/react-client/release?version=${{ steps.version.outputs.version }}" method: GET - name: Deploy beta if: github.ref == 'refs/heads/beta' - uses: satak/webrequest-action@v1.2.3 + uses: satak/webrequest-action@v1.2.4 with: url: "https://webhook.freefeed.net/${{ secrets.WEBHOOK_SECRET }}/react-client/beta?version=beta" method: GET diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 5194d6f90..3b11685d0 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -8,7 +8,7 @@ jobs: preview: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: afc163/surge-preview@v1 id: preview_step with: From bcd2bfa6f9be5a8b9948bdf9ad1e7c783c40a807 Mon Sep 17 00:00:00 2001 From: Ivan Dyachkov Date: Tue, 28 Mar 2023 17:42:56 +0200 Subject: [PATCH 10/55] ci: explicitly specify context for docker buildx --- .github/workflows/docker.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5b73b6cfd..e3c12a9f2 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -44,6 +44,7 @@ jobs: type=match,pattern=freefeed_release_(.*),group=1 - uses: docker/build-push-action@v4 with: + context: . push: ${{ github.event.inputs.push == 'true' }} platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} From 3012f137993694bab29a885506b68a480452f5dc Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 31 Mar 2023 13:20:24 +0300 Subject: [PATCH 11/55] Update test snapshot --- .../post-more-menu.test.jsx.snap | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/test/jest/__snapshots__/post-more-menu.test.jsx.snap b/test/jest/__snapshots__/post-more-menu.test.jsx.snap index 72065f2cd..d08cc9313 100644 --- a/test/jest/__snapshots__/post-more-menu.test.jsx.snap +++ b/test/jest/__snapshots__/post-more-menu.test.jsx.snap @@ -265,35 +265,6 @@ exports[`PostMoreMenu Renders a More menu for a logged-in reader 1`] = `
-
From 00e7c004e619cbac5f2ae3537d0646c74c27f1fd Mon Sep 17 00:00:00 2001 From: Ivan Dyachkov Date: Sun, 2 Apr 2023 10:26:02 +0200 Subject: [PATCH 12/55] ci: fix docker workflow --- .github/workflows/docker.yml | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e3c12a9f2..16b974e5e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -5,16 +5,6 @@ on: - beta tags: - '**' - workflow_dispatch: - inputs: - ref: - required: false - push: - requried: false - default: true - deploy: - required: false - default: true name: Build and push docker image @@ -25,15 +15,12 @@ jobs: steps: - uses: actions/checkout@v3 - with: - ref: ${{ github.event.inputs.ref }} # when input is not given, the event tag is used - uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - uses: docker/setup-buildx-action@v2 - - uses: docker/setup-qemu-action@v2 - uses: docker/metadata-action@v4 id: meta with: @@ -45,8 +32,7 @@ jobs: - uses: docker/build-push-action@v4 with: context: . - push: ${{ github.event.inputs.push == 'true' }} - platforms: linux/amd64,linux/arm64 + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -54,16 +40,8 @@ jobs: needs: build name: Trigger deploy runs-on: ubuntu-latest - if: github.event.inputs.deploy == 'true' steps: - - name: Deploy stable - if: github.ref == 'refs/heads/stable' - uses: satak/webrequest-action@v1.2.4 - with: - url: "https://webhook.freefeed.net/${{ secrets.WEBHOOK_SECRET }}/react-client/stable?version=stable" - method: GET - - name: Get release version if: startsWith(github.ref, 'refs/tags/') id: version @@ -79,10 +57,16 @@ jobs: url: "https://webhook.freefeed.net/${{ secrets.WEBHOOK_SECRET }}/react-client/release?version=${{ steps.version.outputs.version }}" method: GET + - name: Deploy stable + if: github.ref == 'refs/heads/stable' + uses: satak/webrequest-action@v1.2.4 + with: + url: "https://webhook.freefeed.net/${{ secrets.WEBHOOK_SECRET }}/react-client/stable?version=stable" + method: GET + - name: Deploy beta if: github.ref == 'refs/heads/beta' uses: satak/webrequest-action@v1.2.4 with: url: "https://webhook.freefeed.net/${{ secrets.WEBHOOK_SECRET }}/react-client/beta?version=beta" method: GET - From 4be8d80bc3e21cd1a120913213031a100556ee8c Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 7 Apr 2023 15:08:36 +0300 Subject: [PATCH 13/55] Use new feed selector in post create form --- package.json | 2 +- src/components/create-post.jsx | 58 ++-- src/components/feeds-selector/constants.js | 21 ++ src/components/feeds-selector/error.jsx | 130 +++++++++ src/components/feeds-selector/options.jsx | 184 +++++++++++++ src/components/feeds-selector/selector.jsx | 193 +++++++++++++ .../feeds-selector/selector.module.scss | 87 ++++++ src/components/separated.jsx | 16 +- test/unit/components/separated.jsx | 45 ++++ yarn.lock | 253 +++++++++++++++--- 10 files changed, 922 insertions(+), 67 deletions(-) create mode 100644 src/components/feeds-selector/constants.js create mode 100644 src/components/feeds-selector/error.jsx create mode 100644 src/components/feeds-selector/options.jsx create mode 100644 src/components/feeds-selector/selector.jsx create mode 100644 src/components/feeds-selector/selector.module.scss create mode 100644 test/unit/components/separated.jsx diff --git a/package.json b/package.json index dddc3e4df..87f74f164 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "react-redux": "~7.2.9", "react-router": "~3.2.6", "react-router-redux": "~4.0.8", - "react-select": "~1.2.1", + "react-select": "~5.7.2", "react-sortablejs": "~2.0.11", "react-textarea-autosize": "~8.3.4", "recharts": "~2.1.16", diff --git a/src/components/create-post.jsx b/src/components/create-post.jsx index a4f151dc8..6813b7da0 100644 --- a/src/components/create-post.jsx +++ b/src/components/create-post.jsx @@ -6,7 +6,6 @@ import { ButtonLink } from './button-link'; import ErrorBoundary from './error-boundary'; import { Icon } from './fontawesome-icons'; import { MoreWithTriangle } from './more-with-triangle'; -import SendTo from './send-to'; import { SmartTextarea } from './smart-textarea'; import { SubmitModeHint } from './submit-mode-hint'; import { Throbber } from './throbber'; @@ -17,11 +16,13 @@ import { PreventPageLeaving } from './prevent-page-leaving'; import PostAttachments from './post/post-attachments'; import { useBool } from './hooks/bool'; import { useServerValue } from './hooks/server-info'; +import { Selector } from './feeds-selector/selector'; +import { CREATE_DIRECT, CREATE_REGULAR } from './feeds-selector/constants'; const selectMaxFilesCount = (serverInfo) => serverInfo.attachments.maxCountPerPost; const selectMaxPostLength = (serverInfo) => serverInfo.maxTextLength.post; -export default function CreatePost({ sendTo, expandSendTo, user, isDirects }) { +export default function CreatePost({ sendTo, expandSendTo, isDirects }) { const dispatch = useDispatch(); const createPostStatus = useSelector((state) => state.createPostStatus); @@ -34,8 +35,19 @@ export default function CreatePost({ sendTo, expandSendTo, user, isDirects }) { const [commentsDisabled, toggleCommentsDisabled] = useBool(false); const [isMoreOpen, toggleIsMoreOpen] = useBool(false); const [postText, setPostText] = useState(sendTo.invitation || ''); - const [feedsSelector, setFeedsSelector] = useState(null); - const [feeds, setFeeds] = useState([]); + + const defaultFeedNames = useMemo(() => { + if (Array.isArray(sendTo.defaultFeed)) { + return sendTo.defaultFeed; + } else if (sendTo.defaultFeed) { + return [sendTo.defaultFeed]; + } + return []; + }, [sendTo.defaultFeed]); + + const [feeds, setFeeds] = useState(defaultFeedNames); + + useEffect(() => setFeeds(defaultFeedNames), [defaultFeedNames, sendTo.expanded]); const resetLocalState = useCallback(() => { toggleCommentsDisabled(false); @@ -73,14 +85,11 @@ export default function CreatePost({ sendTo, expandSendTo, user, isDirects }) { [fileIds.length, isUploading, postText], ); + const [hasFeedsError, setHasFeedsError] = useState(false); + const canSubmitForm = useMemo(() => { - return ( - isFormDirty && - !createPostStatus.loading && - feeds.length > 0 && - !feedsSelector?.isIncorrectDestinations - ); - }, [createPostStatus.loading, feeds.length, feedsSelector?.isIncorrectDestinations, isFormDirty]); + return isFormDirty && !createPostStatus.loading && feeds.length > 0 && !hasFeedsError; + }, [createPostStatus.loading, feeds.length, hasFeedsError, isFormDirty]); const doCreatePost = useCallback( (e) => { @@ -98,33 +107,26 @@ export default function CreatePost({ sendTo, expandSendTo, user, isDirects }) { useEffect(() => { // Reset form on success if (createPostStatus.success) { - feedsSelector?.reset(); textareaRef.current?.blur(); clearUploads(); resetLocalState(); + setFeeds(defaultFeedNames); dispatch(resetPostCreateForm()); } - }, [clearUploads, createPostStatus.success, dispatch, feedsSelector, resetLocalState]); + }, [clearUploads, createPostStatus.success, defaultFeedNames, dispatch, resetLocalState]); // Reset async status on unmount useEffect(() => () => dispatch(resetPostCreateForm()), [dispatch]); - const registerFeedSelector = useCallback((ref) => { - setFeedsSelector(ref); - if (ref) { - setFeeds(ref.values.slice()); - } else { - setFeeds([]); - } - }, []); - const containerRef = useRef(); + /* useEffect(() => { - const h = () => import('react-select'); + const h = () => import('react-select/creatable'); const el = containerRef.current; el.addEventListener('click', h, { once: true }); return () => el.removeEventListener('click', h, { once: true }); }, []); + */ return (
{sendTo.expanded && ( - )} - [ + values.some((a) => a.type === ACC_ME), + values.filter((a) => a.type === ACC_NOT_FOUND), + values.filter((a) => a.type === ACC_BAD_USER), + values.filter((a) => a.type === ACC_BAD_GROUP), + values.filter((a) => a.type === ACC_BAD_USER || a.type === ACC_USER), + values.filter((a) => a.type === ACC_BAD_GROUP || a.type === ACC_GROUP), + ], + [values], + ); + + const errors = []; + let stillError = false; + if (missing.length === 1) { + errors.push( + + Account @{missing[0].value} doesn’t exist. + , + ); + } else if (missing.length > 1) { + errors.push( + + Accounts{' '} + + {missing.map((a) => ( + @{a.value} + ))} + {' '} + don’t exist. + , + ); + } + + if (isDirect && hasMyFeed) { + errors.push(You can’t send direct message to yourself.); + } + + if (isDirect && allGroups.length > 0) { + errors.push( + + You can’t send direct message to groups ( + + {badGroups.map((a) => ( + {a.label} + ))} + + ). + , + ); + } + + if (isDirect && badUsers.length > 0) { + errors.push( + + You can’t send direct messages to{' '} + + {badUsers.map((a) => ( + @{a.value} + ))} + + . + , + ); + } + + if (!isDirect && badGroups.length > 0) { + errors.push( + + You are not a member of the{' '} + + {badGroups.map((a) => ( + {a.label} + ))} + {' '} + {pluralForm(badGroups.length, 'group')}. + , + ); + } + + if (!isDirect && allUsers.length > 0) { + errors.push( + + You can’t create regular post with a direct receivers ( + + {allUsers.map((a) => ( + {a.label} + ))} + + ). + , + ); + } + + if (!isDirect && values.length === 0) { + errors.push(Please select at least one destination feed.); + } + + if (isDirect && values.length === 0 && !isEditing) { + // Don't display error message but still treat it as an error + stillError = true; + } + + useEffect(() => onError?.(errors.length > 0 || stillError), [errors.length, stillError, onError]); + + if (errors.length === 0) { + return null; + } + + return ( +
+ {errors} +
+ ); +} diff --git a/src/components/feeds-selector/options.jsx b/src/components/feeds-selector/options.jsx new file mode 100644 index 000000000..fef8d5f97 --- /dev/null +++ b/src/components/feeds-selector/options.jsx @@ -0,0 +1,184 @@ +import { + faHome, + faQuestion, + faSpinner, + faUser, + faUsers, + faUserSlash, + faUsersSlash, +} from '@fortawesome/free-solid-svg-icons'; +import { uniq } from 'lodash'; +import { useEffect, useMemo } from 'react'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; +import { Icon } from '../fontawesome-icons'; + +// Local styles +import { getUserInfo } from '../../redux/action-creators'; +import styles from './selector.module.scss'; +import { + ACC_ME, + ACC_GROUP, + ACC_USER, + ACC_BAD_GROUP, + ACC_BAD_USER, + ACC_UNKNOWN, + ACC_NOT_FOUND, + MY_FEED_LABEL, +} from './constants'; + +const typeIcon = { + [ACC_ME]: faHome, + [ACC_GROUP]: faUsers, + [ACC_USER]: faUser, + [ACC_BAD_GROUP]: faUsersSlash, + [ACC_BAD_USER]: faUserSlash, + [ACC_UNKNOWN]: faSpinner, + [ACC_NOT_FOUND]: faQuestion, +}; + +const typeOrder = { + [ACC_ME]: 1, + [ACC_GROUP]: 2, + [ACC_USER]: 3, + [ACC_BAD_GROUP]: 4, + [ACC_BAD_USER]: 5, + [ACC_UNKNOWN]: 6, + [ACC_NOT_FOUND]: 7, +}; + +export function DisplayOption({ option, className }) { + return ( + + {option.type && } + {option.label} + + ); +} + +/** + * @param {string[]} usernames + * @param {string[]} fixedFeedNames + */ +export function useSelectedOptions(usernames, fixedFeedNames) { + const dispatch = useDispatch(); + const userInfoStatuses = useSelector((store) => store.getUserInfoStatuses); + + usernames = useMemo(() => usernames.map((u) => u.toLowerCase()), [usernames]); + + const me = useSelector((store) => store.user); + const mySubscriptions = useSelector( + (store) => store.user.subscriptions.map((id) => store.users[id]), + shallowEqual, + ); + + const notFoundUsers = useSelector( + (store) => usernames.filter((name) => store.usersNotFound.includes(name)), + shallowEqual, + ); + + const userObjects = useSelector( + (store) => Object.values(store.users).filter((u) => usernames.includes(u.username)), + shallowEqual, + ); + + const groupOptions = useMemo( + () => + mySubscriptions + .filter((u) => u?.type === 'group' && u.youCan.includes('post')) + .map((u) => toOption(u, me)) + .sort(compareOptions), + [me, mySubscriptions], + ); + + const userOptions = useMemo( + () => + mySubscriptions + .filter((u) => u?.type === 'user' && u.youCan.includes('dm')) + .map((u) => toOption(u, me)) + .sort(compareOptions), + [me, mySubscriptions], + ); + + const usersByName = useMemo( + () => new Map(userObjects.map((u) => [u.username, u])), + [userObjects], + ); + + const values = useMemo( + () => + uniq(usernames) + .map((name) => { + const props = { + label: name, + value: name, + type: ACC_UNKNOWN, + isFixed: fixedFeedNames.includes(name), + }; + + if (name === me.username) { + // It's me! + return { ...props, label: MY_FEED_LABEL, type: ACC_ME }; + } + + const acc = usersByName.get(name); + if (acc?.type === 'group') { + return { + ...props, + label: acc.screenName, + type: acc.youCan.includes('post') ? ACC_GROUP : ACC_BAD_GROUP, + }; + } else if (acc?.type === 'user') { + return { + ...props, + type: acc.youCan.includes('dm') ? ACC_USER : ACC_BAD_USER, + }; + } else if (!acc && notFoundUsers.includes(name)) { + // Account not found + return { ...props, type: ACC_NOT_FOUND }; + } + + // We are here, so we don't know anything about this account. We need to + // request user's info. + return props; + }) + .sort(compareOptions), + [fixedFeedNames, me.username, usernames, usersByName, notFoundUsers], + ); + + // Load missing accounts + useEffect(() => { + values + .filter((v) => v.type === ACC_UNKNOWN) + .map((v) => v.value) + .filter((username) => !userInfoStatuses[username]) + .forEach((username) => dispatch(getUserInfo(username))); + }, [dispatch, userInfoStatuses, values]); + + const meOption = useMemo(() => toOption(me, me), [me]); + + return { values, meOption, groupOptions, userOptions }; +} + +const collator = new Intl.Collator(undefined, { sensitivity: 'base', ignorePunctuation: true }); +function compareOptions(a, b) { + if (a.isFixed !== b.isFixed) { + return a.isFixed ? -1 : 1; + } + if (a.type !== b.type) { + return typeOrder[a.type] - typeOrder[b.type]; + } + return collator.compare(a.label, b.label); +} + +function toOption(user, me) { + const isMe = user.username === me.username; + return { + label: isMe + ? MY_FEED_LABEL + : user.type === 'group' + ? user.screenName || user.username + : user.username, + value: user.username, + type: isMe ? ACC_ME : user.type === 'group' ? ACC_GROUP : ACC_USER, + }; +} diff --git a/src/components/feeds-selector/selector.jsx b/src/components/feeds-selector/selector.jsx new file mode 100644 index 000000000..432bebc2e --- /dev/null +++ b/src/components/feeds-selector/selector.jsx @@ -0,0 +1,193 @@ +import cn from 'classnames'; +import { useCallback, useMemo } from 'react'; +import CreatableSelect from 'react-select/creatable'; +import { ButtonLink } from '../button-link'; +import { useBool } from '../hooks/bool'; +import { DisplayOption, useSelectedOptions } from './options'; + +// Local styles +import { + ACC_GROUP, + ACC_USER, + CREATE_DIRECT, + EDIT_DIRECT, + isEditing, + EDIT_REGULAR, + ACC_BAD_GROUP, + ACC_BAD_USER, +} from './constants'; +import styles from './selector.module.scss'; +import { SelectorError } from './error'; + +export function Selector({ className, mode, feedNames, fixedFeedNames = [], onChange, onError }) { + const { values, meOption, groupOptions, userOptions } = useSelectedOptions( + feedNames, + fixedFeedNames, + ); + + const isDirect = useMemo(() => { + if (mode === EDIT_REGULAR || mode === EDIT_DIRECT) { + return mode === EDIT_DIRECT; + } + const hasGroup = values.some((v) => v.type === ACC_GROUP || v.type === ACC_BAD_GROUP); + const hasUser = values.some((v) => v.type === ACC_USER || v.type === ACC_BAD_USER); + + if (hasGroup) { + return false; + } else if (hasUser) { + return true; + } + + return null; + }, [mode, values]); + + const options = useMemo(() => { + const directOptions = [ + { + label: 'Direct recipients', + options: userOptions, + }, + ]; + const regularOptions = [ + meOption, + { + label: 'Groups', + options: groupOptions, + }, + ]; + + if (isDirect === true) { + return directOptions; + } else if (isDirect === false) { + return regularOptions; + } + + if (mode === CREATE_DIRECT) { + return [...directOptions, ...regularOptions]; + } + return [...regularOptions, ...directOptions]; + }, [groupOptions, isDirect, meOption, mode, userOptions]); + + const [showStatic, toggleSelector] = useBool(values.length > 0); + + const handleChange = useCallback( + (opts, action) => { + if (action.removedValue?.isFixed) { + return; + } + onChange(opts.map((o) => o.value)); + }, + [onChange], + ); + + return ( +
+
+ {showStatic ? ( + <> + To: + {values.map((opt) => ( + + ))} + + Add/Edit + + + ) : ( + + )} +
+ +
+ ); +} + +const selStyles = { + control: (base, state) => ({ + ...base, + boxShadow: 'none', + '&:hover': { borderColor: 'var(--selector-color-neutral60)' }, + borderColor: state.isFocused ? 'var(--selector-color-neutral60)' : base.borderColor, + }), + multiValueLabel: (base, state) => { + const s = { ...base, color: 'currentColor' }; + return state.data.isFixed ? { ...s, paddingRight: 6 } : s; + }, + multiValueRemove: (base, state) => { + const s = { + ...base, + borderLeft: '1px solid var(--selector-color-primary50)', + marginLeft: '4px', + }; + return state.data.isFixed ? { ...s, display: 'none' } : s; + }, + indicatorSeparator: (base) => ({ ...base, display: 'none' }), + multiValue: (base) => ({ + ...base, + backgroundColor: 'var(--selector-color-primary25)', + color: 'var(--selector-color-value)', + borderRadius: '2px', + border: '1px solid var(--selector-color-primary50)', + }), +}; + +// Only valid usernames are allowed +function isValidNewOption(label) { + return /^[a-z\d]{3,25}$/i.test(label.trim()); +} + +function formatCreateLabel(label) { + return `Send direct message to @${label}`; +} + +function formatOptionLabel(option) { + return ; +} + +const selTheme = (theme) => ({ + ...theme, + borderRadius: 0, + colors: { + danger: 'var(--selector-color-danger)', + dangerLight: 'var(--selector-color-danger-light)', + neutral0: 'var(--selector-color-neutral0)', + neutral5: 'var(--selector-color-neutral5)', + neutral10: 'var(--selector-color-neutral10)', + neutral20: 'var(--selector-color-neutral20)', + neutral30: 'var(--selector-color-neutral30)', + neutral40: 'var(--selector-color-neutral40)', + neutral50: 'var(--selector-color-neutral50)', + neutral60: 'var(--selector-color-neutral60)', + neutral70: 'var(--selector-color-neutral70)', + neutral80: 'var(--selector-color-neutral80)', + neutral90: 'var(--selector-color-neutral90)', + primary: 'var(--selector-color-primary)', + primary25: 'var(--selector-color-primary25)', + primary50: 'var(--selector-color-primary50)', + primary75: 'var(--selector-color-primary75)', + }, +}); diff --git a/src/components/feeds-selector/selector.module.scss b/src/components/feeds-selector/selector.module.scss new file mode 100644 index 000000000..9481a9f4a --- /dev/null +++ b/src/components/feeds-selector/selector.module.scss @@ -0,0 +1,87 @@ +@import '../../../styles/helvetica/dark-vars.scss'; + +.container { + --selector-color-danger: #de350b; + --selector-color-danger-light: #ffbdad; + --selector-color-neutral0: hsl(0deg, 0%, 100%); + --selector-color-neutral5: hsl(0deg, 0%, 95%); + --selector-color-neutral10: hsl(0deg, 0%, 90%); + --selector-color-neutral20: hsl(0deg, 0%, 80%); + --selector-color-neutral30: hsl(0deg, 0%, 70%); + --selector-color-neutral40: hsl(0deg, 0%, 60%); + --selector-color-neutral50: hsl(0deg, 0%, 50%); + --selector-color-neutral60: hsl(0deg, 0%, 40%); + --selector-color-neutral70: hsl(0deg, 0%, 30%); + --selector-color-neutral80: hsl(0deg, 0%, 20%); + --selector-color-neutral90: hsl(0deg, 0%, 10%); + --selector-color-primary: hsl(210deg, 100%, 57%); // was: #2684ff; + --selector-color-primary75: hsl(210deg, 100%, 65%); // was: #4c9aff; + --selector-color-primary50: hsl(210deg, 100%, 85%); // was: #b2d4ff; + --selector-color-primary25: hsl(210deg, 100%, 96%); // was: #deebff; + --selector-color-value: hsl(240deg, 30%, 50%); + + :global(.dark-theme) & { + --selector-color-danger: #de350b; + --selector-color-danger-light: #ffbdad; + --selector-color-neutral0: #{mix($bg-color-lighter, #fff, 100%)}; + --selector-color-neutral5: #{mix($bg-color-lighter, #fff, 95%)}; + --selector-color-neutral10: #{mix($bg-color-lighter, #fff, 90%)}; + --selector-color-neutral20: #{mix($bg-color-lighter, #fff, 80%)}; + --selector-color-neutral30: #{mix($bg-color-lighter, #fff, 70%)}; + --selector-color-neutral40: #{mix($bg-color-lighter, #fff, 60%)}; + --selector-color-neutral50: #{mix($bg-color-lighter, #fff, 50%)}; + --selector-color-neutral60: #{mix($bg-color-lighter, #fff, 40%)}; + --selector-color-neutral70: #{mix($bg-color-lighter, #fff, 30%)}; + --selector-color-neutral80: #{mix($bg-color-lighter, #fff, 20%)}; + --selector-color-neutral90: #{mix($bg-color-lighter, #fff, 10%)}; + --selector-color-primary: #{mix($accent-color, $bg-color-lighter, 100%)}; + --selector-color-primary75: #{mix($accent-color, $bg-color-lighter, 80%)}; + --selector-color-primary50: #{mix($accent-color, $bg-color-lighter, 50%)}; + --selector-color-primary25: #{mix($accent-color, $bg-color-lighter, 20%)}; + --selector-color-value: #{mix($text-color-lighter, $accent-color, 70%)}; + } +} + +.selector { + flex: 1; +} + +.box { + display: flex; + align-items: center; + margin-bottom: 6px; +} + +.box-item { + flex: none; + + &:not(:first-child) { + margin-left: 0.4em; + } +} + +.dest-item { + background-color: rgba(0, 126, 255, 0.08); + border-radius: 2px; + border: 1px solid rgba(0, 126, 255, 0.24); + color: #555599; + display: inline-flex; + height: 24px; + font-size: 0.9em; + line-height: 1; + align-items: center; + padding: 0.2em 0.4em; +} + +.dest-icon { + opacity: 0.8; + margin-right: 0.25em; +} + +:global(.fa-icon-fas-user).dest-icon { + transform: scale(0.8); +} + +.alert { + margin-bottom: 6px; +} diff --git a/src/components/separated.jsx b/src/components/separated.jsx index fff72dd27..f5749f482 100644 --- a/src/components/separated.jsx +++ b/src/components/separated.jsx @@ -3,14 +3,14 @@ import { Fragment, Children } from 'react'; /** * Inserts separator between the renderable children */ -export function Separated({ separator, children }) { +export function Separated({ separator, lastSeparator = separator, children }) { return ( <> {Children.toArray(children) .filter(isRenderable) - .map((child, i) => ( - - {i > 0 && separator} + .map((child, i, arr) => ( + + {i > 0 && (i === arr.length - 1 ? lastSeparator : separator)} {child} ))} @@ -18,6 +18,14 @@ export function Separated({ separator, children }) { ); } +export function CommaAndSeparated({ children }) { + return ( + + {children} + + ); +} + function isRenderable(child) { return typeof child !== 'boolean' && typeof child !== 'undefined' && child !== null; } diff --git a/test/unit/components/separated.jsx b/test/unit/components/separated.jsx new file mode 100644 index 000000000..d9db685e9 --- /dev/null +++ b/test/unit/components/separated.jsx @@ -0,0 +1,45 @@ +import { describe, it } from 'mocha'; +import { renderToStaticMarkup } from 'react-dom/server'; +import unexpected from 'unexpected'; +import unexpectedReact from 'unexpected-react'; +import { Separated } from '../../../src/components/separated'; + +const expect = unexpected.clone().use(unexpectedReact); + +describe('', () => { + const testData = [ + { + children: ['abc'], + separator: ' ', + result: <>abc, + }, + { + children: ['abc', 42], + separator: ', ', + result: ( + <> + <>abc + <>, 42 + + ), + }, + { + children: ['abc', 42, false, 'def'], + separator: ', ', + lastSeparator: ' and ', + result: ( + <> + <>abc + <>, 42 + <> and def + + ), + }, + ]; + + for (const { children, separator, lastSeparator, result } of testData) { + it(`should format "${renderToStaticMarkup(result)}"`, () => { + expect(, 'to render as', result); + }); + } +}); diff --git a/yarn.lock b/yarn.lock index 4534b5022..7d484d3d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -244,7 +244,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-imports@npm:^7.0.0-beta.49, @babel/helper-module-imports@npm:^7.18.6": +"@babel/helper-module-imports@npm:^7.0.0-beta.49, @babel/helper-module-imports@npm:^7.16.7, @babel/helper-module-imports@npm:^7.18.6": version: 7.21.4 resolution: "@babel/helper-module-imports@npm:7.21.4" dependencies: @@ -1477,7 +1477,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.0, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.0, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.21.0 resolution: "@babel/runtime@npm:7.21.0" dependencies: @@ -1558,6 +1558,123 @@ __metadata: languageName: node linkType: hard +"@emotion/babel-plugin@npm:^11.10.6": + version: 11.10.6 + resolution: "@emotion/babel-plugin@npm:11.10.6" + dependencies: + "@babel/helper-module-imports": ^7.16.7 + "@babel/runtime": ^7.18.3 + "@emotion/hash": ^0.9.0 + "@emotion/memoize": ^0.8.0 + "@emotion/serialize": ^1.1.1 + babel-plugin-macros: ^3.1.0 + convert-source-map: ^1.5.0 + escape-string-regexp: ^4.0.0 + find-root: ^1.1.0 + source-map: ^0.5.7 + stylis: 4.1.3 + checksum: 3eed138932e8edf2598352e69ad949b9db3051a4d6fcff190dacbac9aa838d7ef708b9f3e6c48660625d9311dae82d73477ae4e7a31139feef5eb001a5528421 + languageName: node + linkType: hard + +"@emotion/cache@npm:^11.10.5, @emotion/cache@npm:^11.4.0": + version: 11.10.5 + resolution: "@emotion/cache@npm:11.10.5" + dependencies: + "@emotion/memoize": ^0.8.0 + "@emotion/sheet": ^1.2.1 + "@emotion/utils": ^1.2.0 + "@emotion/weak-memoize": ^0.3.0 + stylis: 4.1.3 + checksum: 1dd2d9af2d3ecbd3d4469ecdf91a335eef6034c851b57a474471b2d2280613eb35bbed98c0368cc4625f188619fbdaf04cf07e8107aaffce94b2178444c0fe7b + languageName: node + linkType: hard + +"@emotion/hash@npm:^0.9.0": + version: 0.9.0 + resolution: "@emotion/hash@npm:0.9.0" + checksum: b63428f7c8186607acdca5d003700cecf0ded519d0b5c5cc3b3154eafcad6ff433f8361bd2bac8882715b557e6f06945694aeb6ba8b25c6095d7a88570e2e0bb + languageName: node + linkType: hard + +"@emotion/memoize@npm:^0.8.0": + version: 0.8.0 + resolution: "@emotion/memoize@npm:0.8.0" + checksum: c87bb110b829edd8e1c13b90a6bc37cebc39af29c7599a1e66a48e06f9bec43e8e53495ba86278cc52e7589549492c8dfdc81d19f4fdec0cee6ba13d2ad2c928 + languageName: node + linkType: hard + +"@emotion/react@npm:^11.8.1": + version: 11.10.6 + resolution: "@emotion/react@npm:11.10.6" + dependencies: + "@babel/runtime": ^7.18.3 + "@emotion/babel-plugin": ^11.10.6 + "@emotion/cache": ^11.10.5 + "@emotion/serialize": ^1.1.1 + "@emotion/use-insertion-effect-with-fallbacks": ^1.0.0 + "@emotion/utils": ^1.2.0 + "@emotion/weak-memoize": ^0.3.0 + hoist-non-react-statics: ^3.3.1 + peerDependencies: + react: ">=16.8.0" + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 4762042e39126ffaffe76052dc65c9bb0ba6b8893013687ba3cc13ed4dd834c31597f1230684c3c078e90aecc13ab6cd0e3cde0dec8b7761affd2571f4d80019 + languageName: node + linkType: hard + +"@emotion/serialize@npm:^1.1.1": + version: 1.1.1 + resolution: "@emotion/serialize@npm:1.1.1" + dependencies: + "@emotion/hash": ^0.9.0 + "@emotion/memoize": ^0.8.0 + "@emotion/unitless": ^0.8.0 + "@emotion/utils": ^1.2.0 + csstype: ^3.0.2 + checksum: 24cfd5b16e6f2335c032ca33804a876e0442aaf8f9c94d269d23735ebd194fb1ed142542dd92191a3e6ef8bad5bd560dfc5aaf363a1b70954726dbd4dd93085c + languageName: node + linkType: hard + +"@emotion/sheet@npm:^1.2.1": + version: 1.2.1 + resolution: "@emotion/sheet@npm:1.2.1" + checksum: ce78763588ea522438156344d9f592203e2da582d8d67b32e1b0b98eaba26994c6c270f8c7ad46442fc9c0a9f048685d819cd73ca87e544520fd06f0e24a1562 + languageName: node + linkType: hard + +"@emotion/unitless@npm:^0.8.0": + version: 0.8.0 + resolution: "@emotion/unitless@npm:0.8.0" + checksum: 176141117ed23c0eb6e53a054a69c63e17ae532ec4210907a20b2208f91771821835f1c63dd2ec63e30e22fcc984026d7f933773ee6526dd038e0850919fae7a + languageName: node + linkType: hard + +"@emotion/use-insertion-effect-with-fallbacks@npm:^1.0.0": + version: 1.0.0 + resolution: "@emotion/use-insertion-effect-with-fallbacks@npm:1.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 4f06a3b48258c832aa8022a262572061a31ff078d377e9164cccc99951309d70f4466e774fe704461b2f8715007a82ed625a54a5c7a127c89017d3ce3187d4f1 + languageName: node + linkType: hard + +"@emotion/utils@npm:^1.2.0": + version: 1.2.0 + resolution: "@emotion/utils@npm:1.2.0" + checksum: 55457a49ddd4db6a014ea0454dc09eaa23eedfb837095c8ff90470cb26a303f7ceb5fcc1e2190ef64683e64cfd33d3ba3ca3109cd87d12bc9e379e4195c9a4dd + languageName: node + linkType: hard + +"@emotion/weak-memoize@npm:^0.3.0": + version: 0.3.0 + resolution: "@emotion/weak-memoize@npm:0.3.0" + checksum: f43ef4c8b7de70d9fa5eb3105921724651e4188e895beb71f0c5919dc899a7b8743e1fdd99d38b9092dd5722c7be2312ebb47fbdad0c4e38bea58f6df5885cc0 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.1.2": version: 4.4.0 resolution: "@eslint-community/eslint-utils@npm:4.4.0" @@ -1586,6 +1703,22 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.2.4": + version: 1.2.5 + resolution: "@floating-ui/core@npm:1.2.5" + checksum: 6cda151bb098e0dbd5ac0db141715e00879bf08b21553a8895232ccf429d774e30019295b5a6d2da19dd927a34540fb49b55d926b82820e6002eac7b97405f76 + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.0.1": + version: 1.2.5 + resolution: "@floating-ui/dom@npm:1.2.5" + dependencies: + "@floating-ui/core": ^1.2.4 + checksum: a21c272a36c7cd7d337eaed82c1f8a81ccc5003d04cefa07591dc7fbb0a24d57a2c097b410593b5416145a68ac10a7a7a745c3cc4f8196268fa002364d28804b + languageName: node + linkType: hard + "@fortawesome/fontawesome-common-types@npm:^0.2.36": version: 0.2.36 resolution: "@fortawesome/fontawesome-common-types@npm:0.2.36" @@ -2696,6 +2829,15 @@ __metadata: languageName: node linkType: hard +"@types/react-transition-group@npm:^4.4.0": + version: 4.4.5 + resolution: "@types/react-transition-group@npm:4.4.5" + dependencies: + "@types/react": "*" + checksum: 265f1c74061556708ffe8d15559e35c60d6c11478c9950d3735575d2c116ca69f461d85effa06d73a613eb8b73c84fd32682feb57cf7c5f9e4284021dbca25b0 + languageName: node + linkType: hard + "@types/react@npm:*, @types/react@npm:>=16.9.0": version: 18.0.31 resolution: "@types/react@npm:18.0.31" @@ -3677,6 +3819,17 @@ __metadata: languageName: node linkType: hard +"babel-plugin-macros@npm:^3.1.0": + version: 3.1.0 + resolution: "babel-plugin-macros@npm:3.1.0" + dependencies: + "@babel/runtime": ^7.12.5 + cosmiconfig: ^7.0.0 + resolve: ^1.19.0 + checksum: 765de4abebd3e4688ebdfbff8571ddc8cd8061f839bb6c3e550b0344a4027b04c60491f843296ce3f3379fb356cc873d57a9ee6694262547eb822c14a25be9a6 + languageName: node + linkType: hard + "babel-plugin-polyfill-corejs2@npm:^0.3.3": version: 0.3.3 resolution: "babel-plugin-polyfill-corejs2@npm:0.3.3" @@ -4202,7 +4355,7 @@ __metadata: languageName: node linkType: hard -"classnames@npm:^2.2.3, classnames@npm:^2.2.4, classnames@npm:^2.2.5, classnames@npm:^2.2.6, classnames@npm:~2.3.2": +"classnames@npm:^2.2.3, classnames@npm:^2.2.5, classnames@npm:^2.2.6, classnames@npm:~2.3.2": version: 2.3.2 resolution: "classnames@npm:2.3.2" checksum: 2c62199789618d95545c872787137262e741f9db13328e216b093eea91c85ef2bfb152c1f9e63027204e2559a006a92eb74147d46c800a9f96297ae1d9f96f4e @@ -4574,7 +4727,7 @@ __metadata: languageName: node linkType: hard -"convert-source-map@npm:^1.4.0, convert-source-map@npm:^1.6.0, convert-source-map@npm:^1.7.0": +"convert-source-map@npm:^1.4.0, convert-source-map@npm:^1.5.0, convert-source-map@npm:^1.6.0, convert-source-map@npm:^1.7.0": version: 1.9.0 resolution: "convert-source-map@npm:1.9.0" checksum: dc55a1f28ddd0e9485ef13565f8f756b342f9a46c4ae18b843fe3c30c675d058d6a4823eff86d472f187b176f0adf51ea7b69ea38be34be4a63cbbf91b0593c8 @@ -4648,7 +4801,7 @@ __metadata: languageName: node linkType: hard -"cosmiconfig@npm:^7.1.0": +"cosmiconfig@npm:^7.0.0, cosmiconfig@npm:^7.1.0": version: 7.1.0 resolution: "cosmiconfig@npm:7.1.0" dependencies: @@ -5335,6 +5488,16 @@ __metadata: languageName: node linkType: hard +"dom-helpers@npm:^5.0.1": + version: 5.2.1 + resolution: "dom-helpers@npm:5.2.1" + dependencies: + "@babel/runtime": ^7.8.7 + csstype: ^3.0.2 + checksum: 863ba9e086f7093df3376b43e74ce4422571d404fc9828bf2c56140963d5edf0e56160f9b2f3bb61b282c07f8fc8134f023c98fd684bddcb12daf7b0f14d951c + languageName: node + linkType: hard + "dom-serializer@npm:^1.0.1": version: 1.4.1 resolution: "dom-serializer@npm:1.4.1" @@ -6412,6 +6575,13 @@ __metadata: languageName: node linkType: hard +"find-root@npm:^1.1.0": + version: 1.1.0 + resolution: "find-root@npm:1.1.0" + checksum: b2a59fe4b6c932eef36c45a048ae8f93c85640212ebe8363164814990ee20f154197505965f3f4f102efc33bfb1cbc26fd17c4a2fc739ebc51b886b137cbefaf + languageName: node + linkType: hard + "find-up@npm:5.0.0, find-up@npm:^5.0.0": version: 5.0.0 resolution: "find-up@npm:5.0.0" @@ -7111,7 +7281,7 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2": +"hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.1, hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" dependencies: @@ -9358,7 +9528,7 @@ __metadata: languageName: node linkType: hard -"memoize-one@npm:~6.0.0": +"memoize-one@npm:^6.0.0, memoize-one@npm:~6.0.0": version: 6.0.0 resolution: "memoize-one@npm:6.0.0" checksum: f185ea69f7cceae5d1cb596266dcffccf545e8e7b4106ec6aa93b71ab9d16460dd118ac8b12982c55f6d6322fcc1485de139df07eacffaae94888b9b3ad7675f @@ -11145,7 +11315,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.5.0, prop-types@npm:^15.5.10, prop-types@npm:^15.5.8, prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1, prop-types@npm:~15.8.1": +"prop-types@npm:^15.5.0, prop-types@npm:^15.5.10, prop-types@npm:^15.5.8, prop-types@npm:^15.6.0, prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1, prop-types@npm:~15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -11526,17 +11696,6 @@ __metadata: languageName: node linkType: hard -"react-input-autosize@npm:^2.1.2": - version: 2.2.2 - resolution: "react-input-autosize@npm:2.2.2" - dependencies: - prop-types: ^15.5.8 - peerDependencies: - react: ^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0 - checksum: 5164cbbff5091618f889a2a68368ef95460423dd3addd32d7db7cbde2f880816552ed750839baa278b28c210d77b9e3fbae48faf62ba90f3838abef1cfde58e6 - languageName: node - linkType: hard - "react-is@npm:^16.10.2, react-is@npm:^16.13.0, react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -11670,17 +11829,23 @@ __metadata: languageName: node linkType: hard -"react-select@npm:~1.2.1": - version: 1.2.1 - resolution: "react-select@npm:1.2.1" +"react-select@npm:~5.7.2": + version: 5.7.2 + resolution: "react-select@npm:5.7.2" dependencies: - classnames: ^2.2.4 - prop-types: ^15.5.8 - react-input-autosize: ^2.1.2 + "@babel/runtime": ^7.12.0 + "@emotion/cache": ^11.4.0 + "@emotion/react": ^11.8.1 + "@floating-ui/dom": ^1.0.1 + "@types/react-transition-group": ^4.4.0 + memoize-one: ^6.0.0 + prop-types: ^15.6.0 + react-transition-group: ^4.3.0 + use-isomorphic-layout-effect: ^1.1.2 peerDependencies: - react: ^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0 - react-dom: ^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0 - checksum: 9ffa62be99dc4d6e8170093fc2acfe44bdcc1bf96b5564c12094afa537302fe17309260b847f53c3fbdd9c283c20b8548cbafe7145786e004bcf96211ec21cb0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 1cb03c308be98b0bb89361dd842b92010ebd6769e388c380f2303ccfb14768b2085d0b94478dcd67841991272570fd27f75ea3035a230378fe14215d857e8ff7 languageName: node linkType: hard @@ -11776,6 +11941,21 @@ __metadata: languageName: node linkType: hard +"react-transition-group@npm:^4.3.0": + version: 4.4.5 + resolution: "react-transition-group@npm:4.4.5" + dependencies: + "@babel/runtime": ^7.5.5 + dom-helpers: ^5.0.1 + loose-envify: ^1.4.0 + prop-types: ^15.6.2 + peerDependencies: + react: ">=16.6.0" + react-dom: ">=16.6.0" + checksum: 75602840106aa9c6545149d6d7ae1502fb7b7abadcce70a6954c4b64a438ff1cd16fc77a0a1e5197cdd72da398f39eb929ea06f9005c45b132ed34e056ebdeb1 + languageName: node + linkType: hard + "react@npm:~17.0.2": version: 17.0.2 resolution: "react@npm:17.0.2" @@ -11881,7 +12061,7 @@ __metadata: react-redux: ~7.2.9 react-router: ~3.2.6 react-router-redux: ~4.0.8 - react-select: ~1.2.1 + react-select: ~5.7.2 react-sortablejs: ~2.0.11 react-test-renderer: ~17.0.2 react-textarea-autosize: ~8.3.4 @@ -12299,7 +12479,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.1.6, resolve@npm:^1.1.7, resolve@npm:^1.10.0, resolve@npm:^1.12.0, resolve@npm:^1.14.2, resolve@npm:^1.15.1, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.9.0": +"resolve@npm:^1.1.6, resolve@npm:^1.1.7, resolve@npm:^1.10.0, resolve@npm:^1.12.0, resolve@npm:^1.14.2, resolve@npm:^1.15.1, resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.9.0": version: 1.22.1 resolution: "resolve@npm:1.22.1" dependencies: @@ -12325,7 +12505,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@^1.1.6#~builtin, resolve@patch:resolve@^1.1.7#~builtin, resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.12.0#~builtin, resolve@patch:resolve@^1.14.2#~builtin, resolve@patch:resolve@^1.15.1#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.1#~builtin, resolve@patch:resolve@^1.9.0#~builtin": +"resolve@patch:resolve@^1.1.6#~builtin, resolve@patch:resolve@^1.1.7#~builtin, resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.12.0#~builtin, resolve@patch:resolve@^1.14.2#~builtin, resolve@patch:resolve@^1.15.1#~builtin, resolve@patch:resolve@^1.19.0#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.1#~builtin, resolve@patch:resolve@^1.9.0#~builtin": version: 1.22.1 resolution: "resolve@patch:resolve@npm%3A1.22.1#~builtin::version=1.22.1&hash=c3c19d" dependencies: @@ -13030,7 +13210,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.5.6": +"source-map@npm:^0.5.6, source-map@npm:^0.5.7": version: 0.5.7 resolution: "source-map@npm:0.5.7" checksum: 5dc2043b93d2f194142c7f38f74a24670cd7a0063acdaf4bf01d2964b402257ae843c2a8fa822ad5b71013b5fcafa55af7421383da919752f22ff488bc553f4d @@ -13578,6 +13758,13 @@ __metadata: languageName: node linkType: hard +"stylis@npm:4.1.3": + version: 4.1.3 + resolution: "stylis@npm:4.1.3" + checksum: d04dbffcb9bf2c5ca8d8dc09534203c75df3bf711d33973ea22038a99cc475412a350b661ebd99cbc01daa50d7eedcf0d130d121800eb7318759a197023442a6 + languageName: node + linkType: hard + "supports-color@npm:8.1.1, supports-color@npm:^8.0.0": version: 8.1.1 resolution: "supports-color@npm:8.1.1" @@ -14296,7 +14483,7 @@ __metadata: languageName: node linkType: hard -"use-isomorphic-layout-effect@npm:^1.1.1": +"use-isomorphic-layout-effect@npm:^1.1.1, use-isomorphic-layout-effect@npm:^1.1.2": version: 1.1.2 resolution: "use-isomorphic-layout-effect@npm:1.1.2" peerDependencies: From b11063374d41a5d0f5fc5e31d5df5857ab10b876 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 7 Apr 2023 15:13:47 +0300 Subject: [PATCH 14/55] Remove the directsReceivers reducer --- src/components/select-utils.js | 9 ++------- src/components/user-card.jsx | 3 +-- src/components/user-profile-head.jsx | 2 +- src/redux/reducers.js | 24 ------------------------ test/jest/user-profile-head.test.js | 6 +----- 5 files changed, 5 insertions(+), 39 deletions(-) diff --git a/src/components/select-utils.js b/src/components/select-utils.js index dc2521dcf..b30c08899 100644 --- a/src/components/select-utils.js +++ b/src/components/select-utils.js @@ -262,7 +262,7 @@ export function canAcceptDirects(user, state) { return; } - const { user: me, usersNotFound, directsReceivers } = state; + const { user: me, usersNotFound } = state; if ( !me.id || @@ -273,12 +273,7 @@ export function canAcceptDirects(user, state) { return false; } - // If user subscribed to us - if (me.subscribers.some((s) => s.username === user.username)) { - return true; - } - - return directsReceivers[user.username]; + return user.youCan.includes('dm'); } /** diff --git a/src/components/user-card.jsx b/src/components/user-card.jsx index dabddcd36..42c8c5257 100644 --- a/src/components/user-card.jsx +++ b/src/components/user-card.jsx @@ -20,8 +20,7 @@ class UserCard extends Component { this.arrowRef = createRef(); // Load this user's info if it's not in the store already - // or we have not its 'acceptsDirects' field - if (!props.user.id || props.canAcceptDirects === undefined) { + if (!props.user.id) { setTimeout(() => props.getUserInfo(props.username), 0); } } diff --git a/src/components/user-profile-head.jsx b/src/components/user-profile-head.jsx index 17044ab98..1772e9aa9 100644 --- a/src/components/user-profile-head.jsx +++ b/src/components/user-profile-head.jsx @@ -58,7 +58,7 @@ export const UserProfileHead = withRouter( const user = useSelector((state) => Object.values(state.users).find((u) => u.username === username), ); - const acceptsDirects = useSelector((state) => state.directsReceivers[username]); + const acceptsDirects = user?.youCan.includes('dm'); const isNotFound = useSelector((state) => state.usersNotFound.includes(username)); const allHomeFeeds = useSelector((state) => state.homeFeeds); diff --git a/src/redux/reducers.js b/src/redux/reducers.js index 4191e7fbe..5c43f6a02 100644 --- a/src/redux/reducers.js +++ b/src/redux/reducers.js @@ -846,30 +846,6 @@ export function userPastNames(state = {}, action) { return state; } -/** - * state is a map [username => status] - * status is boolean (user can or canot receive directs from us) - */ -export function directsReceivers(state = {}, action) { - switch (action.type) { - case response(ActionTypes.GET_USER_INFO): { - const { - payload: { - users: { username }, - acceptsDirects, - }, - } = action; - if (state[username] !== acceptsDirects) { - return { - ...state, - [username]: acceptsDirects, - }; - } - } - } - return state; -} - export function users(state = {}, action) { const mergeAccounts = (accounts, options = {}) => mergeByIds(state, (accounts || []).map(userParser), options); diff --git a/test/jest/user-profile-head.test.js b/test/jest/user-profile-head.test.js index ed3978771..333212899 100644 --- a/test/jest/user-profile-head.test.js +++ b/test/jest/user-profile-head.test.js @@ -49,12 +49,9 @@ const defaultState = { }, createdAt: '1430708710865', updatedAt: '1647366122559', - youCan: [], + youCan: ['dm'], }, }, - directsReceivers: { - [USERNAME]: true, - }, usersNotFound: [], usersInHomeFeeds: {}, usersInHomeFeedsStates: {}, @@ -267,7 +264,6 @@ describe('UserProfileHead', () => { isPrivate: '1', }, }, - directsReceivers: [], }; useSelectorMock.mockImplementation((selector) => selector(fakeState)); From da960bfac5b9e8acd9e2d14f723aa380a02bd35e Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 7 Apr 2023 15:45:59 +0300 Subject: [PATCH 15/55] Allow users to create posts in groups they are not subscribed to --- src/components/user.jsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/user.jsx b/src/components/user.jsx index 8841a5b0e..6c97e49cf 100644 --- a/src/components/user.jsx +++ b/src/components/user.jsx @@ -160,11 +160,7 @@ function selectState(state, ownProps) { const shouldIPostToGroup = statusExtension.subscribed && (foundUser.isRestricted === '0' || amIGroupAdmin); - const canIPostToGroup = statusExtension.subscribed && foundUser.youCan.includes('post'); - - statusExtension.canIPostHere = - statusExtension.isUserFound && - ((statusExtension.isItMe && isItPostsPage) || (foundUser.type === 'group' && canIPostToGroup)); + statusExtension.canIPostHere = foundUser?.youCan.includes('post') ?? false; if (shouldIPostToGroup && foundUser.theyDid.includes('block')) { statusExtension.whyCannotPost = 'You are blocked in this group'; From c75fa60e6c8ec915edad291337007f07eecd8507 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 7 Apr 2023 16:41:06 +0300 Subject: [PATCH 16/55] Use new feed selector in post edit form --- src/components/feeds-selector/selector.jsx | 3 +- .../feeds-selector/selector.module.scss | 5 +++ src/components/post/post-edit-form.jsx | 40 ++++++------------- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/components/feeds-selector/selector.jsx b/src/components/feeds-selector/selector.jsx index 432bebc2e..a6a87e26c 100644 --- a/src/components/feeds-selector/selector.jsx +++ b/src/components/feeds-selector/selector.jsx @@ -68,7 +68,7 @@ export function Selector({ className, mode, feedNames, fixedFeedNames = [], onCh return [...regularOptions, ...directOptions]; }, [groupOptions, isDirect, meOption, mode, userOptions]); - const [showStatic, toggleSelector] = useBool(values.length > 0); + const [showStatic, toggleSelector] = useBool(isEditing(mode) || values.length > 0); const handleChange = useCallback( (opts, action) => { @@ -86,6 +86,7 @@ export function Selector({ className, mode, feedNames, fixedFeedNames = [], onCh {showStatic ? ( <> To: + {values.length === 0 && nobody} {values.map((opt) => ( serverInfo.attachments.maxCountPerPost; @@ -25,8 +26,8 @@ export function PostEditForm({ id, isDirect, recipients, createdBy, body, attach const maxFilesCount = useServerValue(selectMaxFilesCount, Infinity); const maxPostLength = useServerValue(selectMaxPostLength, 1e3); - const [feedsSelector, setFeedsSelector] = useState(null); - const [feeds, setFeeds] = useState([]); + const recipientNames = useMemo(() => recipients.map((r) => r.username), [recipients]); + const [feeds, setFeeds] = useState(recipientNames); const [postText, setPostText] = useState(body); const [privacyWarning, setPrivacyWarning] = useState(null); @@ -48,14 +49,7 @@ export function PostEditForm({ id, isDirect, recipients, createdBy, body, attach [canUploadMore, doChooseFiles], ); - const registerFeedSelector = useCallback((ref) => { - setFeedsSelector(ref); - if (ref) { - setFeeds(ref.values.slice()); - } else { - setFeeds([]); - } - }, []); + const [hasFeedsError, setHasFeedsError] = useState(false); // It's a hack and should be replaced with a proper code with privacy checking const store = useStore(); @@ -89,19 +83,11 @@ export function PostEditForm({ id, isDirect, recipients, createdBy, body, attach const canSubmitForm = useMemo(() => { return ( (postText.trim() !== '' || fileIds.length > 0) && - feeds.length > 0 && - !feedsSelector?.isIncorrectDestinations && + !hasFeedsError && !saveState.loading && !isUploading ); - }, [ - postText, - fileIds.length, - feeds.length, - feedsSelector?.isIncorrectDestinations, - saveState.loading, - isUploading, - ]); + }, [postText, fileIds.length, hasFeedsError, saveState.loading, isUploading]); const handleSubmit = useCallback(() => { if (!canSubmitForm) { @@ -122,14 +108,12 @@ export function PostEditForm({ id, isDirect, recipients, createdBy, body, attach return ( <>
- r.username)} - isDirects={isDirect} - isEditing={true} - disableAutoFocus={true} - user={createdBy} +
{privacyWarning}
From baf56d23dfb07dba9ce07df5d46a9f98acfd2333 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 7 Apr 2023 17:14:25 +0300 Subject: [PATCH 17/55] Make all CreateForm controls focusable --- src/components/create-post.jsx | 10 +++++----- src/components/feeds-selector/selector.jsx | 6 +++++- styles/shared/post.scss | 1 + 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/create-post.jsx b/src/components/create-post.jsx index 6813b7da0..60e351cdb 100644 --- a/src/components/create-post.jsx +++ b/src/components/create-post.jsx @@ -1,6 +1,7 @@ import { faPaperclip } from '@fortawesome/free-solid-svg-icons'; import { useCallback, useMemo, useRef, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import cn from 'classnames'; import { createPost, resetPostCreateForm } from '../redux/action-creators'; import { ButtonLink } from './button-link'; import ErrorBoundary from './error-boundary'; @@ -164,14 +165,13 @@ export default function CreatePost({ sendTo, expandSendTo, isDirects }) {
- Add photos or files - + @@ -203,9 +203,9 @@ export default function CreatePost({ sendTo, expandSendTo, isDirects }) { )} diff --git a/src/components/feeds-selector/selector.jsx b/src/components/feeds-selector/selector.jsx index a6a87e26c..344882fd8 100644 --- a/src/components/feeds-selector/selector.jsx +++ b/src/components/feeds-selector/selector.jsx @@ -68,7 +68,8 @@ export function Selector({ className, mode, feedNames, fixedFeedNames = [], onCh return [...regularOptions, ...directOptions]; }, [groupOptions, isDirect, meOption, mode, userOptions]); - const [showStatic, toggleSelector] = useBool(isEditing(mode) || values.length > 0); + const startStatic = useMemo(() => isEditing(mode) || values.length > 0, [mode, values.length]); + const [showStatic, toggleSelector] = useBool(startStatic); const handleChange = useCallback( (opts, action) => { @@ -107,6 +108,9 @@ export function Selector({ className, mode, feedNames, fixedFeedNames = [], onCh } isMulti={true} isClearable={false} + autoFocus={startStatic} + closeMenuOnSelect={true} + openMenuOnFocus={!isEditing(mode)} options={options} value={values} styles={selStyles} diff --git a/styles/shared/post.scss b/styles/shared/post.scss index 3f1d530d2..63f8c83c4 100644 --- a/styles/shared/post.scss +++ b/styles/shared/post.scss @@ -312,6 +312,7 @@ $post-line-height: rem(20px); .post-edit-attachments { cursor: pointer; white-space: nowrap; + color: inherit; &:hover span:last-child { text-decoration: underline; From 684eedc451ea3df0a9476dd74f2627b7517d61d7 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 7 Apr 2023 21:49:51 +0300 Subject: [PATCH 18/55] Fix tab order of post forms controls --- src/components/create-post.jsx | 30 +++++++++---------- src/components/post/post-edit-form.jsx | 40 +++++++++++++------------- styles/shared/post.scss | 1 + 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/components/create-post.jsx b/src/components/create-post.jsx index 60e351cdb..3fc08b32b 100644 --- a/src/components/create-post.jsx +++ b/src/components/create-post.jsx @@ -164,6 +164,21 @@ export default function CreatePost({ sendTo, expandSendTo, isDirects }) {
+
+ {createPostStatus.loading && ( + + + + )} + +
+
- -
- {createPostStatus.loading && ( - - - - )} - -
{!canUploadMore && ( diff --git a/src/components/post/post-edit-form.jsx b/src/components/post/post-edit-form.jsx index 2bfca5413..74030fa21 100644 --- a/src/components/post/post-edit-form.jsx +++ b/src/components/post/post-edit-form.jsx @@ -14,6 +14,7 @@ import { destinationsPrivacy } from '../select-utils'; import { useServerValue } from '../hooks/server-info'; import { Selector } from '../feeds-selector/selector'; import { EDIT_DIRECT, EDIT_REGULAR } from '../feeds-selector/constants'; +import { ButtonLink } from '../button-link'; import PostAttachments from './post-attachments'; const selectMaxFilesCount = (serverInfo) => serverInfo.attachments.maxCountPerPost; @@ -135,36 +136,35 @@ export function PostEditForm({ id, isDirect, recipients, createdBy, body, attach
+
+ + + Cancel + + {saveState.loading && ( + + + + )} +
- Add photos or files - +
- -
- {saveState.loading && ( - - - - )} - - Cancel - - -
{!canUploadMore && ( diff --git a/styles/shared/post.scss b/styles/shared/post.scss index 63f8c83c4..206b574fe 100644 --- a/styles/shared/post.scss +++ b/styles/shared/post.scss @@ -304,6 +304,7 @@ $post-line-height: rem(20px); justify-content: flex-end; align-items: center; align-self: start; + flex-direction: row-reverse; } .post-edit-options { From 240c12ffbe37e2782d896180d3d934b6bc477680 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 7 Apr 2023 22:52:09 +0300 Subject: [PATCH 19/55] Add privacy icon to post submit button --- src/components/create-post.jsx | 40 +++++++++++++++++++++- src/components/feeds-selector/options.jsx | 20 ++++++++++- src/components/feeds-selector/selector.jsx | 16 +++++++-- styles/shared/post.scss | 9 +++++ 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/components/create-post.jsx b/src/components/create-post.jsx index 3fc08b32b..14bdb6edb 100644 --- a/src/components/create-post.jsx +++ b/src/components/create-post.jsx @@ -1,4 +1,9 @@ -import { faPaperclip } from '@fortawesome/free-solid-svg-icons'; +import { + faGlobeAmericas, + faLock, + faPaperclip, + faUserFriends, +} from '@fortawesome/free-solid-svg-icons'; import { useCallback, useMemo, useRef, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import cn from 'classnames'; @@ -129,6 +134,36 @@ export default function CreatePost({ sendTo, expandSendTo, isDirects }) { }, []); */ + const [privacyLevel, setPrivacyLevel] = useState(''); + + const privacyIcon = useMemo( + () => + privacyLevel === 'private' ? ( + + ) : privacyLevel === 'protected' ? ( + + ) : privacyLevel === 'public' ? ( + + ) : privacyLevel === 'direct' ? ( + + ) : null, + [privacyLevel], + ); + + const privacyTitle = useMemo( + () => + privacyLevel === 'private' + ? 'Create private post' + : privacyLevel === 'protected' + ? 'Create protected post' + : privacyLevel === 'public' + ? 'Create public post' + : privacyLevel === 'direct' + ? 'Create direct message' + : null, + [privacyLevel], + ); + return (
)} + {canSubmitForm && privacyIcon} Post
diff --git a/src/components/feeds-selector/options.jsx b/src/components/feeds-selector/options.jsx index fef8d5f97..3349149ce 100644 --- a/src/components/feeds-selector/options.jsx +++ b/src/components/feeds-selector/options.jsx @@ -154,9 +154,27 @@ export function useSelectedOptions(usernames, fixedFeedNames) { .forEach((username) => dispatch(getUserInfo(username))); }, [dispatch, userInfoStatuses, values]); + const privacyLevel = useMemo(() => { + if (values.every((v) => v.type === ACC_USER)) { + return 'direct'; + } + if (values.some((v) => v.type !== ACC_GROUP && v.type !== ACC_ME)) { + return ''; + } + for (const { value: username } of values) { + const acc = usersByName.get(username); + if (acc.isProtected === '0') { + return 'public'; + } else if (acc.isPrivate === '0') { + return 'protected'; + } + } + return 'private'; + }, [usersByName, values]); + const meOption = useMemo(() => toOption(me, me), [me]); - return { values, meOption, groupOptions, userOptions }; + return { values, meOption, groupOptions, userOptions, privacyLevel }; } const collator = new Intl.Collator(undefined, { sensitivity: 'base', ignorePunctuation: true }); diff --git a/src/components/feeds-selector/selector.jsx b/src/components/feeds-selector/selector.jsx index 344882fd8..f954cdca1 100644 --- a/src/components/feeds-selector/selector.jsx +++ b/src/components/feeds-selector/selector.jsx @@ -1,5 +1,5 @@ import cn from 'classnames'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import CreatableSelect from 'react-select/creatable'; import { ButtonLink } from '../button-link'; import { useBool } from '../hooks/bool'; @@ -19,8 +19,16 @@ import { import styles from './selector.module.scss'; import { SelectorError } from './error'; -export function Selector({ className, mode, feedNames, fixedFeedNames = [], onChange, onError }) { - const { values, meOption, groupOptions, userOptions } = useSelectedOptions( +export function Selector({ + className, + mode, + feedNames, + fixedFeedNames = [], + onChange, + onError, + onPrivacyLevel, +}) { + const { values, meOption, groupOptions, userOptions, privacyLevel } = useSelectedOptions( feedNames, fixedFeedNames, ); @@ -81,6 +89,8 @@ export function Selector({ className, mode, feedNames, fixedFeedNames = [], onCh [onChange], ); + useEffect(() => onPrivacyLevel?.(privacyLevel), [onPrivacyLevel, privacyLevel]); + return (
diff --git a/styles/shared/post.scss b/styles/shared/post.scss index 206b574fe..bccc4173f 100644 --- a/styles/shared/post.scss +++ b/styles/shared/post.scss @@ -349,6 +349,15 @@ $post-line-height: rem(20px); } } + .post-submit-icon { + margin-right: rem(4px); + opacity: 0.8; + + &:empty { + display: none; + } + } + .post-cancel { margin-right: rem(10px); } From 949ae85bdc6b94459a2eab954791fdd11cb828e8 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 8 Apr 2023 21:55:23 +0300 Subject: [PATCH 20/55] Move AccountsSelector to the separate component and add dynamic loading --- src/components/create-post.jsx | 3 +- .../feeds-selector/accounts-selector.jsx | 103 ++++++++++++++++++ src/components/feeds-selector/selector.jsx | 74 +------------ .../feeds-selector/selector.module.scss | 4 +- 4 files changed, 107 insertions(+), 77 deletions(-) create mode 100644 src/components/feeds-selector/accounts-selector.jsx diff --git a/src/components/create-post.jsx b/src/components/create-post.jsx index 14bdb6edb..048f2faaf 100644 --- a/src/components/create-post.jsx +++ b/src/components/create-post.jsx @@ -125,14 +125,13 @@ export default function CreatePost({ sendTo, expandSendTo, isDirects }) { useEffect(() => () => dispatch(resetPostCreateForm()), [dispatch]); const containerRef = useRef(); - /* + useEffect(() => { const h = () => import('react-select/creatable'); const el = containerRef.current; el.addEventListener('click', h, { once: true }); return () => el.removeEventListener('click', h, { once: true }); }, []); - */ const [privacyLevel, setPrivacyLevel] = useState(''); diff --git a/src/components/feeds-selector/accounts-selector.jsx b/src/components/feeds-selector/accounts-selector.jsx new file mode 100644 index 000000000..0a713523f --- /dev/null +++ b/src/components/feeds-selector/accounts-selector.jsx @@ -0,0 +1,103 @@ +import { lazyComponent } from '../lazy-component'; +import { Throbber } from '../throbber'; +import styles from './selector.module.scss'; +import { DisplayOption } from './options'; + +function Fallback() { + return ( +
+ Loading selector... +
+ ); +} + +const FixedSelect = lazyComponent(() => import('react-select'), { + fallback: , + errorMessage: "Couldn't load selector", + delay: 0, +}); + +const CreatableSelect = lazyComponent(() => import('react-select/creatable'), { + fallback: , + errorMessage: "Couldn't load selector", + delay: 0, +}); + +export function AccountsSelector({ isCreatable = true, ...props }) { + const Select = isCreatable ? CreatableSelect : FixedSelect; + return ( + this.setState({ message: target.value }); - saveUsersSelectRef = (_u) => (this.userFeedsSelector = _u); - - saveGroupsSelectRef = (_g) => (this.groupFeedsSelector = _g); - changeInvitationLanguage = ({ target }) => this.setState({ lang: target.value }, this.suggestedSubscriptionsChanged); toggleOneTime = ({ target }) => this.setState({ singleUse: target.checked }); + onUsersChanged = (users) => { + this.setState( + (s) => ({ suggestions: { ...s.suggestions, users } }), + this.suggestedSubscriptionsChanged, + ); + }; + + onGroupsChanged = (groups) => { + this.setState( + (s) => ({ suggestions: { ...s.suggestions, groups } }), + this.suggestedSubscriptionsChanged, + ); + }; + suggestedSubscriptionsChanged = () => { const { users: userDescriptions, groups: groupDescriptions } = selectUsersAndGroupsFromText( this.state.message, @@ -211,8 +227,8 @@ class InvitationCreationForm extends Component { const { message, lang } = this.state; const customMessage = clearMessageFromUsersAndGroups(message, this.state.suggestions); const suggestions = { - users: this.userFeedsSelector?.values || [this.props.user.username], - groups: this.groupFeedsSelector?.values || [], + users: toValues(this.state.suggestions.users), + groups: toValues(this.state.suggestions.groups), }; const descriptions = patchDescriptions( this.props.feedsDescriptions, @@ -230,13 +246,13 @@ class InvitationCreationForm extends Component { const newMessage = `${customMessage}${customMessage && suggestionsText ? '\n\n' : ''}${ suggestionsText || '' }`; - this.setState({ message: newMessage, suggestions }); + this.setState({ message: newMessage }); }; createInvitation = () => { - const { message, lang } = this.state; - const users = this.userFeedsSelector.values; - const groups = this.groupFeedsSelector.values; + const { message, lang, suggestions } = this.state; + const users = toValues(suggestions.users); + const groups = toValues(suggestions.groups); const singleUse = this.props.invitationsInfo.singleUseOnly || this.state.singleUse; this.props.createInvitation(message, lang, singleUse, users, groups); }; @@ -353,12 +369,16 @@ function findDescription(username, descriptions) { } function selectUsersAndGroupsFromText(message, { users, groups }) { - const usernameRegexp = formatAllUsernameRegexp(users, groups); + const userNames = users.map((u) => u.value); + const groupNames = groups.map((u) => u.value); + const usernameRegexp = formatAllUsernameRegexp(userNames, groupNames); const usersAndGroupsMentions = message.match(usernameRegexp) || []; return { - users: usersAndGroupsMentions.filter((str) => users.some((user) => str.indexOf(user) === 1)), + users: usersAndGroupsMentions.filter((str) => + userNames.some((user) => str.indexOf(user) === 1), + ), groups: usersAndGroupsMentions.filter((str) => - groups.some((group) => str.indexOf(group) === 1), + groupNames.some((group) => str.indexOf(group) === 1), ), }; } @@ -402,3 +422,18 @@ function mapDispatchToProps(dispatch) { } export default connect(mapStateToProps, mapDispatchToProps)(InvitationCreationForm); + +const toOptions = (feeds, me) => + feeds.map((f) => { + const opt = toOption(f.user, me); + if (opt.type === ACC_ME) { + opt.type = ACC_USER; + opt.label = opt.value; + } + return opt; + }); + +const usersToOptions = memoize(toOptions); +const groupsToOptions = memoize(toOptions); + +const toValues = (options) => options.map((o) => o.value); From 755c38171485b62d25357b896c37d6e06f395478 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sun, 9 Apr 2023 21:05:59 +0300 Subject: [PATCH 22/55] Fix selector styles in dark mode --- src/components/feeds-selector/accounts-selector.jsx | 3 ++- src/components/feeds-selector/selector.jsx | 5 ++++- src/components/feeds-selector/selector.module.scss | 10 ++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/feeds-selector/accounts-selector.jsx b/src/components/feeds-selector/accounts-selector.jsx index 0a713523f..1863c836b 100644 --- a/src/components/feeds-selector/accounts-selector.jsx +++ b/src/components/feeds-selector/accounts-selector.jsx @@ -1,3 +1,4 @@ +import cn from 'classnames'; import { lazyComponent } from '../lazy-component'; import { Throbber } from '../throbber'; import styles from './selector.module.scss'; @@ -27,7 +28,7 @@ export function AccountsSelector({ isCreatable = true, ...props }) { const Select = isCreatable ? CreatableSelect : FixedSelect; return ( - {this.state.isIncorrectDestinations ? ( -
- Unable to create a direct message: direct messages could be sent to user(s) only. - Please create a regular post for publish it in your feed or groups. -
- ) : ( - false - )} -
- )} -
- ); - } -} - -function isSameFeeds(feeds1, feeds2) { - if (Array.isArray(feeds1) && Array.isArray(feeds2)) { - return feeds1.length === feeds2.length && xor(feeds1, feeds2).length === 0; - } - return feeds1 == feeds2; -} - -function selectState({ sendTo: { feeds } }, ownProps) { - if ('feeds' in ownProps) { - return { fixedOptions: true }; - } - return { feeds }; -} - -export default connect(selectState, null, null, { forwardRef: true })(SendTo); From 34a65b4d0bb1a11bf89c62ce527b23890b812ee2 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Mon, 10 Apr 2023 17:43:50 +0300 Subject: [PATCH 25/55] Remove unused code --- src/components/create-post.jsx | 10 +- src/components/discussions.jsx | 9 +- src/components/home.jsx | 4 +- src/components/user-profile.jsx | 1 - src/components/user.jsx | 3 - src/redux/action-creators.js | 4 - src/redux/action-types.js | 1 - src/redux/middlewares.js | 5 - src/redux/reducers.js | 83 ------- styles/helvetica/dark-theme.scss | 3 +- test/unit/redux/reducers/send-to.js | 334 ---------------------------- 11 files changed, 10 insertions(+), 447 deletions(-) delete mode 100644 test/unit/redux/reducers/send-to.js diff --git a/src/components/create-post.jsx b/src/components/create-post.jsx index 048f2faaf..c8ee949fd 100644 --- a/src/components/create-post.jsx +++ b/src/components/create-post.jsx @@ -28,7 +28,7 @@ import { CREATE_DIRECT, CREATE_REGULAR } from './feeds-selector/constants'; const selectMaxFilesCount = (serverInfo) => serverInfo.attachments.maxCountPerPost; const selectMaxPostLength = (serverInfo) => serverInfo.maxTextLength.post; -export default function CreatePost({ sendTo, expandSendTo, isDirects }) { +export default function CreatePost({ sendTo, isDirects }) { const dispatch = useDispatch(); const createPostStatus = useSelector((state) => state.createPostStatus); @@ -41,6 +41,7 @@ export default function CreatePost({ sendTo, expandSendTo, isDirects }) { const [commentsDisabled, toggleCommentsDisabled] = useBool(false); const [isMoreOpen, toggleIsMoreOpen] = useBool(false); const [postText, setPostText] = useState(sendTo.invitation || ''); + const [selectorVisible, setSelectorVisible, expandSendTo] = useBool(isDirects); const defaultFeedNames = useMemo(() => { if (Array.isArray(sendTo.defaultFeed)) { @@ -53,13 +54,14 @@ export default function CreatePost({ sendTo, expandSendTo, isDirects }) { const [feeds, setFeeds] = useState(defaultFeedNames); - useEffect(() => setFeeds(defaultFeedNames), [defaultFeedNames, sendTo.expanded]); + useEffect(() => setFeeds(defaultFeedNames), [defaultFeedNames, selectorVisible]); const resetLocalState = useCallback(() => { toggleCommentsDisabled(false); toggleIsMoreOpen(false); setPostText(sendTo.invitation || ''); - }, [sendTo.invitation, toggleCommentsDisabled, toggleIsMoreOpen]); + setSelectorVisible(isDirects); + }, [isDirects, sendTo.invitation, setSelectorVisible, toggleCommentsDisabled, toggleIsMoreOpen]); // Uploading files const { @@ -173,7 +175,7 @@ export default function CreatePost({ sendTo, expandSendTo, isDirects }) {
- {sendTo.expanded && ( + {selectorVisible && ( { isDirects={props.isDirects} createPost={props.createPost} resetPostCreateForm={props.resetPostCreateForm} - expandSendTo={props.expandSendTo} addAttachmentResponse={props.addAttachmentResponse} showMedia={props.showMedia} /> @@ -54,10 +53,7 @@ function selectState(state) { const defaultFeed = state.routing.locationBeforeTransitions.query.to || (!isDirects && user.username) || undefined; const invitation = formatInvitation(state.routing.locationBeforeTransitions.query.invite); - const sendTo = { ...state.sendTo, defaultFeed, invitation }; - if (isDirects) { - sendTo.expanded = true; - } + const sendTo = { defaultFeed, invitation }; return { user, @@ -76,7 +72,6 @@ function selectActions(dispatch) { createPost: (feeds, postText, attachmentIds, more) => dispatch(createPost(feeds, postText, attachmentIds, more)), resetPostCreateForm: (...args) => dispatch(resetPostCreateForm(...args)), - expandSendTo: () => dispatch(expandSendTo()), }; } diff --git a/src/components/home.jsx b/src/components/home.jsx index 18c746b4d..a93676d17 100644 --- a/src/components/home.jsx +++ b/src/components/home.jsx @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { connect, useSelector, useDispatch } from 'react-redux'; import { withRouter } from 'react-router'; -import { createPost, resetPostCreateForm, expandSendTo, home } from '../redux/action-creators'; +import { createPost, resetPostCreateForm, home } from '../redux/action-creators'; import { postActions } from './select-utils'; import CreatePost from './create-post'; import Feed from './feed'; @@ -48,7 +48,6 @@ const FeedHandler = (props) => { user={props.user} createPost={props.createPost} resetPostCreateForm={props.resetPostCreateForm} - expandSendTo={props.expandSendTo} addAttachmentResponse={props.addAttachmentResponse} showMedia={props.showMedia} /> @@ -104,7 +103,6 @@ function selectActions(dispatch) { createPost: (feeds, postText, attachmentIds, more) => dispatch(createPost(feeds, postText, attachmentIds, more)), resetPostCreateForm: (...args) => dispatch(resetPostCreateForm(...args)), - expandSendTo: () => dispatch(expandSendTo()), }; } diff --git a/src/components/user-profile.jsx b/src/components/user-profile.jsx index 3988f3ad0..faca61456 100644 --- a/src/components/user-profile.jsx +++ b/src/components/user-profile.jsx @@ -37,7 +37,6 @@ export default function UserProfile(props) { user={props.user} createPost={props.createPost} resetPostCreateForm={props.resetPostCreateForm} - expandSendTo={props.expandSendTo} addAttachmentResponse={props.addAttachmentResponse} showMedia={props.showMedia} /> diff --git a/src/components/user.jsx b/src/components/user.jsx index 6c97e49cf..6a1971712 100644 --- a/src/components/user.jsx +++ b/src/components/user.jsx @@ -8,7 +8,6 @@ import { formatPattern } from 'react-router/es/PatternUtils'; import { createPost, resetPostCreateForm, - expandSendTo, getUserInfo, togglePinnedGroup, } from '../redux/action-creators'; @@ -90,7 +89,6 @@ const UserHandler = (props) => { {...props.userActions} user={props.user} sendTo={props.sendTo} - expandSendTo={props.expandSendTo} createPost={props.createPost} resetPostCreateForm={props.resetPostCreateForm} addAttachmentResponse={props.addAttachmentResponse} @@ -195,7 +193,6 @@ function selectActions(dispatch) { createPost: (feeds, postText, attachmentIds, more) => dispatch(createPost(feeds, postText, attachmentIds, more)), resetPostCreateForm: (...args) => dispatch(resetPostCreateForm(...args)), - expandSendTo: () => dispatch(expandSendTo()), userActions: userActions(dispatch), getUserInfo: (username) => dispatch(getUserInfo(username)), togglePinnedGroup: ({ id }) => dispatch(togglePinnedGroup(id)), diff --git a/src/redux/action-creators.js b/src/redux/action-creators.js index ffe5a2145..c72664bc4 100644 --- a/src/redux/action-creators.js +++ b/src/redux/action-creators.js @@ -590,10 +590,6 @@ export function getUserMemories(username, from, offset = 0) { }; } -export function expandSendTo() { - return { type: ActionTypes.EXPAND_SEND_TO }; -} - export function toggleHiddenPosts() { return { type: ActionTypes.TOGGLE_HIDDEN_POSTS }; } diff --git a/src/redux/action-types.js b/src/redux/action-types.js index eb2fe2c9c..500e626eb 100644 --- a/src/redux/action-types.js +++ b/src/redux/action-types.js @@ -61,7 +61,6 @@ export const SEND_SUBSCRIPTION_REQUEST = 'SEND_SUBSCRIPTION_REQUEST'; export const GET_USER_COMMENTS = 'GET_USER_COMMENTS'; export const GET_USER_LIKES = 'GET_USER_LIKES'; export const GET_USER_MEMORIES = 'GET_USER_MEMORIES'; -export const EXPAND_SEND_TO = 'EXPAND_SEND_TO'; export const TOGGLE_HIDDEN_POSTS = 'TOGGLE_HIDDEN_POSTS'; export const SUBSCRIBERS = 'SUBSCRIBERS'; export const SUBSCRIPTIONS = 'SUBSCRIPTIONS'; diff --git a/src/redux/middlewares.js b/src/redux/middlewares.js index 17f45baf1..90226a76a 100644 --- a/src/redux/middlewares.js +++ b/src/redux/middlewares.js @@ -554,11 +554,6 @@ export const markDirectsAsReadMiddleware = (store) => (next) => (action) => { // needed to mark all directs as read store.dispatch(ActionCreators.markAllDirectsAsRead()); } - if (action.type === response(ActionTypes.DIRECT)) { - if (store.getState().routing.locationBeforeTransitions.query.to) { - store.dispatch(ActionCreators.expandSendTo()); - } - } next(action); }; diff --git a/src/redux/reducers.js b/src/redux/reducers.js index 5c43f6a02..6e490010a 100644 --- a/src/redux/reducers.js +++ b/src/redux/reducers.js @@ -1307,89 +1307,6 @@ export function singlePostId(state = null, action) { return state; } -function getValidRecipients(state) { - const subscriptions = _.map(state.subscriptions || [], (rs) => { - const sub = _.find(state.subscriptions || [], { id: rs.id }); - let user = null; - if (sub && sub.name == 'Posts') { - user = _.find(state.subscribers || [], { id: sub.user }); - } - if (user) { - return { id: rs.id, user }; - } - }).filter(Boolean); - - const canPostToGroup = function (subUser) { - return subUser.isRestricted === '0' || (subUser.administrators || []).includes(state.users.id); - }; - - const canSendDirect = function (subUser) { - return _.findIndex(state.users.subscribers || [], { id: subUser.id }) > -1; - }; - - const validRecipients = _.filter(subscriptions, (sub) => { - return ( - (sub.user.type === 'group' && canPostToGroup(sub.user)) || - (sub.user.type === 'user' && canSendDirect(sub.user)) - ); - }); - - return validRecipients; -} - -const INITIAL_SEND_TO_STATE = { expanded: false, feeds: [] }; - -function getHiddenSendTo(state) { - return { - expanded: false, - feeds: state.feeds, - }; -} - -export function sendTo(state = INITIAL_SEND_TO_STATE, action) { - if (ActionHelpers.isFeedRequest(action)) { - return getHiddenSendTo(state); - } - - switch (action.type) { - case response(ActionTypes.WHO_AM_I): { - return { - expanded: state.expanded, - feeds: getValidRecipients(action.payload), - }; - } - case ActionTypes.EXPAND_SEND_TO: { - return { - ...state, - expanded: true, - }; - } - case response(ActionTypes.CREATE_POST): { - return { - ...state, - expanded: false, - }; - } - case response(ActionTypes.CREATE_GROUP): { - const groupId = action.payload.groups.id; - const group = userParser(action.payload.groups); - return { - ...state, - feeds: [...state.feeds, { id: groupId, user: group }], - }; - } - case response(ActionTypes.SUBSCRIBE): - case response(ActionTypes.UNSUBSCRIBE): { - return { - ...state, - feeds: getValidRecipients(action.payload), - }; - } - } - - return state; -} - const GROUPS_SIDEBAR_LIST_LENGTH = 4; function sortRecentGroups(g1, g2) { diff --git a/styles/helvetica/dark-theme.scss b/styles/helvetica/dark-theme.scss index 4ddf7ea54..69a006ec4 100644 --- a/styles/helvetica/dark-theme.scss +++ b/styles/helvetica/dark-theme.scss @@ -133,8 +133,7 @@ body, .post a, .expandable .expand-panel .expand-button, - .image-attachments .show-more, - a.p-sendto-toggler { + .image-attachments .show-more { color: $link-color-dim; } diff --git a/test/unit/redux/reducers/send-to.js b/test/unit/redux/reducers/send-to.js deleted file mode 100644 index bc439fcaf..000000000 --- a/test/unit/redux/reducers/send-to.js +++ /dev/null @@ -1,334 +0,0 @@ -import { describe, it } from 'mocha'; -import expect from 'unexpected'; - -import { sendTo } from '../../../../src/redux/reducers'; -import { WHO_AM_I, SUBSCRIBE, UNSUBSCRIBE } from '../../../../src/redux/action-types'; -import { response } from '../../../../src/redux/action-helpers'; - -const stateInitial = { - expanded: false, - feeds: [], -}; - -const payloadOneSub = { - users: { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - username: 'clbn12', - type: 'user', - screenName: 'clbn12', - email: 'clbn12@clbn12.com', - isPrivate: '0', - frontendPreferences: {}, - profilePictureLargeUrl: '', - profilePictureMediumUrl: '', - banIds: [], - statistics: { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - posts: '0', - likes: '0', - comments: '0', - subscribers: '0', - subscriptions: '1', - }, - administrators: ['86ad24d8-0ca0-42af-905f-743d3a472eb6'], - pendingGroupRequests: false, - subscriptions: [ - '7f2b907a-edc3-4011-bbe8-8979408decef', - '540a5c2b-10d8-4662-8194-7655592653da', - '008d3f1e-f1b5-4395-8b31-30f2d842b3dc', - ], - }, - admins: [ - { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - username: 'clbn12', - type: 'user', - screenName: 'clbn12', - updatedAt: '1456840434186', - isPrivate: '0', - profilePictureLargeUrl: '', - profilePictureMediumUrl: '', - administrators: [{}], - statistics: { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - posts: '0', - likes: '0', - comments: '0', - subscribers: '0', - subscriptions: '1', - }, - }, - ], - subscribers: [ - { - id: 'd49b39eb-b8f1-4e2e-90fc-7be68c6db34f', - username: 'apples', - screenName: 'Яблоки', - type: 'group', - updatedAt: '1455311800566', - createdAt: '1454238830127', - isPrivate: '0', - isRestricted: '0', - profilePictureLargeUrl: '', - profilePictureMediumUrl: '', - }, - ], - subscriptions: [ - { - id: '7f2b907a-edc3-4011-bbe8-8979408decef', - name: 'Likes', - user: 'd49b39eb-b8f1-4e2e-90fc-7be68c6db34f', - }, - { - id: '540a5c2b-10d8-4662-8194-7655592653da', - name: 'Comments', - user: 'd49b39eb-b8f1-4e2e-90fc-7be68c6db34f', - }, - { - id: '008d3f1e-f1b5-4395-8b31-30f2d842b3dc', - name: 'Posts', - user: 'd49b39eb-b8f1-4e2e-90fc-7be68c6db34f', - }, - ], -}; - -const stateOneSub = { - expanded: false, - feeds: [ - { - id: '008d3f1e-f1b5-4395-8b31-30f2d842b3dc', - user: { - id: 'd49b39eb-b8f1-4e2e-90fc-7be68c6db34f', - username: 'apples', - screenName: 'Яблоки', - type: 'group', - updatedAt: '1455311800566', - createdAt: '1454238830127', - isPrivate: '0', - isRestricted: '0', - profilePictureLargeUrl: '', - profilePictureMediumUrl: '', - }, - }, - ], -}; - -const payloadTwoSubs = { - users: { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - username: 'clbn12', - type: 'user', - screenName: 'clbn12', - email: 'clbn12@clbn12.com', - isPrivate: '0', - frontendPreferences: {}, - profilePictureLargeUrl: '', - profilePictureMediumUrl: '', - banIds: [], - statistics: { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - posts: '0', - likes: '0', - comments: '0', - subscribers: '0', - subscriptions: '2', - }, - administrators: ['86ad24d8-0ca0-42af-905f-743d3a472eb6'], - pendingGroupRequests: false, - subscriptions: [ - '60265209-1439-4a54-8491-00749a94a195', - '5d5fb359-1f8f-4e95-a16d-c5ae9b51dd47', - '1d0f5fb9-71f3-45ee-bec2-356e496612ca', - '7f2b907a-edc3-4011-bbe8-8979408decef', - '540a5c2b-10d8-4662-8194-7655592653da', - '008d3f1e-f1b5-4395-8b31-30f2d842b3dc', - ], - }, - admins: [ - { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - username: 'clbn12', - type: 'user', - screenName: 'clbn12', - updatedAt: '1456840434186', - isPrivate: '0', - profilePictureLargeUrl: '', - profilePictureMediumUrl: '', - administrators: [{}], - statistics: { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - posts: '0', - likes: '0', - comments: '0', - subscribers: '0', - subscriptions: '2', - }, - }, - ], - subscribers: [ - { - id: '66d2c0e2-ce15-4c73-bbc7-3205e7f2e259', - username: 'pears', - screenName: 'Груши', - type: 'group', - updatedAt: '1456757417266', - createdAt: '1453995078415', - isPrivate: '0', - isRestricted: '0', - profilePictureLargeUrl: - 'https://frf-api.applied.creagenics.com/profilepics/7be65d5e-ed97-4d8e-9298-385f974891e6_75.jpg', - profilePictureMediumUrl: - 'https://frf-api.applied.creagenics.com/profilepics/7be65d5e-ed97-4d8e-9298-385f974891e6_50.jpg', - }, - { - id: 'd49b39eb-b8f1-4e2e-90fc-7be68c6db34f', - username: 'apples', - screenName: 'Яблоки', - type: 'group', - updatedAt: '1455311800566', - createdAt: '1454238830127', - isPrivate: '0', - isRestricted: '0', - profilePictureLargeUrl: '', - profilePictureMediumUrl: '', - }, - ], - subscriptions: [ - { - id: '60265209-1439-4a54-8491-00749a94a195', - name: 'Comments', - user: '66d2c0e2-ce15-4c73-bbc7-3205e7f2e259', - }, - { - id: '5d5fb359-1f8f-4e95-a16d-c5ae9b51dd47', - name: 'Posts', - user: '66d2c0e2-ce15-4c73-bbc7-3205e7f2e259', - }, - { - id: '1d0f5fb9-71f3-45ee-bec2-356e496612ca', - name: 'Likes', - user: '66d2c0e2-ce15-4c73-bbc7-3205e7f2e259', - }, - { - id: '7f2b907a-edc3-4011-bbe8-8979408decef', - name: 'Likes', - user: 'd49b39eb-b8f1-4e2e-90fc-7be68c6db34f', - }, - { - id: '540a5c2b-10d8-4662-8194-7655592653da', - name: 'Comments', - user: 'd49b39eb-b8f1-4e2e-90fc-7be68c6db34f', - }, - { - id: '008d3f1e-f1b5-4395-8b31-30f2d842b3dc', - name: 'Posts', - user: 'd49b39eb-b8f1-4e2e-90fc-7be68c6db34f', - }, - ], -}; - -const stateTwoSubs = { - expanded: false, - feeds: [ - { - id: '5d5fb359-1f8f-4e95-a16d-c5ae9b51dd47', - user: { - id: '66d2c0e2-ce15-4c73-bbc7-3205e7f2e259', - username: 'pears', - screenName: 'Груши', - type: 'group', - updatedAt: '1456757417266', - createdAt: '1453995078415', - isPrivate: '0', - isRestricted: '0', - profilePictureLargeUrl: - 'https://frf-api.applied.creagenics.com/profilepics/7be65d5e-ed97-4d8e-9298-385f974891e6_75.jpg', - profilePictureMediumUrl: - 'https://frf-api.applied.creagenics.com/profilepics/7be65d5e-ed97-4d8e-9298-385f974891e6_50.jpg', - }, - }, - { - id: '008d3f1e-f1b5-4395-8b31-30f2d842b3dc', - user: { - id: 'd49b39eb-b8f1-4e2e-90fc-7be68c6db34f', - username: 'apples', - screenName: 'Яблоки', - type: 'group', - updatedAt: '1455311800566', - createdAt: '1454238830127', - isPrivate: '0', - isRestricted: '0', - profilePictureLargeUrl: '', - profilePictureMediumUrl: '', - }, - }, - ], -}; - -const payloadZeroSubs = { - users: { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - username: 'clbn12', - type: 'user', - screenName: 'clbn12', - email: 'clbn12@clbn12.com', - isPrivate: '0', - frontendPreferences: {}, - profilePictureLargeUrl: '', - profilePictureMediumUrl: '', - banIds: [], - statistics: { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - posts: '0', - likes: '0', - comments: '0', - subscribers: '0', - subscriptions: '0', - }, - administrators: ['86ad24d8-0ca0-42af-905f-743d3a472eb6'], - pendingGroupRequests: false, - }, - admins: [ - { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - username: 'clbn12', - type: 'user', - screenName: 'clbn12', - updatedAt: '1456840434186', - isPrivate: '0', - profilePictureLargeUrl: '', - profilePictureMediumUrl: '', - administrators: [{}], - statistics: { - id: '86ad24d8-0ca0-42af-905f-743d3a472eb6', - posts: '0', - likes: '0', - comments: '0', - subscribers: '0', - subscriptions: '0', - }, - }, - ], -}; - -describe('sendTo()', () => { - it('should add one existing recipient into the initially empty state after getting WHO_AM_I', () => { - const state = sendTo(stateInitial, { type: response(WHO_AM_I), payload: payloadOneSub }); - expect(state, 'to equal', stateOneSub); - }); - - it('should add a new recipient, increasing their number to two after getting SUBSCRIBE', () => { - const state = sendTo(stateOneSub, { type: response(SUBSCRIBE), payload: payloadTwoSubs }); - expect(state, 'to equal', stateTwoSubs); - }); - - it('should remove one of the two recipients, decreasing their number to one after getting UNSUBSCRIBE', () => { - const state = sendTo(stateTwoSubs, { type: response(UNSUBSCRIBE), payload: payloadOneSub }); - expect(state, 'to equal', stateOneSub); - }); - - it('should remove the only recipient, decreasing their number to zero after getting UNSUBSCRIBE', () => { - const state = sendTo(stateOneSub, { type: response(UNSUBSCRIBE), payload: payloadZeroSubs }); - expect(state, 'to equal', stateInitial); - }); -}); From 4938c23937568e48a810d98d8b22394d358852c2 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Mon, 10 Apr 2023 20:31:36 +0300 Subject: [PATCH 26/55] Update tests --- test/jest/__snapshots__/post.test.js.snap | 82 ++++++++++++++++------- test/jest/post.test.js | 6 +- test/jest/user-profile-head.test.js | 3 +- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/test/jest/__snapshots__/post.test.js.snap b/test/jest/__snapshots__/post.test.js.snap index c92323dc5..5667b9087 100644 --- a/test/jest/__snapshots__/post.test.js.snap +++ b/test/jest/__snapshots__/post.test.js.snap @@ -179,15 +179,44 @@ exports[`Post Renders a textarea with post text when editing the post 1`] = ` >
-
-
-
- Loading selector... -
-
+
+ + To: + + + + My feed + + + Add/Edit +
+
+ + + Cancel + +
- Add photos or files - +
-
- - Cancel - - -
{ subscribers: [{ id: UID }], subscriptions: [UID], }, + users: { [UID]: { ...defaultState.users[UID], youCan: ['dm', 'unsubscribe'] } }, }; useSelectorMock.mockImplementation((selector) => selector(fakeState)); From 13856204602e7cb48581e6682a6b9eb1b5363638 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Mon, 10 Apr 2023 22:18:55 +0300 Subject: [PATCH 27/55] Show usernames and screennames in feeds selector --- .../feeds-selector/accounts-selector.jsx | 4 ++-- src/components/feeds-selector/options.jsx | 19 ++++++++++++------- .../feeds-selector/selector.module.scss | 7 +++++++ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/components/feeds-selector/accounts-selector.jsx b/src/components/feeds-selector/accounts-selector.jsx index 1863c836b..3fd1f90ec 100644 --- a/src/components/feeds-selector/accounts-selector.jsx +++ b/src/components/feeds-selector/accounts-selector.jsx @@ -99,6 +99,6 @@ function isValidNewOption(label) { return /^[a-z\d]{3,25}$/i.test(label.trim()); } -function formatOptionLabel(option) { - return ; +function formatOptionLabel(option, { context }) { + return ; } diff --git a/src/components/feeds-selector/options.jsx b/src/components/feeds-selector/options.jsx index 2cd41d93f..61375ccc6 100644 --- a/src/components/feeds-selector/options.jsx +++ b/src/components/feeds-selector/options.jsx @@ -46,11 +46,20 @@ const typeOrder = { [ACC_NOT_FOUND]: 7, }; -export function DisplayOption({ option, className }) { +export function DisplayOption({ option, context, className }) { return ( {option.type && } - {option.label} + {option.type === ACC_ME ? ( + MY_FEED_LABEL + ) : ( + <> + {option.value} + {context === 'menu' && option.label !== option.value && ( + {option.label} + )} + + )} ); } @@ -191,11 +200,7 @@ function compareOptions(a, b) { export function toOption(user, me) { const isMe = user.username === me.username; return { - label: isMe - ? MY_FEED_LABEL - : user.type === 'group' - ? user.screenName || user.username - : user.username, + label: user.screenName || user.username, value: user.username, type: isMe ? ACC_ME : user.type === 'group' ? ACC_GROUP : ACC_USER, }; diff --git a/src/components/feeds-selector/selector.module.scss b/src/components/feeds-selector/selector.module.scss index 67002caa6..02012cf0c 100644 --- a/src/components/feeds-selector/selector.module.scss +++ b/src/components/feeds-selector/selector.module.scss @@ -83,6 +83,13 @@ margin-right: 0.25em; } +.dest-screenName { + margin-left: 1em; + opacity: 0.5; + font-size: 0.9em; + letter-spacing: 0.03em; +} + :global(.fa-icon-fas-user).dest-icon { transform: scale(0.8); } From 2d2e825895116a004d0724da3a91a95760e43722 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Tue, 11 Apr 2023 18:29:02 +0300 Subject: [PATCH 28/55] Fix direct/regular detection (own feed present means regular post) --- src/components/feeds-selector/error.jsx | 2 +- src/components/feeds-selector/selector.jsx | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/feeds-selector/error.jsx b/src/components/feeds-selector/error.jsx index 4c3d1cbe1..5384236c2 100644 --- a/src/components/feeds-selector/error.jsx +++ b/src/components/feeds-selector/error.jsx @@ -56,7 +56,7 @@ export function SelectorError({ values, isDirect, isEditing, onError }) { You can’t send direct message to groups ( - {badGroups.map((a) => ( + {allGroups.map((a) => ( {a.label} ))} diff --git a/src/components/feeds-selector/selector.jsx b/src/components/feeds-selector/selector.jsx index a10607b69..d5094c4ea 100644 --- a/src/components/feeds-selector/selector.jsx +++ b/src/components/feeds-selector/selector.jsx @@ -14,6 +14,7 @@ import { EDIT_REGULAR, ACC_BAD_GROUP, ACC_BAD_USER, + ACC_ME, } from './constants'; import styles from './selector.module.scss'; import { SelectorError } from './error'; @@ -37,10 +38,12 @@ export function Selector({ if (mode === EDIT_REGULAR || mode === EDIT_DIRECT) { return mode === EDIT_DIRECT; } - const hasGroup = values.some((v) => v.type === ACC_GROUP || v.type === ACC_BAD_GROUP); + const hasGroupOrHome = values.some( + (v) => v.type === ACC_GROUP || v.type === ACC_BAD_GROUP || v.type === ACC_ME, + ); const hasUser = values.some((v) => v.type === ACC_USER || v.type === ACC_BAD_USER); - if (hasGroup) { + if (hasGroupOrHome) { return false; } else if (hasUser) { return true; From ea7f78dda34c26ba91647ac157d3fbee566d0054 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Tue, 11 Apr 2023 22:04:46 +0300 Subject: [PATCH 29/55] Remove group icons, better privacy check --- src/components/create-post.jsx | 20 +++++++-- src/components/feeds-selector/error.jsx | 8 ++-- src/components/feeds-selector/options.jsx | 32 ++++---------- .../feeds-selector/privacy-check.js | 44 +++++++++++++++++++ src/components/feeds-selector/selector.jsx | 16 ++----- src/components/post/post-edit-form.jsx | 40 ++++++++++++++++- 6 files changed, 115 insertions(+), 45 deletions(-) create mode 100644 src/components/feeds-selector/privacy-check.js diff --git a/src/components/create-post.jsx b/src/components/create-post.jsx index c8ee949fd..2c741fda7 100644 --- a/src/components/create-post.jsx +++ b/src/components/create-post.jsx @@ -24,6 +24,8 @@ import { useBool } from './hooks/bool'; import { useServerValue } from './hooks/server-info'; import { Selector } from './feeds-selector/selector'; import { CREATE_DIRECT, CREATE_REGULAR } from './feeds-selector/constants'; +import { CommaAndSeparated } from './separated'; +import { usePrivacyCheck } from './feeds-selector/privacy-check'; const selectMaxFilesCount = (serverInfo) => serverInfo.attachments.maxCountPerPost; const selectMaxPostLength = (serverInfo) => serverInfo.maxTextLength.post; @@ -135,7 +137,7 @@ export default function CreatePost({ sendTo, isDirects }) { return () => el.removeEventListener('click', h, { once: true }); }, []); - const [privacyLevel, setPrivacyLevel] = useState(''); + const [privacyLevel, privacyProblems] = usePrivacyCheck(feeds); const privacyIcon = useMemo( () => @@ -181,7 +183,6 @@ export default function CreatePost({ sendTo, isDirects }) { feedNames={feeds} onChange={setFeeds} onError={setHasFeedsError} - onPrivacyLevel={setPrivacyLevel} /> )} - {canSubmitForm && privacyIcon} + {privacyIcon} Post
@@ -251,6 +252,19 @@ export default function CreatePost({ sendTo, isDirects }) {
+ {privacyProblems.length > 0 && ( +
+ You have specified some {privacyLevel} feeds as a destination. This + will make this post {privacyLevel} and ignore stricter privacy settings + of{' '} + + {privacyProblems.map((p) => ( + @{p} + ))} + +
+ )} + {!canUploadMore && (
The maximum number of attached files ({maxFilesCount}) has been reached diff --git a/src/components/feeds-selector/error.jsx b/src/components/feeds-selector/error.jsx index 5384236c2..e5ca65e21 100644 --- a/src/components/feeds-selector/error.jsx +++ b/src/components/feeds-selector/error.jsx @@ -57,7 +57,7 @@ export function SelectorError({ values, isDirect, isEditing, onError }) { You can’t send direct message to groups ( {allGroups.map((a) => ( - {a.label} + @{a.value} ))} ). @@ -85,10 +85,10 @@ export function SelectorError({ values, isDirect, isEditing, onError }) { You are not a member of the{' '} {badGroups.map((a) => ( - {a.label} + @{a.value} ))} {' '} - {pluralForm(badGroups.length, 'group')}. + {pluralForm(badGroups.length, 'group', null, 'w')}. , ); } @@ -99,7 +99,7 @@ export function SelectorError({ values, isDirect, isEditing, onError }) { You can’t create regular post with a direct receivers ( {allUsers.map((a) => ( - {a.label} + @{a.value} ))} ). diff --git a/src/components/feeds-selector/options.jsx b/src/components/feeds-selector/options.jsx index 61375ccc6..59193d1c0 100644 --- a/src/components/feeds-selector/options.jsx +++ b/src/components/feeds-selector/options.jsx @@ -3,9 +3,9 @@ import { faQuestion, faSpinner, faUser, - faUsers, faUserSlash, - faUsersSlash, + // faUsers, + // faUsersSlash, } from '@fortawesome/free-solid-svg-icons'; import { uniq } from 'lodash'; import { useEffect, useMemo } from 'react'; @@ -28,9 +28,9 @@ import { const typeIcon = { [ACC_ME]: faHome, - [ACC_GROUP]: faUsers, + // [ACC_GROUP]: faUsers, + // [ACC_BAD_GROUP]: faUsersSlash, [ACC_USER]: faUser, - [ACC_BAD_GROUP]: faUsersSlash, [ACC_BAD_USER]: faUserSlash, [ACC_UNKNOWN]: faSpinner, [ACC_NOT_FOUND]: faQuestion, @@ -49,7 +49,9 @@ const typeOrder = { export function DisplayOption({ option, context, className }) { return ( - {option.type && } + {typeIcon[option.type] && ( + + )} {option.type === ACC_ME ? ( MY_FEED_LABEL ) : ( @@ -163,27 +165,9 @@ export function useSelectedOptions(usernames, fixedFeedNames) { .forEach((username) => dispatch(getUserInfo(username))); }, [dispatch, userInfoStatuses, values]); - const privacyLevel = useMemo(() => { - if (values.every((v) => v.type === ACC_USER)) { - return 'direct'; - } - if (values.some((v) => v.type !== ACC_GROUP && v.type !== ACC_ME)) { - return ''; - } - for (const { value: username } of values) { - const acc = usersByName.get(username); - if (acc.isProtected === '0') { - return 'public'; - } else if (acc.isPrivate === '0') { - return 'protected'; - } - } - return 'private'; - }, [usersByName, values]); - const meOption = useMemo(() => toOption(me, me), [me]); - return { values, meOption, groupOptions, userOptions, privacyLevel }; + return { values, meOption, groupOptions, userOptions }; } const collator = new Intl.Collator(undefined, { sensitivity: 'base', ignorePunctuation: true }); diff --git a/src/components/feeds-selector/privacy-check.js b/src/components/feeds-selector/privacy-check.js new file mode 100644 index 000000000..4e7d9e07b --- /dev/null +++ b/src/components/feeds-selector/privacy-check.js @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; + +export function usePrivacyCheck(feedNames) { + const myName = useSelector((store) => store.user.username); + const allUsers = useSelector((store) => Object.values(store.users), shallowEqual); + const level = useMemo(() => { + const accounts = feedNames.map((n) => allUsers.find((u) => u.username === n)); + + if (accounts.length === 0 || accounts.some((u) => !u)) { + return ''; + } + if (accounts.every((u) => u.type === 'user' && u.username !== myName)) { + return 'direct'; + } + + for (const acc of accounts) { + if (acc.type === 'user' && acc.username !== myName) { + return ''; + } + if (acc.isProtected === '0') { + return 'public'; + } + if (acc.isPrivate === '0') { + return 'protected'; + } + } + return 'private'; + }, [allUsers, feedNames, myName]); + + const problematicGroups = useMemo(() => { + if (level === 'public') { + const accounts = feedNames.map((n) => allUsers.find((u) => u.username === n)); + return accounts.filter((acc) => acc.isProtected === '1').map((acc) => acc.username); + } + if (level === 'protected') { + const accounts = feedNames.map((n) => allUsers.find((u) => u.username === n)); + return accounts.filter((acc) => acc.isPrivate === '1').map((acc) => acc.username); + } + return []; + }, [allUsers, feedNames, level]); + + return [level, problematicGroups]; +} diff --git a/src/components/feeds-selector/selector.jsx b/src/components/feeds-selector/selector.jsx index d5094c4ea..e61bd4968 100644 --- a/src/components/feeds-selector/selector.jsx +++ b/src/components/feeds-selector/selector.jsx @@ -1,5 +1,5 @@ import cn from 'classnames'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { ButtonLink } from '../button-link'; import { useBool } from '../hooks/bool'; import { DisplayOption, useSelectedOptions } from './options'; @@ -20,16 +20,8 @@ import styles from './selector.module.scss'; import { SelectorError } from './error'; import { AccountsSelector } from './accounts-selector'; -export function Selector({ - className, - mode, - feedNames, - fixedFeedNames = [], - onChange, - onError, - onPrivacyLevel, -}) { - const { values, meOption, groupOptions, userOptions, privacyLevel } = useSelectedOptions( +export function Selector({ className, mode, feedNames, fixedFeedNames = [], onChange, onError }) { + const { values, meOption, groupOptions, userOptions } = useSelectedOptions( feedNames, fixedFeedNames, ); @@ -92,8 +84,6 @@ export function Selector({ [onChange], ); - useEffect(() => onPrivacyLevel?.(privacyLevel), [onPrivacyLevel, privacyLevel]); - return (
serverInfo.attachments.maxCountPerPost; @@ -106,6 +112,36 @@ export function PostEditForm({ id, isDirect, recipients, createdBy, body, attach const handleCancel = useCallback(() => dispatch(cancelEditingPost(id)), [dispatch, id]); + const [privacyLevel] = usePrivacyCheck(feeds); + + const privacyIcon = useMemo( + () => + privacyLevel === 'private' ? ( + + ) : privacyLevel === 'protected' ? ( + + ) : privacyLevel === 'public' ? ( + + ) : privacyLevel === 'direct' ? ( + + ) : null, + [privacyLevel], + ); + + const privacyTitle = useMemo( + () => + privacyLevel === 'private' + ? 'Update private post' + : privacyLevel === 'protected' + ? 'Update protected post' + : privacyLevel === 'public' + ? 'Update public post' + : privacyLevel === 'direct' + ? 'Update direct message' + : null, + [privacyLevel], + ); + return ( <>
@@ -141,7 +177,9 @@ export function PostEditForm({ id, isDirect, recipients, createdBy, body, attach className="btn btn-default btn-xs" onClick={handleSubmit} disabled={!canSubmitForm} + title={privacyTitle} > + {privacyIcon} Update From 27d1df40bbe07f4feb80ad4cdea035978e37ee6a Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 14 Apr 2023 10:44:11 +0300 Subject: [PATCH 30/55] Fix direct mode detection on Directs page --- src/components/feeds-selector/selector.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/feeds-selector/selector.jsx b/src/components/feeds-selector/selector.jsx index e61bd4968..c50bebef8 100644 --- a/src/components/feeds-selector/selector.jsx +++ b/src/components/feeds-selector/selector.jsx @@ -121,7 +121,7 @@ export function Selector({ className, mode, feedNames, fixedFeedNames = [], onCh
From 6667a975884c47dd75f6da6a3220735ae66b66a2 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 29 Apr 2023 12:36:59 +0300 Subject: [PATCH 31/55] Add privacy icons to groups --- src/components/feeds-selector/options.jsx | 32 +++++++++++++++---- src/components/feeds-selector/selector.jsx | 1 + .../feeds-selector/selector.module.scss | 8 +++-- src/utils/get-privacy.js | 9 ++++++ 4 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 src/utils/get-privacy.js diff --git a/src/components/feeds-selector/options.jsx b/src/components/feeds-selector/options.jsx index 59193d1c0..889fd13ad 100644 --- a/src/components/feeds-selector/options.jsx +++ b/src/components/feeds-selector/options.jsx @@ -1,8 +1,11 @@ import { + faGlobeAmericas, faHome, + faLock, faQuestion, faSpinner, faUser, + faUserFriends, faUserSlash, // faUsers, // faUsersSlash, @@ -10,10 +13,12 @@ import { import { uniq } from 'lodash'; import { useEffect, useMemo } from 'react'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; +import cn from 'classnames'; import { Icon } from '../fontawesome-icons'; // Local styles import { getUserInfo } from '../../redux/action-creators'; +import { getPrivacy } from '../../utils/get-privacy'; import styles from './selector.module.scss'; import { ACC_ME, @@ -36,6 +41,13 @@ const typeIcon = { [ACC_NOT_FOUND]: faQuestion, }; +const privacyIcon = { + public: faGlobeAmericas, + protected: faUserFriends, + private: faLock, + user: faUser, +}; + const typeOrder = { [ACC_ME]: 1, [ACC_GROUP]: 2, @@ -48,9 +60,13 @@ const typeOrder = { export function DisplayOption({ option, context, className }) { return ( - - {typeIcon[option.type] && ( - + + {option.type === ACC_ME || option.type === ACC_GROUP ? ( + + ) : ( + typeIcon[option.type] && ( + + ) )} {option.type === ACC_ME ? ( MY_FEED_LABEL @@ -123,12 +139,13 @@ export function useSelectedOptions(usernames, fixedFeedNames) { label: name, value: name, type: ACC_UNKNOWN, + privacy: 'user', isFixed: fixedFeedNames.includes(name), }; if (name === me.username) { // It's me! - return { ...props, label: MY_FEED_LABEL, type: ACC_ME }; + return { ...props, label: MY_FEED_LABEL, type: ACC_ME, privacy: getPrivacy(me) }; } const acc = usersByName.get(name); @@ -137,6 +154,7 @@ export function useSelectedOptions(usernames, fixedFeedNames) { ...props, label: acc.screenName, type: acc.youCan.includes('post') ? ACC_GROUP : ACC_BAD_GROUP, + privacy: getPrivacy(acc), }; } else if (acc?.type === 'user') { return { @@ -153,7 +171,7 @@ export function useSelectedOptions(usernames, fixedFeedNames) { return props; }) .sort(compareOptions), - [fixedFeedNames, me.username, usernames, usersByName, notFoundUsers], + [usernames, fixedFeedNames, me, usersByName, notFoundUsers], ); // Load missing accounts @@ -170,7 +188,6 @@ export function useSelectedOptions(usernames, fixedFeedNames) { return { values, meOption, groupOptions, userOptions }; } -const collator = new Intl.Collator(undefined, { sensitivity: 'base', ignorePunctuation: true }); function compareOptions(a, b) { if (a.isFixed !== b.isFixed) { return a.isFixed ? -1 : 1; @@ -178,7 +195,7 @@ function compareOptions(a, b) { if (a.type !== b.type) { return typeOrder[a.type] - typeOrder[b.type]; } - return collator.compare(a.label, b.label); + return a.value.localeCompare(b.value); } export function toOption(user, me) { @@ -187,5 +204,6 @@ export function toOption(user, me) { label: user.screenName || user.username, value: user.username, type: isMe ? ACC_ME : user.type === 'group' ? ACC_GROUP : ACC_USER, + privacy: user.type === 'group' || isMe ? getPrivacy(user) : 'user', }; } diff --git a/src/components/feeds-selector/selector.jsx b/src/components/feeds-selector/selector.jsx index c50bebef8..babe670e4 100644 --- a/src/components/feeds-selector/selector.jsx +++ b/src/components/feeds-selector/selector.jsx @@ -99,6 +99,7 @@ export function Selector({ className, mode, feedNames, fixedFeedNames = [], onCh key={opt.value} className={cn(styles['box-item'], styles['dest-item'])} option={opt} + context="value" /> ))} diff --git a/src/components/feeds-selector/selector.module.scss b/src/components/feeds-selector/selector.module.scss index 02012cf0c..171d8b446 100644 --- a/src/components/feeds-selector/selector.module.scss +++ b/src/components/feeds-selector/selector.module.scss @@ -79,8 +79,12 @@ } .dest-icon { - opacity: 0.8; - margin-right: 0.25em; + opacity: 0.5; + margin-right: 0.4em; +} + +.opt-ctx--value .dest-icon { + opacity: 0.85; } .dest-screenName { diff --git a/src/utils/get-privacy.js b/src/utils/get-privacy.js new file mode 100644 index 000000000..ca7b063d7 --- /dev/null +++ b/src/utils/get-privacy.js @@ -0,0 +1,9 @@ +export function getPrivacy({ isPrivate, isProtected }) { + if (isProtected === '0') { + return 'public'; + } + if (isPrivate === '0') { + return 'protected'; + } + return 'private'; +} From e68a446a4b4a968e6692632134a44c5bc7579a0f Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 29 Apr 2023 12:43:28 +0300 Subject: [PATCH 32/55] Better 'create option' message --- src/components/feeds-selector/selector.jsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/feeds-selector/selector.jsx b/src/components/feeds-selector/selector.jsx index babe670e4..2f09f1090 100644 --- a/src/components/feeds-selector/selector.jsx +++ b/src/components/feeds-selector/selector.jsx @@ -84,6 +84,11 @@ export function Selector({ className, mode, feedNames, fixedFeedNames = [], onCh [onChange], ); + const formatCreateLabel = useCallback( + (label) => (isDirect ? `Add @${label} as recipient` : `Post to @${label}`), + [isDirect], + ); + return (
); } - -function formatCreateLabel(label) { - return `Send direct message to @${label}`; -} From dd1c7f8893127d4e7ba414b55200b7d8dc071013 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 29 Apr 2023 13:39:13 +0300 Subject: [PATCH 33/55] Update title of the "Groups" options --- src/components/feeds-selector/selector.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/feeds-selector/selector.jsx b/src/components/feeds-selector/selector.jsx index 2f09f1090..5501d186a 100644 --- a/src/components/feeds-selector/selector.jsx +++ b/src/components/feeds-selector/selector.jsx @@ -54,7 +54,7 @@ export function Selector({ className, mode, feedNames, fixedFeedNames = [], onCh const regularOptions = [ meOption, { - label: 'Groups', + label: 'Groups you’re in', options: groupOptions, }, ]; From 6ab7d6f57a52e873784cd8ce7f464297aad8c2f9 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 29 Apr 2023 13:40:29 +0300 Subject: [PATCH 34/55] Auto-fix incorrect layout when typing in selector US, Russian and Ukrainian layouts are supported --- .../feeds-selector/accounts-selector.jsx | 18 +++++++++++ src/components/feeds-selector/kbd-layouts.js | 32 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/components/feeds-selector/kbd-layouts.js diff --git a/src/components/feeds-selector/accounts-selector.jsx b/src/components/feeds-selector/accounts-selector.jsx index 3fd1f90ec..5a56524d4 100644 --- a/src/components/feeds-selector/accounts-selector.jsx +++ b/src/components/feeds-selector/accounts-selector.jsx @@ -1,8 +1,10 @@ import cn from 'classnames'; +import { createFilter } from 'react-select'; import { lazyComponent } from '../lazy-component'; import { Throbber } from '../throbber'; import styles from './selector.module.scss'; import { DisplayOption } from './options'; +import { kbdVariants } from './kbd-layouts'; function Fallback() { return ( @@ -36,11 +38,27 @@ export function AccountsSelector({ isCreatable = true, ...props }) { styles={selStyles} formatOptionLabel={formatOptionLabel} isValidNewOption={isValidNewOption} + filterOption={customFilter} {...props} /> ); } +const nativeFilter = createFilter(); + +function customFilter(option, input) { + if (!input) { + return true; + } + for (const variant of kbdVariants(input)) { + const ok = nativeFilter(option, variant); + if (ok) { + return true; + } + } + return false; +} + const selTheme = (theme) => ({ ...theme, borderRadius: 0, diff --git a/src/components/feeds-selector/kbd-layouts.js b/src/components/feeds-selector/kbd-layouts.js new file mode 100644 index 000000000..99368fff7 --- /dev/null +++ b/src/components/feeds-selector/kbd-layouts.js @@ -0,0 +1,32 @@ +import memoize from 'memoize-one'; + +// See https://learn.microsoft.com/en-us/globalization/windows-keyboard-layouts +const KBD_US = `\`1234567890-=\\qwertyuiop[]asdfghjkl;'zxcvbnm,./~!@#$%^&*()_+|QWERTYUIOP{}ASDFGHJKL:"ZXCVBNM<>?`; +const KBD_Russian = `ё1234567890-=\\йцукенгшщзхъфывапролджэячсмитьбю.Ё!"№;%:?*()_+/ЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,`; +const KBD_Ukrainian = `\`1234567890-=\\йцукенгшщзхїфівапролджєячсмитьбю.~!"№;%:?*()_+/ЙЦУКЕНГШЩЗХЇФІВАПРОЛДЖЄЯЧСМИТЬБЮ,`; + +const kbdLayouts = [KBD_US, KBD_Russian, KBD_Ukrainian]; + +export const kbdVariants = memoize((input) => { + const results = new Set([input]); + for (let i = 0; i < kbdLayouts.length; i++) { + for (let j = 0; j < kbdLayouts.length; j++) { + if (i === j) { + continue; + } + const l1 = kbdLayouts[i]; + const l2 = kbdLayouts[j]; + let variant = ''; + for (const char of input) { + const idx = l1.indexOf(char); + if (idx >= 0) { + variant += l2[idx]; + } else { + variant += char; + } + } + results.add(variant); + } + } + return results; +}); From b93838b314b1f353cb072d240d7376db10528cc8 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 29 Apr 2023 13:42:48 +0300 Subject: [PATCH 35/55] Update test snapshot --- test/jest/__snapshots__/post.test.js.snap | 26 +++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/test/jest/__snapshots__/post.test.js.snap b/test/jest/__snapshots__/post.test.js.snap index 5667b9087..a318ded5d 100644 --- a/test/jest/__snapshots__/post.test.js.snap +++ b/test/jest/__snapshots__/post.test.js.snap @@ -191,20 +191,15 @@ exports[`Post Renders a textarea with post text when editing the post 1`] = ` To: My feed @@ -244,7 +239,20 @@ Line 2 > Date: Sun, 30 Apr 2023 13:29:52 +0300 Subject: [PATCH 36/55] Update changelog --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35bb7e675..1f8571a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.118] - Not released +### Changed +- Updated feed selector component: + - The react-select library has been updated from v1 to v5 + - It is possible to create posts in non-private groups without being a member + of them. A post creation form is available on the pages of such groups. + - All post creation and editing form controls are now accessible from the + keyboard. + - The feed selector can search by username and screenname. Search with an + incorrect keyboard layout is possible (English, Russian and Ukrainian + layouts are supported). + - Privacy indication is improved: + - The "Post" button shows the privacy icon of the post being created. + - The feed selector shows group privacy icons. + - A warning is shown if the post is published in groups with different + privacy levels. ## [1.117.1] - 2023-04-01 ### Fixed From 9173ea7b6b2ff349897d87eaed0ca6115ec8e532 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 13 Apr 2023 16:08:05 +0300 Subject: [PATCH 37/55] Use only pathname of link to check if it is a link to image --- src/components/media-viewer.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/media-viewer.jsx b/src/components/media-viewer.jsx index dbfc74deb..bc87a8c1f 100644 --- a/src/components/media-viewer.jsx +++ b/src/components/media-viewer.jsx @@ -24,7 +24,7 @@ const ImageAttachmentsLightbox = lazyComponent( ); export const getMediaType = (url) => { - if (url.match(/\.(jpg|png|jpeg|webp|gif)(\?|$|#)/i)) { + if (new URL(url).pathname.match(/\.(jpg|png|jpeg|webp|gif)$/i)) { return 'image'; } else if (isInstagram(url)) { return 'instagram'; From 7eeb5a50d289eb86f991f57f5d97a39077329a17 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Mon, 1 May 2023 16:34:16 +0300 Subject: [PATCH 38/55] Add arial-abel to post submit buttons --- src/components/create-post.jsx | 1 + src/components/post/post-edit-form.jsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/create-post.jsx b/src/components/create-post.jsx index 2c741fda7..b804a5451 100644 --- a/src/components/create-post.jsx +++ b/src/components/create-post.jsx @@ -213,6 +213,7 @@ export default function CreatePost({ sendTo, isDirects }) { className={cn('btn btn-default btn-xs', !canSubmitForm && 'disabled')} aria-disabled={!canSubmitForm} title={privacyTitle} + aria-label={privacyTitle} > {privacyIcon} Post diff --git a/src/components/post/post-edit-form.jsx b/src/components/post/post-edit-form.jsx index c8b4470be..88df9a7b7 100644 --- a/src/components/post/post-edit-form.jsx +++ b/src/components/post/post-edit-form.jsx @@ -176,8 +176,9 @@ export function PostEditForm({ id, isDirect, recipients, createdBy, body, attach
+
@@ -265,6 +268,9 @@ exports[`PostMoreMenu > Renders a More menu for a logged-in reader 1`] = `
+
@@ -537,6 +543,9 @@ exports[`PostMoreMenu > Renders a More menu for a post owner 1`] = `
+
From a0bb3f716e5d60b6310277a2abcf77a03a60bd2f Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 24 Nov 2023 14:28:54 +0300 Subject: [PATCH 46/55] Remove Node 16 from the 'check' action list --- .github/workflows/checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e8bc46f03..9cac5e2a1 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: - node-version: [16, 18, 20] + node-version: [18, 20] steps: - uses: actions/checkout@v3 From 0f944de642bff14b0304b04443b0ae143f553b25 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 1 Mar 2024 17:15:46 +0300 Subject: [PATCH 47/55] Update test snapshot --- test/jest/__snapshots__/post.test.jsx.snap | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/jest/__snapshots__/post.test.jsx.snap b/test/jest/__snapshots__/post.test.jsx.snap index e6bf85895..6855b81bf 100644 --- a/test/jest/__snapshots__/post.test.jsx.snap +++ b/test/jest/__snapshots__/post.test.jsx.snap @@ -140,9 +140,6 @@ exports[`Post > Renders a post and doesn't blow up 1`] = ` Like -
From d19e29844c6c7d73a89692cd7b21bfb7efb849ba Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sun, 7 Apr 2024 07:50:31 +0300 Subject: [PATCH 49/55] Add scroll compensation --- src/components/post/post-comment.jsx | 1 + src/components/post/post-comments/index.jsx | 59 ++++++++++++++++++++- styles/shared/comments.scss | 13 +++-- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/components/post/post-comment.jsx b/src/components/post/post-comment.jsx index a92455a8a..1ae44a70b 100644 --- a/src/components/post/post-comment.jsx +++ b/src/components/post/post-comment.jsx @@ -388,6 +388,7 @@ class PostComment extends Component { data-author={this.props.user && !this.props.isEditing ? this.props.user.username : ''} ref={this.registerCommentContainer} role="comment listitem" + id={this.props.id ? `c-${this.props.id}` : null} > {this.renderCommentIcon()} {this.renderBody()} diff --git a/src/components/post/post-comments/index.jsx b/src/components/post/post-comments/index.jsx index 34108849b..fef37cedc 100644 --- a/src/components/post/post-comments/index.jsx +++ b/src/components/post/post-comments/index.jsx @@ -2,7 +2,7 @@ import { createRef, Fragment, Component } from 'react'; import { preventDefault, pluralForm, handleLeftClick } from '../../../utils'; -import { safeScrollBy } from '../../../services/unscroll'; +import { intentToScroll, safeScrollBy } from '../../../services/unscroll'; import ErrorBoundary from '../../error-boundary'; import { Icon } from '../../fontawesome-icons'; import { faCommentPlus } from '../../fontawesome-custom-icons'; @@ -38,6 +38,8 @@ export default class PostComments extends Component { visibleCommentIds = createRef([]); unfocusTimer = createRef(0); + commentAfterFoldId = null; + constructor(props) { super(props); @@ -309,8 +311,12 @@ export default class PostComments extends Component { this.setState({ folded: true, addedToTail: 0 }); }; + /** @type {string|null} */ + fixedCommentId = null; + expandComments = (count = 0) => { if (count > 0) { + this.fixedCommentId = this.commentAfterFoldId; this.setState({ folded: true, addedToTail: this.state.addedToTail + count }); } else { this.setState({ folded: false, addedToTail: 0 }); @@ -320,7 +326,53 @@ export default class PostComments extends Component { } }; - componentDidUpdate(prevProps, prevState) { + getSnapshotBeforeUpdate() { + if (!this.fixedCommentId) { + return null; + } + + const { fixedCommentId } = this; + + const el = this.rootEl.current.querySelector(`#c-${fixedCommentId}`); + if (!el) { + return null; + } + const { top } = el.getBoundingClientRect(); + intentToScroll(); + return () => { + if (fixedCommentId === this.commentAfterFoldId) { + return; + } + + this.fixedCommentId = null; + const el = this.rootEl.current.querySelector(`#c-${fixedCommentId}`); + if (!el) { + return; + } + const newTop = el.getBoundingClientRect().top; + safeScrollBy(0, newTop - top); + // Compensate scroll again, after the full render + setTimeout(() => safeScrollBy(0, el.getBoundingClientRect().top - top), 0); + + // Unfold animation + { + let commentEl = el; + if (commentEl) { + for (let i = 0; i < this.props.foldStep; i++) { + commentEl = commentEl.previousElementSibling; + if (!commentEl || !commentEl.matches('.comment')) { + break; + } + commentEl.classList.add('just-unfolded'); + commentEl.offsetHeight; // to commit DOM changes + commentEl.classList.remove('just-unfolded'); + } + } + } + }; + } + + componentDidUpdate(prevProps, prevState, actionAfterUpdate) { if (this.state.folded && !prevState.folded) { const linkEl = this.rootEl.current.querySelector('.more-comments-wrapper'); if (!linkEl) { @@ -331,6 +383,7 @@ export default class PostComments extends Component { safeScrollBy(0, top); } } + actionAfterUpdate?.(); } renderAddComment() { @@ -494,6 +547,8 @@ export default class PostComments extends Component { const firstCommentSpacer = comments.length > 0 ? this.renderCommentSpacer(comments[0].createdAt, post.createdAt) : null; + this.commentAfterFoldId = tailComments[0]?.props.id ?? null; + return (
Date: Fri, 12 Apr 2024 12:15:01 +0300 Subject: [PATCH 50/55] Set minToSteppedFold to 15 --- config/default.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/default.js b/config/default.js index 314596042..2a74ed51b 100644 --- a/config/default.js +++ b/config/default.js @@ -114,7 +114,7 @@ export default { // A minimum number of omitted comments (server-side constant) minFolded: 3, // A minimum number of omitted comments when the stepped folding is appear - minToSteppedFold: 30, + minToSteppedFold: 15, // A value of the stepped folding foldStep: 10, }, From 196a5ddf143372791678ed1279f1843c6ca63bce Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 13 Apr 2024 14:50:47 +0300 Subject: [PATCH 51/55] Update style of more-comments-wrapper elements --- styles/shared/comments.scss | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/styles/shared/comments.scss b/styles/shared/comments.scss index 0d9dfa695..0762425d5 100644 --- a/styles/shared/comments.scss +++ b/styles/shared/comments.scss @@ -75,15 +75,20 @@ white-space: nowrap; } + &.more-comments-wrapper { + position: relative; + margin-left: rem(16px); + } + .more-comments-throbber { - display: inline-block; + position: absolute; + left: rem(-16px); width: rem(16px); - margin-left: rem(-1px); - margin-right: rem(4px); } .more-comments-link { font-style: italic; + white-space: nowrap; } .add-comment-link { From 012cb4935fe270ac34e7199a3dbd659c17545c8c Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 13 Apr 2024 23:18:54 +0300 Subject: [PATCH 52/55] Add will-change: transform to unfolded comments --- styles/shared/comments.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/styles/shared/comments.scss b/styles/shared/comments.scss index 0762425d5..e886b91cc 100644 --- a/styles/shared/comments.scss +++ b/styles/shared/comments.scss @@ -126,6 +126,7 @@ transform: translateY(16px); opacity: 0.5; transition: all 0s; + will-change: transform; } } From 6e8de8b55ff54aaadbdde3d17f84007fef34d154 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 19 Apr 2024 11:58:33 +0300 Subject: [PATCH 53/55] Turn off scroll compensation for partial unfold --- src/components/post/post-comment.jsx | 1 - src/components/post/post-comments/index.jsx | 59 +-------------------- styles/shared/comments.scss | 14 ++--- 3 files changed, 5 insertions(+), 69 deletions(-) diff --git a/src/components/post/post-comment.jsx b/src/components/post/post-comment.jsx index 1ae44a70b..a92455a8a 100644 --- a/src/components/post/post-comment.jsx +++ b/src/components/post/post-comment.jsx @@ -388,7 +388,6 @@ class PostComment extends Component { data-author={this.props.user && !this.props.isEditing ? this.props.user.username : ''} ref={this.registerCommentContainer} role="comment listitem" - id={this.props.id ? `c-${this.props.id}` : null} > {this.renderCommentIcon()} {this.renderBody()} diff --git a/src/components/post/post-comments/index.jsx b/src/components/post/post-comments/index.jsx index fef37cedc..34108849b 100644 --- a/src/components/post/post-comments/index.jsx +++ b/src/components/post/post-comments/index.jsx @@ -2,7 +2,7 @@ import { createRef, Fragment, Component } from 'react'; import { preventDefault, pluralForm, handleLeftClick } from '../../../utils'; -import { intentToScroll, safeScrollBy } from '../../../services/unscroll'; +import { safeScrollBy } from '../../../services/unscroll'; import ErrorBoundary from '../../error-boundary'; import { Icon } from '../../fontawesome-icons'; import { faCommentPlus } from '../../fontawesome-custom-icons'; @@ -38,8 +38,6 @@ export default class PostComments extends Component { visibleCommentIds = createRef([]); unfocusTimer = createRef(0); - commentAfterFoldId = null; - constructor(props) { super(props); @@ -311,12 +309,8 @@ export default class PostComments extends Component { this.setState({ folded: true, addedToTail: 0 }); }; - /** @type {string|null} */ - fixedCommentId = null; - expandComments = (count = 0) => { if (count > 0) { - this.fixedCommentId = this.commentAfterFoldId; this.setState({ folded: true, addedToTail: this.state.addedToTail + count }); } else { this.setState({ folded: false, addedToTail: 0 }); @@ -326,53 +320,7 @@ export default class PostComments extends Component { } }; - getSnapshotBeforeUpdate() { - if (!this.fixedCommentId) { - return null; - } - - const { fixedCommentId } = this; - - const el = this.rootEl.current.querySelector(`#c-${fixedCommentId}`); - if (!el) { - return null; - } - const { top } = el.getBoundingClientRect(); - intentToScroll(); - return () => { - if (fixedCommentId === this.commentAfterFoldId) { - return; - } - - this.fixedCommentId = null; - const el = this.rootEl.current.querySelector(`#c-${fixedCommentId}`); - if (!el) { - return; - } - const newTop = el.getBoundingClientRect().top; - safeScrollBy(0, newTop - top); - // Compensate scroll again, after the full render - setTimeout(() => safeScrollBy(0, el.getBoundingClientRect().top - top), 0); - - // Unfold animation - { - let commentEl = el; - if (commentEl) { - for (let i = 0; i < this.props.foldStep; i++) { - commentEl = commentEl.previousElementSibling; - if (!commentEl || !commentEl.matches('.comment')) { - break; - } - commentEl.classList.add('just-unfolded'); - commentEl.offsetHeight; // to commit DOM changes - commentEl.classList.remove('just-unfolded'); - } - } - } - }; - } - - componentDidUpdate(prevProps, prevState, actionAfterUpdate) { + componentDidUpdate(prevProps, prevState) { if (this.state.folded && !prevState.folded) { const linkEl = this.rootEl.current.querySelector('.more-comments-wrapper'); if (!linkEl) { @@ -383,7 +331,6 @@ export default class PostComments extends Component { safeScrollBy(0, top); } } - actionAfterUpdate?.(); } renderAddComment() { @@ -547,8 +494,6 @@ export default class PostComments extends Component { const firstCommentSpacer = comments.length > 0 ? this.renderCommentSpacer(comments[0].createdAt, post.createdAt) : null; - this.commentAfterFoldId = tailComments[0]?.props.id ?? null; - return (
Date: Thu, 27 Jun 2024 22:22:52 +0300 Subject: [PATCH 54/55] Update React states right after text update --- src/components/autocomplete/autocomplete.jsx | 1 + src/components/smart-textarea.jsx | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/components/autocomplete/autocomplete.jsx b/src/components/autocomplete/autocomplete.jsx index d633f773e..85d49222a 100644 --- a/src/components/autocomplete/autocomplete.jsx +++ b/src/components/autocomplete/autocomplete.jsx @@ -131,4 +131,5 @@ function replaceQuery(input, replacement) { input.setSelectionRange(newCaretPos, newCaretPos); input.dispatchEvent(new Event('input', { bubbles: true })); + input.updateStates?.(); } diff --git a/src/components/smart-textarea.jsx b/src/components/smart-textarea.jsx index 55b07dd56..fe03c561e 100644 --- a/src/components/smart-textarea.jsx +++ b/src/components/smart-textarea.jsx @@ -63,7 +63,13 @@ export const SmartTextarea = forwardRef(function SmartTextarea( }; }, [cancelEmptyDraftOnBlur, draftKey, ref]); - ref.current.insertText = useDebouncedInsert(100, ref, onText, draftKey); + // Public component methods + ref.current.insertText = useDebouncedInsert(100, ref); + ref.current.updateStates = useCallback(() => { + const text = ref.current.value; + onText?.(text); + draftKey && setDraftField(draftKey, 'text', text); + }, [draftKey, onText, ref]); useEffect(() => { if (!draftKey && !onText) { @@ -242,7 +248,7 @@ function containsFiles(dndEvent) { return false; } -function useDebouncedInsert(interval, inputRef, onText, draftKey) { +function useDebouncedInsert(interval, inputRef) { const queue = useRef([]); const timer = useRef(0); @@ -267,11 +273,8 @@ function useDebouncedInsert(interval, inputRef, onText, draftKey) { input.value = text; input.setSelectionRange(selStart, selEnd); input.focus(); - onText?.(input.value); - if (draftKey) { - setDraftField(draftKey, 'text', input.value); - } - }, [draftKey, inputRef, onText]); + input.updateStates(); + }, [inputRef]); return useCallback( (insertion) => { From e33d74ec71dd6c90f78aab2b4d45bf7604d6dab0 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Tue, 3 Sep 2024 13:24:04 +0300 Subject: [PATCH 55/55] Exclude index.html from cache --- vite.config.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/vite.config.mjs b/vite.config.mjs index e1e628287..d720e79eb 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -73,8 +73,11 @@ export default defineConfig(({ mode }) => ({ /^\/(config\.json|version\.txt|robots\.txt)$/, /^\/(docs|assets)\//, ], - // Add 'woff2' to the default 'globPatterns' - globPatterns: ['**/*.{js,wasm,css,html,woff2}'], + // Add '.woff2' and exclude (!) '.html' from the default 'globPatterns'. + // We don't want to cache index.html because of beta/non-beta switching + // and the possible config.json inclusion. So we only cache the assets + // but not the page itself. + globPatterns: ['**/*.{js,wasm,css,woff2}'], runtimeCaching: [ // Cache profile pictures (up to 100 entries) {