Porównaj commity

..

13 Commity

Autor SHA1 Wiadomość Data
Alex Gleason 49b996a901
Add StillVideo component 2023-02-08 16:56:33 -06:00
Alex Gleason fd2bb2e16f
Consolidate around MediaPreview component 2023-02-08 16:07:39 -06:00
Alex Gleason bbb29d0388
Merge remote-tracking branch 'origin/develop' into media-gallery 2023-02-08 15:49:50 -06:00
Alex Gleason c8509627a1
Merge remote-tracking branch 'origin/develop' into media-gallery 2023-02-03 12:26:48 -06:00
Alex Gleason ffc8ade279
Reuse ExtensionBadge from StillImage, move ext to MediaItemThumbnail 2023-01-31 17:56:28 -06:00
Alex Gleason 0354e8e96f
Tailwindify MoreMediaOverlay component 2023-01-31 16:42:52 -06:00
Alex Gleason 07323c19b0
Refactor letterboxing code 2023-01-31 16:25:38 -06:00
Alex Gleason 4e74ca3c55
MediaGallery: fix layout adaptive by aspect ratio 2023-01-31 15:48:53 -06:00
Alex Gleason 215c857648
Move gigantic media sizing code into useMediaSizeData file/hook 2023-01-31 14:37:27 -06:00
Alex Gleason 6bbd00c658
Move MediaItem to separate file 2023-01-31 13:48:28 -06:00
Alex Gleason ca4fa5e5c5
Create components/media-gallery subdirectory 2023-01-31 13:37:38 -06:00
Alex Gleason 4167a1de05
Break out MediaItemThumbnail into a separate component 2023-01-31 13:34:29 -06:00
Alex Gleason c5cf252668
media-gallery.scss: switch to @apply rules where possible 2023-01-31 12:22:42 -06:00
683 zmienionych plików z 10722 dodań i 19103 usunięć

Wyświetl plik

@ -56,7 +56,6 @@ module.exports = {
}, },
polyfills: [ polyfills: [
'es:all', // core-js 'es:all', // core-js
'fetch', // not polyfilled, but ignore it
'IntersectionObserver', // npm:intersection-observer 'IntersectionObserver', // npm:intersection-observer
'Promise', // core-js 'Promise', // core-js
'ResizeObserver', // npm:resize-observer-polyfill 'ResizeObserver', // npm:resize-observer-polyfill
@ -261,29 +260,12 @@ module.exports = {
}, },
], ],
'@typescript-eslint/no-duplicate-imports': 'error', '@typescript-eslint/no-duplicate-imports': 'error',
'@typescript-eslint/member-delimiter-style': [
'error',
{
multiline: {
delimiter: 'none',
},
singleline: {
delimiter: 'comma',
},
},
],
'promise/catch-or-return': 'error', 'promise/catch-or-return': 'error',
'react-hooks/rules-of-hooks': 'error', 'react-hooks/rules-of-hooks': 'error',
'tailwindcss/classnames-order': [ 'tailwindcss/classnames-order': 'error',
'error',
{
classRegex: '^(base|container|icon|item|list|outer|wrapper)?[c|C]lass(Name)?$',
config: 'tailwind.config.cjs',
},
],
'tailwindcss/migration-from-tailwind-2': 'error', 'tailwindcss/migration-from-tailwind-2': 'error',
}, },
overrides: [ overrides: [

Wyświetl plik

@ -157,11 +157,11 @@ docker:
# https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df # https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df
script: script:
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG . - docker build -t $CI_REGISTRY_IMAGE .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG - docker push $CI_REGISTRY_IMAGE
rules: only:
- if: $CI_COMMIT_TAG variables:
interruptible: false - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
release: release:
stage: release stage: release

Wyświetl plik

@ -6,61 +6,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- Posts: Support posts filtering on recent Mastodon versions
- Reactions: Support custom emoji reactions
- Compatbility: Support Mastodon v2 timeline filters.
- Posts: Support dislikes on Friendica.
- UI: added a character counter to some textareas.
### Changed
- Posts: truncate Nostr pubkeys in reply mentions.
- Posts: upgraded emoji picker component.
- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist.
### Fixed
- Posts: fixed emojis being cut off in reactions modal.
- Posts: fix audio player progress bar visibility.
- Posts: added missing gap in pending status.
- Compatibility: fixed quote posting compatibility with custom Pleroma forks.
- Profile: fix "load more" button height on account gallery page.
- 18n: fixed Chinese language being detected from the browser.
- Conversations: fixed pagination (Mastodon).
- Compatibility: fix version parsing for Friendica.
## [3.2.0] - 2023-02-15
### Added ### Added
- Admin: redirect the homepage to any URL. - Admin: redirect the homepage to any URL.
- Compatibility: added compatibility with Friendica. - Compatibility: added compatibility with Friendica.
- Posts: bot badge on statuses from bot accounts. - Posts: bot badge on statuses from bot accounts.
- Compatibility: improved browser support for older browsers. - Compatibility: improved browser support for older browsers.
- Events: allow to repost events in event menu. - Events: allow to repost events in event menu.
- Groups: Initial support for groups.
- Profile: Add RSS link to user profiles. - Profile: Add RSS link to user profiles.
- Reactions: adds support for reacting to chat messages.
- Groups: initial support for groups.
- Profile: add RSS link to user profiles.
- Chats: reset chat message field height after sending a message.
- Admin: allow to manage announcements.
### Changed ### Changed
- Chats: improved display of media attachments. - Chats: improved display of media attachments.
- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away. - ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away.
- Posts: increased font size of focused status in threads. - Posts: increased font size of focused status in threads.
- Posts: let "mute conversation" be clicked from any feed, not just noficiations. - Posts: let "mute conversation" be clicked from any feed, not just noficiations.
- Posts: display all emoji reactions.
- Reactions: improved UI of reactions on statuses.
- Profile: make verified badge more prominent, overlapping with avatar.
### Fixed ### Fixed
- Admin: fixed hover card in reports modal shows reporter not reportee
- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load. - Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load.
- Chats: don't display "copy" button for messages without text. - Chats: don't display "copy" button for messages without text.
- Posts: don't have to click the play button twice for embedded videos. - Posts: don't have to click the play button twice for embedded videos.
- index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header. - index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header.
- Modals: fix media modal automatically switching to video. - Modals: fix media modal automatically switching to video.
- Navigation: profile dropdown erratic behavior.
- Posts: fix posts filtering.
### Removed ### Removed
- Admin: single user mode. Now the homepage can be redirected to any URL. - Admin: single user mode. Now the homepage can be redirected to any URL.

Wyświetl plik

@ -75,7 +75,7 @@ One disadvantage of this approach is that it does not help the software spread.
© Alex Gleason & other Soapbox contributors © Alex Gleason & other Soapbox contributors
© Eugen Rochko & other Mastodon contributors © Eugen Rochko & other Mastodon contributors
© Trump Media & Technology Group © Trump Media & Technology Group
© Gab AI, Inc. © Gab AI, Inc.
Soapbox is free software: you can redistribute it and/or modify Soapbox is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by

Wyświetl plik

@ -2,4 +2,4 @@
- verified.svg - Created by Alex Gleason. CC0 - verified.svg - Created by Alex Gleason. CC0
Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg

Wyświetl plik

@ -1,107 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 95 95" width="100" height="100"><path d="M94.909 19.374C94.909 8.674 86.235 0 75.534 0c-10.647 0-19.28 8.591-19.365 19.217l-15.631 2.09c-1.961-6.007-7.598-10.35-14.258-10.35-8.284 0-15.002 6.716-15.002 15.002 0 6.642 4.321 12.267 10.303 14.24l-2.205 16.056c-10.66.049-19.285 8.7-19.285 19.37C.091 86.325 8.765 95 19.466 95c10.677 0 19.332-8.638 19.37-19.304l18.093-2.501c1.979 5.972 7.598 10.285 14.234 10.285 8.284 0 15.002-6.716 15.002-15.002 0-6.891-4.652-12.682-10.983-14.441l1.365-15.339c10.229-.53 18.363-8.966 18.363-19.324zM56.194 67.8l-18.116 2.505a19.39 19.39 0 0 0-13.312-13.3l2.205-16.077a14.98 14.98 0 0 0 14.27-14.222l15.655-2.094c1.894 6.757 7.351 12.009 14.225 13.612l-1.365 15.322c-7.4.688-13.224 6.753-13.562 14.254z" fill="#ffffff"/></svg>
<svg
id="svg2"
style="clip-rule:evenodd;fill-rule:evenodd;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"
sodipodi:docname="soapbox-logo-white.svg"
xml:space="preserve"
version="1.1"
inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
viewBox="0 0 100 100"
width="100"
height="100"
inkscape:export-filename="/home/miklobit/Downloads/citizen4/logo/citizen4-logo-250px.png"
inkscape:export-xdpi="63.5"
inkscape:export-ydpi="63.5"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
id="namedview12"
bordercolor="#666666"
inkscape:pageshadow="2"
guidetolerance="10"
pagecolor="#ffffff"
gridtolerance="10"
inkscape:zoom="5.0135101"
objecttolerance="10"
borderopacity="1"
inkscape:current-layer="g1133"
inkscape:cx="54.253406"
inkscape:cy="42.086282"
inkscape:window-width="1920"
showgrid="false"
inkscape:pageopacity="0"
inkscape:window-height="1016"
inkscape:document-rotation="0"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-x="0"
inkscape:window-y="36"
inkscape:window-maximized="1"
units="mm"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px" />
<defs
id="defs4">
<style
id="style6"
type="text/css">
.fil0 {fill:black}
</style>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath932"><g
id="g936"
style="fill:#ffffff;stroke:#000000;stroke-width:0.468608;stroke-miterlimit:4;stroke-dasharray:none"
transform="matrix(40.327178,0,0,40.327178,-206.26309,-5.404074)">
<path
id="path934"
sodipodi:nodetypes="ccccc"
style="fill:#ffffff;stroke:#000000;stroke-width:0.468608;stroke-miterlimit:4;stroke-dasharray:none"
d="M 4.25,1.3382 0.4955,2.4662 C 0.58759,5.2588 1.2884,8.6655 4.25,9.6618 7.2442,8.694 7.8774,5.2291 8.0045,2.4662 Z" />
</g></clipPath><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath932-3"><g
id="g936-6"
style="fill:#ffffff;stroke:#000000;stroke-width:0.468608;stroke-miterlimit:4;stroke-dasharray:none"
transform="matrix(40.327178,0,0,40.327178,-206.26309,-5.404074)"><path
id="path934-7"
sodipodi:nodetypes="ccccc"
style="fill:#ffffff;stroke:#000000;stroke-width:0.468608;stroke-miterlimit:4;stroke-dasharray:none"
d="M 4.25,1.3382 0.4955,2.4662 C 0.58759,5.2588 1.2884,8.6655 4.25,9.6618 7.2442,8.694 7.8774,5.2291 8.0045,2.4662 Z" /></g></clipPath>
</defs>
<metadata
id="metadata7"><rdf:RDF><cc:Work><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><cc:license
rdf:resource="http://creativecommons.org/licenses/by-nc-sa/4.0/" /><dc:date>2023-02-18T14:20:55</dc:date><dc:source>https://soc.citizen4.eu</dc:source><dc:subject><rdf:Bag><rdf:li>citizen4</rdf:li><rdf:li>logo</rdf:li><rdf:li>shield</rdf:li></rdf:Bag></dc:subject><dc:creator><cc:Agent><dc:title>miklo</dc:title></cc:Agent></dc:creator><dc:description>Citizen4 logo</dc:description></cc:Work><cc:License
rdf:about="http://creativecommons.org/licenses/by-nc-sa/4.0/"><cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" /><cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" /><cc:prohibits
rdf:resource="http://creativecommons.org/ns#CommercialUse" /><cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /><cc:requires
rdf:resource="http://creativecommons.org/ns#ShareAlike" /></cc:License></rdf:RDF></metadata><g
inkscape:groupmode="layer"
id="g1133"
inkscape:label="citizen4"
style="display:inline;fill:#ffffff"
transform="translate(9.1709534,9.343974)"><g
id="g1149"
transform="matrix(0.28130772,0,0,0.28130772,-1.9206898,-6.7154381)"
style="fill:#ffffff"><path
id="path1127"
style="fill:#ffffff;fill-opacity:1;stroke-width:2.298"
d="m 233.61288,175.15307 h -30.0693 v -8.40148 h -35.71547 v -22.85127 h 35.71317 v -8.40148 h 30.0716 c 1.31445,0 2.42898,0.4596 3.34818,1.3765 0.9169,0.9215 1.3788,2.03832 1.3788,3.34818 v 30.20487 c 0,1.22484 -0.4596,2.32098 -1.3788,3.28154 -0.9192,0.95827 -2.03602,1.44314 -3.34818,1.44314 z m 18.90793,-30.07388 c 6.91237,0 10.37315,3.41253 10.37315,10.24447 0,6.83195 -3.45618,10.24217 -10.37315,10.24217 -6.82735,0 -10.24218,-3.41482 -10.24218,-10.24217 0,-6.82734 3.41483,-10.24447 10.24218,-10.24447 z M 70.322893,175.15307 h 30.069297 v -8.40148 h 35.71546 v -22.85127 h -35.71317 v -8.40148 H 70.322893 c -1.31446,0 -2.42898,0.4596 -3.34818,1.3765 -0.9169,0.9215 -1.3788,2.03832 -1.3788,3.34818 v 30.20487 c 0,1.22484 0.4596,2.32098 1.3788,3.28154 0.9192,0.95827 2.03602,1.44314 3.34818,1.44314 z m -18.90793,-30.07388 c -6.91237,0 -10.37315,3.41253 -10.37315,10.24447 0,6.83195 3.45618,10.24217 10.37315,10.24217 6.82735,0 10.24218,-3.41482 10.24218,-10.24217 0,-6.82734 -3.41483,-10.24447 -10.24218,-10.24447 z M 171.79501,73.680969 v 30.069301 h -8.40148 v 35.71546 h -22.85128 v -35.71317 h -8.40147 V 73.680969 c 0,-1.31445 0.45959,-2.42898 1.37649,-3.34818 0.9215,-0.9169 2.03832,-1.3788 3.34819,-1.3788 h 30.20487 c 1.22483,0 2.32097,0.4596 3.28153,1.3788 0.95827,0.9192 1.44315,2.03602 1.44315,3.34818 z m -30.07388,-18.90793 c 0,-6.91237 3.41252,-10.37315 10.24446,-10.37315 6.83195,0 10.24218,3.45618 10.24218,10.37315 0,6.82735 -3.41482,10.24218 -10.24218,10.24218 -6.82734,0 -10.24446,-3.41483 -10.24446,-10.24218 z m 30.07388,182.197911 v -30.06929 h -8.40148 v -35.71547 h -22.85128 v 35.71317 h -8.40147 v 30.07159 c 0,1.31446 0.45959,2.42898 1.37649,3.34818 0.9215,0.9169 2.03832,1.3788 3.34819,1.3788 h 30.20487 c 1.22483,0 2.32097,-0.4596 3.28153,-1.3788 0.95827,-0.9192 1.44315,-2.03602 1.44315,-3.34818 z m -30.07388,18.90793 c 0,6.91237 3.41252,10.37315 10.24446,10.37315 6.83195,0 10.24218,-3.45618 10.24218,-10.37315 0,-6.82735 -3.41482,-10.24218 -10.24218,-10.24218 -6.82734,0 -10.24446,3.41483 -10.24446,10.24218 z M 151.92079,0.52207567 0.51240486,46.01113 C 4.2261345,158.6288 32.487823,296.01139 151.92079,336.18936 272.66842,297.16072 298.20359,157.43109 303.32917,46.01113 Z" /></g></g></svg>

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 6.7 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 812 B

Wyświetl plik

@ -1,127 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 95 95" width="100" height="100"><path d="M94.909 19.374C94.909 8.674 86.235 0 75.534 0c-10.647 0-19.28 8.591-19.365 19.217l-15.631 2.09c-1.961-6.007-7.598-10.35-14.258-10.35-8.284 0-15.002 6.716-15.002 15.002 0 6.642 4.321 12.267 10.303 14.24l-2.205 16.056c-10.66.049-19.285 8.7-19.285 19.37C.091 86.325 8.765 95 19.466 95c10.677 0 19.332-8.638 19.37-19.304l18.093-2.501c1.979 5.972 7.598 10.285 14.234 10.285 8.284 0 15.002-6.716 15.002-15.002 0-6.891-4.652-12.682-10.983-14.441l1.365-15.339c10.229-.53 18.363-8.966 18.363-19.324zM56.194 67.8l-18.116 2.505a19.39 19.39 0 0 0-13.312-13.3l2.205-16.077a14.98 14.98 0 0 0 14.27-14.222l15.655-2.094c1.894 6.757 7.351 12.009 14.225 13.612l-1.365 15.322c-7.4.688-13.224 6.753-13.562 14.254z" fill="#0482d8"/></svg>
<svg
id="svg2"
style="clip-rule:evenodd;fill-rule:evenodd;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"
sodipodi:docname="soapbox-logo.svg"
xml:space="preserve"
version="1.1"
inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
viewBox="0 0 100 100"
width="100"
height="100"
inkscape:export-filename="/home/miklobit/Downloads/citizen4/logo/citizen4-logo-250px.png"
inkscape:export-xdpi="63.5"
inkscape:export-ydpi="63.5"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
id="namedview12"
bordercolor="#666666"
inkscape:pageshadow="2"
guidetolerance="10"
pagecolor="#ffffff"
gridtolerance="10"
inkscape:zoom="5.0135101"
objecttolerance="10"
borderopacity="1"
inkscape:current-layer="svg2"
inkscape:cx="54.253406"
inkscape:cy="42.086282"
inkscape:window-width="1920"
showgrid="false"
inkscape:pageopacity="0"
inkscape:window-height="1016"
inkscape:document-rotation="0"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-x="0"
inkscape:window-y="36"
inkscape:window-maximized="1"
units="mm"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px" />
<defs
id="defs4">
<style
id="style6"
type="text/css">
.fil0 {fill:black}
</style>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath932"><g
id="g936"
style="fill:#ffffff;stroke:#000000;stroke-width:0.468608;stroke-miterlimit:4;stroke-dasharray:none"
transform="matrix(40.327178,0,0,40.327178,-206.26309,-5.404074)">
<path
id="path934"
sodipodi:nodetypes="ccccc"
style="fill:#ffffff;stroke:#000000;stroke-width:0.468608;stroke-miterlimit:4;stroke-dasharray:none"
d="M 4.25,1.3382 0.4955,2.4662 C 0.58759,5.2588 1.2884,8.6655 4.25,9.6618 7.2442,8.694 7.8774,5.2291 8.0045,2.4662 Z" />
</g></clipPath><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath932-3"><g
id="g936-6"
style="fill:#ffffff;stroke:#000000;stroke-width:0.468608;stroke-miterlimit:4;stroke-dasharray:none"
transform="matrix(40.327178,0,0,40.327178,-206.26309,-5.404074)"><path
id="path934-7"
sodipodi:nodetypes="ccccc"
style="fill:#ffffff;stroke:#000000;stroke-width:0.468608;stroke-miterlimit:4;stroke-dasharray:none"
d="M 4.25,1.3382 0.4955,2.4662 C 0.58759,5.2588 1.2884,8.6655 4.25,9.6618 7.2442,8.694 7.8774,5.2291 8.0045,2.4662 Z" /></g></clipPath>
</defs>
<metadata
id="metadata7"><rdf:RDF><cc:Work><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><cc:license
rdf:resource="http://creativecommons.org/licenses/by-nc-sa/4.0/" /><dc:date>2023-02-18T14:20:55</dc:date><dc:source>https://soc.citizen4.eu</dc:source><dc:subject><rdf:Bag><rdf:li>citizen4</rdf:li><rdf:li>logo</rdf:li><rdf:li>shield</rdf:li></rdf:Bag></dc:subject><dc:creator><cc:Agent><dc:title>miklo</dc:title></cc:Agent></dc:creator><dc:description>Citizen4 logo</dc:description></cc:Work><cc:License
rdf:about="http://creativecommons.org/licenses/by-nc-sa/4.0/"><cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" /><cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" /><cc:prohibits
rdf:resource="http://creativecommons.org/ns#CommercialUse" /><cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /><cc:requires
rdf:resource="http://creativecommons.org/ns#ShareAlike" /></cc:License></rdf:RDF></metadata><g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="shield"
style="display:inline"
transform="translate(9.1709534,9.343974)"><g
id="g912"
style="clip-rule:evenodd;fill:#003399;fill-opacity:1;fill-rule:evenodd;stroke:#888888;stroke-width:0.468608;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"
transform="matrix(11.344346,0,0,11.344346,-7.3976698,-21.749578)"><path
id="path910"
sodipodi:nodetypes="ccccc"
style="fill:#003399;fill-opacity:1;stroke:#888888;stroke-width:0.468608;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 4.25,1.3382 0.4955,2.4662 C 0.58759,5.2588 1.2884,8.6655 4.25,9.6618 7.2442,8.694 7.8774,5.2291 8.0045,2.4662 Z" /></g></g><g
inkscape:groupmode="layer"
id="g1133"
inkscape:label="citizen2"
style="display:inline"
transform="translate(9.1709534,9.343974)"><g
id="g1149"
transform="matrix(0.28130772,0,0,0.28130772,-1.9206898,-6.7154381)"><path
id="path1119"
style="fill:#ffcc00;fill-opacity:1;stroke-width:2.298"
d="m 171.79501,236.97095 v -30.06929 h -8.40148 v -35.71547 h -22.85128 v 35.71317 h -8.40147 v 30.07159 c 0,1.31446 0.45959,2.42898 1.37649,3.34818 0.9215,0.9169 2.03832,1.3788 3.34819,1.3788 h 30.20487 c 1.22483,0 2.32097,-0.4596 3.28153,-1.3788 0.95827,-0.9192 1.44315,-2.03602 1.44315,-3.34818 z m -30.07388,18.90793 c 0,6.91237 3.41252,10.37315 10.24446,10.37315 6.83195,0 10.24218,-3.45618 10.24218,-10.37315 0,-6.82735 -3.41482,-10.24218 -10.24218,-10.24218 -6.82734,0 -10.24446,3.41483 -10.24446,10.24218 z" /><path
id="path1121"
style="fill:#ffcc00;fill-opacity:1;stroke-width:2.298"
d="m 171.79501,73.680969 v 30.069301 h -8.40148 v 35.71546 h -22.85128 v -35.71317 h -8.40147 V 73.680969 c 0,-1.31445 0.45959,-2.42898 1.37649,-3.34818 0.9215,-0.9169 2.03832,-1.3788 3.34819,-1.3788 h 30.20487 c 1.22483,0 2.32097,0.4596 3.28153,1.3788 0.95827,0.9192 1.44315,2.03602 1.44315,3.34818 z m -30.07388,-18.90793 c 0,-6.91237 3.41252,-10.37315 10.24446,-10.37315 6.83195,0 10.24218,3.45618 10.24218,10.37315 0,6.82735 -3.41482,10.24218 -10.24218,10.24218 -6.82734,0 -10.24446,-3.41483 -10.24446,-10.24218 z" /><path
id="path1125"
style="fill:#ffcc00;fill-opacity:1;stroke-width:2.298"
d="m 70.322893,175.15307 h 30.069297 v -8.40148 h 35.71546 v -22.85127 h -35.71317 v -8.40148 H 70.322893 c -1.31446,0 -2.42898,0.4596 -3.34818,1.3765 -0.9169,0.9215 -1.3788,2.03832 -1.3788,3.34818 v 30.20487 c 0,1.22484 0.4596,2.32098 1.3788,3.28154 0.9192,0.95827 2.03602,1.44314 3.34818,1.44314 z m -18.90793,-30.07388 c -6.91237,0 -10.37315,3.41253 -10.37315,10.24447 0,6.83195 3.45618,10.24217 10.37315,10.24217 6.82735,0 10.24218,-3.41482 10.24218,-10.24217 0,-6.82734 -3.41483,-10.24447 -10.24218,-10.24447 z" /><path
id="path1127"
style="fill:#ffcc00;fill-opacity:1;stroke-width:2.298"
d="m 233.61288,175.15307 h -30.0693 v -8.40148 h -35.71547 v -22.85127 h 35.71317 v -8.40148 h 30.0716 c 1.31445,0 2.42898,0.4596 3.34818,1.3765 0.9169,0.9215 1.3788,2.03832 1.3788,3.34818 v 30.20487 c 0,1.22484 -0.4596,2.32098 -1.3788,3.28154 -0.9192,0.95827 -2.03602,1.44314 -3.34818,1.44314 z m 18.90793,-30.07388 c 6.91237,0 10.37315,3.41253 10.37315,10.24447 0,6.83195 -3.45618,10.24217 -10.37315,10.24217 -6.82735,0 -10.24218,-3.41482 -10.24218,-10.24217 0,-6.82734 3.41483,-10.24447 10.24218,-10.24447 z" /></g></g></svg>

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 7.6 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 812 B

Wyświetl plik

@ -1,16 +0,0 @@
{
"note": "patriots 900000001",
"discoverable": true,
"id": "109989480368015378",
"domain": null,
"avatar": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg",
"avatar_static": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg",
"header": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png",
"header_static": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png",
"group_visibility": "everyone",
"created_at": "2023-03-08T00:00:00.000Z",
"display_name": "PATRIOT PATRIOTS",
"membership_required": true,
"members_count": 1,
"tags": []
}

Wyświetl plik

@ -228,7 +228,7 @@ const fetchAccountFail = (id: string | null, error: AxiosError) => ({
}); });
type FollowAccountOpts = { type FollowAccountOpts = {
reblogs?: boolean reblogs?: boolean,
notify?: boolean notify?: boolean
}; };

