sforkowany z mirror/soapbox
Porównaj commity
13 Commity
develop
...
media-gall
Autor | SHA1 | Data |
---|---|---|
![]() |
49b996a901 | |
![]() |
fd2bb2e16f | |
![]() |
bbb29d0388 | |
![]() |
c8509627a1 | |
![]() |
ffc8ade279 | |
![]() |
0354e8e96f | |
![]() |
07323c19b0 | |
![]() |
4e74ca3c55 | |
![]() |
215c857648 | |
![]() |
6bbd00c658 | |
![]() |
ca4fa5e5c5 | |
![]() |
4167a1de05 | |
![]() |
c5cf252668 |
|
@ -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: [
|
||||||
|
|
|
@ -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
|
||||||
|
|
36
CHANGELOG.md
36
CHANGELOG.md
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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": []
|
|
||||||
}
|
|
|
@ -228,7 +228,7 @@ const fetchAccountFail = (id: string | null, error: AxiosError) => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
type FollowAccountOpts = {
|
type FollowAccountOpts = {
|
||||||
reblogs?: boolean
|
reblogs?: boolean,
|
||||||
notify?: boolean
|
notify?: boolean
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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 = () => ({
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) =>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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'>
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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[]) => {
|
||||||
|
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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. */
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 };
|
|
|
@ -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> = ({
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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'>
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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);
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -1,3 +0,0 @@
|
||||||
export { default } from './dropdown-menu';
|
|
||||||
export type { Menu } from './dropdown-menu';
|
|
||||||
export type { MenuItem } from './dropdown-menu-item';
|
|
|
@ -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;
|
|
@ -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);
|
|
@ -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> {
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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;
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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. */
|
||||||
|
|
|
@ -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. */
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const ATTACHMENT_LIMIT = 4;
|
||||||
|
export const MAX_FILENAME_LENGTH = 45;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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,
|
||||||
|
};
|
|
@ -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 };
|
|
@ -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;
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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;
|
|
@ -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 };
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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. */
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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. */
|
||||||
|
|
|
@ -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). */
|
||||||
|
|
|
@ -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. */
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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')}
|
||||||
|
|
|
@ -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
Ładowanie…
Reference in New Issue