Wyświetl plik

@ -1,18 +1,13 @@
import { defineMessages } from 'react-intl';
import { fetchRelationships } from 'soapbox/actions/accounts'; import { fetchRelationships } from 'soapbox/actions/accounts';
import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer'; import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer';
import toast from 'soapbox/toast';
import { filterBadges, getTagDiff } from 'soapbox/utils/badges'; import { filterBadges, getTagDiff } from 'soapbox/utils/badges';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import { openModal } from './modals';
import type { AxiosResponse } from 'axios'; import type { AxiosResponse } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Announcement } from 'soapbox/types/entities'; import type { APIEntity } from 'soapbox/types/entities';
const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST'; const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST';
const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS'; const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS';
@ -82,45 +77,16 @@ const ADMIN_USERS_UNSUGGEST_REQUEST = 'ADMIN_USERS_UNSUGGEST_REQUEST';
const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS'; const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS';
const ADMIN_USERS_UNSUGGEST_FAIL = 'ADMIN_USERS_UNSUGGEST_FAIL'; const ADMIN_USERS_UNSUGGEST_FAIL = 'ADMIN_USERS_UNSUGGEST_FAIL';
const ADMIN_USER_INDEX_EXPAND_FAIL = 'ADMIN_USER_INDEX_EXPAND_FAIL'; const ADMIN_USER_INDEX_EXPAND_FAIL = 'ADMIN_USER_INDEX_EXPAND_FAIL';
const ADMIN_USER_INDEX_EXPAND_REQUEST = 'ADMIN_USER_INDEX_EXPAND_REQUEST'; const ADMIN_USER_INDEX_EXPAND_REQUEST = 'ADMIN_USER_INDEX_EXPAND_REQUEST';
const ADMIN_USER_INDEX_EXPAND_SUCCESS = 'ADMIN_USER_INDEX_EXPAND_SUCCESS'; const ADMIN_USER_INDEX_EXPAND_SUCCESS = 'ADMIN_USER_INDEX_EXPAND_SUCCESS';
const ADMIN_USER_INDEX_FETCH_FAIL = 'ADMIN_USER_INDEX_FETCH_FAIL'; const ADMIN_USER_INDEX_FETCH_FAIL = 'ADMIN_USER_INDEX_FETCH_FAIL';
const ADMIN_USER_INDEX_FETCH_REQUEST = 'ADMIN_USER_INDEX_FETCH_REQUEST'; const ADMIN_USER_INDEX_FETCH_REQUEST = 'ADMIN_USER_INDEX_FETCH_REQUEST';
const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS'; const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS';
const ADMIN_USER_INDEX_QUERY_SET = 'ADMIN_USER_INDEX_QUERY_SET'; const ADMIN_USER_INDEX_QUERY_SET = 'ADMIN_USER_INDEX_QUERY_SET';
const ADMIN_ANNOUNCEMENTS_FETCH_FAIL = 'ADMIN_ANNOUNCEMENTS_FETCH_FAILS';
const ADMIN_ANNOUNCEMENTS_FETCH_REQUEST = 'ADMIN_ANNOUNCEMENTS_FETCH_REQUEST';
const ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS = 'ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS';
const ADMIN_ANNOUNCEMENTS_EXPAND_FAIL = 'ADMIN_ANNOUNCEMENTS_EXPAND_FAILS';
const ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST = 'ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST';
const ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS = 'ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS';
const ADMIN_ANNOUNCEMENT_CHANGE_CONTENT = 'ADMIN_ANNOUNCEMENT_CHANGE_CONTENT';
const ADMIN_ANNOUNCEMENT_CHANGE_START_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_START_TIME';
const ADMIN_ANNOUNCEMENT_CHANGE_END_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_END_TIME';
const ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY = 'ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY';
const ADMIN_ANNOUNCEMENT_CREATE_REQUEST = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST';
const ADMIN_ANNOUNCEMENT_CREATE_SUCCESS = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST';
const ADMIN_ANNOUNCEMENT_CREATE_FAIL = 'ADMIN_ANNOUNCEMENT_CREATE_FAIL';
const ADMIN_ANNOUNCEMENT_DELETE_REQUEST = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST';
const ADMIN_ANNOUNCEMENT_DELETE_SUCCESS = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST';
const ADMIN_ANNOUNCEMENT_DELETE_FAIL = 'ADMIN_ANNOUNCEMENT_DELETE_FAIL';
const ADMIN_ANNOUNCEMENT_MODAL_INIT = 'ADMIN_ANNOUNCEMENT_MODAL_INIT';
const messages = defineMessages({
announcementCreateSuccess: { id: 'admin.edit_announcement.created', defaultMessage: 'Announcement created' },
announcementDeleteSuccess: { id: 'admin.edit_announcement.deleted', defaultMessage: 'Announcement deleted' },
announcementUpdateSuccess: { id: 'admin.edit_announcement.updated', defaultMessage: 'Announcement edited' },
});
const nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct); const nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct);
const fetchConfig = () => const fetchConfig = () =>
@ -632,93 +598,6 @@ const expandUserIndex = () =>
}); });
}; };
const fetchAdminAnnouncements = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_REQUEST });
return api(getState)
.get('/api/pleroma/admin/announcements', { params: { limit: 50 } })
.then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS, announcements: data });
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_FAIL, error });
});
};
const expandAdminAnnouncements = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const page = getState().admin_announcements.page;
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST });
return api(getState)
.get('/api/pleroma/admin/announcements', { params: { limit: 50, offset: page * 50 } })
.then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS, announcements: data });
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_FAIL, error });
});
};
const changeAnnouncementContent = (content: string) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_CONTENT,
value: content,
});
const changeAnnouncementStartTime = (time: Date | null) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_START_TIME,
value: time,
});
const changeAnnouncementEndTime = (time: Date | null) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_END_TIME,
value: time,
});
const changeAnnouncementAllDay = (allDay: boolean) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY,
value: allDay,
});
const handleCreateAnnouncement = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_REQUEST });
const { id, content, starts_at, ends_at, all_day } = getState().admin_announcements.form;
return api(getState)[id ? 'patch' : 'post'](
id ? `/api/pleroma/admin/announcements/${id}` : '/api/pleroma/admin/announcements',
{ content, starts_at, ends_at, all_day },
).then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_SUCCESS, announcement: data });
toast.success(id ? messages.announcementUpdateSuccess : messages.announcementCreateSuccess);
dispatch(fetchAdminAnnouncements());
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_FAIL, error });
});
};
const deleteAnnouncement = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_REQUEST, id });
return api(getState).delete(`/api/pleroma/admin/announcements/${id}`).then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_SUCCESS, id });
toast.success(messages.announcementDeleteSuccess);
dispatch(fetchAdminAnnouncements());
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_FAIL, id, error });
});
};
const initAnnouncementModal = (announcement?: Announcement) =>
(dispatch: AppDispatch) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_MODAL_INIT, announcement });
dispatch(openModal('EDIT_ANNOUNCEMENT'));
};
export { export {
ADMIN_CONFIG_FETCH_REQUEST, ADMIN_CONFIG_FETCH_REQUEST,
ADMIN_CONFIG_FETCH_SUCCESS, ADMIN_CONFIG_FETCH_SUCCESS,
@ -778,23 +657,6 @@ export {
ADMIN_USER_INDEX_FETCH_REQUEST, ADMIN_USER_INDEX_FETCH_REQUEST,
ADMIN_USER_INDEX_FETCH_SUCCESS, ADMIN_USER_INDEX_FETCH_SUCCESS,
ADMIN_USER_INDEX_QUERY_SET, ADMIN_USER_INDEX_QUERY_SET,
ADMIN_ANNOUNCEMENTS_FETCH_FAIL,
ADMIN_ANNOUNCEMENTS_FETCH_REQUEST,
ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS,
ADMIN_ANNOUNCEMENTS_EXPAND_FAIL,
ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST,
ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS,
ADMIN_ANNOUNCEMENT_CHANGE_CONTENT,
ADMIN_ANNOUNCEMENT_CHANGE_START_TIME,
ADMIN_ANNOUNCEMENT_CHANGE_END_TIME,
ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY,
ADMIN_ANNOUNCEMENT_CREATE_FAIL,
ADMIN_ANNOUNCEMENT_CREATE_REQUEST,
ADMIN_ANNOUNCEMENT_CREATE_SUCCESS,
ADMIN_ANNOUNCEMENT_DELETE_FAIL,
ADMIN_ANNOUNCEMENT_DELETE_REQUEST,
ADMIN_ANNOUNCEMENT_DELETE_SUCCESS,
ADMIN_ANNOUNCEMENT_MODAL_INIT,
fetchConfig, fetchConfig,
updateConfig, updateConfig,
updateSoapboxConfig, updateSoapboxConfig,
@ -824,13 +686,4 @@ export {
setUserIndexQuery, setUserIndexQuery,
fetchUserIndex, fetchUserIndex,
expandUserIndex, expandUserIndex,
fetchAdminAnnouncements,
expandAdminAnnouncements,
changeAnnouncementContent,
changeAnnouncementStartTime,
changeAnnouncementEndTime,
changeAnnouncementAllDay,
handleCreateAnnouncement,
deleteAnnouncement,
initAnnouncementModal,
}; };

Wyświetl plik

@ -4,8 +4,7 @@ import throttle from 'lodash/throttle';
import { defineMessages, IntlShape } from 'react-intl'; import { defineMessages, IntlShape } from 'react-intl';
import api from 'soapbox/api'; import api from 'soapbox/api';
import { isNativeEmoji } from 'soapbox/features/emoji'; import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light';
import emojiSearch from 'soapbox/features/emoji/search';
import { tagHistory } from 'soapbox/settings'; import { tagHistory } from 'soapbox/settings';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth'; import { isLoggedIn } from 'soapbox/utils/auth';
@ -20,8 +19,8 @@ import { openModal, closeModal } from './modals';
import { getSettings } from './settings'; import { getSettings } from './settings';
import { createStatus } from './statuses'; import { createStatus } from './statuses';
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import type { Emoji } from 'soapbox/features/emoji';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities'; import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
import type { History } from 'soapbox/types/history'; import type { History } from 'soapbox/types/history';
@ -278,7 +277,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
const idempotencyKey = compose.idempotencyKey; const idempotencyKey = compose.idempotencyKey;
const params: Record<string, any> = { const params = {
status, status,
in_reply_to_id: compose.in_reply_to, in_reply_to_id: compose.in_reply_to,
quote_id: compose.quote, quote_id: compose.quote,
@ -290,10 +289,9 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
poll: compose.poll, poll: compose.poll,
scheduled_at: compose.schedule, scheduled_at: compose.schedule,
to, to,
group_id: compose.privacy === 'group' ? compose.group_id : null,
}; };
if (compose.privacy === 'group') params.group_id = compose.group_id;
dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) { dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) {
if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) { if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) {
routerHistory.push('/messages'); routerHistory.push('/messages');
@ -517,9 +515,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId,
}, 200, { leading: true, trailing: true }); }, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => { const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
const state = getState(); const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any);
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }, state.custom_emojis);
dispatch(readyComposeSuggestionsEmojis(composeId, token, results)); dispatch(readyComposeSuggestionsEmojis(composeId, token, results));
}; };
@ -564,7 +560,7 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str
let completion, startPosition; let completion, startPosition;
if (typeof suggestion === 'object' && suggestion.id) { if (typeof suggestion === 'object' && suggestion.id) {
completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons; completion = suggestion.native || suggestion.colons;
startPosition = position - 1; startPosition = position - 1;
dispatch(useEmoji(suggestion)); dispatch(useEmoji(suggestion));

Wyświetl plik

@ -1,8 +1,13 @@
import type { DropdownPlacement } from 'soapbox/components/dropdown-menu';
const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
const openDropdownMenu = () => ({ type: DROPDOWN_MENU_OPEN }); const openDropdownMenu = (id: number, placement: DropdownPlacement, keyboard: boolean) =>
const closeDropdownMenu = () => ({ type: DROPDOWN_MENU_CLOSE }); ({ type: DROPDOWN_MENU_OPEN, id, placement, keyboard });
const closeDropdownMenu = (id: number) =>
({ type: DROPDOWN_MENU_CLOSE, id });
export { export {
DROPDOWN_MENU_OPEN, DROPDOWN_MENU_OPEN,

Wyświetl plik

@ -25,7 +25,7 @@ const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL';
const noOp = () => () => new Promise(f => f(undefined)); const noOp = () => () => new Promise(f => f(undefined));
const simpleEmojiReact = (status: Status, emoji: string, custom?: string) => const simpleEmojiReact = (status: Status, emoji: string) =>
(dispatch: AppDispatch) => { (dispatch: AppDispatch) => {
const emojiReacts: ImmutableList<ImmutableMap<string, any>> = status.pleroma.get('emoji_reactions') || ImmutableList(); const emojiReacts: ImmutableList<ImmutableMap<string, any>> = status.pleroma.get('emoji_reactions') || ImmutableList();
@ -43,7 +43,7 @@ const simpleEmojiReact = (status: Status, emoji: string, custom?: string) =>
if (emoji === '👍') { if (emoji === '👍') {
dispatch(favourite(status)); dispatch(favourite(status));
} else { } else {
dispatch(emojiReact(status, emoji, custom)); dispatch(emojiReact(status, emoji));
} }
}).catch(err => { }).catch(err => {
console.error(err); console.error(err);
@ -70,11 +70,11 @@ const fetchEmojiReacts = (id: string, emoji: string) =>
}); });
}; };
const emojiReact = (status: Status, emoji: string, custom?: string) => const emojiReact = (status: Status, emoji: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return dispatch(noOp()); if (!isLoggedIn(getState)) return dispatch(noOp());
dispatch(emojiReactRequest(status, emoji, custom)); dispatch(emojiReactRequest(status, emoji));
return api(getState) return api(getState)
.put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`) .put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`)
@ -120,11 +120,10 @@ const fetchEmojiReactsFail = (id: string, error: AxiosError) => ({
error, error,
}); });
const emojiReactRequest = (status: Status, emoji: string, custom?: string) => ({ const emojiReactRequest = (status: Status, emoji: string) => ({
type: EMOJI_REACT_REQUEST, type: EMOJI_REACT_REQUEST,
status, status,
emoji, emoji,
custom,
skipLoading: true, skipLoading: true,
}); });

Wyświetl plik

@ -1,6 +1,6 @@
import { saveSettings } from './settings'; import { saveSettings } from './settings';
import type { Emoji } from 'soapbox/features/emoji'; import type { Emoji } from 'soapbox/components/autosuggest-emoji';
import type { AppDispatch } from 'soapbox/store'; import type { AppDispatch } from 'soapbox/store';
const EMOJI_USE = 'EMOJI_USE'; const EMOJI_USE = 'EMOJI_USE';

Wyświetl plik

@ -569,7 +569,7 @@ const rejectEventParticipationRequestFail = (id: string, accountId: string, erro
}); });
const fetchEventIcs = (id: string) => const fetchEventIcs = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => (dispatch: any, getState: () => RootState) =>
api(getState).get(`/api/v1/pleroma/events/${id}/ics`); api(getState).get(`/api/v1/pleroma/events/${id}/ics`);
const cancelEventCompose = () => ({ const cancelEventCompose = () => ({

Wyświetl plik

@ -34,8 +34,8 @@ type ExportDataActions = {
| typeof EXPORT_BLOCKS_FAIL | typeof EXPORT_BLOCKS_FAIL
| typeof EXPORT_MUTES_REQUEST | typeof EXPORT_MUTES_REQUEST
| typeof EXPORT_MUTES_SUCCESS | typeof EXPORT_MUTES_SUCCESS
| typeof EXPORT_MUTES_FAIL | typeof EXPORT_MUTES_FAIL,
error?: any error?: any,
} }
function fileExport(content: string, fileName: string) { function fileExport(content: string, fileName: string) {

Wyświetl plik

@ -11,25 +11,25 @@ export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCES
export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL'; export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL';
type FamiliarFollowersFetchRequestAction = { type FamiliarFollowersFetchRequestAction = {
type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST,
id: string id: string,
} }
type FamiliarFollowersFetchRequestSuccessAction = { type FamiliarFollowersFetchRequestSuccessAction = {
type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
id: string id: string,
accounts: Array<APIEntity> accounts: Array<APIEntity>,
} }
type FamiliarFollowersFetchRequestFailAction = { type FamiliarFollowersFetchRequestFailAction = {
type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL,
id: string id: string,
error: any error: any,
} }
type AccountsImportAction = { type AccountsImportAction = {
type: typeof ACCOUNTS_IMPORT type: typeof ACCOUNTS_IMPORT,
accounts: Array<APIEntity> accounts: Array<APIEntity>,
} }
export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction

Wyświetl plik

@ -12,18 +12,10 @@ const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL';
const FILTER_FETCH_REQUEST = 'FILTER_FETCH_REQUEST';
const FILTER_FETCH_SUCCESS = 'FILTER_FETCH_SUCCESS';
const FILTER_FETCH_FAIL = 'FILTER_FETCH_FAIL';
const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL';
const FILTERS_UPDATE_REQUEST = 'FILTERS_UPDATE_REQUEST';
const FILTERS_UPDATE_SUCCESS = 'FILTERS_UPDATE_SUCCESS';
const FILTERS_UPDATE_FAIL = 'FILTERS_UPDATE_FAIL';
const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST'; const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST';
const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS'; const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS';
const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL'; const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL';
@ -33,16 +25,22 @@ const messages = defineMessages({
removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' },
}); });
type FilterKeywords = { keyword: string, whole_word: boolean }[]; const fetchFilters = () =>
const fetchFiltersV1 = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (!features.filters) return;
dispatch({ dispatch({
type: FILTERS_FETCH_REQUEST, type: FILTERS_FETCH_REQUEST,
skipLoading: true, skipLoading: true,
}); });
return api(getState) api(getState)
.get('/api/v1/filters') .get('/api/v1/filters')
.then(({ data }) => dispatch({ .then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS, type: FILTERS_FETCH_SUCCESS,
@ -57,105 +55,15 @@ const fetchFiltersV1 = () =>
})); }));
}; };
const fetchFiltersV2 = () => const createFilter = (phrase: string, expires_at: string, context: Array<string>, whole_word: boolean, irreversible: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FILTERS_FETCH_REQUEST,
skipLoading: true,
});
return api(getState)
.get('/api/v2/filters')
.then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS,
filters: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTERS_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
const fetchFilters = (fromFiltersPage = false) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2 && fromFiltersPage) return dispatch(fetchFiltersV2());
if (features.filters) return dispatch(fetchFiltersV1());
};
const fetchFilterV1 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FILTER_FETCH_REQUEST,
skipLoading: true,
});
return api(getState)
.get(`/api/v1/filters/${id}`)
.then(({ data }) => dispatch({
type: FILTER_FETCH_SUCCESS,
filter: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTER_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
const fetchFilterV2 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FILTER_FETCH_REQUEST,
skipLoading: true,
});
return api(getState)
.get(`/api/v2/filters/${id}`)
.then(({ data }) => dispatch({
type: FILTER_FETCH_SUCCESS,
filter: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTER_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
const fetchFilter = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2) return dispatch(fetchFilterV2(id));
if (features.filters) return dispatch(fetchFilterV1(id));
};
const createFilterV1 = (title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_CREATE_REQUEST }); dispatch({ type: FILTERS_CREATE_REQUEST });
return api(getState).post('/api/v1/filters', { return api(getState).post('/api/v1/filters', {
phrase: keywords[0].keyword, phrase,
context, context,
irreversible: hide, irreversible,
whole_word: keywords[0].whole_word, whole_word,
expires_in, expires_at,
}).then(response => { }).then(response => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
toast.success(messages.added); toast.success(messages.added);
@ -164,80 +72,7 @@ const createFilterV1 = (title: string, expires_in: string | null, context: Array
}); });
}; };
const createFilterV2 = (title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords_attributes: FilterKeywords) => const deleteFilter = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_CREATE_REQUEST });
return api(getState).post('/api/v2/filters', {
title,
context,
filter_action: hide ? 'hide' : 'warn',
expires_in,
keywords_attributes,
}).then(response => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
toast.success(messages.added);
}).catch(error => {
dispatch({ type: FILTERS_CREATE_FAIL, error });
});
};
const createFilter = (title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2) return dispatch(createFilterV2(title, expires_in, context, hide, keywords));
return dispatch(createFilterV1(title, expires_in, context, hide, keywords));
};
const updateFilterV1 = (id: string, title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_UPDATE_REQUEST });
return api(getState).patch(`/api/v1/filters/${id}`, {
phrase: keywords[0].keyword,
context,
irreversible: hide,
whole_word: keywords[0].whole_word,
expires_in,
}).then(response => {
dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data });
toast.success(messages.added);
}).catch(error => {
dispatch({ type: FILTERS_UPDATE_FAIL, error });
});
};
const updateFilterV2 = (id: string, title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords_attributes: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_UPDATE_REQUEST });
return api(getState).patch(`/api/v2/filters/${id}`, {
title,
context,
filter_action: hide ? 'hide' : 'warn',
expires_in,
keywords_attributes,
}).then(response => {
dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data });
toast.success(messages.added);
}).catch(error => {
dispatch({ type: FILTERS_UPDATE_FAIL, error });
});
};
const updateFilter = (id: string, title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2) return dispatch(updateFilterV2(id, title, expires_in, context, hide, keywords));
return dispatch(updateFilterV1(id, title, expires_in, context, hide, keywords));
};
const deleteFilterV1 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_DELETE_REQUEST }); dispatch({ type: FILTERS_DELETE_REQUEST });
return api(getState).delete(`/api/v1/filters/${id}`).then(response => { return api(getState).delete(`/api/v1/filters/${id}`).then(response => {
@ -248,47 +83,17 @@ const deleteFilterV1 = (id: string) =>
}); });
}; };
const deleteFilterV2 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_DELETE_REQUEST });
return api(getState).delete(`/api/v2/filters/${id}`).then(response => {
dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data });
toast.success(messages.removed);
}).catch(error => {
dispatch({ type: FILTERS_DELETE_FAIL, error });
});
};
const deleteFilter = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2) return dispatch(deleteFilterV2(id));
return dispatch(deleteFilterV1(id));
};
export { export {
FILTERS_FETCH_REQUEST, FILTERS_FETCH_REQUEST,
FILTERS_FETCH_SUCCESS, FILTERS_FETCH_SUCCESS,
FILTERS_FETCH_FAIL, FILTERS_FETCH_FAIL,
FILTER_FETCH_REQUEST,
FILTER_FETCH_SUCCESS,
FILTER_FETCH_FAIL,
FILTERS_CREATE_REQUEST, FILTERS_CREATE_REQUEST,
FILTERS_CREATE_SUCCESS, FILTERS_CREATE_SUCCESS,
FILTERS_CREATE_FAIL, FILTERS_CREATE_FAIL,
FILTERS_UPDATE_REQUEST,
FILTERS_UPDATE_SUCCESS,
FILTERS_UPDATE_FAIL,
FILTERS_DELETE_REQUEST, FILTERS_DELETE_REQUEST,
FILTERS_DELETE_SUCCESS, FILTERS_DELETE_SUCCESS,
FILTERS_DELETE_FAIL, FILTERS_DELETE_FAIL,
fetchFilters, fetchFilters,
fetchFilter,
createFilter, createFilter,
updateFilter,
deleteFilter, deleteFilter,
}; };

Wyświetl plik

@ -1,6 +1,5 @@
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { deleteEntities } from 'soapbox/entity-store/actions';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
@ -41,6 +40,14 @@ const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST';
const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS'; const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS';
const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL'; const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL';
const GROUP_JOIN_REQUEST = 'GROUP_JOIN_REQUEST';
const GROUP_JOIN_SUCCESS = 'GROUP_JOIN_SUCCESS';
const GROUP_JOIN_FAIL = 'GROUP_JOIN_FAIL';
const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST';
const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS';
const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_FAIL';
const GROUP_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST'; const GROUP_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST';
const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS'; const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS';
const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_FAIL'; const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_FAIL';
@ -141,8 +148,7 @@ const createGroup = (params: Record<string, any>, shouldReset?: boolean) =>
if (shouldReset) { if (shouldReset) {
dispatch(resetGroupEditor()); dispatch(resetGroupEditor());
} }
dispatch(closeModal('MANAGE_GROUP'));
return data;
}).catch(err => dispatch(createGroupFail(err))); }).catch(err => dispatch(createGroupFail(err)));
}; };
@ -192,7 +198,7 @@ const updateGroupFail = (error: AxiosError) => ({
}); });
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(deleteEntities([id], 'Group')); dispatch(deleteGroupRequest(id));
return api(getState).delete(`/api/v1/groups/${id}`) return api(getState).delete(`/api/v1/groups/${id}`)
.then(() => dispatch(deleteGroupSuccess(id))) .then(() => dispatch(deleteGroupSuccess(id)))
@ -306,6 +312,70 @@ const fetchGroupRelationshipsFail = (error: AxiosError) => ({
skipNotFound: true, skipNotFound: true,
}); });
const joinGroup = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const locked = (getState().groups.items.get(id) as any).locked || false;
dispatch(joinGroupRequest(id, locked));
return api(getState).post(`/api/v1/groups/${id}/join`).then(response => {
dispatch(joinGroupSuccess(response.data));
toast.success(locked ? messages.joinRequestSuccess : messages.joinSuccess);
}).catch(error => {
dispatch(joinGroupFail(error, locked));
});
};
const leaveGroup = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(leaveGroupRequest(id));
return api(getState).post(`/api/v1/groups/${id}/leave`).then(response => {
dispatch(leaveGroupSuccess(response.data));
toast.success(messages.leaveSuccess);
}).catch(error => {
dispatch(leaveGroupFail(error));
});
};
const joinGroupRequest = (id: string, locked: boolean) => ({
type: GROUP_JOIN_REQUEST,
id,
locked,
skipLoading: true,
});
const joinGroupSuccess = (relationship: APIEntity) => ({
type: GROUP_JOIN_SUCCESS,
relationship,
skipLoading: true,
});
const joinGroupFail = (error: AxiosError, locked: boolean) => ({
type: GROUP_JOIN_FAIL,
error,
locked,
skipLoading: true,
});
const leaveGroupRequest = (id: string) => ({
type: GROUP_LEAVE_REQUEST,
id,
skipLoading: true,
});
const leaveGroupSuccess = (relationship: APIEntity) => ({
type: GROUP_LEAVE_SUCCESS,
relationship,
skipLoading: true,
});
const leaveGroupFail = (error: AxiosError) => ({
type: GROUP_LEAVE_FAIL,
error,
skipLoading: true,
});
const groupDeleteStatus = (groupId: string, statusId: string) => const groupDeleteStatus = (groupId: string, statusId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(groupDeleteStatusRequest(groupId, statusId)); dispatch(groupDeleteStatusRequest(groupId, statusId));
@ -789,11 +859,9 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
const note = getState().group_editor.note; const note = getState().group_editor.note;
const avatar = getState().group_editor.avatar; const avatar = getState().group_editor.avatar;
const header = getState().group_editor.header; const header = getState().group_editor.header;
const visibility = getState().group_editor.locked ? 'members_only' : 'everyone'; // Truth Social
const params: Record<string, any> = { const params: Record<string, any> = {
display_name: displayName, display_name: displayName,
group_visibility: visibility,
note, note,
}; };
@ -801,9 +869,9 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
if (header) params.header = header; if (header) params.header = header;
if (groupId === null) { if (groupId === null) {
return dispatch(createGroup(params, shouldReset)); dispatch(createGroup(params, shouldReset));
} else { } else {
return dispatch(updateGroup(groupId, params, shouldReset)); dispatch(updateGroup(groupId, params, shouldReset));
} }
}; };
@ -827,6 +895,12 @@ export {
GROUP_RELATIONSHIPS_FETCH_REQUEST, GROUP_RELATIONSHIPS_FETCH_REQUEST,
GROUP_RELATIONSHIPS_FETCH_SUCCESS, GROUP_RELATIONSHIPS_FETCH_SUCCESS,
GROUP_RELATIONSHIPS_FETCH_FAIL, GROUP_RELATIONSHIPS_FETCH_FAIL,
GROUP_JOIN_REQUEST,
GROUP_JOIN_SUCCESS,
GROUP_JOIN_FAIL,
GROUP_LEAVE_REQUEST,
GROUP_LEAVE_SUCCESS,
GROUP_LEAVE_FAIL,
GROUP_DELETE_STATUS_REQUEST, GROUP_DELETE_STATUS_REQUEST,
GROUP_DELETE_STATUS_SUCCESS, GROUP_DELETE_STATUS_SUCCESS,
GROUP_DELETE_STATUS_FAIL, GROUP_DELETE_STATUS_FAIL,
@ -899,6 +973,14 @@ export {
fetchGroupRelationshipsRequest, fetchGroupRelationshipsRequest,
fetchGroupRelationshipsSuccess, fetchGroupRelationshipsSuccess,
fetchGroupRelationshipsFail, fetchGroupRelationshipsFail,
joinGroup,
leaveGroup,
joinGroupRequest,
joinGroupSuccess,
joinGroupFail,
leaveGroupRequest,
leaveGroupSuccess,
leaveGroupFail,
groupDeleteStatus, groupDeleteStatus,
groupDeleteStatusRequest, groupDeleteStatusRequest,
groupDeleteStatusSuccess, groupDeleteStatusSuccess,

Wyświetl plik

@ -27,8 +27,8 @@ type ImportDataActions = {
| typeof IMPORT_BLOCKS_FAIL | typeof IMPORT_BLOCKS_FAIL
| typeof IMPORT_MUTES_REQUEST | typeof IMPORT_MUTES_REQUEST
| typeof IMPORT_MUTES_SUCCESS | typeof IMPORT_MUTES_SUCCESS
| typeof IMPORT_MUTES_FAIL | typeof IMPORT_MUTES_FAIL,
error?: any error?: any,
config?: string config?: string
} }

Wyświetl plik

@ -1,8 +1,3 @@
import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { Group, groupSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import { getSettings } from '../settings'; import { getSettings } from '../settings';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
@ -23,11 +18,11 @@ const importAccount = (account: APIEntity) =>
const importAccounts = (accounts: APIEntity[]) => const importAccounts = (accounts: APIEntity[]) =>
({ type: ACCOUNTS_IMPORT, accounts }); ({ type: ACCOUNTS_IMPORT, accounts });
const importGroup = (group: Group) => const importGroup = (group: APIEntity) =>
importEntities([group], Entities.GROUPS); ({ type: GROUP_IMPORT, group });
const importGroups = (groups: Group[]) => const importGroups = (groups: APIEntity[]) =>
importEntities(groups, Entities.GROUPS); ({ type: GROUPS_IMPORT, groups });
const importStatus = (status: APIEntity, idempotencyKey?: string) => const importStatus = (status: APIEntity, idempotencyKey?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
@ -74,8 +69,17 @@ const importFetchedGroup = (group: APIEntity) =>
importFetchedGroups([group]); importFetchedGroups([group]);
const importFetchedGroups = (groups: APIEntity[]) => { const importFetchedGroups = (groups: APIEntity[]) => {
const entities = filteredArray(groupSchema).catch([]).parse(groups); const normalGroups: APIEntity[] = [];
return importGroups(entities);
const processGroup = (group: APIEntity) => {
if (!group.id) return;
normalGroups.push(group);
};
groups.forEach(processGroup);
return importGroups(normalGroups);
}; };
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) => const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>

Wyświetl plik

@ -20,10 +20,6 @@ const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
const DISLIKE_REQUEST = 'DISLIKE_REQUEST';
const DISLIKE_SUCCESS = 'DISLIKE_SUCCESS';
const DISLIKE_FAIL = 'DISLIKE_FAIL';
const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
@ -32,10 +28,6 @@ const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
const UNDISLIKE_REQUEST = 'UNDISLIKE_REQUEST';
const UNDISLIKE_SUCCESS = 'UNDISLIKE_SUCCESS';
const UNDISLIKE_FAIL = 'UNDISLIKE_FAIL';
const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
@ -44,10 +36,6 @@ const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
const DISLIKES_FETCH_REQUEST = 'DISLIKES_FETCH_REQUEST';
const DISLIKES_FETCH_SUCCESS = 'DISLIKES_FETCH_SUCCESS';
const DISLIKES_FETCH_FAIL = 'DISLIKES_FETCH_FAIL';
const REACTIONS_FETCH_REQUEST = 'REACTIONS_FETCH_REQUEST'; const REACTIONS_FETCH_REQUEST = 'REACTIONS_FETCH_REQUEST';
const REACTIONS_FETCH_SUCCESS = 'REACTIONS_FETCH_SUCCESS'; const REACTIONS_FETCH_SUCCESS = 'REACTIONS_FETCH_SUCCESS';
const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_FAIL'; const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_FAIL';
@ -108,7 +96,7 @@ const unreblog = (status: StatusEntity) =>
}; };
const toggleReblog = (status: StatusEntity) => const toggleReblog = (status: StatusEntity) =>
(dispatch: AppDispatch) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (status.reblogged) { if (status.reblogged) {
dispatch(unreblog(status)); dispatch(unreblog(status));
} else { } else {
@ -181,7 +169,7 @@ const unfavourite = (status: StatusEntity) =>
}; };
const toggleFavourite = (status: StatusEntity) => const toggleFavourite = (status: StatusEntity) =>
(dispatch: AppDispatch) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (status.favourited) { if (status.favourited) {
dispatch(unfavourite(status)); dispatch(unfavourite(status));
} else { } else {
@ -227,79 +215,6 @@ const unfavouriteFail = (status: StatusEntity, error: AxiosError) => ({
skipLoading: true, skipLoading: true,
}); });
const dislike = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(dislikeRequest(status));
api(getState).post(`/api/friendica/statuses/${status.get('id')}/dislike`).then(function() {
dispatch(dislikeSuccess(status));
}).catch(function(error) {
dispatch(dislikeFail(status, error));
});
};
const undislike = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(undislikeRequest(status));
api(getState).post(`/api/friendica/statuses/${status.get('id')}/undislike`).then(() => {
dispatch(undislikeSuccess(status));
}).catch(error => {
dispatch(undislikeFail(status, error));
});
};
const toggleDislike = (status: StatusEntity) =>
(dispatch: AppDispatch) => {
if (status.disliked) {
dispatch(undislike(status));
} else {
dispatch(dislike(status));
}
};
const dislikeRequest = (status: StatusEntity) => ({
type: DISLIKE_REQUEST,
status: status,
skipLoading: true,
});
const dislikeSuccess = (status: StatusEntity) => ({
type: DISLIKE_SUCCESS,
status: status,
skipLoading: true,
});
const dislikeFail = (status: StatusEntity, error: AxiosError) => ({
type: DISLIKE_FAIL,
status: status,
error: error,
skipLoading: true,
});
const undislikeRequest = (status: StatusEntity) => ({
type: UNDISLIKE_REQUEST,
status: status,
skipLoading: true,
});
const undislikeSuccess = (status: StatusEntity) => ({
type: UNDISLIKE_SUCCESS,
status: status,
skipLoading: true,
});
const undislikeFail = (status: StatusEntity, error: AxiosError) => ({
type: UNDISLIKE_FAIL,
status: status,
error: error,
skipLoading: true,
});
const bookmark = (status: StatusEntity) => const bookmark = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(bookmarkRequest(status)); dispatch(bookmarkRequest(status));
@ -436,38 +351,6 @@ const fetchFavouritesFail = (id: string, error: AxiosError) => ({
error, error,
}); });
const fetchDislikes = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(fetchDislikesRequest(id));
api(getState).get(`/api/friendica/statuses/${id}/disliked_by`).then(response => {
dispatch(importFetchedAccounts(response.data));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
dispatch(fetchDislikesSuccess(id, response.data));
}).catch(error => {
dispatch(fetchDislikesFail(id, error));
});
};
const fetchDislikesRequest = (id: string) => ({
type: DISLIKES_FETCH_REQUEST,
id,
});
const fetchDislikesSuccess = (id: string, accounts: APIEntity[]) => ({
type: DISLIKES_FETCH_SUCCESS,
id,
accounts,
});
const fetchDislikesFail = (id: string, error: AxiosError) => ({
type: DISLIKES_FETCH_FAIL,
id,
error,
});
const fetchReactions = (id: string) => const fetchReactions = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchReactionsRequest(id)); dispatch(fetchReactionsRequest(id));
@ -615,27 +498,18 @@ export {
FAVOURITE_REQUEST, FAVOURITE_REQUEST,
FAVOURITE_SUCCESS, FAVOURITE_SUCCESS,
FAVOURITE_FAIL, FAVOURITE_FAIL,
DISLIKE_REQUEST,
DISLIKE_SUCCESS,
DISLIKE_FAIL,
UNREBLOG_REQUEST, UNREBLOG_REQUEST,
UNREBLOG_SUCCESS, UNREBLOG_SUCCESS,
UNREBLOG_FAIL, UNREBLOG_FAIL,
UNFAVOURITE_REQUEST, UNFAVOURITE_REQUEST,
UNFAVOURITE_SUCCESS, UNFAVOURITE_SUCCESS,
UNFAVOURITE_FAIL, UNFAVOURITE_FAIL,
UNDISLIKE_REQUEST,
UNDISLIKE_SUCCESS,
UNDISLIKE_FAIL,
REBLOGS_FETCH_REQUEST, REBLOGS_FETCH_REQUEST,
REBLOGS_FETCH_SUCCESS, REBLOGS_FETCH_SUCCESS,
REBLOGS_FETCH_FAIL, REBLOGS_FETCH_FAIL,
FAVOURITES_FETCH_REQUEST, FAVOURITES_FETCH_REQUEST,
FAVOURITES_FETCH_SUCCESS, FAVOURITES_FETCH_SUCCESS,
FAVOURITES_FETCH_FAIL, FAVOURITES_FETCH_FAIL,
DISLIKES_FETCH_REQUEST,
DISLIKES_FETCH_SUCCESS,
DISLIKES_FETCH_FAIL,
REACTIONS_FETCH_REQUEST, REACTIONS_FETCH_REQUEST,
REACTIONS_FETCH_SUCCESS, REACTIONS_FETCH_SUCCESS,
REACTIONS_FETCH_FAIL, REACTIONS_FETCH_FAIL,
@ -672,15 +546,6 @@ export {
unfavouriteRequest, unfavouriteRequest,
unfavouriteSuccess, unfavouriteSuccess,
unfavouriteFail, unfavouriteFail,
dislike,
undislike,
toggleDislike,
dislikeRequest,
dislikeSuccess,
dislikeFail,
undislikeRequest,
undislikeSuccess,
undislikeFail,
bookmark, bookmark,
unbookmark, unbookmark,
toggleBookmark, toggleBookmark,
@ -698,10 +563,6 @@ export {
fetchFavouritesRequest, fetchFavouritesRequest,
fetchFavouritesSuccess, fetchFavouritesSuccess,
fetchFavouritesFail, fetchFavouritesFail,
fetchDislikes,
fetchDislikesRequest,
fetchDislikesSuccess,
fetchDislikesFail,
fetchReactions, fetchReactions,
fetchReactionsRequest, fetchReactionsRequest,
fetchReactionsSuccess, fetchReactionsSuccess,

Wyświetl plik

@ -112,6 +112,27 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () =
})); }));
}; };
const rejectUserModal = (intl: IntlShape, accountId: string, afterConfirm = () => {}) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const acct = state.accounts.get(accountId)!.acct;
const name = state.accounts.get(accountId)!.username;
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/user-off.svg'),
heading: intl.formatMessage(messages.rejectUserHeading, { acct }),
message: intl.formatMessage(messages.rejectUserPrompt, { acct }),
confirm: intl.formatMessage(messages.rejectUserConfirm, { name }),
onConfirm: () => {
dispatch(deleteUsers([accountId]))
.then(() => {
afterConfirm();
})
.catch(() => {});
},
}));
};
const toggleStatusSensitivityModal = (intl: IntlShape, statusId: string, sensitive: boolean, afterConfirm = () => {}) => const toggleStatusSensitivityModal = (intl: IntlShape, statusId: string, sensitive: boolean, afterConfirm = () => {}) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState(); const state = getState();
@ -157,6 +178,7 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = ()
export { export {
deactivateUserModal, deactivateUserModal,
deleteUserModal, deleteUserModal,
rejectUserModal,
toggleStatusSensitivityModal, toggleStatusSensitivityModal,
deleteStatusModal, deleteStatusModal,
}; };

Wyświetl plik

@ -37,8 +37,8 @@ const subscribe = (registration: ServiceWorkerRegistration, getState: () => Root
}); });
const unsubscribe = ({ registration, subscription }: { const unsubscribe = ({ registration, subscription }: {
registration: ServiceWorkerRegistration registration: ServiceWorkerRegistration,
subscription: PushSubscription | null subscription: PushSubscription | null,
}) => }) =>
subscription ? subscription.unsubscribe().then(() => registration) : new Promise<ServiceWorkerRegistration>(r => r(registration)); subscription ? subscription.unsubscribe().then(() => registration) : new Promise<ServiceWorkerRegistration>(r => r(registration));
@ -82,8 +82,8 @@ const register = () =>
.then(getPushSubscription) .then(getPushSubscription)
// @ts-ignore // @ts-ignore
.then(({ registration, subscription }: { .then(({ registration, subscription }: {
registration: ServiceWorkerRegistration registration: ServiceWorkerRegistration,
subscription: PushSubscription | null subscription: PushSubscription | null,
}) => { }) => {
if (subscription !== null) { if (subscription !== null) {
// We have a subscription, check if it is still valid // We have a subscription, check if it is still valid

Wyświetl plik

@ -4,7 +4,7 @@ import { openModal } from './modals';
import type { AxiosError } from 'axios'; import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account, ChatMessage, Group, Status } from 'soapbox/types/entities'; import type { Account, ChatMessage, Status } from 'soapbox/types/entities';
const REPORT_INIT = 'REPORT_INIT'; const REPORT_INIT = 'REPORT_INIT';
const REPORT_CANCEL = 'REPORT_CANCEL'; const REPORT_CANCEL = 'REPORT_CANCEL';
@ -20,29 +20,19 @@ const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE';
const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE'; const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE';
enum ReportableEntities {
ACCOUNT = 'ACCOUNT',
CHAT_MESSAGE = 'CHAT_MESSAGE',
GROUP = 'GROUP',
STATUS = 'STATUS'
}
type ReportedEntity = { type ReportedEntity = {
status?: Status status?: Status,
chatMessage?: ChatMessage chatMessage?: ChatMessage
group?: Group
} }
const initReport = (entityType: ReportableEntities, account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => { const initReport = (account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => {
const { status, chatMessage, group } = entities || {}; const { status, chatMessage } = entities || {};
dispatch({ dispatch({
type: REPORT_INIT, type: REPORT_INIT,
entityType,
account, account,
status, status,
chatMessage, chatMessage,
group,
}); });
return dispatch(openModal('REPORT')); return dispatch(openModal('REPORT'));
@ -66,8 +56,7 @@ const submitReport = () =>
return api(getState).post('/api/v1/reports', { return api(getState).post('/api/v1/reports', {
account_id: reports.getIn(['new', 'account_id']), account_id: reports.getIn(['new', 'account_id']),
status_ids: reports.getIn(['new', 'status_ids']), status_ids: reports.getIn(['new', 'status_ids']),
message_ids: [reports.getIn(['new', 'chat_message', 'id'])].filter(Boolean), message_ids: [reports.getIn(['new', 'chat_message', 'id'])],
group_id: reports.getIn(['new', 'group', 'id']),
rule_ids: reports.getIn(['new', 'rule_ids']), rule_ids: reports.getIn(['new', 'rule_ids']),
comment: reports.getIn(['new', 'comment']), comment: reports.getIn(['new', 'comment']),
forward: reports.getIn(['new', 'forward']), forward: reports.getIn(['new', 'forward']),
@ -108,7 +97,6 @@ const changeReportRule = (ruleId: string) => ({
}); });
export { export {
ReportableEntities,
REPORT_INIT, REPORT_INIT,
REPORT_CANCEL, REPORT_CANCEL,
REPORT_SUBMIT_REQUEST, REPORT_SUBMIT_REQUEST,

Wyświetl plik

@ -1,10 +1,9 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { defineMessage } from 'react-intl'; import { defineMessages } from 'react-intl';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { patchMe } from 'soapbox/actions/me'; import { patchMe } from 'soapbox/actions/me';
import messages from 'soapbox/locales/messages';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth'; import { isLoggedIn } from 'soapbox/utils/auth';
@ -19,10 +18,12 @@ const FE_NAME = 'soapbox_fe';
/** Options when changing/saving settings. */ /** Options when changing/saving settings. */
type SettingOpts = { type SettingOpts = {
/** Whether to display an alert when settings are saved. */ /** Whether to display an alert when settings are saved. */
showAlert?: boolean showAlert?: boolean,
} }
const saveSuccessMessage = defineMessage({ id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' }); const messages = defineMessages({
saveSuccess: { id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' },
});
const defaultSettings = ImmutableMap({ const defaultSettings = ImmutableMap({
onboarded: false, onboarded: false,
@ -39,7 +40,7 @@ const defaultSettings = ImmutableMap({
defaultPrivacy: 'public', defaultPrivacy: 'public',
defaultContentType: 'text/plain', defaultContentType: 'text/plain',
themeMode: 'system', themeMode: 'system',
locale: navigator.language || 'en', locale: navigator.language.split(/[-_]/)[0] || 'en',
showExplanationBox: true, showExplanationBox: true,
explanationBox: true, explanationBox: true,
autoloadTimelines: true, autoloadTimelines: true,
@ -220,7 +221,7 @@ const saveSettingsImmediate = (opts?: SettingOpts) =>
dispatch({ type: SETTING_SAVE }); dispatch({ type: SETTING_SAVE });
if (opts?.showAlert) { if (opts?.showAlert) {
toast.success(saveSuccessMessage); toast.success(messages.saveSuccess);
} }
}).catch(error => { }).catch(error => {
toast.showAlertForError(error); toast.showAlertForError(error);
@ -230,12 +231,6 @@ const saveSettingsImmediate = (opts?: SettingOpts) =>
const saveSettings = (opts?: SettingOpts) => const saveSettings = (opts?: SettingOpts) =>
(dispatch: AppDispatch) => dispatch(saveSettingsImmediate(opts)); (dispatch: AppDispatch) => dispatch(saveSettingsImmediate(opts));
const getLocale = (state: RootState, fallback = 'en') => {
const localeWithVariant = (getSettings(state).get('locale') as string).replace('_', '-');
const locale = localeWithVariant.split('-')[0];
return Object.keys(messages).includes(localeWithVariant) ? localeWithVariant : Object.keys(messages).includes(locale) ? locale : fallback;
};
export { export {
SETTING_CHANGE, SETTING_CHANGE,
SETTING_SAVE, SETTING_SAVE,
@ -247,5 +242,4 @@ export {
changeSetting, changeSetting,
saveSettingsImmediate, saveSettingsImmediate,
saveSettings, saveSettings,
getLocale,
}; };

Wyświetl plik

@ -32,8 +32,8 @@ const getSoapboxConfig = createSelector([
} }
// If RGI reacts aren't supported, strip VS16s // If RGI reacts aren't supported, strip VS16s
// https://git.pleroma.social/pleroma/pleroma/-/issues/2355 // // https://git.pleroma.social/pleroma/pleroma/-/issues/2355
if (features.emojiReactsNonRGI) { if (!features.emojiReactsRGI) {
soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s)); soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s));
} }
}); });

Wyświetl plik

@ -48,8 +48,6 @@ const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
const STATUS_UNFILTER = 'STATUS_UNFILTER';
const statusExists = (getState: () => RootState, statusId: string) => { const statusExists = (getState: () => RootState, statusId: string) => {
return (getState().statuses.get(statusId) || null) !== null; return (getState().statuses.get(statusId) || null) !== null;
}; };
@ -337,11 +335,6 @@ const undoStatusTranslation = (id: string) => ({
id, id,
}); });
const unfilterStatus = (id: string) => ({
type: STATUS_UNFILTER,
id,
});
export { export {
STATUS_CREATE_REQUEST, STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS, STATUS_CREATE_SUCCESS,
@ -370,7 +363,6 @@ export {
STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_FAIL, STATUS_TRANSLATE_FAIL,
STATUS_TRANSLATE_UNDO, STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER,
createStatus, createStatus,
editStatus, editStatus,
fetchStatus, fetchStatus,
@ -389,5 +381,4 @@ export {
toggleStatusHidden, toggleStatusHidden,
translateStatus, translateStatus,
undoStatusTranslation, undoStatusTranslation,
unfilterStatus,
}; };

Wyświetl plik

@ -1,8 +1,8 @@
import { getLocale, getSettings } from 'soapbox/actions/settings'; import { getSettings } from 'soapbox/actions/settings';
import messages from 'soapbox/locales/messages'; import messages from 'soapbox/locales/messages';
import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats'; import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client'; import { queryClient } from 'soapbox/queries/client';
import { getUnreadChatsCount, updateChatListItem, updateChatMessage } from 'soapbox/utils/chats'; import { getUnreadChatsCount, updateChatListItem } from 'soapbox/utils/chats';
import { removePageItem } from 'soapbox/utils/queries'; import { removePageItem } from 'soapbox/utils/queries';
import { play, soundCache } from 'soapbox/utils/sounds'; import { play, soundCache } from 'soapbox/utils/sounds';
@ -34,6 +34,13 @@ import type { APIEntity, Chat } from 'soapbox/types/entities';
const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE'; const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE';
const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE'; const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE';
const validLocale = (locale: string) => Object.keys(messages).includes(locale);
const getLocale = (state: RootState) => {
const locale = getSettings(state).get('locale') as string;
return validLocale(locale) ? locale : 'en';
};
const updateFollowRelationships = (relationships: APIEntity) => const updateFollowRelationships = (relationships: APIEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const me = getState().me; const me = getState().me;
@ -74,7 +81,7 @@ const updateChatQuery = (chat: IChat) => {
}; };
interface StreamOpts { interface StreamOpts {
statContext?: IStatContext statContext?: IStatContext,
} }
const connectTimelineStream = ( const connectTimelineStream = (
@ -163,9 +170,6 @@ const connectTimelineStream = (
} }
}); });
break; break;
case 'chat_message.reaction': // TruthSocial
updateChatMessage(JSON.parse(data.payload));
break;
case 'pleroma:follow_relationships_update': case 'pleroma:follow_relationships_update':
dispatch(updateFollowRelationships(JSON.parse(data.payload))); dispatch(updateFollowRelationships(JSON.parse(data.payload)));
break; break;

Wyświetl plik

@ -31,14 +31,14 @@ const AGE: Challenge = 'age';
export type Challenge = 'age' | 'sms' | 'email' export type Challenge = 'age' | 'sms' | 'email'
type Challenges = { type Challenges = {
email?: 0 | 1 email?: 0 | 1,
sms?: 0 | 1 sms?: 0 | 1,
age?: 0 | 1 age?: 0 | 1,
} }
type Verification = { type Verification = {
token?: string token?: string,
challenges?: Challenges challenges?: Challenges,
challengeTypes?: Array<'age' | 'sms' | 'email'> challengeTypes?: Array<'age' | 'sms' | 'email'>
}; };

Wyświetl plik

@ -23,12 +23,7 @@ export const getLinks = (response: AxiosResponse): LinkHeader => {
export const getNextLink = (response: AxiosResponse) => { export const getNextLink = (response: AxiosResponse) => {
const nextLink = new LinkHeader(response.headers?.link); const nextLink = new LinkHeader(response.headers?.link);
return nextLink.refs.find(link => link.rel === 'next')?.uri; return nextLink.refs.find((ref) => ref.uri)?.uri;
};
export const getPrevLink = (response: AxiosResponse) => {
const prevLink = new LinkHeader(response.headers?.link);
return prevLink.refs.find(link => link.rel === 'prev')?.uri;
}; };
export const baseClient = (...params: any[]) => { export const baseClient = (...params: any[]) => {

Wyświetl plik

@ -29,10 +29,6 @@ export const getNextLink = (response: AxiosResponse): string | undefined => {
return getLinks(response).refs.find(link => link.rel === 'next')?.uri; return getLinks(response).refs.find(link => link.rel === 'next')?.uri;
}; };
export const getPrevLink = (response: AxiosResponse): string | undefined => {
return getLinks(response).refs.find(link => link.rel === 'prev')?.uri;
};
const getToken = (state: RootState, authType: string) => { const getToken = (state: RootState, authType: string) => {
return authType === 'app' ? getAppToken(state) : getAccessToken(state); return authType === 'app' ? getAppToken(state) : getAccessToken(state);
}; };

Wyświetl plik

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
interface IInlineSVG { interface IInlineSVG {
loader?: JSX.Element loader?: JSX.Element,
} }
const InlineSVG: React.FC<IInlineSVG> = ({ loader }): JSX.Element => { const InlineSVG: React.FC<IInlineSVG> = ({ loader }): JSX.Element => {

Wyświetl plik

@ -0,0 +1,16 @@
import React from 'react';
import { render, screen } from '../../jest/test-helpers';
import EmojiSelector from '../emoji-selector';
describe('<EmojiSelector />', () => {
it('renders correctly', () => {
const children = <EmojiSelector />;
// @ts-ignore
children.__proto__.addEventListener = () => {};
render(children);
expect(screen.queryAllByRole('button')).toHaveLength(6);
});
});

Wyświetl plik

@ -12,9 +12,9 @@ const messages = defineMessages({
interface IAccountSearch { interface IAccountSearch {
/** Callback when a searched account is chosen. */ /** Callback when a searched account is chosen. */
onSelected: (accountId: string) => void onSelected: (accountId: string) => void,
/** Override the default placeholder of the input. */ /** Override the default placeholder of the input. */
placeholder?: string placeholder?: string,
} }
/** Input to search for accounts. */ /** Input to search for accounts. */

Wyświetl plik

@ -1,11 +1,11 @@
import React, { useRef } from 'react'; import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper'; import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import VerificationBadge from 'soapbox/components/verification-badge'; import VerificationBadge from 'soapbox/components/verification-badge';
import ActionButton from 'soapbox/features/ui/components/action-button'; import ActionButton from 'soapbox/features/ui/components/action-button';
import { useAppSelector } from 'soapbox/hooks'; import { useAppSelector, useOnScreen } from 'soapbox/hooks';
import { getAcct } from 'soapbox/utils/accounts'; import { getAcct } from 'soapbox/utils/accounts';
import { displayFqn } from 'soapbox/utils/state'; import { displayFqn } from 'soapbox/utils/state';
@ -14,12 +14,11 @@ import RelativeTimestamp from './relative-timestamp';
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui'; import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
import type { StatusApprovalStatus } from 'soapbox/normalizers/status'; import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
import type { Account as AccountSchema } from 'soapbox/schemas';
import type { Account as AccountEntity } from 'soapbox/types/entities'; import type { Account as AccountEntity } from 'soapbox/types/entities';
interface IInstanceFavicon { interface IInstanceFavicon {
account: AccountEntity | AccountSchema account: AccountEntity,
disabled?: boolean disabled?: boolean,
} }
const messages = defineMessages({ const messages = defineMessages({
@ -54,7 +53,7 @@ const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
}; };
interface IProfilePopper { interface IProfilePopper {
condition: boolean condition: boolean,
wrapper: (children: React.ReactNode) => React.ReactNode wrapper: (children: React.ReactNode) => React.ReactNode
children: React.ReactNode children: React.ReactNode
} }
@ -68,31 +67,30 @@ const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children
}; };
export interface IAccount { export interface IAccount {
account: AccountEntity | AccountSchema account: AccountEntity,
action?: React.ReactElement action?: React.ReactElement,
actionAlignment?: 'center' | 'top' actionAlignment?: 'center' | 'top',
actionIcon?: string actionIcon?: string,
actionTitle?: string actionTitle?: string,
/** Override other actions for specificity like mute/unmute. */ /** Override other actions for specificity like mute/unmute. */
actionType?: 'muting' | 'blocking' | 'follow_request' actionType?: 'muting' | 'blocking' | 'follow_request',
avatarSize?: number avatarSize?: number,
hidden?: boolean hidden?: boolean,
hideActions?: boolean hideActions?: boolean,
id?: string id?: string,
onActionClick?: (account: any) => void onActionClick?: (account: any) => void,
showProfileHoverCard?: boolean showProfileHoverCard?: boolean,
timestamp?: string timestamp?: string,
timestampUrl?: string timestampUrl?: string,
futureTimestamp?: boolean futureTimestamp?: boolean,
withAccountNote?: boolean withAccountNote?: boolean,
withDate?: boolean withDate?: boolean,
withLinkToProfile?: boolean withLinkToProfile?: boolean,
withRelationship?: boolean withRelationship?: boolean,
showEdit?: boolean showEdit?: boolean,
approvalStatus?: StatusApprovalStatus approvalStatus?: StatusApprovalStatus,
emoji?: string emoji?: string,
emojiUrl?: string note?: string,
note?: string
} }
const Account = ({ const Account = ({
@ -117,17 +115,21 @@ const Account = ({
showEdit = false, showEdit = false,
approvalStatus, approvalStatus,
emoji, emoji,
emojiUrl,
note, note,
}: IAccount) => { }: IAccount) => {
const overflowRef = useRef<HTMLDivElement>(null); const overflowRef = React.useRef<HTMLDivElement>(null);
const actionRef = useRef<HTMLDivElement>(null); const actionRef = React.useRef<HTMLDivElement>(null);
// @ts-ignore
const isOnScreen = useOnScreen(overflowRef);
const [style, setStyle] = React.useState<React.CSSProperties>({ visibility: 'hidden' });
const me = useAppSelector((state) => state.me); const me = useAppSelector((state) => state.me);
const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null); const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null);
const handleAction = () => { const handleAction = () => {
onActionClick!(account); // @ts-ignore
onActionClick(account);
}; };
const renderAction = () => { const renderAction = () => {
@ -146,7 +148,7 @@ const Account = ({
title={actionTitle} title={actionTitle}
onClick={handleAction} onClick={handleAction}
className='bg-transparent text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500' className='bg-transparent text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500'
iconClassName='h-4 w-4' iconClassName='w-4 h-4'
/> />
); );
} }
@ -160,6 +162,19 @@ const Account = ({
const intl = useIntl(); const intl = useIntl();
React.useEffect(() => {
const style: React.CSSProperties = {};
const actionWidth = actionRef.current?.clientWidth || 0;
if (overflowRef.current) {
style.maxWidth = overflowRef.current.clientWidth - 30 - avatarSize - actionWidth;
} else {
style.visibility = 'hidden';
}
setStyle(style);
}, [isOnScreen, overflowRef, actionRef]);
if (!account) { if (!account) {
return null; return null;
} }
@ -180,7 +195,7 @@ const Account = ({
return ( return (
<div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}> <div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'> <HStack alignItems={actionAlignment} justifyContent='between'>
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3} className='overflow-hidden'> <HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3}>
<ProfilePopper <ProfilePopper
condition={showProfileHoverCard} condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>} wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -193,15 +208,14 @@ const Account = ({
<Avatar src={account.avatar} size={avatarSize} /> <Avatar src={account.avatar} size={avatarSize} />
{emoji && ( {emoji && (
<Emoji <Emoji
className='absolute bottom-0 -right-1.5 h-5 w-5' className='absolute -bottom-1.5 -right-1.5 h-5 w-5'
emoji={emoji} emoji={emoji}
src={emojiUrl}
/> />
)} )}
</LinkEl> </LinkEl>
</ProfilePopper> </ProfilePopper>
<div className='grow overflow-hidden'> <div className='grow'>
<ProfilePopper <ProfilePopper
condition={showProfileHoverCard} condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>} wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -211,7 +225,7 @@ const Account = ({
title={account.acct} title={account.acct}
onClick={(event: React.MouseEvent) => event.stopPropagation()} onClick={(event: React.MouseEvent) => event.stopPropagation()}
> >
<HStack space={1} alignItems='center' grow> <HStack space={1} alignItems='center' grow style={style}>
<Text <Text
size='sm' size='sm'
weight='semibold' weight='semibold'
@ -227,7 +241,7 @@ const Account = ({
</ProfilePopper> </ProfilePopper>
<Stack space={withAccountNote || note ? 1 : 0}> <Stack space={withAccountNote || note ? 1 : 0}>
<HStack alignItems='center' space={1}> <HStack alignItems='center' space={1} style={style}>
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text> <Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
{account.favicon && ( {account.favicon && (

Wyświetl plik

@ -15,8 +15,8 @@ const obfuscatedCount = (count: number) => {
}; };
interface IAnimatedNumber { interface IAnimatedNumber {
value: number value: number;
obfuscate?: boolean obfuscate?: boolean;
} }
const AnimatedNumber: React.FC<IAnimatedNumber> = ({ value, obfuscate }) => { const AnimatedNumber: React.FC<IAnimatedNumber> = ({ value, obfuscate }) => {

Wyświetl plik

@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom';
import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities'; import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities';
interface IAnnouncementContent { interface IAnnouncementContent {
announcement: AnnouncementEntity announcement: AnnouncementEntity;
} }
const AnnouncementContent: React.FC<IAnnouncementContent> = ({ announcement }) => { const AnnouncementContent: React.FC<IAnnouncementContent> = ({ announcement }) => {

Wyświetl plik

@ -11,10 +11,10 @@ import type { Map as ImmutableMap } from 'immutable';
import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities'; import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities';
interface IAnnouncement { interface IAnnouncement {
announcement: AnnouncementEntity announcement: AnnouncementEntity;
addReaction: (id: string, name: string) => void addReaction: (id: string, name: string) => void;
removeReaction: (id: string, name: string) => void removeReaction: (id: string, name: string) => void;
emojiMap: ImmutableMap<string, ImmutableMap<string, string>> emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
} }
const Announcement: React.FC<IAnnouncement> = ({ announcement, addReaction, removeReaction, emojiMap }) => { const Announcement: React.FC<IAnnouncement> = ({ announcement, addReaction, removeReaction, emojiMap }) => {

Wyświetl plik

@ -1,15 +1,15 @@
import React from 'react'; import React from 'react';
import unicodeMapping from 'soapbox/features/emoji/mapping'; import unicodeMapping from 'soapbox/features/emoji/emoji-unicode-mapping-light';
import { useSettings } from 'soapbox/hooks'; import { useSettings } from 'soapbox/hooks';
import { joinPublicPath } from 'soapbox/utils/static'; import { joinPublicPath } from 'soapbox/utils/static';
import type { Map as ImmutableMap } from 'immutable'; import type { Map as ImmutableMap } from 'immutable';
interface IEmoji { interface IEmoji {
emoji: string emoji: string;
emojiMap: ImmutableMap<string, ImmutableMap<string, string>> emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
hovered: boolean hovered: boolean;
} }
const Emoji: React.FC<IEmoji> = ({ emoji, emojiMap, hovered }) => { const Emoji: React.FC<IEmoji> = ({ emoji, emojiMap, hovered }) => {

Wyświetl plik

@ -2,7 +2,7 @@ import clsx from 'clsx';
import React, { useState } from 'react'; import React, { useState } from 'react';
import AnimatedNumber from 'soapbox/components/animated-number'; import AnimatedNumber from 'soapbox/components/animated-number';
import unicodeMapping from 'soapbox/features/emoji/mapping'; import unicodeMapping from 'soapbox/features/emoji/emoji-unicode-mapping-light';
import Emoji from './emoji'; import Emoji from './emoji';
@ -10,12 +10,12 @@ import type { Map as ImmutableMap } from 'immutable';
import type { AnnouncementReaction } from 'soapbox/types/entities'; import type { AnnouncementReaction } from 'soapbox/types/entities';
interface IReaction { interface IReaction {
announcementId: string announcementId: string;
reaction: AnnouncementReaction reaction: AnnouncementReaction;
emojiMap: ImmutableMap<string, ImmutableMap<string, string>> emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
addReaction: (id: string, name: string) => void addReaction: (id: string, name: string) => void;
removeReaction: (id: string, name: string) => void removeReaction: (id: string, name: string) => void;
style: React.CSSProperties style: React.CSSProperties;
} }
const Reaction: React.FC<IReaction> = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => { const Reaction: React.FC<IReaction> = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => {

Wyświetl plik

@ -2,28 +2,29 @@ import clsx from 'clsx';
import React from 'react'; import React from 'react';
import { TransitionMotion, spring } from 'react-motion'; import { TransitionMotion, spring } from 'react-motion';
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container'; import { Icon } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown';
import { useSettings } from 'soapbox/hooks'; import { useSettings } from 'soapbox/hooks';
import Reaction from './reaction'; import Reaction from './reaction';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import type { Emoji, NativeEmoji } from 'soapbox/features/emoji'; import type { Emoji } from 'soapbox/components/autosuggest-emoji';
import type { AnnouncementReaction } from 'soapbox/types/entities'; import type { AnnouncementReaction } from 'soapbox/types/entities';
interface IReactionsBar { interface IReactionsBar {
announcementId: string announcementId: string;
reactions: ImmutableList<AnnouncementReaction> reactions: ImmutableList<AnnouncementReaction>;
emojiMap: ImmutableMap<string, ImmutableMap<string, string>> emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
addReaction: (id: string, name: string) => void addReaction: (id: string, name: string) => void;
removeReaction: (id: string, name: string) => void removeReaction: (id: string, name: string) => void;
} }
const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => { const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => {
const reduceMotion = useSettings().get('reduceMotion'); const reduceMotion = useSettings().get('reduceMotion');
const handleEmojiPick = (data: Emoji) => { const handleEmojiPick = (data: Emoji) => {
addReaction(announcementId, (data as NativeEmoji).native.replace(/:/g, '')); addReaction(announcementId, data.native.replace(/:/g, ''));
}; };
const willEnter = () => ({ scale: reduceMotion ? 1 : 0 }); const willEnter = () => ({ scale: reduceMotion ? 1 : 0 });
@ -54,7 +55,7 @@ const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addR
/> />
))} ))}
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} />} {visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} button={<Icon className='h-4 w-4 text-gray-400 hover:text-gray-600 dark:hover:text-white' src={require('@tabler/icons/plus.svg')} />} />}
</div> </div>
)} )}
</TransitionMotion> </TransitionMotion>

Wyświetl plik

@ -1,139 +0,0 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { HStack, IconButton, Text } from 'soapbox/components/ui';
interface IAuthorizeRejectButtons {
onAuthorize(): Promise<unknown> | unknown
onReject(): Promise<unknown> | unknown
countdown?: number
}
/** Buttons to approve or reject a pending item, usually an account. */
const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize, onReject, countdown }) => {
const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending');
const timeout = useRef<NodeJS.Timeout>();
function handleAction(
present: 'authorizing' | 'rejecting',
past: 'authorized' | 'rejected',
action: () => Promise<unknown> | unknown,
): void {
if (state === present) {
if (timeout.current) {
clearTimeout(timeout.current);
}
setState('pending');
} else {
const doAction = async () => {
try {
await action();
setState(past);
} catch (e) {
console.error(e);
}
};
if (typeof countdown === 'number') {
setState(present);
timeout.current = setTimeout(doAction, countdown);
} else {
doAction();
}
}
}
const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize);
const handleReject = async () => handleAction('rejecting', 'rejected', onReject);
useEffect(() => {
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};
}, []);
switch (state) {
case 'authorized':
return (
<ActionEmblem text={<FormattedMessage id='authorize.success' defaultMessage='Approved' />} />
);
case 'rejected':
return (
<ActionEmblem text={<FormattedMessage id='reject.success' defaultMessage='Rejected' />} />
);
default:
return (
<HStack space={3} alignItems='center'>
<AuthorizeRejectButton
theme='danger'
icon={require('@tabler/icons/x.svg')}
action={handleReject}
isLoading={state === 'rejecting'}
disabled={state === 'authorizing'}
/>
<AuthorizeRejectButton
theme='primary'
icon={require('@tabler/icons/check.svg')}
action={handleAuthorize}
isLoading={state === 'authorizing'}
disabled={state === 'rejecting'}
/>
</HStack>
);
}
};
interface IActionEmblem {
text: React.ReactNode
}
const ActionEmblem: React.FC<IActionEmblem> = ({ text }) => {
return (
<div className='rounded-full bg-gray-100 px-4 py-2 dark:bg-gray-800'>
<Text theme='muted' size='sm'>
{text}
</Text>
</div>
);
};
interface IAuthorizeRejectButton {
theme: 'primary' | 'danger'
icon: string
action(): void
isLoading?: boolean
disabled?: boolean
}
const AuthorizeRejectButton: React.FC<IAuthorizeRejectButton> = ({ theme, icon, action, isLoading, disabled }) => {
return (
<div className='relative'>
<IconButton
src={isLoading ? require('@tabler/icons/player-stop-filled.svg') : icon}
onClick={action}
theme='seamless'
className={clsx('h-10 w-10 items-center justify-center border-2', {
'border-primary-500/10 hover:border-primary-500': theme === 'primary',
'border-danger-600/10 hover:border-danger-600': theme === 'danger',
})}
iconClassName={clsx('h-6 w-6', {
'text-primary-500': theme === 'primary',
'text-danger-600': theme === 'danger',
})}
disabled={disabled}
/>
{(isLoading) && (
<div
className={clsx('pointer-events-none absolute inset-0 h-10 w-10 animate-spin rounded-full border-2 border-transparent', {
'border-t-primary-500': theme === 'primary',
'border-t-danger-600': theme === 'danger',
})}
/>
)}
</div>
);
};
export { AuthorizeRejectButtons };

Wyświetl plik

@ -12,16 +12,16 @@ import type { InputThemes } from 'soapbox/components/ui/input/input';
const noOp = () => { }; const noOp = () => { };
interface IAutosuggestAccountInput { interface IAutosuggestAccountInput {
onChange: React.ChangeEventHandler<HTMLInputElement> onChange: React.ChangeEventHandler<HTMLInputElement>,
onSelected: (accountId: string) => void onSelected: (accountId: string) => void,
autoFocus?: boolean autoFocus?: boolean,
value: string value: string,
limit?: number limit?: number,
className?: string className?: string,
autoSelect?: boolean autoSelect?: boolean,
menu?: Menu menu?: Menu,
onKeyDown?: React.KeyboardEventHandler onKeyDown?: React.KeyboardEventHandler,
theme?: InputThemes theme?: InputThemes,
} }
const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({

Wyświetl plik

@ -1,30 +1,38 @@
import React from 'react'; import React from 'react';
import { isCustomEmoji } from 'soapbox/features/emoji'; import unicodeMapping from 'soapbox/features/emoji/emoji-unicode-mapping-light';
import unicodeMapping from 'soapbox/features/emoji/mapping';
import { joinPublicPath } from 'soapbox/utils/static'; import { joinPublicPath } from 'soapbox/utils/static';
import type { Emoji } from 'soapbox/features/emoji'; export type Emoji = {
id: string,
custom: boolean,
imageUrl: string,
native: string,
colons: string,
}
type UnicodeMapping = {
filename: string,
}
interface IAutosuggestEmoji { interface IAutosuggestEmoji {
emoji: Emoji emoji: Emoji,
} }
const AutosuggestEmoji: React.FC<IAutosuggestEmoji> = ({ emoji }) => { const AutosuggestEmoji: React.FC<IAutosuggestEmoji> = ({ emoji }) => {
let url, alt; let url;
if (isCustomEmoji(emoji)) { if (emoji.custom) {
url = emoji.imageUrl; url = emoji.imageUrl;
alt = emoji.colons;
} else { } else {
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; // @ts-ignore
const mapping: UnicodeMapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
if (!mapping) { if (!mapping) {
return null; return null;
} }
url = joinPublicPath(`packs/emoji/${mapping.unified}.svg`); url = joinPublicPath(`packs/emoji/${mapping.filename}.svg`);
alt = emoji.native;
} }
return ( return (
@ -32,7 +40,7 @@ const AutosuggestEmoji: React.FC<IAutosuggestEmoji> = ({ emoji }) => {
<img <img
className='emojione' className='emojione'
src={url} src={url}
alt={alt} alt={emoji.native || emoji.colons}
/> />
{emoji.colons} {emoji.colons}

Wyświetl plik

@ -1,39 +1,39 @@
import { Portal } from '@reach/portal';
import clsx from 'clsx'; import clsx from 'clsx';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React from 'react'; import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji'; import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest-emoji';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { Input, Portal } from 'soapbox/components/ui'; import { Input } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account'; import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
import { isRtl } from 'soapbox/rtl'; import { isRtl } from 'soapbox/rtl';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu'; import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
import type { InputThemes } from 'soapbox/components/ui/input/input'; import type { InputThemes } from 'soapbox/components/ui/input/input';
import type { Emoji } from 'soapbox/features/emoji';
export type AutoSuggestion = string | Emoji; export type AutoSuggestion = string | Emoji;
export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'onKeyUp' | 'onKeyDown'> { export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'onKeyUp' | 'onKeyDown'> {
value: string value: string,
suggestions: ImmutableList<any> suggestions: ImmutableList<any>,
disabled?: boolean disabled?: boolean,
placeholder?: string placeholder?: string,
onSuggestionSelected: (tokenStart: number, lastToken: string | null, suggestion: AutoSuggestion) => void onSuggestionSelected: (tokenStart: number, lastToken: string | null, suggestion: AutoSuggestion) => void,
onSuggestionsClearRequested: () => void onSuggestionsClearRequested: () => void,
onSuggestionsFetchRequested: (token: string) => void onSuggestionsFetchRequested: (token: string) => void,
autoFocus: boolean autoFocus: boolean,
autoSelect: boolean autoSelect: boolean,
className?: string className?: string,
id?: string id?: string,
searchTokens: string[] searchTokens: string[],
maxLength?: number maxLength?: number,
menu?: Menu menu?: Menu,
renderSuggestion?: React.FC<{ id: string }> renderSuggestion?: React.FC<{ id: string }>,
hidePortal?: boolean hidePortal?: boolean,
theme?: InputThemes theme?: InputThemes,
} }
export default class AutosuggestInput extends ImmutablePureComponent<IAutosuggestInput> { export default class AutosuggestInput extends ImmutablePureComponent<IAutosuggestInput> {

Wyświetl plik

@ -19,7 +19,7 @@ export const ADDRESS_ICONS: Record<string, string> = {
}; };
interface IAutosuggestLocation { interface IAutosuggestLocation {
id: string id: string,
} }
const AutosuggestLocation: React.FC<IAutosuggestLocation> = ({ id }) => { const AutosuggestLocation: React.FC<IAutosuggestLocation> = ({ id }) => {

Wyświetl plik

@ -1,36 +1,36 @@
import { Portal } from '@reach/portal';
import clsx from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import { Portal } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
import { isRtl } from 'soapbox/rtl';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestEmoji from './autosuggest-emoji'; import AutosuggestAccount from '../features/compose/components/autosuggest-account';
import { isRtl } from '../rtl';
import AutosuggestEmoji, { Emoji } from './autosuggest-emoji';
import type { List as ImmutableList } from 'immutable'; import type { List as ImmutableList } from 'immutable';
import type { Emoji } from 'soapbox/features/emoji';
interface IAutosuggesteTextarea { interface IAutosuggesteTextarea {
id?: string id?: string,
value: string value: string,
suggestions: ImmutableList<string> suggestions: ImmutableList<string>,
disabled: boolean disabled: boolean,
placeholder: string placeholder: string,
onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void,
onSuggestionsClearRequested: () => void onSuggestionsClearRequested: () => void,
onSuggestionsFetchRequested: (token: string | number) => void onSuggestionsFetchRequested: (token: string | number) => void,
onChange: React.ChangeEventHandler<HTMLTextAreaElement> onChange: React.ChangeEventHandler<HTMLTextAreaElement>,
onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement> onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement>,
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement> onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>,
onPaste: (files: FileList) => void onPaste: (files: FileList) => void,
autoFocus: boolean autoFocus: boolean,
onFocus: () => void onFocus: () => void,
onBlur?: () => void onBlur?: () => void,
condensed?: boolean condensed?: boolean,
children: React.ReactNode children: React.ReactNode,
} }
class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> { class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> {

Wyświetl plik

@ -2,8 +2,8 @@ import clsx from 'clsx';
import React from 'react'; import React from 'react';
interface IBadge { interface IBadge {
title: React.ReactNode title: React.ReactNode,
slug: string slug: string,
} }
/** Badge to display on a user's profile. */ /** Badge to display on a user's profile. */
const Badge: React.FC<IBadge> = ({ title, slug }) => { const Badge: React.FC<IBadge> = ({ title, slug }) => {

Wyświetl plik

@ -15,9 +15,9 @@ const messages = defineMessages({
}); });
interface IBirthdayInput { interface IBirthdayInput {
value?: string value?: string,
onChange: (value: string) => void onChange: (value: string) => void,
required?: boolean required?: boolean,
} }
const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required }) => { const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required }) => {
@ -56,15 +56,15 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
nextYearButtonDisabled, nextYearButtonDisabled,
date, date,
}: { }: {
decreaseMonth(): void decreaseMonth(): void,
increaseMonth(): void increaseMonth(): void,
prevMonthButtonDisabled: boolean prevMonthButtonDisabled: boolean,
nextMonthButtonDisabled: boolean nextMonthButtonDisabled: boolean,
decreaseYear(): void decreaseYear(): void,
increaseYear(): void increaseYear(): void,
prevYearButtonDisabled: boolean prevYearButtonDisabled: boolean,
nextYearButtonDisabled: boolean nextYearButtonDisabled: boolean,
date: Date date: Date,
}) => { }) => {
return ( return (
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>

Wyświetl plik

@ -3,18 +3,18 @@ import React, { useRef, useEffect } from 'react';
interface IBlurhash { interface IBlurhash {
/** Hash to render */ /** Hash to render */
hash: string | null | undefined hash: string | null | undefined,
/** Width of the blurred region in pixels. Defaults to 32. */ /** Width of the blurred region in pixels. Defaults to 32. */
width?: number width?: number,
/** Height of the blurred region in pixels. Defaults to width. */ /** Height of the blurred region in pixels. Defaults to width. */
height?: number height?: number,
/** /**
* Whether dummy mode is enabled. If enabled, nothing is rendered * Whether dummy mode is enabled. If enabled, nothing is rendered
* and canvas left untouched. * and canvas left untouched.
*/ */
dummy?: boolean dummy?: boolean,
/** className of the canvas element. */ /** className of the canvas element. */
className?: string className?: string,
} }
/** /**

Wyświetl plik

@ -5,7 +5,7 @@ import { Button, HStack, Input } from './ui';
interface ICopyableInput { interface ICopyableInput {
/** Text to be copied. */ /** Text to be copied. */
value: string value: string,
} }
/** An input with copy abilities. */ /** An input with copy abilities. */
@ -29,7 +29,7 @@ const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
type='text' type='text'
value={value} value={value}
className='rounded-r-none rtl:rounded-l-none rtl:rounded-r-lg' className='rounded-r-none rtl:rounded-l-none rtl:rounded-r-lg'
outerClassName='grow' outerClassName='flex-grow'
onClick={selectInput} onClick={selectInput}
readOnly readOnly
/> />

Wyświetl plik

@ -12,7 +12,7 @@ const messages = defineMessages({
}); });
interface IDomain { interface IDomain {
domain: string domain: string,
} }
const Domain: React.FC<IDomain> = ({ domain }) => { const Domain: React.FC<IDomain> = ({ domain }) => {

Wyświetl plik

@ -0,0 +1,420 @@
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React from 'react';
import { spring } from 'react-motion';
// @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions
import Overlay from 'react-overlays/lib/Overlay';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { Counter, IconButton } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import Motion from 'soapbox/features/ui/util/optional-motion';
import type { Status } from 'soapbox/types/entities';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
let id = 0;
export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
middleClick?: React.EventHandler<React.MouseEvent>,
text: string,
href?: string,
to?: string,
newTab?: boolean,
isLogout?: boolean,
icon?: string,
count?: number,
destructive?: boolean,
meta?: string,
active?: boolean,
}
export type Menu = Array<MenuItem | null>;
interface IDropdownMenu extends RouteComponentProps {
items: Menu,
onClose: () => void,
style?: React.CSSProperties,
placement?: DropdownPlacement,
arrowOffsetLeft?: string,
arrowOffsetTop?: string,
openedViaKeyboard: boolean,
}
interface IDropdownMenuState {
mounted: boolean,
}
class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState> {
static defaultProps: Partial<IDropdownMenu> = {
style: {},
placement: 'bottom',
};
state = {
mounted: false,
};
node: HTMLDivElement | null = null;
focusedItem: HTMLAnchorElement | null = null;
handleDocumentClick = (e: Event) => {
if (this.node && !this.node.contains(e.target as Node)) {
this.props.onClose();
}
};
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true });
}
this.setState({ mounted: true });
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('touchend', this.handleDocumentClick);
}
setRef: React.RefCallback<HTMLDivElement> = c => {
this.node = c;
};
setFocusRef: React.RefCallback<HTMLAnchorElement> = c => {
this.focusedItem = c;
};
handleKeyDown = (e: KeyboardEvent) => {
if (!this.node) return;
const items = Array.from(this.node.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement as any);
let element = null;
switch (e.key) {
case 'ArrowDown':
element = items[index + 1] || items[0];
break;
case 'ArrowUp':
element = items[index - 1] || items[items.length - 1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index - 1] || items[items.length - 1];
} else {
element = items[index + 1] || items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length - 1];
break;
case 'Escape':
this.props.onClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
};
handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
this.props.onClose();
e.stopPropagation();
if (to) {
e.preventDefault();
this.props.history.push(to);
} else if (typeof action === 'function') {
e.preventDefault();
action(e);
}
};
handleMiddleClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { middleClick } = item;
this.props.onClose();
if (e.button === 1 && typeof middleClick === 'function') {
e.preventDefault();
middleClick(e);
}
};
handleAuxClick: React.EventHandler<React.MouseEvent> = e => {
if (e.button === 1) {
this.handleMiddleClick(e);
}
};
renderItem(option: MenuItem | null, i: number): JSX.Element {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
return (
<li className={clsx('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
<a
href={href || to || '#'}
role='button'
tabIndex={0}
ref={i === 0 ? this.setFocusRef : null}
onClick={this.handleClick}
onAuxClick={this.handleAuxClick}
onKeyPress={this.handleItemKeyPress}
data-index={i}
target={newTab ? '_blank' : undefined}
data-method={isLogout ? 'delete' : undefined}
title={text}
>
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
<span className='truncate'>{text}</span>
{count ? (
<span className='ml-auto h-5 w-5 flex-none'>
<Counter count={count} />
</span>
) : null}
</a>
</li>
);
}
render() {
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
const { mounted } = this.state;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div
className={`dropdown-menu ${placement}`}
style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }}
ref={this.setRef}
data-testid='dropdown-menu'
>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<ul>
{items.map((option, i) => this.renderItem(option, i))}
</ul>
</div>
)}
</Motion>
);
}
}
const RouterDropdownMenu = withRouter(DropdownMenu);
export interface IDropdown extends RouteComponentProps {
icon?: string,
src?: string,
items: Menu,
size?: number,
active?: boolean,
pressed?: boolean,
title?: string,
disabled?: boolean,
status?: Status,
isUserTouching?: () => boolean,
isModalOpen?: boolean,
onOpen?: (
id: number,
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
dropdownPlacement: DropdownPlacement,
keyboard: boolean,
) => void,
onClose?: (id: number) => void,
dropdownPlacement?: string,
openDropdownId?: number | null,
openedViaKeyboard?: boolean,
text?: string,
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
children?: JSX.Element,
dropdownMenuStyle?: React.CSSProperties,
}
interface IDropdownState {
id: number,
open: boolean,
}
export type DropdownPlacement = 'top' | 'bottom';
class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
static defaultProps: Partial<IDropdown> = {
title: 'Menu',
};
state = {
id: id++,
open: false,
};
target: HTMLButtonElement | null = null;
activeElement: Element | null = null;
handleClick: React.EventHandler<React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>> = e => {
const { onOpen, onShiftClick, openDropdownId } = this.props;
e.stopPropagation();
if (onShiftClick && e.shiftKey) {
e.preventDefault();
onShiftClick(e);
} else if (this.state.id === openDropdownId) {
this.handleClose();
} else if (onOpen) {
const { top } = e.currentTarget.getBoundingClientRect();
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
}
};
handleClose = () => {
if (this.activeElement && this.activeElement === this.target) {
(this.activeElement as HTMLButtonElement).focus();
this.activeElement = null;
}
if (this.props.onClose) {
this.props.onClose(this.state.id);
}
};
handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (e) => {
switch (e.key) {
case ' ':
case 'Enter':
this.handleMouseDown(e);
break;
}
};
handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (e) => {
switch (e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
}
};
handleItemClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
this.handleClose();
e.preventDefault();
e.stopPropagation();
if (typeof action === 'function') {
action(e);
} else if (to) {
this.props.history?.push(to);
}
};
setTargetRef: React.RefCallback<HTMLButtonElement> = c => {
this.target = c;
};
findTarget = () => {
return this.target;
};
componentWillUnmount = () => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
}
};
render() {
const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children, dropdownMenuStyle } = this.props;
const open = this.state.id === openDropdownId;
return (
<>
{children ? (
React.cloneElement(children, {
disabled,
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown,
onKeyPress: this.handleKeyPress,
ref: this.setTargetRef,
})
) : (
<IconButton
disabled={disabled}
className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'text-gray-700 dark:text-gray-500': open,
})}
title={title}
src={src}
aria-pressed={pressed}
text={text}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
ref={this.setTargetRef}
/>
)}
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
<RouterDropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} style={dropdownMenuStyle} />
</Overlay>
</>
);
}
}
export default withRouter(Dropdown);

Wyświetl plik

@ -1,109 +0,0 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { useHistory } from 'react-router-dom';
import { Counter, Icon } from '../ui';
export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>
active?: boolean
count?: number
destructive?: boolean
href?: string
icon?: string
meta?: string
middleClick?(event: React.MouseEvent): void
target?: React.HTMLAttributeAnchorTarget
text: string
to?: string
}
interface IDropdownMenuItem {
index: number
item: MenuItem | null
onClick?(): void
}
const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
const history = useHistory();
const itemRef = useRef<HTMLAnchorElement>(null);
const handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = (event) => {
event.stopPropagation();
if (!item) return;
if (onClick) onClick();
if (item.to) {
event.preventDefault();
history.push(item.to);
} else if (typeof item.action === 'function') {
event.preventDefault();
item.action(event);
}
};
const handleAuxClick: React.EventHandler<React.MouseEvent> = (event) => {
if (!item) return;
if (onClick) onClick();
if (event.button === 1 && item.middleClick) {
item.middleClick(event);
}
};
const handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
handleClick(event);
}
};
useEffect(() => {
const firstItem = index === 0;
if (itemRef.current && firstItem) {
itemRef.current.focus({ preventScroll: true });
}
}, [itemRef.current, index]);
if (item === null) {
return <li className='my-1 mx-2 h-[2px] bg-gray-100 dark:bg-gray-800' />;
}
return (
<li className='truncate focus-visible:ring-2 focus-visible:ring-primary-500'>
<a
href={item.href || item.to || '#'}
role='button'
tabIndex={0}
ref={itemRef}
data-index={index}
onClick={handleClick}
onAuxClick={handleAuxClick}
onKeyPress={handleItemKeyPress}
target={item.target}
title={item.text}
className={
clsx({
'flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none cursor-pointer': true,
'text-danger-600 dark:text-danger-400': item.destructive,
})
}
>
{item.icon && <Icon src={item.icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
<span className='truncate'>{item.text}</span>
{item.count ? (
<span className='ml-auto h-5 w-5 flex-none'>
<Counter count={item.count} />
</span>
) : null}
</a>
</li>
);
};
export default DropdownMenuItem;

Wyświetl plik

@ -1,346 +0,0 @@
import { offset, Placement, useFloating, flip, arrow } from '@floating-ui/react';
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
import {
closeDropdownMenu as closeDropdownMenuRedux,
openDropdownMenu,
} from 'soapbox/actions/dropdown-menu';
import { closeModal, openModal } from 'soapbox/actions/modals';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import { IconButton, Portal } from '../ui';
import DropdownMenuItem, { MenuItem } from './dropdown-menu-item';
import type { Status } from 'soapbox/types/entities';
export type Menu = Array<MenuItem | null>;
interface IDropdownMenu {
children?: React.ReactElement
disabled?: boolean
items: Menu
onClose?: () => void
onOpen?: () => void
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>
placement?: Placement
src?: string
status?: Status
title?: string
}
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const DropdownMenu = (props: IDropdownMenu) => {
const {
children,
disabled,
items,
onClose,
onOpen,
onShiftClick,
placement: initialPlacement = 'top',
src = require('@tabler/icons/dots.svg'),
title = 'Menu',
...filteredProps
} = props;
const dispatch = useAppDispatch();
const history = useHistory();
const [isOpen, setIsOpen] = useState<boolean>(false);
const isOpenRedux = useAppSelector(state => state.dropdown_menu.isOpen);
const arrowRef = useRef<HTMLDivElement>(null);
const activeElement = useRef<Element | null>(null);
const isOnMobile = isUserTouching();
const { x, y, strategy, refs, middlewareData, placement } = useFloating<HTMLButtonElement>({
placement: initialPlacement,
middleware: [
offset(12),
flip(),
arrow({
element: arrowRef,
}),
],
});
const handleClick: React.EventHandler<
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
> = (event) => {
event.stopPropagation();
if (onShiftClick && event.shiftKey) {
event.preventDefault();
onShiftClick(event);
return;
}
if (isOpen) {
handleClose();
} else {
handleOpen();
}
};
/**
* On mobile screens, let's replace the Popper dropdown with a Modal.
*/
const handleOpen = () => {
if (isOnMobile) {
dispatch(
openModal('ACTIONS', {
status: filteredProps.status,
actions: items,
onClick: handleItemClick,
}),
);
} else {
dispatch(openDropdownMenu());
setIsOpen(true);
}
if (onOpen) {
onOpen();
}
};
const handleClose = () => {
if (activeElement.current && activeElement.current === refs.reference.current) {
(activeElement.current as any).focus();
activeElement.current = null;
}
if (isOnMobile) {
dispatch(closeModal('ACTIONS'));
} else {
closeDropdownMenu();
setIsOpen(false);
}
if (onClose) {
onClose();
}
};
const closeDropdownMenu = () => {
if (isOpenRedux) {
dispatch(closeDropdownMenuRedux());
}
};
const handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
if (!isOpen) {
activeElement.current = document.activeElement;
}
};
const handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (event) => {
switch (event.key) {
case ' ':
case 'Enter':
handleMouseDown(event);
break;
}
};
const handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (event) => {
switch (event.key) {
case ' ':
case 'Enter':
event.stopPropagation();
event.preventDefault();
handleClick(event);
break;
}
};
const handleItemClick: React.EventHandler<React.MouseEvent> = (event) => {
event.preventDefault();
event.stopPropagation();
const i = Number(event.currentTarget.getAttribute('data-index'));
const item = items[i];
if (!item) return;
const { action, to } = item;
handleClose();
if (typeof action === 'function') {
action(event);
} else if (to) {
history.push(to);
}
};
const handleDocumentClick = (event: Event) => {
if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) {
handleClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!refs.floating.current) return;
const items = Array.from(refs.floating.current.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement as any);
let element = null;
switch (e.key) {
case 'ArrowDown':
element = items[index + 1] || items[0];
break;
case 'ArrowUp':
element = items[index - 1] || items[items.length - 1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index - 1] || items[items.length - 1];
} else {
element = items[index + 1] || items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length - 1];
break;
case 'Escape':
handleClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
const arrowProps: React.CSSProperties = useMemo(() => {
if (middlewareData.arrow) {
const { x, y } = middlewareData.arrow;
const staticPlacement = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.split('-')[0]];
return {
left: x !== null ? `${x}px` : '',
top: y !== null ? `${y}px` : '',
// Ensure the static side gets unset when
// flipping to other placements' axes.
right: '',
bottom: '',
[staticPlacement as string]: `${(-(arrowRef.current?.offsetWidth || 0)) / 2}px`,
transform: 'rotate(45deg)',
};
}
return {};
}, [middlewareData.arrow, placement]);
useEffect(() => {
return () => {
closeDropdownMenu();
};
}, []);
useEffect(() => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('touchend', handleDocumentClick);
};
}, [refs.floating.current]);
if (items.length === 0) {
return null;
}
return (
<>
{children ? (
React.cloneElement(children, {
disabled,
onClick: handleClick,
onMouseDown: handleMouseDown,
onKeyDown: handleButtonKeyDown,
onKeyPress: handleKeyPress,
ref: refs.setReference,
})
) : (
<IconButton
disabled={disabled}
className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'text-gray-700 dark:text-gray-500': isOpen,
})}
title={title}
src={src}
onClick={handleClick}
onMouseDown={handleMouseDown}
onKeyDown={handleButtonKeyDown}
onKeyPress={handleKeyPress}
ref={refs.setReference}
/>
)}
{isOpen ? (
<Portal>
<div
data-testid='dropdown-menu'
ref={refs.setFloating}
className={
clsx('z-[1001] w-56 rounded-md bg-white py-1 shadow-lg transition-opacity duration-100 focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', {
'opacity-0 pointer-events-none': !isOpen,
})
}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
}}
>
<ul>
{items.map((item, idx) => (
<DropdownMenuItem
key={idx}
item={item}
index={idx}
onClick={handleClose}
/>
))}
</ul>
{/* Arrow */}
<div
ref={arrowRef}
style={arrowProps}
className='pointer-events-none absolute z-[-1] h-3 w-3 bg-white dark:bg-gray-900'
/>
</div>
</Portal>
) : null}
</>
);
};
export default DropdownMenu;

Wyświetl plik

@ -1,3 +0,0 @@
export { default } from './dropdown-menu';
export type { Menu } from './dropdown-menu';
export type { MenuItem } from './dropdown-menu-item';

Wyświetl plik

@ -1,19 +1,21 @@
import clsx from 'clsx';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { usePopper } from 'react-popper';
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts'; import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { EmojiSelector, Portal } from 'soapbox/components/ui'; import { EmojiSelector } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile'; import { isUserTouching } from 'soapbox/is-mobile';
import { getReactForStatus } from 'soapbox/utils/emoji-reacts'; import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
interface IStatusReactionWrapper { interface IEmojiButtonWrapper {
statusId: string statusId: string,
children: JSX.Element children: JSX.Element,
} }
/** Provides emoji reaction functionality to the underlying button component */ /** Provides emoji reaction functionality to the underlying button component */
const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, children }): JSX.Element | null => { const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children }): JSX.Element | null => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const ownAccount = useOwnAccount(); const ownAccount = useOwnAccount();
const status = useAppSelector(state => state.statuses.get(statusId)); const status = useAppSelector(state => state.statuses.get(statusId));
@ -21,8 +23,24 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
const timeout = useRef<NodeJS.Timeout>(); const timeout = useRef<NodeJS.Timeout>();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
// const [focused, setFocused] = useState(false);
// `useRef` won't trigger a re-render, while `useState` does.
// https://popper.js.org/react-popper/v2/
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start',
modifiers: [
{
name: 'offset',
options: {
offset: [-10, 0],
},
},
],
});
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -60,9 +78,9 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
} }
}; };
const handleReact = (emoji: string, custom?: string): void => { const handleReact = (emoji: string): void => {
if (ownAccount) { if (ownAccount) {
dispatch(simpleEmojiReact(status, emoji, custom)); dispatch(simpleEmojiReact(status, emoji));
} else { } else {
handleUnauthorized(); handleUnauthorized();
} }
@ -71,7 +89,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
}; };
const handleClick: React.EventHandler<React.MouseEvent> = e => { const handleClick: React.EventHandler<React.MouseEvent> = e => {
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.get('name') || '👍'; const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍';
if (isUserTouching()) { if (isUserTouching()) {
if (ownAccount) { if (ownAccount) {
@ -98,6 +116,28 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
})); }));
}; };
// const handleUnfocus: React.EventHandler<React.KeyboardEvent> = () => {
// setFocused(false);
// };
const selector = (
<div
className={clsx('z-50 transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible,
})}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<EmojiSelector
emojis={soapboxConfig.allowedEmoji}
onReact={handleReact}
// focused={focused}
// onUnfocus={handleUnfocus}
/>
</div>
);
return ( return (
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}> <div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{React.cloneElement(children, { {React.cloneElement(children, {
@ -105,19 +145,9 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
ref: setReferenceElement, ref: setReferenceElement,
})} })}
{visible && ( {selector}
<Portal>
<EmojiSelector
placement='top-start'
referenceElement={referenceElement}
onReact={handleReact}
visible={visible}
onClose={() => setVisible(false)}
/>
</Portal>
)}
</div> </div>
); );
}; };
export default StatusReactionWrapper; export default EmojiButtonWrapper;

Wyświetl plik

@ -0,0 +1,142 @@
// import clsx from 'clsx';
import React from 'react';
import { HotKeys } from 'react-hotkeys';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { EmojiSelector as RealEmojiSelector } from 'soapbox/components/ui';
import type { List as ImmutableList } from 'immutable';
import type { RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState) => ({
allowedEmoji: getSoapboxConfig(state).allowedEmoji,
});
interface IEmojiSelector {
allowedEmoji: ImmutableList<string>,
onReact: (emoji: string) => void,
onUnfocus: () => void,
visible: boolean,
focused?: boolean,
}
class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
static defaultProps: Partial<IEmojiSelector> = {
onReact: () => { },
onUnfocus: () => { },
visible: false,
};
node?: HTMLDivElement = undefined;
handleBlur: React.FocusEventHandler<HTMLDivElement> = e => {
const { focused, onUnfocus } = this.props;
if (focused && (!e.currentTarget || !e.currentTarget.classList.contains('emoji-react-selector__emoji'))) {
onUnfocus();
}
};
_selectPreviousEmoji = (i: number): void => {
if (!this.node) return;
if (i !== 0) {
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`);
button?.focus();
} else {
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:last-child');
button?.focus();
}
};
_selectNextEmoji = (i: number) => {
if (!this.node) return;
if (i !== this.props.allowedEmoji.size - 1) {
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`);
button?.focus();
} else {
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:first-child');
button?.focus();
}
};
handleKeyDown = (i: number): React.KeyboardEventHandler => e => {
const { onUnfocus } = this.props;
switch (e.key) {
case 'Tab':
e.preventDefault();
if (e.shiftKey) this._selectPreviousEmoji(i);
else this._selectNextEmoji(i);
break;
case 'Left':
case 'ArrowLeft':
this._selectPreviousEmoji(i);
break;
case 'Right':
case 'ArrowRight':
this._selectNextEmoji(i);
break;
case 'Escape':
onUnfocus();
break;
}
};
handleReact = (emoji: string) => (): void => {
const { onReact, focused, onUnfocus } = this.props;
onReact(emoji);
if (focused) {
onUnfocus();
}
};
handlers = {
open: () => { },
};
setRef = (c: HTMLDivElement): void => {
this.node = c;
};
render() {
const { visible, focused, allowedEmoji, onReact } = this.props;
return (
<HotKeys handlers={this.handlers}>
{/*<div
className={clsx('flex absolute bg-white dark:bg-gray-500 px-2 py-3 rounded-full shadow-md opacity-0 pointer-events-none duration-100 w-max', { 'opacity-100 pointer-events-auto z-[999]': visible || focused })}
onBlur={this.handleBlur}
ref={this.setRef}
>
{allowedEmoji.map((emoji, i) => (
<button
key={i}
className='emoji-react-selector__emoji'
onClick={this.handleReact(emoji)}
onKeyDown={this.handleKeyDown(i)}
tabIndex={(visible || focused) ? 0 : -1}
>
<Emoji emoji={emoji} />
</button>
))}
</div>*/}
<RealEmojiSelector
emojis={allowedEmoji.toArray()}
onReact={onReact}
visible={visible}
focused={focused}
/>
</HotKeys>
);
}
}
export default connect(mapStateToProps)(EmojiSelector);

Wyświetl plik

@ -31,10 +31,10 @@ interface Props extends ReturnType<typeof mapStateToProps> {
} }
type State = { type State = {
hasError: boolean hasError: boolean,
error: any error: any,
componentStack: any componentStack: any,
browser?: Bowser.Parser.Parser browser?: Bowser.Parser.Parser,
} }
class ErrorBoundary extends React.PureComponent<Props, State> { class ErrorBoundary extends React.PureComponent<Props, State> {

Wyświetl plik

@ -3,14 +3,14 @@ import React, { useEffect, useRef } from 'react';
import { isIOS } from 'soapbox/is-mobile'; import { isIOS } from 'soapbox/is-mobile';
interface IExtendedVideoPlayer { interface IExtendedVideoPlayer {
src: string src: string,
alt?: string alt?: string,
width?: number width?: number,
height?: number height?: number,
time?: number time?: number,
controls?: boolean controls?: boolean,
muted?: boolean muted?: boolean,
onClick?: () => void onClick?: () => void,
} }
const ExtendedVideoPlayer: React.FC<IExtendedVideoPlayer> = ({ src, alt, time, controls, muted, onClick }) => { const ExtendedVideoPlayer: React.FC<IExtendedVideoPlayer> = ({ src, alt, time, controls, muted, onClick }) => {

Wyświetl plik

@ -0,0 +1,17 @@
import React from 'react';
interface IExtensionBadge {
/** File extension. */
ext: string,
}
/** Badge displaying a file extension. */
const ExtensionBadge: React.FC<IExtensionBadge> = ({ ext }) => {
return (
<div className='inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-sm font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100'>
{ext}
</div>
);
};
export default ExtensionBadge;

Wyświetl plik

@ -9,9 +9,9 @@ import clsx from 'clsx';
import React from 'react'; import React from 'react';
export interface IForkAwesomeIcon extends React.HTMLAttributes<HTMLLIElement> { export interface IForkAwesomeIcon extends React.HTMLAttributes<HTMLLIElement> {
id: string id: string,
className?: string className?: string,
fixedWidth?: boolean fixedWidth?: boolean,
} }
const ForkAwesomeIcon: React.FC<IForkAwesomeIcon> = ({ id, className, fixedWidth, ...rest }) => { const ForkAwesomeIcon: React.FC<IForkAwesomeIcon> = ({ id, className, fixedWidth, ...rest }) => {

Wyświetl plik

@ -1,12 +1,7 @@
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import GroupMemberCount from 'soapbox/features/group/components/group-member-count'; import { Avatar, HStack, Icon, Stack, Text } from './ui';
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
import GroupRelationship from 'soapbox/features/group/components/group-relationship';
import GroupAvatar from './groups/group-avatar';
import { HStack, Stack, Text } from './ui';
import type { Group as GroupEntity } from 'soapbox/types/entities'; import type { Group as GroupEntity } from 'soapbox/types/entities';
@ -22,42 +17,43 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
const intl = useIntl(); const intl = useIntl();
return ( return (
<Stack <div className='overflow-hidden'>
className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900' <Stack className='rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900 sm:rounded-xl'>
data-testid='group-card' <div className='relative -m-[1px] mb-0 h-[120px] rounded-t-lg bg-primary-100 dark:bg-gray-800 sm:rounded-t-xl'>
> {group.header && <img className='h-full w-full rounded-t-lg object-cover sm:rounded-t-xl' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
{/* Group Cover Image */} <div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'> <Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
{group.header && ( </div>
<img </div>
className='absolute inset-0 h-full w-full rounded-t-lg object-cover' <Stack className='p-3 pt-9' alignItems='center' space={3}>
src={group.header} alt={intl.formatMessage(messages.groupHeader)}
/>
)}
</Stack>
{/* Group Avatar */}
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<GroupAvatar group={group} size={64} withRing />
</div>
{/* Group Info */}
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
<HStack alignItems='center' space={1.5}>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} /> <Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
{group.relationship?.pending_requests && ( {group.relationship?.role === 'admin' ? (
<div className='h-2 w-2 rounded-full bg-secondary-500' /> <HStack space={1} alignItems='center'>
)} <Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
</HStack> <span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
</HStack>
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap> ) : group.relationship?.role === 'moderator' && (
<GroupRelationship group={group} /> <HStack space={1} alignItems='center'>
<GroupPrivacy group={group} /> <Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
<GroupMemberCount group={group} /> <span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
</HStack> </HStack>
)}
{group.locked ? (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
</HStack>
) : (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
</HStack>
)}
</HStack>
</Stack>
</Stack> </Stack>
</Stack> </div>
); );
}; };

Wyświetl plik

@ -1,37 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { GroupRoles } from 'soapbox/schemas/group-member';
import { Avatar } from '../ui';
import type { Group } from 'soapbox/schemas';
interface IGroupAvatar {
group: Group
size: number
withRing?: boolean
}
const GroupAvatar = (props: IGroupAvatar) => {
const { group, size, withRing = false } = props;
const isOwner = group.relationship?.role === GroupRoles.OWNER;
return (
<Avatar
className={
clsx('relative rounded-full', {
'shadow-[0_0_0_2px_theme(colors.primary.600),0_0_0_4px_theme(colors.white)]': isOwner && withRing,
'dark:shadow-[0_0_0_2px_theme(colors.primary.600),0_0_0_4px_theme(colors.gray.800)]': isOwner && withRing,
'shadow-[0_0_0_2px_theme(colors.primary.600)]': isOwner && !withRing,
'shadow-[0_0_0_2px_theme(colors.white)] dark:shadow-[0_0_0_2px_theme(colors.gray.800)]': !isOwner && withRing,
})
}
src={group.avatar}
size={size}
/>
);
};
export default GroupAvatar;

Wyświetl plik

@ -1,99 +0,0 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Button, Divider, HStack, Popover, Stack, Text } from 'soapbox/components/ui';
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
import GroupAvatar from '../group-avatar';
import type { Group } from 'soapbox/schemas';
interface IGroupPopoverContainer {
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>
isEnabled: boolean
group: Group
}
const messages = defineMessages({
title: { id: 'group.popover.title', defaultMessage: 'Membership required' },
summary: { id: 'group.popover.summary', defaultMessage: 'You must be a member of the group in order to reply to this status.' },
action: { id: 'group.popover.action', defaultMessage: 'View Group' },
});
const GroupPopover = (props: IGroupPopoverContainer) => {
const { children, group, isEnabled } = props;
const intl = useIntl();
if (!isEnabled) {
return children;
}
return (
<Popover
interaction='click'
referenceElementClassName='cursor-pointer'
content={
<Stack space={4} className='w-80'>
<Stack
className='relative h-60 rounded-lg bg-white dark:border-primary-800 dark:bg-primary-900'
data-testid='group-card'
>
{/* Group Cover Image */}
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
{group.header && (
<img
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
src={group.header}
alt=''
/>
)}
</Stack>
{/* Group Avatar */}
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<GroupAvatar group={group} size={64} withRing />
</div>
{/* Group Info */}
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
<GroupPrivacy group={group} />
<GroupMemberCount group={group} />
</HStack>
</Stack>
</Stack>
<Divider />
<Stack space={0.5} className='px-4'>
<Text weight='semibold'>
{intl.formatMessage(messages.title)}
</Text>
<Text theme='muted'>
{intl.formatMessage(messages.summary)}
</Text>
</Stack>
<div className='px-4 pb-4'>
<Link to={`/groups/${group.id}`}>
<Button type='button' theme='secondary' block>
{intl.formatMessage(messages.action)}
</Button>
</Link>
</div>
</Stack>
}
isFlush
children={
<div className='inline-block'>{children}</div>
}
/>
);
};
export default GroupPopover;

Wyświetl plik

@ -10,7 +10,7 @@ import { HStack, Stack, Text } from './ui';
import type { Tag } from 'soapbox/types/entities'; import type { Tag } from 'soapbox/types/entities';
interface IHashtag { interface IHashtag {
hashtag: Tag hashtag: Tag,
} }
const Hashtag: React.FC<IHashtag> = ({ hashtag }) => { const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {

Wyświetl plik

@ -15,10 +15,10 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => {
}, 600); }, 600);
interface IHoverRefWrapper { interface IHoverRefWrapper {
accountId: string accountId: string,
inline?: boolean inline?: boolean,
className?: string className?: string,
children: React.ReactNode children: React.ReactNode,
} }
/** Makes a profile hover card appear when the wrapped element is hovered. */ /** Makes a profile hover card appear when the wrapped element is hovered. */

Wyświetl plik

@ -14,10 +14,10 @@ const showStatusHoverCard = debounce((dispatch, ref, statusId) => {
}, 300); }, 300);
interface IHoverStatusWrapper { interface IHoverStatusWrapper {
statusId: any statusId: any,
inline: boolean inline: boolean,
className?: string className?: string,
children: React.ReactNode children: React.ReactNode,
} }
/** Makes a status hover card appear when the wrapped element is hovered. */ /** Makes a status hover card appear when the wrapped element is hovered. */

Wyświetl plik

@ -4,10 +4,10 @@ import Icon, { IIcon } from 'soapbox/components/icon';
import { Counter } from 'soapbox/components/ui'; import { Counter } from 'soapbox/components/ui';
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> { interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
count: number count: number,
countMax?: number countMax?: number
icon?: string icon?: string;
src?: string src?: string;
} }
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, countMax, ...rest }) => { const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, countMax, ...rest }) => {

Wyświetl plik

@ -8,15 +8,12 @@ import React from 'react';
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
export interface IIcon extends React.HTMLAttributes<HTMLDivElement> { export interface IIcon extends React.HTMLAttributes<HTMLDivElement> {
src: string src: string,
id?: string id?: string,
alt?: string alt?: string,
className?: string className?: string,
} }
/**
* @deprecated Use the UI Icon component directly.
*/
const Icon: React.FC<IIcon> = ({ src, alt, className, ...rest }) => { const Icon: React.FC<IIcon> = ({ src, alt, className, ...rest }) => {
return ( return (
<div <div

Wyświetl plik

@ -4,7 +4,8 @@ import { v4 as uuidv4 } from 'uuid';
import { SelectDropdown } from '../features/forms'; import { SelectDropdown } from '../features/forms';
import { Icon, HStack, Select } from './ui'; import Icon from './icon';
import { HStack, Select } from './ui';
interface IList { interface IList {
children: React.ReactNode children: React.ReactNode
@ -15,9 +16,9 @@ const List: React.FC<IList> = ({ children }) => (
); );
interface IListItem { interface IListItem {
label: React.ReactNode label: React.ReactNode,
hint?: React.ReactNode hint?: React.ReactNode,
onClick?(): void onClick?(): void,
onSelect?(): void onSelect?(): void
isSelected?: boolean isSelected?: boolean
children?: React.ReactNode children?: React.ReactNode
@ -57,13 +58,13 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
return ( return (
<Comp <Comp
className={clsx({ className={clsx({
'flex items-center justify-between px-4 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/20 to-gradient-end/20 dark:from-gradient-start/10 dark:to-gradient-end/10': true, 'flex items-center justify-between px-3 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/10 to-gradient-end/10': true,
'cursor-pointer hover:from-gradient-start/30 hover:to-gradient-end/30 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined' || typeof onSelect !== 'undefined', 'cursor-pointer hover:from-gradient-start/20 hover:to-gradient-end/20 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined' || typeof onSelect !== 'undefined',
})} })}
{...linkProps} {...linkProps}
> >
<div className='flex flex-col py-1.5 pr-4 rtl:pl-4 rtl:pr-0'> <div className='flex flex-col py-1.5 pr-4 rtl:pl-4 rtl:pr-0'>
<LabelComp className='font-medium text-gray-900 dark:text-gray-100' htmlFor={domId}>{label}</LabelComp> <LabelComp className='text-gray-900 dark:text-gray-100' htmlFor={domId}>{label}</LabelComp>
{hint ? ( {hint ? (
<span className='text-sm text-gray-700 dark:text-gray-600'>{hint}</span> <span className='text-sm text-gray-700 dark:text-gray-600'>{hint}</span>
@ -82,26 +83,9 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
<div className='flex flex-row items-center text-gray-700 dark:text-gray-600'> <div className='flex flex-row items-center text-gray-700 dark:text-gray-600'>
{children} {children}
<div {isSelected ? (
className={ <Icon src={require('@tabler/icons/check.svg')} className='ml-1 text-primary-500 dark:text-primary-400' />
clsx({ ) : null}
'flex h-6 w-6 items-center justify-center rounded-full border-2 border-solid border-primary-500 dark:border-primary-400 transition': true,
'bg-primary-500 dark:bg-primary-400': isSelected,
'bg-transparent': !isSelected,
})
}
>
<Icon
src={require('@tabler/icons/check.svg')}
className={
clsx({
'h-4 w-4 text-white dark:text-white transition-all duration-500': true,
'opacity-0 scale-50': !isSelected,
'opacity-100 scale-100': isSelected,
})
}
/>
</div>
</div> </div>
) : null} ) : null}

Wyświetl plik

@ -8,9 +8,9 @@ const messages = defineMessages({
}); });
interface ILoadGap { interface ILoadGap {
disabled?: boolean disabled?: boolean,
maxId: string maxId: string,
onClick: (id: string) => void onClick: (id: string) => void,
} }
const LoadGap: React.FC<ILoadGap> = ({ disabled, maxId, onClick }) => { const LoadGap: React.FC<ILoadGap> = ({ disabled, maxId, onClick }) => {

Wyświetl plik

@ -4,19 +4,18 @@ import { FormattedMessage } from 'react-intl';
import { Button } from 'soapbox/components/ui'; import { Button } from 'soapbox/components/ui';
interface ILoadMore { interface ILoadMore {
onClick: React.MouseEventHandler onClick: React.MouseEventHandler,
disabled?: boolean disabled?: boolean,
visible?: boolean visible?: Boolean,
className?: string
} }
const LoadMore: React.FC<ILoadMore> = ({ onClick, disabled, visible = true, className }) => { const LoadMore: React.FC<ILoadMore> = ({ onClick, disabled, visible = true }) => {
if (!visible) { if (!visible) {
return null; return null;
} }
return ( return (
<Button className={className} theme='primary' block disabled={disabled || !visible} onClick={onClick}> <Button theme='primary' block disabled={disabled || !visible} onClick={onClick}>
<FormattedMessage id='status.load_more' defaultMessage='Load more' /> <FormattedMessage id='status.load_more' defaultMessage='Load more' />
</Button> </Button>
); );

Wyświetl plik

@ -18,7 +18,7 @@ const messages = defineMessages({
}); });
interface ILocationSearch { interface ILocationSearch {
onSelected: (locationId: string) => void onSelected: (locationId: string) => void,
} }
const LocationSearch: React.FC<ILocationSearch> = ({ onSelected }) => { const LocationSearch: React.FC<ILocationSearch> = ({ onSelected }) => {

Wyświetl plik

@ -1,298 +1,31 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useState, useRef, useLayoutEffect } from 'react'; import React, { useState, useRef, useLayoutEffect } from 'react';
import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon';
import StillImage from 'soapbox/components/still-image';
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
import { useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities'; import { Attachment } from 'soapbox/types/entities';
import { truncateFilename } from 'soapbox/utils/media';
import { isIOS } from '../is-mobile'; import { ATTACHMENT_LIMIT } from './media-gallery/constants';
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media-aspect-ratio'; import MediaItem from './media-gallery/media-item';
import { useMediaSizeData } from './media-gallery/useMediaSizeData';
import type { Property } from 'csstype';
import type { List as ImmutableList } from 'immutable'; import type { List as ImmutableList } from 'immutable';
const ATTACHMENT_LIMIT = 4; interface IMediaGallery {
const MAX_FILENAME_LENGTH = 45; sensitive?: boolean,
media: ImmutableList<Attachment>,
interface Dimensions { height?: number,
w: Property.Width | number onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void,
h: Property.Height | number defaultWidth?: number,
t?: Property.Top cacheWidth?: (width: number) => void,
r?: Property.Right visible?: boolean,
b?: Property.Bottom onToggleVisibility?: () => void,
l?: Property.Left displayMedia?: string,
float?: Property.Float compact: boolean,
pos?: Property.Position
}
interface SizeData {
style: React.CSSProperties
itemsDimensions: Dimensions[]
size: number
width: number
}
const withinLimits = (aspectRatio: number) => {
return aspectRatio >= minimumAspectRatio && aspectRatio <= maximumAspectRatio;
};
const shouldLetterbox = (attachment: Attachment): boolean => {
const aspectRatio = attachment.getIn(['meta', 'original', 'aspect']) as number | undefined;
if (!aspectRatio) return true;
return !withinLimits(aspectRatio);
};
interface IItem {
attachment: Attachment
standalone?: boolean
index: number
size: number
onClick: (index: number) => void
displayWidth?: number
visible: boolean
dimensions: Dimensions
last?: boolean
total: number
}
const Item: React.FC<IItem> = ({
attachment,
index,
onClick,
standalone = false,
visible,
dimensions,
last,
total,
}) => {
const settings = useSettings();
const autoPlayGif = settings.get('autoPlayGif') === true;
const { mediaPreview } = useSoapboxConfig();
const handleMouseEnter: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
if (hoverToPlay()) {
video.play();
}
};
const handleMouseLeave: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
if (hoverToPlay()) {
video.pause();
video.currentTime = 0;
}
};
const hoverToPlay = () => {
return !autoPlayGif && attachment.type === 'gifv';
};
// FIXME: wtf?
const handleClick: React.MouseEventHandler = (e: any) => {
if (isIOS() && !e.target.autoPlay) {
e.target.autoPlay = true;
e.preventDefault();
} else {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
if (hoverToPlay()) {
e.target.pause();
e.target.currentTime = 0;
}
e.preventDefault();
onClick(index);
}
}
e.stopPropagation();
};
const handleVideoHover: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
video.playbackRate = 3.0;
video.play();
};
const handleVideoLeave: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
video.pause();
video.currentTime = 0;
};
let width: Dimensions['w'] = 100;
let height: Dimensions['h'] = '100%';
let top: Dimensions['t'] = 'auto';
let left: Dimensions['l'] = 'auto';
let bottom: Dimensions['b'] = 'auto';
let right: Dimensions['r'] = 'auto';
let float: Dimensions['float'] = 'left';
let position: Dimensions['pos'] = 'relative';
if (dimensions) {
width = dimensions.w;
height = dimensions.h;
top = dimensions.t || 'auto';
right = dimensions.r || 'auto';
bottom = dimensions.b || 'auto';
left = dimensions.l || 'auto';
float = dimensions.float || 'left';
position = dimensions.pos || 'relative';
}
let thumbnail: React.ReactNode = '';
if (attachment.type === 'unknown') {
const filename = truncateFilename(attachment.url, MAX_FILENAME_LENGTH);
const attachmentIcon = (
<Icon
className='h-16 w-16 text-gray-800 dark:text-gray-200'
src={MIMETYPE_ICONS[attachment.getIn(['pleroma', 'mime_type']) as string] || require('@tabler/icons/paperclip.svg')}
/>
);
return (
<div
className={clsx('media-gallery__item', {
standalone,
'rounded-md': total > 1,
})}
key={attachment.id}
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
>
<a className='media-gallery__item-thumbnail' href={attachment.url} target='_blank' style={{ cursor: 'pointer' }}>
<Blurhash hash={attachment.blurhash} className='media-gallery__preview' />
<span className='media-gallery__item__icons'>{attachmentIcon}</span>
<span className='media-gallery__filename__label'>{filename}</span>
</a>
</div>
);
} else if (attachment.type === 'image') {
const letterboxed = total === 1 && shouldLetterbox(attachment);
thumbnail = (
<a
className='media-gallery__item-thumbnail'
href={attachment.url}
onClick={handleClick}
target='_blank'
>
<StillImage
className='h-full w-full'
src={mediaPreview ? attachment.preview_url : attachment.url}
alt={attachment.description}
letterboxed={letterboxed}
showExt
/>
</a>
);
} else if (attachment.type === 'gifv') {
const conditionalAttributes: React.VideoHTMLAttributes<HTMLVideoElement> = {};
if (isIOS()) {
conditionalAttributes.playsInline = true;
}
if (autoPlayGif) {
conditionalAttributes.autoPlay = true;
}
thumbnail = (
<div className={clsx('media-gallery__gifv', { autoplay: autoPlayGif })}>
<video
className='media-gallery__item-gifv-thumbnail'
aria-label={attachment.description}
title={attachment.description}
role='application'
src={attachment.url}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
loop
muted
{...conditionalAttributes}
/>
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
} else if (attachment.type === 'audio') {
const ext = attachment.url.split('.').pop()?.toUpperCase();
thumbnail = (
<a
className={clsx('media-gallery__item-thumbnail')}
href={attachment.url}
onClick={handleClick}
target='_blank'
title={attachment.description}
>
<span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/volume.svg')} /></span>
<span className='media-gallery__file-extension__label'>{ext}</span>
</a>
);
} else if (attachment.type === 'video') {
const ext = attachment.url.split('.').pop()?.toUpperCase();
thumbnail = (
<a
className={clsx('media-gallery__item-thumbnail')}
href={attachment.url}
onClick={handleClick}
target='_blank'
title={attachment.description}
>
<video
muted
loop
onMouseOver={handleVideoHover}
onMouseOut={handleVideoLeave}
>
<source src={attachment.url} />
</video>
<span className='media-gallery__file-extension__label'>{ext}</span>
</a>
);
}
return (
<div
className={clsx('media-gallery__item', `media-gallery__item--${attachment.type}`, {
standalone,
'rounded-md': total > 1,
})}
key={attachment.id}
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
>
{last && total > ATTACHMENT_LIMIT && (
<div className='media-gallery__item-overflow'>
+{total - ATTACHMENT_LIMIT + 1}
</div>
)}
<Blurhash
hash={attachment.blurhash}
className='media-gallery__preview'
/>
{visible && thumbnail}
</div>
);
};
export interface IMediaGallery {
sensitive?: boolean
media: ImmutableList<Attachment>
height?: number
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void
defaultWidth?: number
cacheWidth?: (width: number) => void
visible?: boolean
onToggleVisibility?: () => void
displayMedia?: string
compact?: boolean
className?: string
} }
const MediaGallery: React.FC<IMediaGallery> = (props) => { const MediaGallery: React.FC<IMediaGallery> = (props) => {
const { const {
media, media,
defaultWidth = 0, defaultWidth = 0,
className,
onOpenMedia, onOpenMedia,
cacheWidth, cacheWidth,
compact, compact,
@ -301,241 +34,14 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
const [width, setWidth] = useState<number>(defaultWidth); const [width, setWidth] = useState<number>(defaultWidth);
const node = useRef<HTMLDivElement>(null); const node = useRef<HTMLDivElement>(null);
const sizeData = useMediaSizeData({ media, width, height, defaultWidth });
const handleClick = (index: number) => { const handleClick = (index: number) => {
onOpenMedia(media, index); onOpenMedia(media, index);
}; };
const getSizeDataSingle = (): SizeData => {
const w = width || defaultWidth;
const aspectRatio = media.getIn([0, 'meta', 'original', 'aspect']) as number | undefined;
const getHeight = () => {
if (!aspectRatio) return w * 9 / 16;
if (isPanoramic(aspectRatio)) return Math.floor(w / maximumAspectRatio);
if (isPortrait(aspectRatio)) return Math.floor(w / minimumAspectRatio);
return Math.floor(w / aspectRatio);
};
return {
style: { height: getHeight() },
itemsDimensions: [],
size: 1,
width,
};
};
const getSizeDataMultiple = (size: number): SizeData => {
const w = width || defaultWidth;
const panoSize = Math.floor(w / maximumAspectRatio);
const panoSize_px = `${Math.floor(w / maximumAspectRatio)}px`;
const style: React.CSSProperties = {};
let itemsDimensions: Dimensions[] = [];
const ratios = Array(size).fill(null).map((_, i) =>
media.getIn([i, 'meta', 'original', 'aspect']) as number,
);
const [ar1, ar2, ar3, ar4] = ratios;
if (size === 2) {
if (isPortrait(ar1) && isPortrait(ar2)) {
style.height = w - (w / maximumAspectRatio);
} else if (isPanoramic(ar1) && isPanoramic(ar2)) {
style.height = panoSize * 2;
} else if (
(isPanoramic(ar1) && isPortrait(ar2)) ||
(isPortrait(ar1) && isPanoramic(ar2)) ||
(isPanoramic(ar1) && isNonConformingRatio(ar2)) ||
(isNonConformingRatio(ar1) && isPanoramic(ar2))
) {
style.height = (w * 0.6) + (w / maximumAspectRatio);
} else {
style.height = w / 2;
}
if (isPortrait(ar1) && isPortrait(ar2)) {
itemsDimensions = [
{ w: 50, h: '100%', r: '2px' },
{ w: 50, h: '100%', l: '2px' },
];
} else if (isPanoramic(ar1) && isPanoramic(ar2)) {
itemsDimensions = [
{ w: 100, h: panoSize_px, b: '2px' },
{ w: 100, h: panoSize_px, t: '2px' },
];
} else if (
(isPanoramic(ar1) && isPortrait(ar2)) ||
(isPanoramic(ar1) && isNonConformingRatio(ar2))
) {
itemsDimensions = [
{ w: 100, h: `${(w / maximumAspectRatio)}px`, b: '2px' },
{ w: 100, h: `${(w * 0.6)}px`, t: '2px' },
];
} else if (
(isPortrait(ar1) && isPanoramic(ar2)) ||
(isNonConformingRatio(ar1) && isPanoramic(ar2))
) {
itemsDimensions = [
{ w: 100, h: `${(w * 0.6)}px`, b: '2px' },
{ w: 100, h: `${(w / maximumAspectRatio)}px`, t: '2px' },
];
} else {
itemsDimensions = [
{ w: 50, h: '100%', r: '2px' },
{ w: 50, h: '100%', l: '2px' },
];
}
} else if (size === 3) {
if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
style.height = panoSize * 3;
} else if (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) {
style.height = Math.floor(w / minimumAspectRatio);
} else {
style.height = w;
}
if (isPanoramic(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
itemsDimensions = [
{ w: 100, h: '50%', b: '2px' },
{ w: 50, h: '50%', t: '2px', r: '2px' },
{ w: 50, h: '50%', t: '2px', l: '2px' },
];
} else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
itemsDimensions = [
{ w: 100, h: panoSize_px, b: '4px' },
{ w: 100, h: panoSize_px },
{ w: 100, h: panoSize_px, t: '4px' },
];
} else if (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
itemsDimensions = [
{ w: 50, h: '100%', r: '2px' },
{ w: 50, h: '50%', b: '2px', l: '2px' },
{ w: 50, h: '50%', t: '2px', l: '2px' },
];
} else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3)) {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: '50%', l: '-2px', b: '-2px', pos: 'absolute', float: 'none' },
{ w: 50, h: '100%', r: '-2px', t: '0px', b: '0px', pos: 'absolute', float: 'none' },
];
} else if (
(isNonConformingRatio(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3)) ||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3))
) {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: '100%', l: '2px', float: 'right' },
{ w: 50, h: '50%', t: '2px', r: '2px' },
];
} else if (
(isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3)) ||
(isPanoramic(ar1) && isPanoramic(ar2) && isPortrait(ar3))
) {
itemsDimensions = [
{ w: 50, h: panoSize_px, b: '2px', r: '2px' },
{ w: 50, h: panoSize_px, b: '2px', l: '2px' },
{ w: 100, h: `${w - panoSize}px`, t: '2px' },
];
} else if (
(isNonConformingRatio(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) ||
(isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3))
) {
itemsDimensions = [
{ w: 100, h: `${w - panoSize}px`, b: '2px' },
{ w: 50, h: panoSize_px, t: '2px', r: '2px' },
{ w: 50, h: panoSize_px, t: '2px', l: '2px' },
];
} else {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: '50%', b: '2px', l: '2px' },
{ w: 100, h: '50%', t: '2px' },
];
}
} else if (size >= 4) {
if (
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isNonConformingRatio(ar4)) ||
(isPortrait(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3) && isPortrait(ar4)) ||
(isPortrait(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
(isNonConformingRatio(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4))
) {
style.height = Math.floor(w / minimumAspectRatio);
} else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
style.height = panoSize * 2;
} else if (
(isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
(isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
) {
style.height = panoSize + (w / 2);
} else {
style.height = w;
}
if (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) {
itemsDimensions = [
{ w: 50, h: panoSize_px, b: '2px', r: '2px' },
{ w: 50, h: panoSize_px, b: '2px', l: '2px' },
{ w: 50, h: `${(w / 2)}px`, t: '2px', r: '2px' },
{ w: 50, h: `${(w / 2)}px`, t: '2px', l: '2px' },
];
} else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
itemsDimensions = [
{ w: 50, h: `${(w / 2)}px`, b: '2px', r: '2px' },
{ w: 50, h: `${(w / 2)}px`, b: '2px', l: '2px' },
{ w: 50, h: panoSize_px, t: '2px', r: '2px' },
{ w: 50, h: panoSize_px, t: '2px', l: '2px' },
];
} else if (
(isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
(isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
) {
itemsDimensions = [
{ w: 67, h: '100%', r: '2px' },
{ w: 33, h: '33%', b: '4px', l: '2px' },
{ w: 33, h: '33%', l: '2px' },
{ w: 33, h: '33%', t: '4px', l: '2px' },
];
} else {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: '50%', b: '2px', l: '2px' },
{ w: 50, h: '50%', t: '2px', r: '2px' },
{ w: 50, h: '50%', t: '2px', l: '2px' },
];
}
}
return {
style,
itemsDimensions,
size,
width: w,
};
};
const getSizeData = (size: number): Readonly<SizeData> => {
const w = width || defaultWidth;
if (w) {
if (size === 1) return getSizeDataSingle();
if (size > 1) return getSizeDataMultiple(size);
}
return {
style: { height },
itemsDimensions: [],
size,
width: w,
};
};
const sizeData: SizeData = getSizeData(media.size);
const children = media.take(ATTACHMENT_LIMIT).map((attachment, i) => ( const children = media.take(ATTACHMENT_LIMIT).map((attachment, i) => (
<Item <MediaItem
key={attachment.id} key={attachment.id}
onClick={handleClick} onClick={handleClick}
attachment={attachment} attachment={attachment}
@ -562,11 +68,7 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
}, [node.current]); }, [node.current]);
return ( return (
<div <div className={clsx('media-gallery', { 'media-gallery--compact': compact })} style={sizeData.style} ref={node}>
className={clsx(className, 'media-gallery', { 'media-gallery--compact': compact })}
style={sizeData.style}
ref={node}
>
{children} {children}
</div> </div>
); );

Wyświetl plik

@ -0,0 +1,2 @@
export const ATTACHMENT_LIMIT = 4;
export const MAX_FILENAME_LENGTH = 45;

Wyświetl plik

@ -0,0 +1,91 @@
import clsx from 'clsx';
import React from 'react';
import Blurhash from 'soapbox/components/blurhash';
import MediaPreview from '../media/media-preview';
import { ATTACHMENT_LIMIT } from './constants';
import MoreMediaOverlay from './more-media-overlay';
import type { Dimensions } from './types';
import type { Attachment } from 'soapbox/types/entities';
interface IMediaItem {
attachment: Attachment,
standalone?: boolean,
index: number,
size: number,
onClick: (index: number) => void,
displayWidth?: number,
visible: boolean,
dimensions: Dimensions,
last?: boolean,
total: number,
}
const MediaItem: React.FC<IMediaItem> = ({
attachment,
index,
onClick,
standalone = false,
visible,
dimensions,
last,
total,
}) => {
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
onClick(index);
e.stopPropagation();
};
let width: Dimensions['w'] = 100;
let height: Dimensions['h'] = '100%';
let top: Dimensions['t'] = 'auto';
let left: Dimensions['l'] = 'auto';
let bottom: Dimensions['b'] = 'auto';
let right: Dimensions['r'] = 'auto';
let float: Dimensions['float'] = 'left';
let position: Dimensions['pos'] = 'relative';
if (dimensions) {
width = dimensions.w;
height = dimensions.h;
top = dimensions.t || 'auto';
right = dimensions.r || 'auto';
bottom = dimensions.b || 'auto';
left = dimensions.l || 'auto';
float = dimensions.float || 'left';
position = dimensions.pos || 'relative';
}
return (
<div
key={attachment.id}
className={clsx('media-gallery__item', `media-gallery__item--${attachment.type}`, { standalone })}
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
>
{last && total > ATTACHMENT_LIMIT && (
<MoreMediaOverlay
count={total - ATTACHMENT_LIMIT + 1}
/>
)}
<Blurhash
hash={attachment.blurhash}
className='pointer-events-none absolute inset-0 -z-10 h-full w-full'
/>
{visible && (
<button
onClick={handleClick}
className='h-full w-full cursor-zoom-in'
>
<MediaPreview attachment={attachment} />
</button>
)}
</div>
);
};
export default MediaItem;

Wyświetl plik

@ -0,0 +1,16 @@
import React from 'react';
interface IMoreMediaOverlay {
count: number
}
/** Overlay on the final image in a gallery to indicate more images can be seen by clicking into the modal. */
const MoreMediaOverlay: React.FC<IMoreMediaOverlay> = ({ count }) => {
return (
<div className='pointer-events-none absolute inset-0 z-20 flex h-full w-full items-center justify-center bg-white/75 text-center text-5xl font-bold text-gray-900'>
<span>+{count}</span>
</div>
);
};
export default MoreMediaOverlay;

Wyświetl plik

@ -0,0 +1,24 @@
import type { Property } from 'csstype';
interface Dimensions {
w: Property.Width | number,
h: Property.Height | number,
t?: Property.Top,
r?: Property.Right,
b?: Property.Bottom,
l?: Property.Left,
float?: Property.Float,
pos?: Property.Position,
}
interface SizeData {
style: React.CSSProperties,
itemsDimensions: Dimensions[],
size: number,
width: number,
}
export type {
Dimensions,
SizeData,
};

Wyświetl plik

@ -0,0 +1,242 @@
import { containAspectRatio, isNonConformingRatio, isPanoramic, isPortrait, maximumAspectRatio, minimumAspectRatio } from 'soapbox/utils/media-aspect-ratio';
import type { Dimensions, SizeData } from './types';
import type { List as ImmutableList } from 'immutable';
import type { Attachment } from 'soapbox/types/entities';
interface UseMediaSizeDataOpts {
width: number
height?: number
defaultWidth: number
media: ImmutableList<Attachment>
}
const useMediaSizeData = ({ width, height, defaultWidth, media }: UseMediaSizeDataOpts) => {
const getSizeDataSingle = (): SizeData => {
const w = width || defaultWidth;
const aspectRatio = media.getIn([0, 'meta', 'original', 'aspect']) as number | undefined;
const getHeight = () => {
if (!aspectRatio) return w * 9 / 16;
return Math.floor(w / containAspectRatio(aspectRatio));
};
return {
style: { height: getHeight() },
itemsDimensions: [],
size: 1,
width,
};
};
const getSizeDataMultiple = (size: number): SizeData => {
const w = width || defaultWidth;
const panoSize = Math.floor(w / maximumAspectRatio);
const panoSize_px = `${Math.floor(w / maximumAspectRatio)}px`;
const style: React.CSSProperties = {};
let itemsDimensions: Dimensions[] = [];
const ratios = Array(size).fill(null).map((_, i) =>
media.getIn([i, 'meta', 'original', 'aspect']) as number,
);
const [ar1, ar2, ar3, ar4] = ratios;
if (size === 2) {
if (isPortrait(ar1) && isPortrait(ar2)) {
style.height = w - (w / maximumAspectRatio);
} else if (isPanoramic(ar1) && isPanoramic(ar2)) {
style.height = panoSize * 2;
} else if (
(isPanoramic(ar1) && isPortrait(ar2)) ||
(isPortrait(ar1) && isPanoramic(ar2)) ||
(isPanoramic(ar1) && isNonConformingRatio(ar2)) ||
(isNonConformingRatio(ar1) && isPanoramic(ar2))
) {
style.height = (w * 0.6) + (w / maximumAspectRatio);
} else {
style.height = w / 2;
}
if (isPortrait(ar1) && isPortrait(ar2)) {
itemsDimensions = [
{ w: 50, h: '100%', r: '2px' },
{ w: 50, h: '100%', l: '2px' },
];
} else if (isPanoramic(ar1) && isPanoramic(ar2)) {
itemsDimensions = [
{ w: 100, h: panoSize_px, b: '2px' },
{ w: 100, h: panoSize_px, t: '2px' },
];
} else if (
(isPanoramic(ar1) && isPortrait(ar2)) ||
(isPanoramic(ar1) && isNonConformingRatio(ar2))
) {
itemsDimensions = [
{ w: 100, h: `${(w / maximumAspectRatio)}px`, b: '2px' },
{ w: 100, h: `${(w * 0.6)}px`, t: '2px' },
];
} else if (
(isPortrait(ar1) && isPanoramic(ar2)) ||
(isNonConformingRatio(ar1) && isPanoramic(ar2))
) {
itemsDimensions = [
{ w: 100, h: `${(w * 0.6)}px`, b: '2px' },
{ w: 100, h: `${(w / maximumAspectRatio)}px`, t: '2px' },
];
} else {
itemsDimensions = [
{ w: 50, h: '100%', r: '2px' },
{ w: 50, h: '100%', l: '2px' },
];
}
} else if (size === 3) {
if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
style.height = panoSize * 3;
} else if (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) {
style.height = Math.floor(w / minimumAspectRatio);
} else {
style.height = w;
}
if (isPanoramic(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
itemsDimensions = [
{ w: 100, h: '50%', b: '2px' },
{ w: 50, h: '50%', t: '2px', r: '2px' },
{ w: 50, h: '50%', t: '2px', l: '2px' },
];
} else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
itemsDimensions = [
{ w: 100, h: panoSize_px, b: '4px' },
{ w: 100, h: panoSize_px },
{ w: 100, h: panoSize_px, t: '4px' },
];
} else if (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
itemsDimensions = [
{ w: 50, h: '100%', r: '2px' },
{ w: 50, h: '50%', b: '2px', l: '2px' },
{ w: 50, h: '50%', t: '2px', l: '2px' },
];
} else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3)) {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: '50%', l: '-2px', b: '-2px', pos: 'absolute', float: 'none' },
{ w: 50, h: '100%', r: '-2px', t: '0px', b: '0px', pos: 'absolute', float: 'none' },
];
} else if (
(isNonConformingRatio(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3)) ||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3))
) {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: '100%', l: '2px', float: 'right' },
{ w: 50, h: '50%', t: '2px', r: '2px' },
];
} else if (
(isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3)) ||
(isPanoramic(ar1) && isPanoramic(ar2) && isPortrait(ar3))
) {
itemsDimensions = [
{ w: 50, h: panoSize_px, b: '2px', r: '2px' },
{ w: 50, h: panoSize_px, b: '2px', l: '2px' },
{ w: 100, h: `${w - panoSize}px`, t: '2px' },
];
} else if (
(isNonConformingRatio(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) ||
(isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3))
) {
itemsDimensions = [
{ w: 100, h: `${w - panoSize}px`, b: '2px' },
{ w: 50, h: panoSize_px, t: '2px', r: '2px' },
{ w: 50, h: panoSize_px, t: '2px', l: '2px' },
];
} else {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: '50%', b: '2px', l: '2px' },
{ w: 100, h: '50%', t: '2px' },
];
}
} else if (size >= 4) {
if (
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isNonConformingRatio(ar4)) ||
(isPortrait(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3) && isPortrait(ar4)) ||
(isPortrait(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
(isNonConformingRatio(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4))
) {
style.height = Math.floor(w / minimumAspectRatio);
} else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
style.height = panoSize * 2;
} else if (
(isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
(isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
) {
style.height = panoSize + (w / 2);
} else {
style.height = w;
}
if (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) {
itemsDimensions = [
{ w: 50, h: panoSize_px, b: '2px', r: '2px' },
{ w: 50, h: panoSize_px, b: '2px', l: '2px' },
{ w: 50, h: `${(w / 2)}px`, t: '2px', r: '2px' },
{ w: 50, h: `${(w / 2)}px`, t: '2px', l: '2px' },
];
} else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
itemsDimensions = [
{ w: 50, h: `${(w / 2)}px`, b: '2px', r: '2px' },
{ w: 50, h: `${(w / 2)}px`, b: '2px', l: '2px' },
{ w: 50, h: panoSize_px, t: '2px', r: '2px' },
{ w: 50, h: panoSize_px, t: '2px', l: '2px' },
];
} else if (
(isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
(isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
) {
itemsDimensions = [
{ w: 67, h: '100%', r: '2px' },
{ w: 33, h: '33%', b: '4px', l: '2px' },
{ w: 33, h: '33%', l: '2px' },
{ w: 33, h: '33%', t: '4px', l: '2px' },
];
} else {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: '50%', b: '2px', l: '2px' },
{ w: 50, h: '50%', t: '2px', r: '2px' },
{ w: 50, h: '50%', t: '2px', l: '2px' },
];
}
}
return {
style,
itemsDimensions,
size,
width: w,
};
};
const getSizeData = (size: number): Readonly<SizeData> => {
const w = width || defaultWidth;
if (w) {
if (size === 1) return getSizeDataSingle();
if (size > 1) return getSizeDataMultiple(size);
}
return {
style: { height },
itemsDimensions: [],
size,
width: w,
};
};
return getSizeData(media.size);
};
export { useMediaSizeData };

Wyświetl plik

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import StillVideo from 'soapbox/components/still-video';
import { Icon } from 'soapbox/components/ui'; import { Icon } from 'soapbox/components/ui';
import { MIMETYPE_ICONS } from 'soapbox/components/upload'; import { MIMETYPE_ICONS } from 'soapbox/components/upload';
@ -7,16 +8,16 @@ import type { Attachment } from 'soapbox/types/entities';
const defaultIcon = require('@tabler/icons/paperclip.svg'); const defaultIcon = require('@tabler/icons/paperclip.svg');
interface IChatUploadPreview { interface IMediaPreview {
className?: string
attachment: Attachment attachment: Attachment
withExt?: boolean
} }
/** /**
* Displays a generic preview for an upload depending on its media type. * Displays a generic preview for an attachment depending on its media type.
* It fills its container and is expected to be sized by its parent. * It fills its container and is expected to be sized by its parent.
*/ */
const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment }) => { const MediaPreview: React.FC<IMediaPreview> = ({ attachment, withExt }) => {
const mimeType = attachment.pleroma.get('mime_type') as string | undefined; const mimeType = attachment.pleroma.get('mime_type') as string | undefined;
switch (attachment.type) { switch (attachment.type) {
@ -31,14 +32,10 @@ const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment
); );
case 'video': case 'video':
return ( return (
<video <StillVideo
className='pointer-events-none h-full w-full object-cover' className='h-full w-full object-cover'
src={attachment.preview_url} src={attachment.url}
autoPlay withExt={withExt}
playsInline
controls={false}
muted
loop
/> />
); );
default: default:
@ -53,4 +50,4 @@ const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment
} }
}; };
export default ChatUploadPreview; export default MediaPreview;

Wyświetl plik

@ -39,10 +39,10 @@ export const checkEventComposeContent = (compose?: ReturnType<typeof ReducerComp
}; };
interface IModalRoot { interface IModalRoot {
onCancel?: () => void onCancel?: () => void,
onClose: (type?: ModalType) => void onClose: (type?: ModalType) => void,
type: ModalType type: ModalType,
children: React.ReactNode children: React.ReactNode,
} }
const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type }) => { const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type }) => {

Wyświetl plik

@ -2,8 +2,8 @@ import clsx from 'clsx';
import React from 'react'; import React from 'react';
interface IOutlineBox extends React.HTMLAttributes<HTMLDivElement> { interface IOutlineBox extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode children: React.ReactNode,
className?: string className?: string,
} }
/** Wraps children in a container with an outline. */ /** Wraps children in a container with an outline. */
@ -18,4 +18,4 @@ const OutlineBox: React.FC<IOutlineBox> = ({ children, className, ...rest }) =>
); );
}; };
export default OutlineBox; export default OutlineBox;

Wyświetl plik

@ -1,54 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { HStack, Icon, Text } from 'soapbox/components/ui';
interface IPendingItemsRow {
/** Path to navigate the user when clicked. */
to: string
/** Number of pending items. */
count: number
/** Size of the icon. */
size?: 'md' | 'lg'
}
const PendingItemsRow: React.FC<IPendingItemsRow> = ({ to, count, size = 'md' }) => {
return (
<Link to={to} className='group' data-testid='pending-items-row'>
<HStack alignItems='center' justifyContent='between'>
<HStack alignItems='center' space={2}>
<div className={clsx('rounded-full bg-primary-200 text-primary-500 dark:bg-primary-800 dark:text-primary-200', {
'p-3': size === 'lg',
'p-2.5': size === 'md',
})}
>
<Icon
src={require('@tabler/icons/exclamation-circle.svg')}
className={clsx({
'h-5 w-5': size === 'md',
'h-7 w-7': size === 'lg',
})}
/>
</div>
<Text weight='bold' size='md'>
<FormattedMessage
id='groups.pending.count'
defaultMessage='{number, plural, one {# pending request} other {# pending requests}}'
values={{ number: count }}
/>
</Text>
</HStack>
<Icon
src={require('@tabler/icons/chevron-right.svg')}
className='h-5 w-5 text-gray-600 transition-colors group-hover:text-gray-700 dark:text-gray-600 dark:group-hover:text-gray-500'
/>
</HStack>
</Link>
);
};
export { PendingItemsRow };

Wyświetl plik

@ -16,9 +16,9 @@ const messages = defineMessages({
}); });
interface IPollFooter { interface IPollFooter {
poll: PollEntity poll: PollEntity,
showResults: boolean showResults: boolean,
selected: Selected selected: Selected,
} }
const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX.Element => { const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX.Element => {

Wyświetl plik

@ -29,7 +29,7 @@ const PollPercentageBar: React.FC<{ percent: number, leading: boolean }> = ({ pe
}; };
interface IPollOptionText extends IPollOption { interface IPollOptionText extends IPollOption {
percent: number percent: number,
} }
const PollOptionText: React.FC<IPollOptionText> = ({ poll, option, index, active, onToggle }) => { const PollOptionText: React.FC<IPollOptionText> = ({ poll, option, index, active, onToggle }) => {
@ -95,12 +95,12 @@ const PollOptionText: React.FC<IPollOptionText> = ({ poll, option, index, active
}; };
interface IPollOption { interface IPollOption {
poll: PollEntity poll: PollEntity,
option: PollOptionEntity option: PollOptionEntity,
index: number index: number,
showResults?: boolean showResults?: boolean,
active: boolean active: boolean,
onToggle: (value: number) => void onToggle: (value: number) => void,
} }
const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => { const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {

Wyświetl plik

@ -13,8 +13,8 @@ import PollOption from './poll-option';
export type Selected = Record<number, boolean>; export type Selected = Record<number, boolean>;
interface IPoll { interface IPoll {
id: string id: string,
status?: string status?: string,
} }
const messages = defineMessages({ const messages = defineMessages({

Wyświetl plik

@ -54,7 +54,7 @@ const handleMouseLeave = (dispatch: AppDispatch): React.MouseEventHandler => {
}; };
interface IProfileHoverCard { interface IProfileHoverCard {
visible: boolean visible: boolean,
} }
/** Popup profile preview that appears when hovering avatars and display names. */ /** Popup profile preview that appears when hovering avatars and display names. */

Wyświetl plik

@ -2,10 +2,10 @@ import clsx from 'clsx';
import React from 'react'; import React from 'react';
interface IProgressCircle { interface IProgressCircle {
progress: number progress: number,
radius?: number radius?: number,
stroke?: number stroke?: number,
title?: string title?: string,
} }
const ProgressCircle: React.FC<IProgressCircle> = ({ progress, radius = 12, stroke = 4, title }) => { const ProgressCircle: React.FC<IProgressCircle> = ({ progress, radius = 12, stroke = 4, title }) => {

Wyświetl plik

@ -4,10 +4,10 @@ import PTRComponent from 'react-simple-pull-to-refresh';
import { Spinner } from 'soapbox/components/ui'; import { Spinner } from 'soapbox/components/ui';
interface IPullToRefresh { interface IPullToRefresh {
onRefresh?: () => Promise<any> onRefresh?: () => Promise<any>;
refreshingContent?: JSX.Element | string refreshingContent?: JSX.Element | string;
pullingContent?: JSX.Element | string pullingContent?: JSX.Element | string;
children: React.ReactNode children: React.ReactNode;
} }
/** /**

Wyświetl plik

@ -3,7 +3,7 @@ import React from 'react';
import PullToRefresh from './pull-to-refresh'; import PullToRefresh from './pull-to-refresh';
interface IPullable { interface IPullable {
children: React.ReactNode children: React.ReactNode,
} }
/** /**

Wyświetl plik

@ -23,11 +23,11 @@ const messages = defineMessages({
interface IQuotedStatus { interface IQuotedStatus {
/** The quoted status entity. */ /** The quoted status entity. */
status?: StatusEntity status?: StatusEntity,
/** Callback when cancelled (during compose). */ /** Callback when cancelled (during compose). */
onCancel?: Function onCancel?: Function,
/** Whether the status is shown in the post composer. */ /** Whether the status is shown in the post composer. */
compose?: boolean compose?: boolean,
} }
/** Status embedded in a quote post. */ /** Status embedded in a quote post. */
@ -133,7 +133,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
collapsable collapsable
/> />
{status.media_attachments.size > 0 && ( {(status.card || status.media_attachments.size > 0) && (
<StatusMedia <StatusMedia
status={status} status={status}
muted={compose} muted={compose}

Wyświetl plik

@ -16,11 +16,11 @@ const RadioGroup = ({ onChange, children }: IRadioGroup) => {
}; };
interface IRadioItem { interface IRadioItem {
label: React.ReactNode label: React.ReactNode,
hint?: React.ReactNode hint?: React.ReactNode,
value: string value: string,
checked: boolean checked: boolean,
onChange?: React.ChangeEventHandler onChange?: React.ChangeEventHandler,
} }
const RadioItem: React.FC<IRadioItem> = ({ label, hint, checked = false, onChange, value }) => { const RadioItem: React.FC<IRadioItem> = ({ label, hint, checked = false, onChange, value }) => {

Wyświetl plik

@ -113,14 +113,14 @@ const timeRemainingString = (intl: IntlShape, date: Date, now: number) => {
}; };
interface RelativeTimestampProps extends IText { interface RelativeTimestampProps extends IText {
intl: IntlShape intl: IntlShape,
timestamp: string timestamp: string,
year?: number year?: number,
futureDate?: boolean futureDate?: boolean,
} }
interface RelativeTimestampState { interface RelativeTimestampState {
now: number now: number,
} }
/** Displays a timestamp compared to the current time, eg "1m" for one minute ago. */ /** Displays a timestamp compared to the current time, eg "1m" for one minute ago. */

Wyświetl plik

@ -2,13 +2,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
interface ISafeEmbed { interface ISafeEmbed {
/** Styles for the outer frame element. */ /** Styles for the outer frame element. */
className?: string className?: string,
/** Space-separate list of restrictions to ALLOW for the iframe. */ /** Space-separate list of restrictions to ALLOW for the iframe. */
sandbox?: string sandbox?: string,
/** Unique title for the iframe. */ /** Unique title for the iframe. */
title: string title: string,
/** HTML body to embed. */ /** HTML body to embed. */
html?: string html?: string,
} }
/** Safely embeds arbitrary HTML content on the page (by putting it in an iframe). */ /** Safely embeds arbitrary HTML content on the page (by putting it in an iframe). */

Wyświetl plik

@ -9,15 +9,15 @@ import { useSettings } from 'soapbox/hooks';
interface IScrollTopButton { interface IScrollTopButton {
/** Callback when clicked, and also when scrolled to the top. */ /** Callback when clicked, and also when scrolled to the top. */
onClick: () => void onClick: () => void,
/** Number of unread items. */ /** Number of unread items. */
count: number count: number,
/** Message to display in the button (should contain a `{count}` value). */ /** Message to display in the button (should contain a `{count}` value). */
message: MessageDescriptor message: MessageDescriptor,
/** Distance from the top of the screen (scrolling down) before the button appears. */ /** Distance from the top of the screen (scrolling down) before the button appears. */
threshold?: number threshold?: number,
/** Distance from the top of the screen (scrolling up) before the action is triggered. */ /** Distance from the top of the screen (scrolling up) before the action is triggered. */
autoloadThreshold?: number autoloadThreshold?: number,
} }
/** Floating new post counter above timelines, clicked to scroll to top. */ /** Floating new post counter above timelines, clicked to scroll to top. */

Wyświetl plik

@ -10,14 +10,14 @@ import { Card, Spinner } from './ui';
/** Custom Viruoso component context. */ /** Custom Viruoso component context. */
type Context = { type Context = {
itemClassName?: string itemClassName?: string,
listClassName?: string listClassName?: string,
} }
/** Scroll position saved in sessionStorage. */ /** Scroll position saved in sessionStorage. */
type SavedScrollPosition = { type SavedScrollPosition = {
index: number index: number,
offset: number offset: number,
} }
/** Custom Virtuoso Item component representing a single scrollable item. */ /** Custom Virtuoso Item component representing a single scrollable item. */
@ -37,46 +37,44 @@ const List: Components<JSX.Element, Context>['List'] = React.forwardRef((props,
interface IScrollableList extends VirtuosoProps<any, any> { interface IScrollableList extends VirtuosoProps<any, any> {
/** Unique key to preserve the scroll position when navigating back. */ /** Unique key to preserve the scroll position when navigating back. */
scrollKey?: string scrollKey?: string,
/** Pagination callback when the end of the list is reached. */ /** Pagination callback when the end of the list is reached. */
onLoadMore?: () => void onLoadMore?: () => void,
/** Whether the data is currently being fetched. */ /** Whether the data is currently being fetched. */
isLoading?: boolean isLoading?: boolean,
/** Whether to actually display the loading state. */ /** Whether to actually display the loading state. */
showLoading?: boolean showLoading?: boolean,
/** Whether we expect an additional page of data. */ /** Whether we expect an additional page of data. */
hasMore?: boolean hasMore?: boolean,
/** Additional element to display at the top of the list. */ /** Additional element to display at the top of the list. */
prepend?: React.ReactNode prepend?: React.ReactNode,
/** Whether to display the prepended element. */ /** Whether to display the prepended element. */
alwaysPrepend?: boolean alwaysPrepend?: boolean,
/** Message to display when the list is loaded but empty. */ /** Message to display when the list is loaded but empty. */
emptyMessage?: React.ReactNode emptyMessage?: React.ReactNode,
/** Should the empty message be displayed in a Card */
emptyMessageCard?: boolean
/** Scrollable content. */ /** Scrollable content. */
children: Iterable<React.ReactNode> children: Iterable<React.ReactNode>,
/** Callback when the list is scrolled to the top. */ /** Callback when the list is scrolled to the top. */
onScrollToTop?: () => void onScrollToTop?: () => void,
/** Callback when the list is scrolled. */ /** Callback when the list is scrolled. */
onScroll?: () => void onScroll?: () => void,
/** Placeholder component to render while loading. */ /** Placeholder component to render while loading. */
placeholderComponent?: React.ComponentType | React.NamedExoticComponent placeholderComponent?: React.ComponentType | React.NamedExoticComponent,
/** Number of placeholders to render while loading. */ /** Number of placeholders to render while loading. */
placeholderCount?: number placeholderCount?: number,
/** /**
* Pull to refresh callback. * Pull to refresh callback.
* @deprecated Put a PTR around the component instead. * @deprecated Put a PTR around the component instead.
*/ */
onRefresh?: () => Promise<any> onRefresh?: () => Promise<any>,
/** Extra class names on the Virtuoso element. */ /** Extra class names on the Virtuoso element. */
className?: string className?: string,
/** Class names on each item container. */ /** Class names on each item container. */
itemClassName?: string itemClassName?: string,
/** `id` attribute on the Virtuoso element. */ /** `id` attribute on the Virtuoso element. */
id?: string id?: string,
/** CSS styles on the Virtuoso element. */ /** CSS styles on the Virtuoso element. */
style?: React.CSSProperties style?: React.CSSProperties,
/** Whether to use the window to scroll the content instead of Virtuoso's container. */ /** Whether to use the window to scroll the content instead of Virtuoso's container. */
useWindowScroll?: boolean useWindowScroll?: boolean
} }
@ -89,7 +87,6 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
children, children,
isLoading, isLoading,
emptyMessage, emptyMessage,
emptyMessageCard = true,
showLoading, showLoading,
onRefresh, onRefresh,
onScroll, onScroll,
@ -161,17 +158,13 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
<div className='mt-2'> <div className='mt-2'>
{alwaysPrepend && prepend} {alwaysPrepend && prepend}
{isLoading ? ( <Card variant='rounded' size='lg'>
<Spinner /> {isLoading ? (
) : ( <Spinner />
<> ) : (
{emptyMessageCard ? ( emptyMessage
<Card variant='rounded' size='lg'> )}
{emptyMessage} </Card>
</Card>
) : emptyMessage}
</>
)}
</div> </div>
); );
}; };

Wyświetl plik

@ -10,7 +10,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
import ProfileStats from 'soapbox/features/ui/components/profile-stats'; import ProfileStats from 'soapbox/features/ui/components/profile-stats';
import { useAppDispatch, useAppSelector, useGroupsPath, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors'; import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors';
import { Divider, HStack, Icon, IconButton, Text } from './ui'; import { Divider, HStack, Icon, IconButton, Text } from './ui';
@ -43,11 +43,11 @@ const messages = defineMessages({
}); });
interface ISidebarLink { interface ISidebarLink {
href?: string href?: string,
to?: string to?: string,
icon: string icon: string,
text: string | JSX.Element text: string | JSX.Element,
onClick: React.EventHandler<React.MouseEvent> onClick: React.EventHandler<React.MouseEvent>,
} }
const SidebarLink: React.FC<ISidebarLink> = ({ href, to, icon, text, onClick }) => { const SidebarLink: React.FC<ISidebarLink> = ({ href, to, icon, text, onClick }) => {
@ -90,7 +90,6 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
const settings = useAppSelector((state) => getSettings(state)); const settings = useAppSelector((state) => getSettings(state));
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
const groupsPath = useGroupsPath();
const closeButtonRef = React.useRef(null); const closeButtonRef = React.useRef(null);
@ -211,7 +210,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
{features.groups && ( {features.groups && (
<SidebarLink <SidebarLink
to={groupsPath} to='/groups'
icon={require('@tabler/icons/circles.svg')} icon={require('@tabler/icons/circles.svg')}
text={intl.formatMessage(messages.groups)} text={intl.formatMessage(messages.groups)}
onClick={onClose} onClick={onClose}
@ -297,7 +296,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/> />
)} )}
{(features.filters || features.filtersV2) && ( {features.filters && (
<SidebarLink <SidebarLink
to='/filters' to='/filters'
icon={require('@tabler/icons/filter.svg')} icon={require('@tabler/icons/filter.svg')}

Wyświetl plik

@ -6,17 +6,17 @@ import { Icon, Text } from './ui';
interface ISidebarNavigationLink { interface ISidebarNavigationLink {
/** Notification count, if any. */ /** Notification count, if any. */
count?: number count?: number,
/** Optional max to cap count (ie: N+) */ /** Optional max to cap count (ie: N+) */
countMax?: number countMax?: number
/** URL to an SVG icon. */ /** URL to an SVG icon. */
icon: string icon: string,
/** Link label. */ /** Link label. */
text: React.ReactNode text: React.ReactNode,
/** Route to an internal page. */ /** Route to an internal page. */
to?: string to?: string,
/** Callback when the link is clicked. */ /** Callback when the link is clicked. */
onClick?: React.EventHandler<React.MouseEvent> onClick?: React.EventHandler<React.MouseEvent>,
} }
/** Desktop sidebar navigation link. */ /** Desktop sidebar navigation link. */

Some files were not shown because too many files have changed in this diff Show More