mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-13 07:49:29 +00:00
Compare commits
334 Commits
split-comp
...
status-sty
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
866e441df3 | ||
|
|
4dc0ddc601 | ||
|
|
7a1ca8b0df | ||
|
|
b8791ae79b | ||
|
|
b9a2ceca35 | ||
|
|
a3e53bd442 | ||
|
|
8eb6d171e6 | ||
|
|
5942347407 | ||
|
|
22db947225 | ||
|
|
5d408fd9aa | ||
|
|
47579ec58c | ||
|
|
3363a05539 | ||
|
|
87f10d476c | ||
|
|
70c5eccc12 | ||
|
|
41c3389d76 | ||
|
|
e7a5a188ef | ||
|
|
eb7fc34708 | ||
|
|
91836d577e | ||
|
|
7de0fa698d | ||
|
|
811d895f7b | ||
|
|
71384b2ef9 | ||
|
|
d1d465347a | ||
|
|
5eba129b0f | ||
|
|
021a83ead4 | ||
|
|
5ee45fa571 | ||
|
|
61a06eb328 | ||
|
|
df605f0f8b | ||
|
|
029786442a | ||
|
|
7b42d14f45 | ||
|
|
f34f33c19e | ||
|
|
9d1f8b9d6a | ||
|
|
400616813e | ||
|
|
724be2d5fe | ||
|
|
76da330155 | ||
|
|
ab60aa2266 | ||
|
|
0bbd5789b5 | ||
|
|
fae71b653a | ||
|
|
dfcd2834f9 | ||
|
|
09e86ef90b | ||
|
|
9ba7d526a0 | ||
|
|
94e233e7b2 | ||
|
|
ac53736814 | ||
|
|
8c0e78ae43 | ||
|
|
26ab702304 | ||
|
|
8b58153583 | ||
|
|
8150689b48 | ||
|
|
7ef8482568 | ||
|
|
559fd08845 | ||
|
|
202942a76f | ||
|
|
c3e355388a | ||
|
|
d4c4820c03 | ||
|
|
e05606c8d0 | ||
|
|
161f72cce3 | ||
|
|
8ccb3b96ab | ||
|
|
e9ee249fd5 | ||
|
|
4b6cd1dfdb | ||
|
|
b9ec3b7e7c | ||
|
|
9b247c3d88 | ||
|
|
c7cc806251 | ||
|
|
82b4cf4acb | ||
|
|
3e7a541e09 | ||
|
|
93aafa8549 | ||
|
|
bb85043f46 | ||
|
|
e1fcad34a9 | ||
|
|
155ba8fd3a | ||
|
|
e44f03bc71 | ||
|
|
b61e3daf98 | ||
|
|
6ff084dbbb | ||
|
|
970297a138 | ||
|
|
29abc9438c | ||
|
|
f91284d230 | ||
|
|
feadf7553d | ||
|
|
ea33cdc30b | ||
|
|
579e85f606 | ||
|
|
ea144ba302 | ||
|
|
4f04981dde | ||
|
|
990cea471e | ||
|
|
0913351dcf | ||
|
|
57a794d8eb | ||
|
|
a5e0cf2450 | ||
|
|
a46ba4a8f5 | ||
|
|
c71874b84c | ||
|
|
9aaf3218d2 | ||
|
|
53b2b1b238 | ||
|
|
634b71ed1d | ||
|
|
3d378ed0b4 | ||
|
|
7e0c00a555 | ||
|
|
f0bb2c6d1e | ||
|
|
13bb1ddc7f | ||
|
|
fdb65dcbee | ||
|
|
4e2f2fab73 | ||
|
|
6e186b9c77 | ||
|
|
ff9d344d4c | ||
|
|
b3c44e95a9 | ||
|
|
8c0dd33ce4 | ||
|
|
12874eafa6 | ||
|
|
afb593b44e | ||
|
|
296bfa23aa | ||
|
|
534da4f24f | ||
|
|
62a9da62a6 | ||
|
|
58eea59864 | ||
|
|
c7de92e0df | ||
|
|
c1633eeb0f | ||
|
|
f93f306053 | ||
|
|
e67fc997dc | ||
|
|
3e01a7e677 | ||
|
|
0f92119ceb | ||
|
|
b7d47c2aef | ||
|
|
6270f9ce34 | ||
|
|
e54cc15cbd | ||
|
|
2654f3be82 | ||
|
|
9004151e34 | ||
|
|
6884dd79ba | ||
|
|
f9075577e4 | ||
|
|
50d38d7605 | ||
|
|
aa803153e2 | ||
|
|
f2233c3e25 | ||
|
|
73890c3cac | ||
|
|
e1798d0eb0 | ||
|
|
4f0b638cda | ||
|
|
bb96ba13cf | ||
|
|
5bf4838e2f | ||
|
|
bdf573d140 | ||
|
|
97a48f237d | ||
|
|
6654c30033 | ||
|
|
f49339ca9c | ||
|
|
cb69e35b3b | ||
|
|
994d948c39 | ||
|
|
f5e228ad2e | ||
|
|
92cb451da8 | ||
|
|
55bee84c97 | ||
|
|
a248be4fce | ||
|
|
8b43d6bf9c | ||
|
|
b8adb4d7fa | ||
|
|
4ba33f99fc | ||
|
|
7905739c2a | ||
|
|
6a6a62f13f | ||
|
|
aa8fa71df6 | ||
|
|
7874c6d630 | ||
|
|
7bf0afb1dc | ||
|
|
2f8bfb3d38 | ||
|
|
4115043dc7 | ||
|
|
7062cb764f | ||
|
|
e82021e0e6 | ||
|
|
8925731c98 | ||
|
|
4c233b4f3a | ||
|
|
b7cf758fbe | ||
|
|
7e5691804d | ||
|
|
9891ff80f9 | ||
|
|
7232cdf7e8 | ||
|
|
9f97c8c750 | ||
|
|
edadc93757 | ||
|
|
a6ea7e282f | ||
|
|
e5c0aa6493 | ||
|
|
02744f29ef | ||
|
|
a31d24ee18 | ||
|
|
6957c5b5c6 | ||
|
|
696bcff6bf | ||
|
|
f52ce92f2b | ||
|
|
c80046a77b | ||
|
|
ebf5a06084 | ||
|
|
23e854cb91 | ||
|
|
de105d64d5 | ||
|
|
07d93716aa | ||
|
|
88b5e0b703 | ||
|
|
32fa312b2a | ||
|
|
1306d637a2 | ||
|
|
462b3752e4 | ||
|
|
029f2c4545 | ||
|
|
b3e7beb7c5 | ||
|
|
a549d1ae6b | ||
|
|
467456f7a1 | ||
|
|
2374d63536 | ||
|
|
117eb3b2bc | ||
|
|
de985a30bc | ||
|
|
06d905f415 | ||
|
|
0ad41be0f3 | ||
|
|
d6f5dbff3e | ||
|
|
1e665a0bf4 | ||
|
|
ef16089c6d | ||
|
|
4b4ea1f929 | ||
|
|
45af29912f | ||
|
|
9075c90c46 | ||
|
|
63a2566007 | ||
|
|
43cad817e8 | ||
|
|
ed4c754fff | ||
|
|
1e0c7a0afc | ||
|
|
3a3b556065 | ||
|
|
9244f6b628 | ||
|
|
ff26b72333 | ||
|
|
6803935c4d | ||
|
|
3757546f1b | ||
|
|
a677ac8384 | ||
|
|
bdbfb10cff | ||
|
|
4d661e1183 | ||
|
|
dd28b557ae | ||
|
|
0e0f18ce7c | ||
|
|
7964bfccdb | ||
|
|
3c515f2cd2 | ||
|
|
852acbd738 | ||
|
|
db73ac92d7 | ||
|
|
6913426e48 | ||
|
|
3ba7c1e725 | ||
|
|
9b74a12045 | ||
|
|
4cd82d442e | ||
|
|
74a0cc6a11 | ||
|
|
311871eefc | ||
|
|
a929f7e6ac | ||
|
|
cf51e07bde | ||
|
|
984d2d4cb6 | ||
|
|
8d6c3cd48a | ||
|
|
0244019ca1 | ||
|
|
604654ccb4 | ||
|
|
3817704806 | ||
|
|
d4c6bf770d | ||
|
|
399f9f4a4e | ||
|
|
f2390e2803 | ||
|
|
dbaa6a0e13 | ||
|
|
7bf7ed6123 | ||
|
|
a390abdefb | ||
|
|
c1bc5e14eb | ||
|
|
0efd7e7406 | ||
|
|
4b911fea03 | ||
|
|
e7edb4d1ee | ||
|
|
d235224692 | ||
|
|
1fcdaafa6f | ||
|
|
f24b81e27f | ||
|
|
e01966f7b8 | ||
|
|
dcb9497148 | ||
|
|
4f2513337f | ||
|
|
015269914e | ||
|
|
bbdcfd6baf | ||
|
|
f0d6550f16 | ||
|
|
8400bee3b1 | ||
|
|
bc1f9dc24b | ||
|
|
cdc349a2d1 | ||
|
|
c2c93f8cd6 | ||
|
|
9fc082ea81 | ||
|
|
4c7a9adb98 | ||
|
|
030e5cec58 | ||
|
|
716f4cb11c | ||
|
|
a5a07da892 | ||
|
|
72108b20e2 | ||
|
|
0a678cf377 | ||
|
|
7a77f7b3bb | ||
|
|
767117f9b0 | ||
|
|
fb7f06a752 | ||
|
|
df74e26baf | ||
|
|
d69fa9e1f4 | ||
|
|
0b4006fc47 | ||
|
|
0ccd47f413 | ||
|
|
02f896c12e | ||
|
|
bb4c3831b2 | ||
|
|
3267e4a785 | ||
|
|
89b988cab5 | ||
|
|
4d42a38954 | ||
|
|
8387b3928e | ||
|
|
afa52e4d63 | ||
|
|
8949aad030 | ||
|
|
c0c7af2194 | ||
|
|
f5382ec085 | ||
|
|
407073d7a2 | ||
|
|
7f4375822a | ||
|
|
719ab720a7 | ||
|
|
b11ac88692 | ||
|
|
c727eae441 | ||
|
|
681c33d1f4 | ||
|
|
7f35947d8e | ||
|
|
68941d4dfa | ||
|
|
1d2616b79b | ||
|
|
d4b097a88c | ||
|
|
902c5cf7ca | ||
|
|
b15f790221 | ||
|
|
a47c2e8890 | ||
|
|
d0aad1ac85 | ||
|
|
21b04af524 | ||
|
|
a3202fd51e | ||
|
|
1cceefce33 | ||
|
|
033f970af3 | ||
|
|
d1c3e35d3f | ||
|
|
a6328fc1b1 | ||
|
|
35b868eeca | ||
|
|
3ea02314b9 | ||
|
|
4715161a93 | ||
|
|
144db8ea1d | ||
|
|
bc4202d00b | ||
|
|
09cfc079b0 | ||
|
|
695439775e | ||
|
|
05cd37097c | ||
|
|
08d021916d | ||
|
|
99f24ab0c7 | ||
|
|
3a526e2369 | ||
|
|
bd915d9398 | ||
|
|
8c45cd0e36 | ||
|
|
3fbf1bf35a | ||
|
|
cd9b2ab2f7 | ||
|
|
de397f3bc1 | ||
|
|
72bd73f605 | ||
|
|
1896a154f5 | ||
|
|
1618b68bfa | ||
|
|
c1f201c49a | ||
|
|
8d224ad23b | ||
|
|
e2685ccc81 | ||
|
|
51e3ac2534 | ||
|
|
75aafc932e | ||
|
|
c42092ba7a | ||
|
|
999170d898 | ||
|
|
37430a3401 | ||
|
|
0fa9dd8527 | ||
|
|
489d162477 | ||
|
|
9008ab3407 | ||
|
|
87b96f8d33 | ||
|
|
a49be27145 | ||
|
|
27b2355738 | ||
|
|
eeb5923e89 | ||
|
|
a9067167bb | ||
|
|
a9a0c854e1 | ||
|
|
0c7c188c45 | ||
|
|
c2753fdfb4 | ||
|
|
c29c20ab3c | ||
|
|
6ce806f913 | ||
|
|
35fda84ba8 | ||
|
|
5770d461b2 | ||
|
|
2e0645c26c | ||
|
|
880a5eb25c | ||
|
|
e48d3bfd01 | ||
|
|
5abb3d8150 | ||
|
|
66b1174d25 | ||
|
|
c45a75ad34 | ||
|
|
3567ac3d3e | ||
|
|
43f868de3d | ||
|
|
f41590912d | ||
|
|
183f993b01 | ||
|
|
e53fbb4a09 |
3
.babelrc
3
.babelrc
@@ -22,7 +22,8 @@
|
||||
{
|
||||
"messagesDir": "./build/messages"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preval"
|
||||
],
|
||||
"env": {
|
||||
"development": {
|
||||
|
||||
@@ -4,7 +4,6 @@ public/system
|
||||
public/assets
|
||||
public/packs
|
||||
node_modules
|
||||
storybook
|
||||
neo4j
|
||||
vendor/bundle
|
||||
.DS_Store
|
||||
|
||||
@@ -31,6 +31,17 @@ PAPERCLIP_SECRET=
|
||||
SECRET_KEY_BASE=
|
||||
OTP_SECRET=
|
||||
|
||||
# VAPID keys (used for push notifications
|
||||
# You can generate the keys using the following command (first is the private key, second is the public one)
|
||||
# You should only generate this once per instance. If you later decide to change it, all push subscription will
|
||||
# be invalidated, requiring the users to access the website again to resubscribe.
|
||||
#
|
||||
# Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose)
|
||||
#
|
||||
# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_PUBLIC_KEY=
|
||||
|
||||
# Registrations
|
||||
# Single user mode will disable registrations and redirect frontpage to the first profile
|
||||
# SINGLE_USER_MODE=true
|
||||
@@ -58,7 +69,7 @@ SMTP_FROM_ADDRESS=notifications@example.com
|
||||
#SMTP_CA_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
#SMTP_OPENSSL_VERIFY_MODE=peer
|
||||
#SMTP_ENABLE_STARTTLS_AUTO=true
|
||||
|
||||
#SMTP_TLS=true
|
||||
|
||||
# Optional user upload path and URL (images, avatars). Default is :rails_root/public/system. If you set this variable, you are responsible for making your HTTP server (eg. nginx) serve these files.
|
||||
# PAPERCLIP_ROOT_PATH=/var/lib/mastodon/public-system
|
||||
|
||||
@@ -112,7 +112,7 @@ rules:
|
||||
jsx-a11y/iframe-has-title: warn
|
||||
jsx-a11y/img-has-alt: warn
|
||||
jsx-a11y/img-redundant-alt: warn
|
||||
jsx-a11y/label-has-for: warn
|
||||
jsx-a11y/label-has-for: off
|
||||
jsx-a11y/mouse-events-have-key-events: warn
|
||||
jsx-a11y/no-access-key: warn
|
||||
jsx-a11y/no-distracting-elements: warn
|
||||
@@ -121,6 +121,6 @@ rules:
|
||||
jsx-a11y/onclick-has-focus: warn
|
||||
jsx-a11y/onclick-has-role: warn
|
||||
jsx-a11y/role-has-required-aria-props: warn
|
||||
jsx-a11y/role-supports-aria-props: warn
|
||||
jsx-a11y/role-supports-aria-props: off
|
||||
jsx-a11y/scope: warn
|
||||
jsx-a11y/tabindex-no-positive: warn
|
||||
|
||||
@@ -14,7 +14,6 @@ node_modules/
|
||||
public/assets/
|
||||
public/system/
|
||||
spec/
|
||||
storybook/
|
||||
tmp/
|
||||
.vagrant/
|
||||
vendor/bundle/
|
||||
|
||||
@@ -6,3 +6,4 @@ plugins:
|
||||
- last 2 versions
|
||||
- IE >= 11
|
||||
- iOS >= 9
|
||||
postcss-object-fit-images: {}
|
||||
|
||||
@@ -27,6 +27,7 @@ Metrics/AbcSize:
|
||||
Max: 100
|
||||
|
||||
Metrics/BlockLength:
|
||||
Max: 35
|
||||
Exclude:
|
||||
- 'lib/tasks/**/*'
|
||||
|
||||
@@ -35,10 +36,10 @@ Metrics/BlockNesting:
|
||||
|
||||
Metrics/ClassLength:
|
||||
CountComments: false
|
||||
Max: 200
|
||||
Max: 300
|
||||
|
||||
Metrics/CyclomaticComplexity:
|
||||
Max: 15
|
||||
Max: 25
|
||||
|
||||
Metrics/LineLength:
|
||||
AllowURI: true
|
||||
@@ -53,11 +54,11 @@ Metrics/ModuleLength:
|
||||
Max: 200
|
||||
|
||||
Metrics/ParameterLists:
|
||||
Max: 4
|
||||
Max: 5
|
||||
CountKeywordArgs: true
|
||||
|
||||
Metrics/PerceivedComplexity:
|
||||
Max: 10
|
||||
Max: 20
|
||||
|
||||
Rails:
|
||||
Enabled: true
|
||||
|
||||
@@ -2,4 +2,3 @@ node_modules/
|
||||
.cache/
|
||||
docs/
|
||||
spec/
|
||||
storybook/
|
||||
|
||||
@@ -6,6 +6,7 @@ cache:
|
||||
- node_modules
|
||||
- public/assets
|
||||
- public/packs-test
|
||||
- tmp/cache/babel-loader
|
||||
dist: trusty
|
||||
sudo: required
|
||||
|
||||
|
||||
9
Aptfile
9
Aptfile
@@ -1,6 +1,9 @@
|
||||
protobuf-compiler
|
||||
libprotobuf-dev
|
||||
ffmpeg
|
||||
libicu-dev
|
||||
libidn11
|
||||
libidn11-dev
|
||||
libpq-dev
|
||||
libprotobuf-dev
|
||||
libxdamage1
|
||||
libxfixes3
|
||||
libicu-dev
|
||||
protobuf-compiler
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
# Contributing to Mastodon Glitch Edition #
|
||||
|
||||
Thank you for your interest in contributing to the `glitch-soc` project!
|
||||
Here are some guidelines, and ways you can help.
|
||||
|
||||
> (This document is a bit of a work-in-progress, so please bear with us.
|
||||
> If you don't see what you're looking for here, please don't hesitate to reach out!)
|
||||
|
||||
## Planning ##
|
||||
|
||||
Right now a lot of the planning for this project takes place in our development Discord, or through GitHub Issues and Projects.
|
||||
We're working on ways to improve the planning structure and better solicit feedback, and if you feel like you can help in this respect, feel free to give us a holler.
|
||||
|
||||
## Documentation ##
|
||||
|
||||
The documentation for this repository is available at [`glitch-soc/docs`](https://github.com/glitch-soc/docs) (online at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/)).
|
||||
Right now, we've mostly focused on the features that make this fork different from upstream in some manner.
|
||||
Adding screenshots, improving descriptions, and so forth are all ways to help contribute to the project even if you don't know any code.
|
||||
|
||||
## Frontend Development ##
|
||||
|
||||
Check out [the documentation here](https://glitch-soc.github.io/docs/contributing/frontend/) for more information.
|
||||
|
||||
## Backend Development ##
|
||||
|
||||
See the guidelines below.
|
||||
|
||||
- - -
|
||||
|
||||
You should also try to follow the guidelines set out in the original `CONTRIBUTING.md` from `tootsuite/mastodon`, reproduced below.
|
||||
|
||||
<blockquote>
|
||||
|
||||
CONTRIBUTING
|
||||
============
|
||||
|
||||
@@ -49,3 +82,5 @@ It is expected that you have a working development environment set up (see back-
|
||||
* If you are introducing new strings, they must be using localization methods
|
||||
|
||||
If the JavaScript or CSS assets won't compile due to a syntax error, it's a good sign that the pull request isn't ready for submission yet.
|
||||
|
||||
</blockquote>
|
||||
|
||||
30
Dockerfile
30
Dockerfile
@@ -7,16 +7,21 @@ ENV UID=991 GID=991 \
|
||||
RAILS_SERVE_STATIC_FILES=true \
|
||||
RAILS_ENV=production NODE_ENV=production
|
||||
|
||||
ARG LIBICONV_VERSION=1.15
|
||||
ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178
|
||||
|
||||
EXPOSE 3000 4000
|
||||
|
||||
WORKDIR /mastodon
|
||||
|
||||
RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories \
|
||||
&& echo "@edge https://nl.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories \
|
||||
&& apk -U upgrade \
|
||||
&& apk add -t build-dependencies \
|
||||
build-base \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
icu-dev \
|
||||
libidn-dev \
|
||||
libtool \
|
||||
postgresql-dev \
|
||||
protobuf-dev \
|
||||
python \
|
||||
@@ -25,23 +30,34 @@ RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/reposit
|
||||
ffmpeg \
|
||||
file \
|
||||
git \
|
||||
icu-dev \
|
||||
icu-libs \
|
||||
imagemagick@edge \
|
||||
libidn \
|
||||
libpq \
|
||||
libxml2 \
|
||||
libxslt \
|
||||
nodejs-npm@edge \
|
||||
nodejs@edge \
|
||||
protobuf \
|
||||
su-exec \
|
||||
tini \
|
||||
&& npm install -g npm@3 && npm install -g yarn \
|
||||
yarn@edge \
|
||||
&& update-ca-certificates \
|
||||
&& wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \
|
||||
&& echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \
|
||||
&& mkdir -p /tmp/src \
|
||||
&& tar -xzf libiconv.tar.gz -C /tmp/src \
|
||||
&& rm libiconv.tar.gz \
|
||||
&& cd /tmp/src/libiconv-$LIBICONV_VERSION \
|
||||
&& ./configure --prefix=/usr/local \
|
||||
&& make -j$(getconf _NPROCESSORS_ONLN)\
|
||||
&& make install \
|
||||
&& libtool --finish /usr/local/lib \
|
||||
&& cd /mastodon \
|
||||
&& rm -rf /tmp/* /var/cache/apk/*
|
||||
|
||||
COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/
|
||||
|
||||
RUN bundle install --deployment --without test development \
|
||||
RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \
|
||||
&& bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \
|
||||
&& yarn --ignore-optional --pure-lockfile
|
||||
|
||||
COPY . /mastodon
|
||||
|
||||
7
Gemfile
7
Gemfile
@@ -28,13 +28,14 @@ gem 'devise', '~> 4.2'
|
||||
gem 'devise-two-factor', '~> 3.0'
|
||||
gem 'doorkeeper', '~> 4.2'
|
||||
gem 'fast_blank', '~> 1.0'
|
||||
gem 'goldfinger', '~> 1.2'
|
||||
gem 'goldfinger', '~> 2.0'
|
||||
gem 'hiredis', '~> 0.6'
|
||||
gem 'redis-namespace', '~> 1.5'
|
||||
gem 'htmlentities', '~> 4.3'
|
||||
gem 'http', '~> 2.2'
|
||||
gem 'http_accept_language', '~> 2.1'
|
||||
gem 'httplog', '~> 0.99'
|
||||
gem 'idn-ruby', require: 'idn'
|
||||
gem 'kaminari', '~> 1.0'
|
||||
gem 'link_header', '~> 0.0'
|
||||
gem 'mime-types', '~> 3.1'
|
||||
@@ -50,6 +51,7 @@ gem 'rack-timeout', '~> 0.4'
|
||||
gem 'rails-i18n', '~> 5.0'
|
||||
gem 'rails-settings-cached', '~> 0.6'
|
||||
gem 'redis', '~> 3.3', require: ['redis', 'redis/connection/hiredis']
|
||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||
gem 'rqrcode', '~> 0.10'
|
||||
gem 'ruby-oembed', '~> 0.12', require: 'oembed'
|
||||
gem 'sanitize', '~> 4.4'
|
||||
@@ -64,6 +66,7 @@ gem 'statsd-instrument', '~> 2.1'
|
||||
gem 'twitter-text', '~> 1.14'
|
||||
gem 'tzinfo-data', '~> 1.2017'
|
||||
gem 'webpacker', '~> 2.0'
|
||||
gem 'webpush'
|
||||
|
||||
group :development, :test do
|
||||
gem 'fabrication', '~> 2.16'
|
||||
@@ -77,7 +80,7 @@ group :test do
|
||||
gem 'capybara', '~> 2.14'
|
||||
gem 'climate_control', '~> 0.2'
|
||||
gem 'faker', '~> 1.7'
|
||||
gem 'microformats2', '~> 3.0'
|
||||
gem 'microformats', '~> 4.0'
|
||||
gem 'rails-controller-testing', '~> 1.0'
|
||||
gem 'rspec-sidekiq', '~> 3.0'
|
||||
gem 'simplecov', '~> 0.14', require: false
|
||||
|
||||
144
Gemfile.lock
144
Gemfile.lock
@@ -1,25 +1,25 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (5.1.2)
|
||||
actionpack (= 5.1.2)
|
||||
actioncable (5.1.3)
|
||||
actionpack (= 5.1.3)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (~> 0.6.1)
|
||||
actionmailer (5.1.2)
|
||||
actionpack (= 5.1.2)
|
||||
actionview (= 5.1.2)
|
||||
activejob (= 5.1.2)
|
||||
actionmailer (5.1.3)
|
||||
actionpack (= 5.1.3)
|
||||
actionview (= 5.1.3)
|
||||
activejob (= 5.1.3)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (5.1.2)
|
||||
actionview (= 5.1.2)
|
||||
activesupport (= 5.1.2)
|
||||
actionpack (5.1.3)
|
||||
actionview (= 5.1.3)
|
||||
activesupport (= 5.1.3)
|
||||
rack (~> 2.0)
|
||||
rack-test (~> 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
||||
actionview (5.1.2)
|
||||
activesupport (= 5.1.2)
|
||||
actionview (5.1.3)
|
||||
activesupport (= 5.1.3)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
@@ -30,16 +30,16 @@ GEM
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.2)
|
||||
active_record_query_trace (1.5.4)
|
||||
activejob (5.1.2)
|
||||
activesupport (= 5.1.2)
|
||||
activejob (5.1.3)
|
||||
activesupport (= 5.1.3)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (5.1.2)
|
||||
activesupport (= 5.1.2)
|
||||
activerecord (5.1.2)
|
||||
activemodel (= 5.1.2)
|
||||
activesupport (= 5.1.2)
|
||||
activemodel (5.1.3)
|
||||
activesupport (= 5.1.3)
|
||||
activerecord (5.1.3)
|
||||
activemodel (= 5.1.3)
|
||||
activesupport (= 5.1.3)
|
||||
arel (~> 8.0)
|
||||
activesupport (5.1.2)
|
||||
activesupport (5.1.3)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (~> 0.7)
|
||||
minitest (~> 5.1)
|
||||
@@ -57,14 +57,14 @@ GEM
|
||||
encryptor (~> 3.0.0)
|
||||
av (0.9.0)
|
||||
cocaine (~> 0.5.3)
|
||||
aws-sdk (2.10.6)
|
||||
aws-sdk-resources (= 2.10.6)
|
||||
aws-sdk-core (2.10.6)
|
||||
aws-sdk (2.10.21)
|
||||
aws-sdk-resources (= 2.10.21)
|
||||
aws-sdk-core (2.10.21)
|
||||
aws-sigv4 (~> 1.0)
|
||||
jmespath (~> 1.0)
|
||||
aws-sdk-resources (2.10.6)
|
||||
aws-sdk-core (= 2.10.6)
|
||||
aws-sigv4 (1.0.0)
|
||||
aws-sdk-resources (2.10.21)
|
||||
aws-sdk-core (= 2.10.21)
|
||||
aws-sigv4 (1.0.1)
|
||||
bcrypt (3.1.11)
|
||||
better_errors (2.1.1)
|
||||
coderay (>= 1.0.0)
|
||||
@@ -72,7 +72,7 @@ GEM
|
||||
rack (>= 0.9.0)
|
||||
binding_of_caller (0.7.2)
|
||||
debug_inspector (>= 0.0.1)
|
||||
bootsnap (1.1.1)
|
||||
bootsnap (1.1.2)
|
||||
msgpack (~> 1.0)
|
||||
brakeman (3.6.2)
|
||||
browser (2.4.0)
|
||||
@@ -155,7 +155,7 @@ GEM
|
||||
et-orbi (1.0.5)
|
||||
tzinfo
|
||||
execjs (2.7.0)
|
||||
fabrication (2.16.1)
|
||||
fabrication (2.16.2)
|
||||
faker (1.7.3)
|
||||
i18n (~> 0.5)
|
||||
fast_blank (1.0.0)
|
||||
@@ -165,10 +165,11 @@ GEM
|
||||
ruby-progressbar (~> 1.4)
|
||||
globalid (0.4.0)
|
||||
activesupport (>= 4.2.0)
|
||||
goldfinger (1.2.0)
|
||||
addressable (~> 2.4)
|
||||
http (~> 2.0)
|
||||
nokogiri (~> 1.6)
|
||||
goldfinger (2.0.1)
|
||||
addressable (~> 2.5)
|
||||
http (~> 2.2)
|
||||
nokogiri (~> 1.8)
|
||||
oj (~> 3.0)
|
||||
hamlit (2.8.4)
|
||||
temple (>= 0.8.0)
|
||||
thor
|
||||
@@ -178,9 +179,10 @@ GEM
|
||||
activesupport (>= 4.0.1)
|
||||
hamlit (>= 1.2.0)
|
||||
railties (>= 4.0.1)
|
||||
hashdiff (0.3.4)
|
||||
hashdiff (0.3.5)
|
||||
highline (1.7.8)
|
||||
hiredis (0.6.1)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (2.2.2)
|
||||
addressable (~> 2.3)
|
||||
@@ -192,11 +194,11 @@ GEM
|
||||
http-form_data (1.0.3)
|
||||
http_accept_language (2.1.1)
|
||||
http_parser.rb (0.6.0)
|
||||
httplog (0.99.4)
|
||||
httplog (0.99.7)
|
||||
colorize
|
||||
rack
|
||||
i18n (0.8.4)
|
||||
i18n-tasks (0.9.15)
|
||||
i18n (0.8.6)
|
||||
i18n-tasks (0.9.16)
|
||||
activesupport (>= 4.0.2)
|
||||
ast (>= 2.1.0)
|
||||
easy_translate (>= 0.5.0)
|
||||
@@ -206,9 +208,11 @@ GEM
|
||||
parser (>= 2.2.3.0)
|
||||
rainbow (~> 2.2)
|
||||
terminal-table (>= 1.5.1)
|
||||
idn-ruby (0.1.0)
|
||||
jmespath (1.3.1)
|
||||
json (2.1.0)
|
||||
jsonapi-renderer (0.1.2)
|
||||
jsonapi-renderer (0.1.3)
|
||||
jwt (1.5.6)
|
||||
kaminari (1.0.1)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.0.1)
|
||||
@@ -238,8 +242,10 @@ GEM
|
||||
nokogiri (>= 1.5.9)
|
||||
mail (2.6.6)
|
||||
mime-types (>= 1.16, < 4)
|
||||
mario-redis-lock (1.2.0)
|
||||
redis (~> 3, >= 3.0.5)
|
||||
method_source (0.8.2)
|
||||
microformats2 (3.1.0)
|
||||
microformats (4.0.7)
|
||||
json
|
||||
nokogiri
|
||||
mime-types (3.1)
|
||||
@@ -247,7 +253,7 @@ GEM
|
||||
mime-types-data (3.2016.0521)
|
||||
mimemagic (0.3.2)
|
||||
mini_portile2 (2.2.0)
|
||||
minitest (5.10.2)
|
||||
minitest (5.10.3)
|
||||
msgpack (1.1.0)
|
||||
multi_json (1.12.1)
|
||||
net-scp (1.2.1)
|
||||
@@ -258,7 +264,7 @@ GEM
|
||||
mini_portile2 (~> 2.2.0)
|
||||
nokogumbo (1.4.13)
|
||||
nokogiri
|
||||
oj (3.2.0)
|
||||
oj (3.3.4)
|
||||
openssl (2.0.4)
|
||||
orm_adapter (0.5.0)
|
||||
ostatus2 (2.0.1)
|
||||
@@ -277,14 +283,14 @@ GEM
|
||||
av (~> 0.9.0)
|
||||
paperclip (>= 2.5.2)
|
||||
parallel (1.11.2)
|
||||
parallel_tests (2.14.1)
|
||||
parallel_tests (2.14.2)
|
||||
parallel
|
||||
parser (2.4.0.0)
|
||||
ast (~> 2.2)
|
||||
pg (0.21.0)
|
||||
pghero (1.7.0)
|
||||
activerecord
|
||||
pkg-config (1.2.3)
|
||||
pkg-config (1.2.4)
|
||||
powerpack (0.1.1)
|
||||
pry (0.10.4)
|
||||
coderay (~> 1.1.0)
|
||||
@@ -307,17 +313,17 @@ GEM
|
||||
rack-test (0.6.3)
|
||||
rack (>= 1.0)
|
||||
rack-timeout (0.4.2)
|
||||
rails (5.1.2)
|
||||
actioncable (= 5.1.2)
|
||||
actionmailer (= 5.1.2)
|
||||
actionpack (= 5.1.2)
|
||||
actionview (= 5.1.2)
|
||||
activejob (= 5.1.2)
|
||||
activemodel (= 5.1.2)
|
||||
activerecord (= 5.1.2)
|
||||
activesupport (= 5.1.2)
|
||||
bundler (>= 1.3.0, < 2.0)
|
||||
railties (= 5.1.2)
|
||||
rails (5.1.3)
|
||||
actioncable (= 5.1.3)
|
||||
actionmailer (= 5.1.3)
|
||||
actionpack (= 5.1.3)
|
||||
actionview (= 5.1.3)
|
||||
activejob (= 5.1.3)
|
||||
activemodel (= 5.1.3)
|
||||
activerecord (= 5.1.3)
|
||||
activesupport (= 5.1.3)
|
||||
bundler (>= 1.3.0)
|
||||
railties (= 5.1.3)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-controller-testing (1.0.2)
|
||||
actionpack (~> 5.x, >= 5.0.1)
|
||||
@@ -331,11 +337,11 @@ GEM
|
||||
rails-i18n (5.0.4)
|
||||
i18n (~> 0.7)
|
||||
railties (~> 5.0)
|
||||
rails-settings-cached (0.6.5)
|
||||
rails-settings-cached (0.6.6)
|
||||
rails (>= 4.2.0)
|
||||
railties (5.1.2)
|
||||
actionpack (= 5.1.2)
|
||||
activesupport (= 5.1.2)
|
||||
railties (5.1.3)
|
||||
actionpack (= 5.1.3)
|
||||
activesupport (= 5.1.3)
|
||||
method_source
|
||||
rake (>= 0.8.7)
|
||||
thor (>= 0.18.1, < 2.0)
|
||||
@@ -347,7 +353,7 @@ GEM
|
||||
actionpack (>= 4.0, < 6)
|
||||
redis-rack (>= 1, < 3)
|
||||
redis-store (>= 1.1.0, < 1.4.0)
|
||||
redis-activesupport (5.0.2)
|
||||
redis-activesupport (5.0.3)
|
||||
activesupport (>= 3, < 6)
|
||||
redis-store (~> 1.3.0)
|
||||
redis-namespace (1.5.3)
|
||||
@@ -407,7 +413,7 @@ GEM
|
||||
scss_lint (0.54.0)
|
||||
rake (>= 0.9, < 13)
|
||||
sass (~> 3.4.20)
|
||||
sidekiq (5.0.3)
|
||||
sidekiq (5.0.4)
|
||||
concurrent-ruby (~> 1.0)
|
||||
connection_pool (~> 2.2, >= 2.2.0)
|
||||
rack-protection (>= 1.5.0)
|
||||
@@ -415,12 +421,12 @@ GEM
|
||||
sidekiq-bulk (0.1.1)
|
||||
activesupport
|
||||
sidekiq
|
||||
sidekiq-scheduler (2.1.7)
|
||||
sidekiq-scheduler (2.1.8)
|
||||
redis (~> 3)
|
||||
rufus-scheduler (~> 3.2)
|
||||
sidekiq (>= 3)
|
||||
tilt (>= 1.4.0)
|
||||
sidekiq-unique-jobs (5.0.8)
|
||||
sidekiq-unique-jobs (5.0.9)
|
||||
sidekiq (>= 4.0, <= 6.0)
|
||||
thor (~> 0)
|
||||
simple-navigation (4.0.5)
|
||||
@@ -444,15 +450,15 @@ GEM
|
||||
sshkit (1.13.1)
|
||||
net-scp (>= 1.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
statsd-instrument (2.1.2)
|
||||
statsd-instrument (2.1.4)
|
||||
temple (0.8.0)
|
||||
terminal-table (1.8.0)
|
||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||
thor (0.19.4)
|
||||
thread (0.2.2)
|
||||
thread_safe (0.3.6)
|
||||
tilt (2.0.7)
|
||||
twitter-text (1.14.6)
|
||||
tilt (2.0.8)
|
||||
twitter-text (1.14.7)
|
||||
unf (~> 0.1.0)
|
||||
tzinfo (1.2.3)
|
||||
thread_safe (~> 0.1)
|
||||
@@ -475,6 +481,9 @@ GEM
|
||||
activesupport (>= 4.2)
|
||||
multi_json (~> 1.2)
|
||||
railties (>= 4.2)
|
||||
webpush (0.3.2)
|
||||
hkdf (~> 0.2)
|
||||
jwt
|
||||
websocket-driver (0.6.5)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.2)
|
||||
@@ -513,7 +522,7 @@ DEPENDENCIES
|
||||
faker (~> 1.7)
|
||||
fast_blank (~> 1.0)
|
||||
fuubar (~> 2.2)
|
||||
goldfinger (~> 1.2)
|
||||
goldfinger (~> 2.0)
|
||||
hamlit-rails (~> 0.2)
|
||||
hiredis (~> 0.6)
|
||||
htmlentities (~> 4.3)
|
||||
@@ -521,12 +530,14 @@ DEPENDENCIES
|
||||
http_accept_language (~> 2.1)
|
||||
httplog (~> 0.99)
|
||||
i18n-tasks (~> 0.9)
|
||||
idn-ruby
|
||||
kaminari (~> 1.0)
|
||||
letter_opener (~> 1.4)
|
||||
letter_opener_web (~> 1.3)
|
||||
link_header (~> 0.0)
|
||||
lograge (~> 0.5)
|
||||
microformats2 (~> 3.0)
|
||||
mario-redis-lock (~> 1.2)
|
||||
microformats (~> 4.0)
|
||||
mime-types (~> 3.1)
|
||||
nokogiri (~> 1.7)
|
||||
oj (~> 3.0)
|
||||
@@ -573,9 +584,10 @@ DEPENDENCIES
|
||||
uglifier (~> 3.2)
|
||||
webmock (~> 3.0)
|
||||
webpacker (~> 2.0)
|
||||
webpush
|
||||
|
||||
RUBY VERSION
|
||||
ruby 2.4.1p111
|
||||
|
||||
BUNDLED WITH
|
||||
1.15.1
|
||||
1.15.3
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
Mastodon Glitch Edition
|
||||
========
|
||||
Now with automated deploys!
|
||||
# Mastodon Glitch Edition #
|
||||
|
||||
> Now with automated deploys!
|
||||
|
||||
[](https://travis-ci.org/glitch-soc/mastodon)
|
||||
|
||||
So here's the deal: we all work on this code, and then it runs on dev.glitch.social and anyone who uses that does so absolutely at their own risk. can you dig it?
|
||||
|
||||
- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/).
|
||||
- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/).
|
||||
|
||||
3
Vagrantfile
vendored
3
Vagrantfile
vendored
@@ -35,9 +35,10 @@ sudo apt-get install \
|
||||
postgresql-contrib \
|
||||
protobuf-compiler \
|
||||
yarn \
|
||||
libicu-dev \
|
||||
libidn11-dev \
|
||||
libprotobuf-dev \
|
||||
libreadline-dev \
|
||||
libicu-dev \
|
||||
-y
|
||||
|
||||
# Install rvm
|
||||
|
||||
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
||||
"name": "Mastodon",
|
||||
"description": "A GNU Social-compatible microblogging server",
|
||||
"repository": "https://github.com/tootsuite/mastodon",
|
||||
"logo": "https://github.com/tootsuite/mastodon/raw/master/app/assets/images/logo.png",
|
||||
"logo": "https://github.com/tootsuite.png",
|
||||
"env": {
|
||||
"HEROKU": {
|
||||
"description": "Leave this as true",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
class AccountsController < ApplicationController
|
||||
include AccountControllerConcern
|
||||
include SignatureVerification
|
||||
|
||||
def show
|
||||
respond_to do |format|
|
||||
@@ -12,10 +13,12 @@ class AccountsController < ApplicationController
|
||||
|
||||
format.atom do
|
||||
@entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id])
|
||||
render xml: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a))
|
||||
render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.to_a))
|
||||
end
|
||||
|
||||
format.activitystreams2
|
||||
format.json do
|
||||
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
27
app/controllers/activitypub/outboxes_controller.rb
Normal file
27
app/controllers/activitypub/outboxes_controller.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::OutboxesController < Api::BaseController
|
||||
before_action :set_account
|
||||
|
||||
def show
|
||||
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
|
||||
@statuses = cache_collection(@statuses, Status)
|
||||
|
||||
render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Account.find_local!(params[:account_username])
|
||||
end
|
||||
|
||||
def outbox_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_outbox_url(@account),
|
||||
type: :ordered,
|
||||
size: @account.statuses_count,
|
||||
items: @statuses
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -6,15 +6,26 @@ module Admin
|
||||
@instances = ordered_instances
|
||||
end
|
||||
|
||||
def resubscribe
|
||||
params.require(:by_domain)
|
||||
Pubsubhubbub::SubscribeWorker.push_bulk(subscribeable_accounts.pluck(:id))
|
||||
redirect_to admin_instances_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def paginated_instances
|
||||
Account.remote.by_domain_accounts.page(params[:page])
|
||||
end
|
||||
|
||||
helper_method :paginated_instances
|
||||
|
||||
def ordered_instances
|
||||
paginated_instances.map { |account| Instance.new(account) }
|
||||
end
|
||||
|
||||
def subscribeable_accounts
|
||||
Account.with_followers.remote.where(domain: params[:by_domain])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,14 @@ module Admin
|
||||
include Authorization
|
||||
|
||||
before_action :set_report
|
||||
before_action :set_status
|
||||
before_action :set_status, only: [:update, :destroy]
|
||||
|
||||
def create
|
||||
@form = Form::StatusBatch.new(form_status_batch_params)
|
||||
flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save
|
||||
|
||||
redirect_to admin_report_path(@report)
|
||||
end
|
||||
|
||||
def update
|
||||
@status.update(status_params)
|
||||
@@ -15,7 +22,7 @@ module Admin
|
||||
def destroy
|
||||
authorize @status, :destroy?
|
||||
RemovalWorker.perform_async(@status.id)
|
||||
redirect_to admin_report_path(@report)
|
||||
render json: @status
|
||||
end
|
||||
|
||||
private
|
||||
@@ -24,6 +31,10 @@ module Admin
|
||||
params.require(:status).permit(:sensitive)
|
||||
end
|
||||
|
||||
def form_status_batch_params
|
||||
params.require(:form_status_batch).permit(:action, status_ids: [])
|
||||
end
|
||||
|
||||
def set_report
|
||||
@report = Report.find(params[:report_id])
|
||||
end
|
||||
|
||||
@@ -8,7 +8,9 @@ module Admin
|
||||
@reports = filtered_reports.page(params[:page])
|
||||
end
|
||||
|
||||
def show; end
|
||||
def show
|
||||
@form = Form::StatusBatch.new
|
||||
end
|
||||
|
||||
def update
|
||||
process_report
|
||||
|
||||
69
app/controllers/admin/statuses_controller.rb
Normal file
69
app/controllers/admin/statuses_controller.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class StatusesController < BaseController
|
||||
include Authorization
|
||||
|
||||
helper_method :current_params
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_status, only: [:update, :destroy]
|
||||
|
||||
PAR_PAGE = 20
|
||||
|
||||
def index
|
||||
@statuses = @account.statuses
|
||||
if params[:media]
|
||||
account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
|
||||
@statuses.merge!(Status.where(id: account_media_status_ids))
|
||||
end
|
||||
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PAR_PAGE)
|
||||
|
||||
@form = Form::StatusBatch.new
|
||||
end
|
||||
|
||||
def create
|
||||
@form = Form::StatusBatch.new(form_status_batch_params)
|
||||
flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save
|
||||
|
||||
redirect_to admin_account_statuses_path(@account.id, current_params)
|
||||
end
|
||||
|
||||
def update
|
||||
@status.update(status_params)
|
||||
redirect_to admin_account_statuses_path(@account.id, current_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @status, :destroy?
|
||||
RemovalWorker.perform_async(@status.id)
|
||||
render json: @status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def status_params
|
||||
params.require(:status).permit(:sensitive)
|
||||
end
|
||||
|
||||
def form_status_batch_params
|
||||
params.require(:form_status_batch).permit(:action, status_ids: [])
|
||||
end
|
||||
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:id])
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
def current_params
|
||||
page = (params[:page] || 1).to_i
|
||||
{
|
||||
media: params[:media],
|
||||
page: page > 1 && page,
|
||||
}.select { |_, value| value.present? }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,27 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::ActivityPub::ActivitiesController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
# before_action :set_follow, only: [:show_follow]
|
||||
before_action :set_status, only: [:show_status]
|
||||
|
||||
respond_to :activitystreams2
|
||||
|
||||
# Show a status in AS2 format, as either an Announce (reblog) or a Create (post) activity.
|
||||
def show_status
|
||||
authorize @status, :show?
|
||||
|
||||
if @status.reblog?
|
||||
render :show_status_announce
|
||||
else
|
||||
render :show_status_create
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_status
|
||||
@status = Status.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -1,19 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::ActivityPub::NotesController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action :set_status
|
||||
|
||||
respond_to :activitystreams2
|
||||
|
||||
def show
|
||||
authorize @status, :show?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_status
|
||||
@status = Status.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -1,69 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::ActivityPub::OutboxController < Api::BaseController
|
||||
before_action :set_account
|
||||
|
||||
respond_to :activitystreams2
|
||||
|
||||
def show
|
||||
if params[:max_id] || params[:since_id]
|
||||
show_outbox_page
|
||||
else
|
||||
show_base_outbox
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def show_base_outbox
|
||||
@statuses = Status.as_outbox_timeline(@account)
|
||||
@statuses = cache_collection(@statuses)
|
||||
|
||||
set_maps(@statuses)
|
||||
|
||||
set_first_last_page(@statuses)
|
||||
|
||||
render :show
|
||||
end
|
||||
|
||||
def show_outbox_page
|
||||
all_statuses = Status.as_outbox_timeline(@account)
|
||||
@statuses = all_statuses.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
|
||||
|
||||
all_statuses = cache_collection(all_statuses)
|
||||
@statuses = cache_collection(@statuses)
|
||||
|
||||
set_maps(@statuses)
|
||||
|
||||
set_first_last_page(all_statuses)
|
||||
|
||||
@next_page_url = api_activitypub_outbox_url(pagination_params(max_id: @statuses.last.id)) unless @statuses.empty?
|
||||
@prev_page_url = api_activitypub_outbox_url(pagination_params(since_id: @statuses.first.id)) unless @statuses.empty?
|
||||
|
||||
@paginated = @next_page_url || @prev_page_url
|
||||
@part_of_url = api_activitypub_outbox_url
|
||||
|
||||
set_pagination_headers(@next_page_url, @prev_page_url)
|
||||
|
||||
render :show_page
|
||||
end
|
||||
|
||||
def cache_collection(raw)
|
||||
super(raw, Status)
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:id])
|
||||
end
|
||||
|
||||
def set_first_last_page(statuses) # rubocop:disable Style/AccessorMethodName
|
||||
return if statuses.empty?
|
||||
|
||||
@first_page_url = api_activitypub_outbox_url(max_id: statuses.first.id + 1)
|
||||
@last_page_url = api_activitypub_outbox_url(since_id: statuses.last.id - 1)
|
||||
end
|
||||
|
||||
def pagination_params(core_params)
|
||||
params.permit(:local, :limit).merge(core_params)
|
||||
end
|
||||
end
|
||||
@@ -17,11 +17,7 @@ class Api::BaseController < ApplicationController
|
||||
render json: { error: 'Record not found' }, status: 404
|
||||
end
|
||||
|
||||
rescue_from Goldfinger::Error do
|
||||
render json: { error: 'Remote account could not be resolved' }, status: 422
|
||||
end
|
||||
|
||||
rescue_from HTTP::Error do
|
||||
rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do
|
||||
render json: { error: 'Remote data could not be fetched' }, status: 503
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::PushController < Api::BaseController
|
||||
include SignatureVerification
|
||||
|
||||
def update
|
||||
response, status = process_push_request
|
||||
render plain: response, status: status
|
||||
@@ -11,7 +13,7 @@ class Api::PushController < Api::BaseController
|
||||
def process_push_request
|
||||
case hub_mode
|
||||
when 'subscribe'
|
||||
Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds)
|
||||
Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds, verified_domain)
|
||||
when 'unsubscribe'
|
||||
Pubsubhubbub::UnsubscribeService.new.call(account_from_topic, hub_callback)
|
||||
else
|
||||
@@ -57,6 +59,10 @@ class Api::PushController < Api::BaseController
|
||||
TagManager.instance.web_domain?(hub_topic_domain)
|
||||
end
|
||||
|
||||
def verified_domain
|
||||
return signed_request_account.domain if signed_request_account
|
||||
end
|
||||
|
||||
def hub_topic_domain
|
||||
hub_topic_uri.host + (hub_topic_uri.port ? ":#{hub_topic_uri.port}" : '')
|
||||
end
|
||||
|
||||
@@ -42,7 +42,7 @@ class Api::SubscriptionsController < Api::BaseController
|
||||
end
|
||||
|
||||
def lease_seconds_or_default
|
||||
(params['hub.lease_seconds'] || 86_400).to_i.seconds
|
||||
(params['hub.lease_seconds'] || 1.day).to_i.seconds
|
||||
end
|
||||
|
||||
def set_account
|
||||
|
||||
@@ -20,9 +20,7 @@ class Api::V1::FavouritesController < Api::BaseController
|
||||
|
||||
def cached_favourites
|
||||
cache_collection(
|
||||
Status.where(
|
||||
id: results.map(&:status_id)
|
||||
),
|
||||
Status.reorder(nil).joins(:favourites).merge(results),
|
||||
Status
|
||||
)
|
||||
end
|
||||
|
||||
@@ -24,11 +24,20 @@ class Api::V1::NotificationsController < Api::BaseController
|
||||
render_empty
|
||||
end
|
||||
|
||||
def destroy
|
||||
dismiss
|
||||
end
|
||||
|
||||
def dismiss
|
||||
current_account.notifications.find_by!(id: params[:id]).destroy!
|
||||
render_empty
|
||||
end
|
||||
|
||||
def destroy_multiple
|
||||
current_account.notifications.where(id: params[:ids]).destroy_all
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_notifications
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::SearchController < Api::BaseController
|
||||
RESULTS_LIMIT = 5
|
||||
RESULTS_LIMIT = 10
|
||||
|
||||
before_action -> { doorkeeper_authorize! :read }
|
||||
before_action :require_user!
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
|
||||
|
||||
UnfavouriteWorker.perform_async(current_user.account_id, @status.id)
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, favourites_map: @favourites_map)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -20,7 +20,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
||||
authorize status_for_destroy, :unreblog?
|
||||
RemovalWorker.perform_async(status_for_destroy.id)
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
52
app/controllers/api/web/push_subscriptions_controller.rb
Normal file
52
app/controllers/api/web/push_subscriptions_controller.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::Web::PushSubscriptionsController < Api::BaseController
|
||||
respond_to :json
|
||||
|
||||
before_action :require_user!
|
||||
|
||||
def create
|
||||
params.require(:subscription).require(:endpoint)
|
||||
params.require(:subscription).require(:keys).require([:auth, :p256dh])
|
||||
|
||||
active_session = current_session
|
||||
|
||||
unless active_session.web_push_subscription.nil?
|
||||
active_session.web_push_subscription.destroy!
|
||||
active_session.update!(web_push_subscription: nil)
|
||||
end
|
||||
|
||||
# Mobile devices do not support regular notifications, so we enable push notifications by default
|
||||
alerts_enabled = active_session.detection.device.mobile? || active_session.detection.device.tablet?
|
||||
|
||||
data = {
|
||||
alerts: {
|
||||
follow: alerts_enabled,
|
||||
favourite: alerts_enabled,
|
||||
reblog: alerts_enabled,
|
||||
mention: alerts_enabled,
|
||||
},
|
||||
}
|
||||
|
||||
web_subscription = ::Web::PushSubscription.create!(
|
||||
endpoint: params[:subscription][:endpoint],
|
||||
key_p256dh: params[:subscription][:keys][:p256dh],
|
||||
key_auth: params[:subscription][:keys][:auth],
|
||||
data: data
|
||||
)
|
||||
|
||||
active_session.update!(web_push_subscription: web_subscription)
|
||||
|
||||
render json: web_subscription.as_payload
|
||||
end
|
||||
|
||||
def update
|
||||
params.require([:id, :data])
|
||||
|
||||
web_subscription = ::Web::PushSubscription.find(params[:id])
|
||||
|
||||
web_subscription.update!(data: params[:data])
|
||||
|
||||
render json: web_subscription.as_payload
|
||||
end
|
||||
end
|
||||
@@ -43,6 +43,10 @@ class ApplicationController < ActionController::Base
|
||||
forbidden if current_user.account.suspended?
|
||||
end
|
||||
|
||||
def after_sign_out_path_for(_resource_or_scope)
|
||||
new_user_session_path
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def forbidden
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Auth::PasswordsController < Devise::PasswordsController
|
||||
before_action :check_validity_of_reset_password_token, only: :edit
|
||||
|
||||
layout 'auth'
|
||||
|
||||
private
|
||||
|
||||
def check_validity_of_reset_password_token
|
||||
unless reset_password_token_is_valid?
|
||||
flash[:error] = I18n.t('auth.invalid_reset_password_token')
|
||||
redirect_to new_password_path(resource_name)
|
||||
end
|
||||
end
|
||||
|
||||
def reset_password_token_is_valid?
|
||||
resource_class.with_reset_password_token(params[:reset_password_token]).present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AuthorizeFollowsController < ApplicationController
|
||||
layout 'public'
|
||||
layout 'modal'
|
||||
|
||||
before_action :authenticate_user!
|
||||
|
||||
|
||||
87
app/controllers/concerns/signature_verification.rb
Normal file
87
app/controllers/concerns/signature_verification.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Implemented according to HTTP signatures (Draft 6)
|
||||
# <https://tools.ietf.org/html/draft-cavage-http-signatures-06>
|
||||
module SignatureVerification
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def signed_request?
|
||||
request.headers['Signature'].present?
|
||||
end
|
||||
|
||||
def signed_request_account
|
||||
return @signed_request_account if defined?(@signed_request_account)
|
||||
|
||||
unless signed_request?
|
||||
@signed_request_account = nil
|
||||
return
|
||||
end
|
||||
|
||||
raw_signature = request.headers['Signature']
|
||||
signature_params = {}
|
||||
|
||||
raw_signature.split(',').each do |part|
|
||||
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
|
||||
next if parsed_parts.nil? || parsed_parts.size != 3
|
||||
signature_params[parsed_parts[1]] = parsed_parts[2]
|
||||
end
|
||||
|
||||
if incompatible_signature?(signature_params)
|
||||
@signed_request_account = nil
|
||||
return
|
||||
end
|
||||
|
||||
account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, ''))
|
||||
|
||||
if account.nil?
|
||||
@signed_request_account = nil
|
||||
return
|
||||
end
|
||||
|
||||
signature = Base64.decode64(signature_params['signature'])
|
||||
compare_signed_string = build_signed_string(signature_params['headers'])
|
||||
|
||||
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
|
||||
@signed_request_account = account
|
||||
@signed_request_account
|
||||
else
|
||||
@signed_request_account = nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_signed_string(signed_headers)
|
||||
signed_headers = 'date' if signed_headers.blank?
|
||||
|
||||
signed_headers.split(' ').map do |signed_header|
|
||||
if signed_header == Request::REQUEST_TARGET
|
||||
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||
else
|
||||
"#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
|
||||
end
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def matches_time_window?
|
||||
begin
|
||||
time_sent = DateTime.httpdate(request.headers['Date'])
|
||||
rescue ArgumentError
|
||||
return false
|
||||
end
|
||||
|
||||
(Time.now.utc - time_sent).abs <= 30
|
||||
end
|
||||
|
||||
def to_header_name(name)
|
||||
name.split(/-/).map(&:capitalize).join('-')
|
||||
end
|
||||
|
||||
def incompatible_signature?(signature_params)
|
||||
signature_params['keyId'].blank? ||
|
||||
signature_params['signature'].blank? ||
|
||||
signature_params['algorithm'].blank? ||
|
||||
signature_params['algorithm'] != 'rsa-sha256' ||
|
||||
!signature_params['keyId'].start_with?('acct:')
|
||||
end
|
||||
end
|
||||
@@ -5,5 +5,24 @@ class FollowerAccountsController < ApplicationController
|
||||
|
||||
def index
|
||||
@follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
|
||||
format.json do
|
||||
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_followers_url(@account),
|
||||
type: :ordered,
|
||||
size: @account.followers_count,
|
||||
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,5 +5,24 @@ class FollowingAccountsController < ApplicationController
|
||||
|
||||
def index
|
||||
@follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
|
||||
format.json do
|
||||
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_following_index_url(@account),
|
||||
type: :ordered,
|
||||
size: @account.following_count,
|
||||
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class HomeController < ApplicationController
|
||||
|
||||
def index
|
||||
@body_classes = 'app-body'
|
||||
@frontend = (params[:frontend] and Rails.configuration.x.available_frontends.include? params[:frontend] + '.js') ? params[:frontend] : 'mastodon'
|
||||
end
|
||||
|
||||
private
|
||||
@@ -22,6 +23,7 @@ class HomeController < ApplicationController
|
||||
def initial_state_params
|
||||
{
|
||||
settings: Web::Setting.find_by(user: current_user)&.data || {},
|
||||
push_subscription: current_account.user.web_push_subscription(current_session),
|
||||
current_account: current_account,
|
||||
token: current_session.token,
|
||||
admin: Account.find_local(Setting.site_contact_username),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RemoteFollowController < ApplicationController
|
||||
layout 'public'
|
||||
layout 'modal'
|
||||
|
||||
before_action :set_account
|
||||
before_action :gone, if: :suspended_account?
|
||||
|
||||
@@ -35,10 +35,12 @@ class Settings::PreferencesController < ApplicationController
|
||||
params.require(:user).permit(
|
||||
:setting_default_privacy,
|
||||
:setting_default_sensitive,
|
||||
:setting_unfollow_modal,
|
||||
:setting_boost_modal,
|
||||
:setting_delete_modal,
|
||||
:setting_auto_play_gif,
|
||||
:setting_system_font_ui,
|
||||
:setting_noindex,
|
||||
notification_emails: %i(follow follow_request reblog favourite mention digest),
|
||||
interactions: %i(must_be_follower must_be_following)
|
||||
)
|
||||
|
||||
17
app/controllers/settings/sessions_controller.rb
Normal file
17
app/controllers/settings/sessions_controller.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Settings::SessionsController < ApplicationController
|
||||
before_action :set_session, only: :destroy
|
||||
|
||||
def destroy
|
||||
@session.destroy!
|
||||
flash[:notice] = I18n.t('sessions.revoke_success')
|
||||
redirect_to edit_user_registration_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_session
|
||||
@session = current_user.session_activations.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -18,7 +18,7 @@ module Settings
|
||||
end
|
||||
|
||||
def destroy
|
||||
if current_user.validate_and_consume_otp!(confirmation_params[:code])
|
||||
if acceptable_code?
|
||||
current_user.otp_required_for_login = false
|
||||
current_user.save!
|
||||
redirect_to settings_two_factor_authentication_path
|
||||
@@ -38,5 +38,10 @@ module Settings
|
||||
def verify_otp_required
|
||||
redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login?
|
||||
end
|
||||
|
||||
def acceptable_code?
|
||||
current_user.validate_and_consume_otp!(confirmation_params[:code]) ||
|
||||
current_user.invalidate_otp_backup_code!(confirmation_params[:code])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,10 +11,22 @@ class StatusesController < ApplicationController
|
||||
before_action :check_account_suspension
|
||||
|
||||
def show
|
||||
@ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
|
||||
@descendants = cache_collection(@status.descendants(current_account), Status)
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
@ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
|
||||
@descendants = cache_collection(@status.descendants(current_account), Status)
|
||||
|
||||
render 'stream_entries/show'
|
||||
render 'stream_entries/show'
|
||||
end
|
||||
|
||||
format.json do
|
||||
render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def activity
|
||||
render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
class StreamEntriesController < ApplicationController
|
||||
include Authorization
|
||||
include SignatureVerification
|
||||
|
||||
layout 'public'
|
||||
|
||||
@@ -18,7 +19,7 @@ class StreamEntriesController < ApplicationController
|
||||
end
|
||||
|
||||
format.atom do
|
||||
render xml: AtomSerializer.render(AtomSerializer.new.entry(@stream_entry, true))
|
||||
render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,26 @@ class TagsController < ApplicationController
|
||||
|
||||
def show
|
||||
@tag = Tag.find_by!(name: params[:id].downcase)
|
||||
@statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
|
||||
@statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
|
||||
@statuses = cache_collection(@statuses, Status)
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
|
||||
format.json do
|
||||
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: tag_url(@tag),
|
||||
type: :ordered,
|
||||
size: @tag.statuses.count,
|
||||
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Activitystreams2BuilderHelper
|
||||
# Gets a usable name for an account, using display name or username.
|
||||
def account_name(account)
|
||||
account.display_name.presence || account.username
|
||||
end
|
||||
end
|
||||
24
app/helpers/emoji_helper.rb
Normal file
24
app/helpers/emoji_helper.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EmojiHelper
|
||||
def emojify(text)
|
||||
return text if text.blank?
|
||||
|
||||
text.gsub(emoji_pattern) do |match|
|
||||
emoji = Emoji.instance.unicode($1) # rubocop:disable Style/PerlBackrefs
|
||||
|
||||
if emoji
|
||||
emoji
|
||||
else
|
||||
match
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def emoji_pattern
|
||||
@emoji_pattern ||=
|
||||
/(?<=[^[:alnum:]:]|\n|^)
|
||||
(#{Emoji.instance.names.map { |name| Regexp.escape(name) }.join('|')})
|
||||
(?=[^[:alnum:]:]|$)/x
|
||||
end
|
||||
end
|
||||
@@ -1,17 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HttpHelper
|
||||
def http_client(options = {})
|
||||
timeout = { write: 10, connect: 10, read: 10 }.merge(options)
|
||||
|
||||
HTTP.headers(user_agent: user_agent)
|
||||
.timeout(:per_operation, timeout)
|
||||
.follow
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_agent
|
||||
@user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +http://#{Rails.configuration.x.local_domain}/)"
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
module InstanceHelper
|
||||
def site_title
|
||||
Setting.site_title.to_s
|
||||
Setting.site_title.presence || site_hostname
|
||||
end
|
||||
|
||||
def site_hostname
|
||||
|
||||
@@ -11,7 +11,7 @@ module RoutingHelper
|
||||
end
|
||||
end
|
||||
|
||||
def full_asset_url(source)
|
||||
Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source)).to_s
|
||||
def full_asset_url(source, options = {})
|
||||
Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source, options)).to_s
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
/*
|
||||
|
||||
`actions/local_settings`
|
||||
========================
|
||||
|
||||
> For more information on the contents of this file, please contact:
|
||||
>
|
||||
> - kibigo! [@kibi@glitch.social]
|
||||
|
||||
This file provides our Redux actions related to local settings. It
|
||||
consists of the following:
|
||||
|
||||
- __`changesLocalSetting(key, value)` :__
|
||||
Changes the local setting with the given `key` to the given
|
||||
`value`. `key` **MUST** be an array of strings, as required by
|
||||
`Immutable.Map.prototype.getIn()`.
|
||||
|
||||
- __`saveLocalSettings()` :__
|
||||
Saves the local settings to `localStorage` as a JSON object. We
|
||||
shouldn't ever need to call this ourselves.
|
||||
|
||||
*/
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Constants:
|
||||
----------
|
||||
|
||||
We provide the following constants:
|
||||
|
||||
- __`LOCAL_SETTING_CHANGE` :__
|
||||
This string constant is used to dispatch a setting change to our
|
||||
reducer in `reducers/local_settings`, where the setting is
|
||||
actually changed.
|
||||
|
||||
*/
|
||||
|
||||
export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
`changeLocalSetting(key, value)`:
|
||||
---------------------------------
|
||||
|
||||
Changes the local setting with the given `key` to the given `value`.
|
||||
`key` **MUST** be an array of strings, as required by
|
||||
`Immutable.Map.prototype.getIn()`.
|
||||
|
||||
To accomplish this, we just dispatch a `LOCAL_SETTING_CHANGE` to our
|
||||
reducer in `reducers/local_settings`.
|
||||
|
||||
*/
|
||||
|
||||
export function changeLocalSetting(key, value) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
@@ -12,6 +67,24 @@ export function changeLocalSetting(key, value) {
|
||||
};
|
||||
};
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
`saveLocalSettings()`:
|
||||
----------------------
|
||||
|
||||
Saves the local settings to `localStorage` as a JSON object.
|
||||
`changeLocalSetting()` calls this whenever it changes a setting. We
|
||||
shouldn't ever need to call this ourselves.
|
||||
|
||||
> __TODO :__
|
||||
> Right now `saveLocalSettings()` doesn't keep track of which user
|
||||
> is currently signed in, but it might be better to give each user
|
||||
> their *own* local settings.
|
||||
|
||||
*/
|
||||
|
||||
export function saveLocalSettings() {
|
||||
return (_, getState) => {
|
||||
const localSettings = getState().get('local_settings').toJS();
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
/*
|
||||
|
||||
`<AccountHeader>`
|
||||
=================
|
||||
|
||||
> For more information on the contents of this file, please contact:
|
||||
>
|
||||
> - kibigo! [@kibi@glitch.social]
|
||||
|
||||
Original file by @gargron@mastodon.social et al as part of
|
||||
tootsuite/mastodon. We've expanded it in order to handle user bio
|
||||
frontmatter.
|
||||
|
||||
The `<AccountHeader>` component provides the header for account
|
||||
timelines. It is a fairly simple component which mostly just consists
|
||||
of a `render()` method.
|
||||
|
||||
__Props:__
|
||||
|
||||
- __`account` (`ImmutablePropTypes.map`) :__
|
||||
The account to render a header for.
|
||||
|
||||
- __`me` (`PropTypes.number.isRequired`) :__
|
||||
The id of the currently-signed-in account.
|
||||
|
||||
- __`onFollow` (`PropTypes.func.isRequired`) :__
|
||||
The function to call when the user clicks the "follow" button.
|
||||
|
||||
- __`intl` (`PropTypes.object.isRequired`) :__
|
||||
Our internationalization object, inserted by `@injectIntl`.
|
||||
|
||||
*/
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Imports:
|
||||
--------
|
||||
|
||||
*/
|
||||
|
||||
// Package imports //
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
@@ -14,25 +56,63 @@ import Avatar from '../../../mastodon/components/avatar';
|
||||
// Our imports //
|
||||
import { processBio } from '../../util/bio_metadata';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Inital setup:
|
||||
-------------
|
||||
|
||||
The `messages` constant is used to define any messages that we need
|
||||
from inside props. In our case, these are the `unfollow`, `follow`, and
|
||||
`requested` messages used in the `title` of our buttons.
|
||||
|
||||
*/
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||
});
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Implementation:
|
||||
---------------
|
||||
|
||||
*/
|
||||
|
||||
@injectIntl
|
||||
export default class Header extends ImmutablePureComponent {
|
||||
export default class AccountHeader extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map,
|
||||
me: PropTypes.number.isRequired,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
account : ImmutablePropTypes.map,
|
||||
me : PropTypes.number.isRequired,
|
||||
onFollow : PropTypes.func.isRequired,
|
||||
intl : PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
### `render()`
|
||||
|
||||
The `render()` function is used to render our component.
|
||||
|
||||
*/
|
||||
|
||||
render () {
|
||||
const { account, me, intl } = this.props;
|
||||
|
||||
/*
|
||||
|
||||
If no `account` is provided, then we can't render a header. Otherwise,
|
||||
we get the `displayName` for the account, if available. If it's blank,
|
||||
then we set the `displayName` to just be the `username` of the account.
|
||||
|
||||
*/
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
@@ -40,17 +120,30 @@ export default class Header extends ImmutablePureComponent {
|
||||
let displayName = account.get('display_name');
|
||||
let info = '';
|
||||
let actionBtn = '';
|
||||
let lockedIcon = '';
|
||||
let following = false;
|
||||
|
||||
if (displayName.length === 0) {
|
||||
displayName = account.get('username');
|
||||
}
|
||||
|
||||
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
|
||||
info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
|
||||
}
|
||||
/*
|
||||
|
||||
Next, we handle the account relationships. If the account follows the
|
||||
user, then we add an `info` message. If the user has requested a
|
||||
follow, then we disable the `actionBtn` and display an hourglass.
|
||||
Otherwise, if the account isn't blocked, we set the `actionBtn` to the
|
||||
appropriate icon.
|
||||
|
||||
*/
|
||||
|
||||
if (me !== account.get('id')) {
|
||||
if (account.getIn(['relationship', 'followed_by'])) {
|
||||
info = (
|
||||
<span className='account--follows-info'>
|
||||
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (account.getIn(['relationship', 'requested'])) {
|
||||
actionBtn = (
|
||||
<div className='account--action-button'>
|
||||
@@ -58,30 +151,60 @@ export default class Header extends ImmutablePureComponent {
|
||||
</div>
|
||||
);
|
||||
} else if (!account.getIn(['relationship', 'blocking'])) {
|
||||
following = account.getIn(['relationship', 'following']);
|
||||
actionBtn = (
|
||||
<div className='account--action-button'>
|
||||
<IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
|
||||
<IconButton
|
||||
size={26}
|
||||
icon={following ? 'user-times' : 'user-plus'}
|
||||
active={following}
|
||||
title={intl.formatMessage(following ? messages.unfollow : messages.follow)}
|
||||
onClick={this.props.onFollow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.get('locked')) {
|
||||
lockedIcon = <i className='fa fa-lock' />;
|
||||
}
|
||||
/*
|
||||
|
||||
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
|
||||
`displayNameHTML` processes the `displayName` and prepares it for
|
||||
insertion into the document. Meanwhile, we extract the `text` and
|
||||
`metadata` from our account's `note` using `processBio()`.
|
||||
|
||||
*/
|
||||
|
||||
const displayNameHTML = {
|
||||
__html : emojify(escapeTextContentForBrowser(displayName)),
|
||||
};
|
||||
const { text, metadata } = processBio(account.get('note'));
|
||||
|
||||
/*
|
||||
|
||||
Here, we render our component using all the things we've defined above.
|
||||
|
||||
*/
|
||||
|
||||
return (
|
||||
<div className='account__header__wrapper'>
|
||||
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
|
||||
<div
|
||||
className='account__header'
|
||||
style={{ backgroundImage: `url(${account.get('header')})` }}
|
||||
>
|
||||
<div>
|
||||
<a href={account.get('url')} target='_blank' rel='noopener'>
|
||||
<span className='account__header__avatar'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={90} /></span>
|
||||
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
|
||||
<span className='account__header__avatar'>
|
||||
<Avatar account={account} size={90} />
|
||||
</span>
|
||||
<span
|
||||
className='account__header__display-name'
|
||||
dangerouslySetInnerHTML={displayNameHTML}
|
||||
/>
|
||||
</a>
|
||||
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
|
||||
<span className='account__header__username'>
|
||||
@{account.get('acct')}
|
||||
{account.get('locked') ? <i className='fa fa-lock' /> : null}
|
||||
</span>
|
||||
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
|
||||
|
||||
{info}
|
||||
@@ -91,18 +214,20 @@ export default class Header extends ImmutablePureComponent {
|
||||
|
||||
{metadata.length && (
|
||||
<table className='account__metadata'>
|
||||
{(() => {
|
||||
let data = [];
|
||||
for (let i = 0; i < metadata.length; i++) {
|
||||
data.push(
|
||||
<tr key={i}>
|
||||
<th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
|
||||
<td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return data;
|
||||
})()}
|
||||
<tbody>
|
||||
{(() => {
|
||||
let data = [];
|
||||
for (let i = 0; i < metadata.length; i++) {
|
||||
data.push(
|
||||
<tr key={i}>
|
||||
<th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
|
||||
<td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return data;
|
||||
})()}
|
||||
</tbody>
|
||||
</table>
|
||||
) || null}
|
||||
</div>
|
||||
|
||||
113
app/javascript/glitch/components/common/avatar/index.js
Normal file
113
app/javascript/glitch/components/common/avatar/index.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// <CommonAvatar>
|
||||
// ========
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/common/avatar
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
|
||||
export default class CommonAvatar extends React.PureComponent {
|
||||
|
||||
// Props and state.
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
animate: PropTypes.bool,
|
||||
circular: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
comrade: ImmutablePropTypes.map,
|
||||
}
|
||||
state = {
|
||||
hovering: false,
|
||||
}
|
||||
|
||||
// Starts or stops animation on hover.
|
||||
handleMouseEnter = () => {
|
||||
if (this.props.animate) return;
|
||||
this.setState({ hovering: true });
|
||||
}
|
||||
handleMouseLeave = () => {
|
||||
if (this.props.animate) return;
|
||||
this.setState({ hovering: false });
|
||||
}
|
||||
|
||||
// Renders the component.
|
||||
render () {
|
||||
const {
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
} = this;
|
||||
const {
|
||||
account,
|
||||
animate,
|
||||
circular,
|
||||
className,
|
||||
comrade,
|
||||
...others
|
||||
} = this.props;
|
||||
const { hovering } = this.state;
|
||||
const computedClass = classNames('glitch', 'glitch__common__avatar', {
|
||||
_circular: circular,
|
||||
}, className);
|
||||
|
||||
// We store the image srcs here for later.
|
||||
const src = account.get('avatar');
|
||||
const staticSrc = account.get('avatar_static');
|
||||
const comradeSrc = comrade ? comrade.get('avatar') : null;
|
||||
const comradeStaticSrc = comrade ? comrade.get('avatar_static') : null;
|
||||
|
||||
// Avatars are a straightforward div with image(s) inside.
|
||||
return comrade ? (
|
||||
<div
|
||||
className={computedClass}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...others}
|
||||
>
|
||||
<img
|
||||
className='avatar\main'
|
||||
src={hovering || animate ? src : staticSrc}
|
||||
alt=''
|
||||
/>
|
||||
<img
|
||||
className='avatar\comrade'
|
||||
src={hovering || animate ? comradeSrc : comradeStaticSrc}
|
||||
alt=''
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={computedClass}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...others}
|
||||
>
|
||||
<img
|
||||
className='avatar\solo'
|
||||
src={hovering || animate ? src : staticSrc}
|
||||
alt=''
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
41
app/javascript/glitch/components/common/avatar/style.scss
Normal file
41
app/javascript/glitch/components/common/avatar/style.scss
Normal file
@@ -0,0 +1,41 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.glitch__common__avatar {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
& > img {
|
||||
display: block;
|
||||
position: static;
|
||||
margin: 0;
|
||||
border-radius: $ui-avatar-border-size;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.avatar\\comrade {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&.avatar\\main {
|
||||
margin: 0 30% 30% 0;
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
&._circular {
|
||||
& > img {
|
||||
transition: border-radius ($glitch-animation-speed * .3s);
|
||||
}
|
||||
|
||||
&:not(:hover) {
|
||||
& > img {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
146
app/javascript/glitch/components/common/button/index.js
Normal file
146
app/javascript/glitch/components/common/button/index.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// <CommonButton>
|
||||
// ========
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/common/button
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// Our imports.
|
||||
import CommonLink from 'glitch/components/common/link';
|
||||
import CommonIcon from 'glitch/components/common/icon';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
|
||||
export default class CommonButton extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
animate: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
href: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
showTitle: PropTypes.bool,
|
||||
title: PropTypes.string,
|
||||
}
|
||||
state = {
|
||||
loaded: false,
|
||||
}
|
||||
|
||||
// The `loaded` state property activates our animations. We wait
|
||||
// until an activation change in order to prevent unsightly
|
||||
// animations when the component first mounts.
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const { active } = this.props;
|
||||
|
||||
// The double "not"s here cast both arguments to booleans.
|
||||
if (!nextProps.active !== !active) this.setState({ loaded: true });
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
const { onClick } = this.props;
|
||||
if (!onClick) return;
|
||||
onClick(e);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Rendering the component.
|
||||
render () {
|
||||
const { handleClick } = this;
|
||||
const {
|
||||
active,
|
||||
animate,
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
href,
|
||||
icon,
|
||||
onClick,
|
||||
showTitle,
|
||||
title,
|
||||
...others
|
||||
} = this.props;
|
||||
const { loaded } = this.state;
|
||||
const computedClass = classNames('glitch', 'glitch__common__button', className, {
|
||||
_active: active && !href, // Links can't be active
|
||||
_animated: animate && loaded,
|
||||
_disabled: disabled,
|
||||
_link: href,
|
||||
_star: icon === 'star',
|
||||
'_with-text': children || title && showTitle,
|
||||
});
|
||||
let conditionalProps = {};
|
||||
|
||||
// If href is provided, we render a link.
|
||||
if (href) {
|
||||
if (!disabled && href) conditionalProps.href = href;
|
||||
if (title && !showTitle) {
|
||||
if (!children) conditionalProps.title = title;
|
||||
else conditionalProps['aria-label'] = title;
|
||||
}
|
||||
if (onClick) {
|
||||
if (!disabled) conditionalProps.onClick = handleClick;
|
||||
else conditionalProps['aria-disabled'] = true;
|
||||
conditionalProps.role = 'button';
|
||||
conditionalProps.tabIndex = 0;
|
||||
}
|
||||
return (
|
||||
<CommonLink
|
||||
className={computedClass}
|
||||
{...conditionalProps}
|
||||
{...others}
|
||||
>
|
||||
{children}
|
||||
{title && showTitle ? <span className='button\title'>{title}</span> : null}
|
||||
<CommonIcon name={icon} className='button\icon' />
|
||||
</CommonLink>
|
||||
);
|
||||
|
||||
// Otherwise, we render a button.
|
||||
} else {
|
||||
if (active !== void 0) conditionalProps['aria-pressed'] = active;
|
||||
if (title && !showTitle) {
|
||||
if (!children) conditionalProps.title = title;
|
||||
else conditionalProps['aria-label'] = title;
|
||||
}
|
||||
if (onClick && !disabled) {
|
||||
conditionalProps.onClick = handleClick;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={computedClass}
|
||||
{...conditionalProps}
|
||||
disabled={disabled}
|
||||
{...others}
|
||||
tabIndex='0'
|
||||
type='button'
|
||||
>
|
||||
{children}
|
||||
{title && showTitle ? <span className='button\title'>{title}</span> : null}
|
||||
<CommonIcon name={icon} className='button\icon' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
134
app/javascript/glitch/components/common/button/style.scss
Normal file
134
app/javascript/glitch/components/common/button/style.scss
Normal file
@@ -0,0 +1,134 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.glitch__common__button {
|
||||
display: inline-block;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: $ui-base-lighter-color;
|
||||
background: transparent;
|
||||
outline: thin transparent dotted;
|
||||
font-size: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color ($glitch-animation-speed * .15s) ease-in, outline-color ($glitch-animation-speed * .3s) ease-in-out;
|
||||
|
||||
&._animated .button\\icon {
|
||||
animation-name: glitch__common__button__deactivate;
|
||||
animation-duration: .9s;
|
||||
animation-timing-function: ease-in-out;
|
||||
|
||||
@keyframes glitch__common__button__deactivate {
|
||||
from {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
57% {
|
||||
transform: rotate(-60deg);
|
||||
}
|
||||
86% {
|
||||
transform: rotate(30deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&._active {
|
||||
.button\\icon {
|
||||
color: $ui-highlight-color;
|
||||
}
|
||||
|
||||
&._animated .button\\icon {
|
||||
animation-name: glitch__common__button__activate;
|
||||
|
||||
@keyframes glitch__common__button__activate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
57% {
|
||||
transform: rotate(420deg); // Blazin' 😎
|
||||
}
|
||||
86% {
|
||||
transform: rotate(330deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The special `._star` class is given to buttons which have a star
|
||||
icon (see JS). When they are active, we give them a gold star ⭐️.
|
||||
*/
|
||||
&._star .button\\icon {
|
||||
color: $gold-star;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
For links, we consider them disabled if they don't have an `href`
|
||||
attribute (see JS).
|
||||
*/
|
||||
&._disabled {
|
||||
opacity: $glitch-disabled-opacity;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/*
|
||||
This is confusing becuase of the names, but the `color .3 ease-out`
|
||||
transition is actually used when easing *in* to a hovering/active/
|
||||
focusing state, and the default transition is used when leaving. Our
|
||||
buttons are a little slower to glow than they are to fade.
|
||||
*/
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: $glitch-lighter-color;
|
||||
transition: color ($glitch-animation-speed * .3s) ease-out, outline-color ($glitch-animation-speed * .15s) ease-in-out;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: currentColor;
|
||||
}
|
||||
|
||||
/*
|
||||
Buttons with text have a number of different styling rules and an
|
||||
overall different appearance.
|
||||
*/
|
||||
&._with-text {
|
||||
display: inline-block;
|
||||
border: none;
|
||||
border-radius: .35em;
|
||||
padding: 0 .5em;
|
||||
color: $glitch-texture-color;
|
||||
background: $ui-base-lighter-color;
|
||||
font-size: .75em;
|
||||
font-weight: inherit;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.6;
|
||||
cursor: pointer;
|
||||
vertical-align: baseline;
|
||||
transition: background-color ($glitch-animation-speed * .15s) ease-in, outline-color ($glitch-animation-speed * .3s) ease-in-out;
|
||||
|
||||
.button\\icon {
|
||||
display: inline-block;
|
||||
font-size: 1.25em;
|
||||
vertical-align: -.1em;
|
||||
}
|
||||
|
||||
& > *:not(:first-child) {
|
||||
margin: 0 0 0 .4em;
|
||||
border-left: 1px solid currentColor;
|
||||
padding: 0 0 0 .3em;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: $glitch-texture-color;
|
||||
background: $glitch-lighter-color;
|
||||
transition: background-color ($glitch-animation-speed * .3s) ease-out, outline-color ($glitch-animation-speed * .15s) ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/javascript/glitch/components/common/icon/index.js
Normal file
59
app/javascript/glitch/components/common/icon/index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// <CommonIcon>
|
||||
// ========
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/common/icon
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
|
||||
const CommonIcon = ({
|
||||
className,
|
||||
name,
|
||||
proportional,
|
||||
title,
|
||||
...others
|
||||
}) => name ? (
|
||||
<span
|
||||
className={classNames('glitch', 'glitch__common__icon', className)}
|
||||
{...others}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`fa ${proportional ? '' : 'fa-fw'} fa-${name} icon\fa`}
|
||||
{...(title ? { title } : {})}
|
||||
/>
|
||||
{title ? (
|
||||
<span className='_for-screenreader'>{title}</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
// Props.
|
||||
CommonIcon.propTypes = {
|
||||
className: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
proportional: PropTypes.bool,
|
||||
title: PropTypes.string,
|
||||
};
|
||||
|
||||
// Export.
|
||||
export default CommonIcon;
|
||||
14
app/javascript/glitch/components/common/icon/style.scss
Normal file
14
app/javascript/glitch/components/common/icon/style.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.glitch__common__icon {
|
||||
display: inline-block;
|
||||
|
||||
._for-screenreader {
|
||||
position: absolute;
|
||||
margin: -1px -1px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
74
app/javascript/glitch/components/common/link/index.js
Normal file
74
app/javascript/glitch/components/common/link/index.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// <CommonLink>
|
||||
// ========
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/common/link
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
|
||||
export default class CommonLink extends React.PureComponent {
|
||||
|
||||
// Props.
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
destination: PropTypes.string,
|
||||
history: PropTypes.object,
|
||||
href: PropTypes.string,
|
||||
};
|
||||
|
||||
// We only reroute the link if it is an unadorned click, we have
|
||||
// access to the router, and there is somewhere to reroute it *to*.
|
||||
handleClick = (e) => {
|
||||
const { destination, history } = this.props;
|
||||
if (!history || !destination || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
|
||||
history.push(destination);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Rendering.
|
||||
render () {
|
||||
const { handleClick } = this;
|
||||
const { children, className, destination, history, href, ...others } = this.props;
|
||||
const computedClass = classNames('glitch', 'glitch__common__link', className);
|
||||
const conditionalProps = {};
|
||||
if (href) {
|
||||
conditionalProps.href = href;
|
||||
conditionalProps.onClick = handleClick;
|
||||
} else if (destination) {
|
||||
conditionalProps.onClick = handleClick;
|
||||
conditionalProps.role = 'link';
|
||||
conditionalProps.tabIndex = 0;
|
||||
} else conditionalProps.role = 'presentation';
|
||||
|
||||
return (
|
||||
<a
|
||||
className={computedClass}
|
||||
{...conditionalProps}
|
||||
{...others}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
>{children}</a>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
11
app/javascript/glitch/components/common/link/style.scss
Normal file
11
app/javascript/glitch/components/common/link/style.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
@import 'variables';
|
||||
|
||||
/*
|
||||
Most link styling happens elsewhere but we disable text-decoration
|
||||
here.
|
||||
*/
|
||||
.glitch.glitch__common__link {
|
||||
display: inline;
|
||||
color: $ui-secondary-color;
|
||||
text-decoration: none;
|
||||
}
|
||||
49
app/javascript/glitch/components/common/separator/index.js
Normal file
49
app/javascript/glitch/components/common/separator/index.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// <CommonSeparator>
|
||||
// ========
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/common/separator
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
|
||||
const CommonSeparator = ({
|
||||
className,
|
||||
visible,
|
||||
...others
|
||||
}) => visible ? (
|
||||
<span
|
||||
className={
|
||||
classNames('glitch', 'glitch__common__separator', className)
|
||||
}
|
||||
{...others}
|
||||
role='separator'
|
||||
/> // Contents provided via CSS.
|
||||
) : null;
|
||||
|
||||
// Props.
|
||||
CommonSeparator.propTypes = {
|
||||
className: PropTypes.string,
|
||||
visible: PropTypes.bool,
|
||||
};
|
||||
|
||||
// Export.
|
||||
export default CommonSeparator;
|
||||
15
app/javascript/glitch/components/common/separator/style.scss
Normal file
15
app/javascript/glitch/components/common/separator/style.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
@import 'variables';
|
||||
|
||||
/*
|
||||
The default contents for a separator is an interpunct, surrounded by
|
||||
spaces. However, this can be changed using CSS selectors.
|
||||
*/
|
||||
.glitch.glitch__common__separator {
|
||||
display: inline-block;
|
||||
|
||||
&::after {
|
||||
display: inline-block;
|
||||
padding: 0 .3em;
|
||||
content: "·";
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// Package imports //
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Toggle from 'react-toggle';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
// Mastodon imports //
|
||||
import IconButton from '../../../mastodon/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
local_only_short: { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
|
||||
local_only_long: { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
|
||||
advanced_options_icon_title: { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
|
||||
});
|
||||
|
||||
const iconStyle = {
|
||||
height: null,
|
||||
lineHeight: '27px',
|
||||
};
|
||||
|
||||
@injectIntl
|
||||
export default class ComposeAdvancedOptions extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
values: ImmutablePropTypes.contains({
|
||||
do_not_federate: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
onToggleDropdown = () => {
|
||||
this.setState({ open: !this.state.open });
|
||||
};
|
||||
|
||||
onGlobalClick = (e) => {
|
||||
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
|
||||
this.setState({ open: false });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
window.addEventListener('click', this.onGlobalClick);
|
||||
window.addEventListener('touchstart', this.onGlobalClick);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('click', this.onGlobalClick);
|
||||
window.removeEventListener('touchstart', this.onGlobalClick);
|
||||
}
|
||||
|
||||
state = {
|
||||
open: false,
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
const option = e.currentTarget.getAttribute('data-index');
|
||||
e.preventDefault();
|
||||
this.props.onChange(option);
|
||||
}
|
||||
|
||||
toggleHandler(option) {
|
||||
return () => this.props.onChange(option);
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { open } = this.state;
|
||||
const { intl, values } = this.props;
|
||||
|
||||
const options = [
|
||||
{ icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, key: 'do_not_federate' },
|
||||
];
|
||||
|
||||
const anyEnabled = values.some((enabled) => enabled);
|
||||
const optionElems = options.map((option) => {
|
||||
const active = values.get(option.key);
|
||||
return (
|
||||
<div role='button' className='advanced-options-dropdown__option' key={option.key} >
|
||||
<div className='advanced-options-dropdown__option__toggle'>
|
||||
<Toggle checked={active} onChange={this.toggleHandler(option.key)} />
|
||||
</div>
|
||||
<div className='advanced-options-dropdown__option__content'>
|
||||
<strong>{intl.formatMessage(option.shortText)}</strong>
|
||||
{intl.formatMessage(option.longText)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (<div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${anyEnabled ? 'active' : ''} `}>
|
||||
<div className='advanced-options-dropdown__value'>
|
||||
<IconButton
|
||||
className='advanced-options-dropdown__value'
|
||||
title={intl.formatMessage(messages.advanced_options_icon_title)}
|
||||
icon='ellipsis-h' active={open || anyEnabled}
|
||||
size={18}
|
||||
style={iconStyle}
|
||||
onClick={this.onToggleDropdown}
|
||||
/>
|
||||
</div>
|
||||
<div className='advanced-options-dropdown__dropdown'>
|
||||
{optionElems}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
|
||||
`<ComposeAdvancedOptionsContainer>`
|
||||
===================================
|
||||
|
||||
This container connects `<ComposeAdvancedOptions>` to the Redux store.
|
||||
|
||||
*/
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Imports:
|
||||
--------
|
||||
|
||||
*/
|
||||
|
||||
// Package imports //
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// Mastodon imports //
|
||||
import { toggleComposeAdvancedOption } from '../../../../mastodon/actions/compose';
|
||||
|
||||
// Our imports //
|
||||
import ComposeAdvancedOptions from '.';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
State mapping:
|
||||
--------------
|
||||
|
||||
The `mapStateToProps()` function maps various state properties to the
|
||||
props of our component. The only property we care about is
|
||||
`compose.advanced_options`.
|
||||
|
||||
*/
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
values: state.getIn(['compose', 'advanced_options']),
|
||||
});
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Dispatch mapping:
|
||||
-----------------
|
||||
|
||||
The `mapDispatchToProps()` function maps dispatches to our store to the
|
||||
various props of our component. We just need to provide a dispatch for
|
||||
when an advanced option toggle changes.
|
||||
|
||||
*/
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
onChange (option) {
|
||||
dispatch(toggleComposeAdvancedOption(option));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
|
||||
`<ComposeAdvancedOptions>`
|
||||
==========================
|
||||
|
||||
> For more information on the contents of this file, please contact:
|
||||
>
|
||||
> - surinna [@srn@dev.glitch.social]
|
||||
|
||||
This adds an advanced options dropdown to the toot compose box, for
|
||||
toggles that don't necessarily fit elsewhere.
|
||||
|
||||
__Props:__
|
||||
|
||||
- __`values` (`ImmutablePropTypes.contains(…).isRequired`) :__
|
||||
An Immutable map with the following values:
|
||||
|
||||
- __`do_not_federate` (`PropTypes.bool.isRequired`) :__
|
||||
Specifies whether or not to federate the status.
|
||||
|
||||
- __`onChange` (`PropTypes.func.isRequired`) :__
|
||||
The function to call when a toggle is changed. We pass this from
|
||||
our container to the toggle.
|
||||
|
||||
- __`intl` (`PropTypes.object.isRequired`) :__
|
||||
Our internationalization object, inserted by `@injectIntl`.
|
||||
|
||||
__State:__
|
||||
|
||||
- __`open` :__
|
||||
This tells whether the dropdown is currently open or closed.
|
||||
|
||||
*/
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Imports:
|
||||
--------
|
||||
|
||||
*/
|
||||
|
||||
// Package imports //
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
// Mastodon imports //
|
||||
import IconButton from '../../../../mastodon/components/icon_button';
|
||||
|
||||
// Our imports //
|
||||
import ComposeAdvancedOptionsToggle from './toggle';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Inital setup:
|
||||
-------------
|
||||
|
||||
The `messages` constant is used to define any messages that we need
|
||||
from inside props. These are the various titles and labels on our
|
||||
toggles.
|
||||
|
||||
`iconStyle` styles the icon used for the dropdown button.
|
||||
|
||||
*/
|
||||
|
||||
const messages = defineMessages({
|
||||
local_only_short :
|
||||
{ id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
|
||||
local_only_long :
|
||||
{ id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
|
||||
advanced_options_icon_title :
|
||||
{ id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
|
||||
});
|
||||
|
||||
const iconStyle = {
|
||||
height : null,
|
||||
lineHeight : '27px',
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
Implementation:
|
||||
---------------
|
||||
|
||||
*/
|
||||
|
||||
@injectIntl
|
||||
export default class ComposeAdvancedOptions extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
values : ImmutablePropTypes.contains({
|
||||
do_not_federate : PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
onChange : PropTypes.func.isRequired,
|
||||
intl : PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
open: false,
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
### `onToggleDropdown()`
|
||||
|
||||
This function toggles the opening and closing of the advanced options
|
||||
dropdown.
|
||||
|
||||
*/
|
||||
|
||||
onToggleDropdown = () => {
|
||||
this.setState({ open: !this.state.open });
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
### `onGlobalClick(e)`
|
||||
|
||||
This function closes the advanced options dropdown if you click
|
||||
anywhere else on the screen.
|
||||
|
||||
*/
|
||||
|
||||
onGlobalClick = (e) => {
|
||||
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
|
||||
this.setState({ open: false });
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
### `componentDidMount()`, `componentWillUnmount()`
|
||||
|
||||
This function closes the advanced options dropdown if you click
|
||||
anywhere else on the screen.
|
||||
|
||||
*/
|
||||
|
||||
componentDidMount () {
|
||||
window.addEventListener('click', this.onGlobalClick);
|
||||
window.addEventListener('touchstart', this.onGlobalClick);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('click', this.onGlobalClick);
|
||||
window.removeEventListener('touchstart', this.onGlobalClick);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
### `setRef(c)`
|
||||
|
||||
`setRef()` stores a reference to the dropdown's `<div> in `this.node`.
|
||||
|
||||
*/
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
### `render()`
|
||||
|
||||
`render()` actually puts our component on the screen.
|
||||
|
||||
*/
|
||||
|
||||
render () {
|
||||
const { open } = this.state;
|
||||
const { intl, values } = this.props;
|
||||
|
||||
/*
|
||||
|
||||
The `options` array provides all of the available advanced options
|
||||
alongside their icon, text, and name.
|
||||
|
||||
*/
|
||||
const options = [
|
||||
{ icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
|
||||
];
|
||||
|
||||
/*
|
||||
|
||||
`anyEnabled` tells us if any of our advanced options have been enabled.
|
||||
|
||||
*/
|
||||
|
||||
const anyEnabled = values.some((enabled) => enabled);
|
||||
|
||||
/*
|
||||
|
||||
`optionElems` takes our `options` and creates
|
||||
`<ComposeAdvancedOptionsToggle>`s out of them. We use the `name` of the
|
||||
toggle as its `key` so that React can keep track of it.
|
||||
|
||||
*/
|
||||
|
||||
const optionElems = options.map((option) => {
|
||||
return (
|
||||
<ComposeAdvancedOptionsToggle
|
||||
onChange={this.props.onChange}
|
||||
active={values.get(option.name)}
|
||||
key={option.name}
|
||||
name={option.name}
|
||||
shortText={intl.formatMessage(option.shortText)}
|
||||
longText={intl.formatMessage(option.longText)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Finally, we can render our component.
|
||||
|
||||
*/
|
||||
|
||||
return (
|
||||
<div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${anyEnabled ? 'active' : ''} `}>
|
||||
<div className='advanced-options-dropdown__value'>
|
||||
<IconButton
|
||||
className='advanced-options-dropdown__value'
|
||||
title={intl.formatMessage(messages.advanced_options_icon_title)}
|
||||
icon='ellipsis-h' active={open || anyEnabled}
|
||||
size={18}
|
||||
style={iconStyle}
|
||||
onClick={this.onToggleDropdown}
|
||||
/>
|
||||
</div>
|
||||
<div className='advanced-options-dropdown__dropdown'>
|
||||
{optionElems}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
|
||||
`<ComposeAdvancedOptionsToggle>`
|
||||
================================
|
||||
|
||||
> For more information on the contents of this file, please contact:
|
||||
>
|
||||
> - surinna [@srn@dev.glitch.social]
|
||||
|
||||
This creates the toggle used by `<ComposeAdvancedOptions>`.
|
||||
|
||||
__Props:__
|
||||
|
||||
- __`onChange` (`PropTypes.func`) :__
|
||||
This provides the function to call when the toggle is
|
||||
(de-?)activated.
|
||||
|
||||
- __`active` (`PropTypes.bool`) :__
|
||||
This prop controls whether the toggle is currently active or not.
|
||||
|
||||
- __`name` (`PropTypes.string`) :__
|
||||
This identifies the toggle, and is sent to `onChange()` when it is
|
||||
called.
|
||||
|
||||
- __`shortText` (`PropTypes.string`) :__
|
||||
This is a short string used as the title of the toggle.
|
||||
|
||||
- __`longText` (`PropTypes.string`) :__
|
||||
This is a longer string used as a subtitle for the toggle.
|
||||
|
||||
*/
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Imports:
|
||||
--------
|
||||
|
||||
*/
|
||||
|
||||
// Package imports //
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Implementation:
|
||||
---------------
|
||||
|
||||
*/
|
||||
|
||||
export default class ComposeAdvancedOptionsToggle extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
active: PropTypes.bool.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
shortText: PropTypes.string.isRequired,
|
||||
longText: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
### `onToggle()`
|
||||
|
||||
The `onToggle()` function simply calls the `onChange()` prop with the
|
||||
toggle's `name`.
|
||||
|
||||
*/
|
||||
|
||||
onToggle = () => {
|
||||
this.props.onChange(this.props.name);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
### `render()`
|
||||
|
||||
The `render()` function is used to render our component. We just render
|
||||
a `<Toggle>` and place next to it our text.
|
||||
|
||||
*/
|
||||
|
||||
render() {
|
||||
const { active, shortText, longText } = this.props;
|
||||
return (
|
||||
<div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}>
|
||||
<div className='advanced-options-dropdown__option__toggle'>
|
||||
<Toggle checked={active} onChange={this.onToggle} />
|
||||
</div>
|
||||
<div className='advanced-options-dropdown__option__content'>
|
||||
<strong>{shortText}</strong>
|
||||
{longText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// <ListConversationContainer>
|
||||
// =================
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/list/conversation/container
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// Mastodon imports.
|
||||
import { fetchContext } from 'mastodon/actions/statuses';
|
||||
|
||||
// Our imports.
|
||||
import ListConversation from '.';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// State mapping
|
||||
// -------------
|
||||
|
||||
const mapStateToProps = (state, { id }) => {
|
||||
return {
|
||||
ancestors : state.getIn(['contexts', 'ancestors', id]),
|
||||
descendants : state.getIn(['contexts', 'descendants', id]),
|
||||
};
|
||||
};
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Dispatch mapping
|
||||
// ----------------
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
fetch (id) {
|
||||
dispatch(fetchContext(id));
|
||||
},
|
||||
});
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Connecting
|
||||
// ----------
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ListConversation);
|
||||
80
app/javascript/glitch/components/list/conversation/index.js
Normal file
80
app/javascript/glitch/components/list/conversation/index.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// <ListConversation>
|
||||
// ====================
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/list/conversation
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ScrollContainer from 'react-router-scroll';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
// Our imports.
|
||||
import StatusContainer from 'glitch/components/status/container';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
|
||||
export default class ListConversation extends ImmutablePureComponent {
|
||||
|
||||
// Props.
|
||||
static propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
ancestors: ImmutablePropTypes.list,
|
||||
descendants: ImmutablePropTypes.list,
|
||||
fetch: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
// If this is a detailed status, we should fetch its contents and
|
||||
// context upon mounting.
|
||||
componentWillMount () {
|
||||
const { id, fetch } = this.props;
|
||||
fetch(id);
|
||||
}
|
||||
|
||||
// Similarly, if the component receives new props, we need to fetch
|
||||
// the new status.
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const { id, fetch } = this.props;
|
||||
if (nextProps.id !== id) fetch(nextProps.id);
|
||||
}
|
||||
|
||||
// We just render our status inside a column with its
|
||||
// ancestors and decendants.
|
||||
render () {
|
||||
const { id, ancestors, descendants } = this.props;
|
||||
return (
|
||||
<ScrollContainer scrollKey='thread'>
|
||||
<div className='glitch glitch__list__conversation scrollable'>
|
||||
{ancestors && ancestors.size > 0 ? (
|
||||
ancestors.map(
|
||||
ancestor => <StatusContainer key={ancestor} id={ancestor} route />
|
||||
)
|
||||
) : null}
|
||||
<StatusContainer key={id} id={id} detailed route />
|
||||
{descendants && descendants.size > 0 ? (
|
||||
descendants.map(
|
||||
descendant => <StatusContainer key={descendant} id={descendant} route />
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
|
||||
`<NotificationPurgeButtonsContainer>`
|
||||
=========================
|
||||
|
||||
This container connects `<NotificationPurgeButtons>`s to the Redux store.
|
||||
|
||||
*/
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Imports:
|
||||
--------
|
||||
|
||||
*/
|
||||
|
||||
// Package imports //
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// Our imports //
|
||||
import NotificationPurgeButtons from './notification_purge_buttons';
|
||||
import {
|
||||
deleteMarkedNotifications,
|
||||
enterNotificationClearingMode,
|
||||
markAllNotifications,
|
||||
} from '../../../../mastodon/actions/notifications';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { openModal } from '../../../../mastodon/actions/modal';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Dispatch mapping:
|
||||
-----------------
|
||||
|
||||
The `mapDispatchToProps()` function maps dispatches to our store to the
|
||||
various props of our component. We only need to provide a dispatch for
|
||||
deleting notifications.
|
||||
|
||||
*/
|
||||
|
||||
const messages = defineMessages({
|
||||
clearMessage: { id: 'notifications.marked_clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all selected notifications?' },
|
||||
clearConfirm: { id: 'notifications.marked_clear', defaultMessage: 'Clear selected notifications' },
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onEnterCleaningMode(yes) {
|
||||
dispatch(enterNotificationClearingMode(yes));
|
||||
},
|
||||
|
||||
onDeleteMarked() {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.clearMessage),
|
||||
confirm: intl.formatMessage(messages.clearConfirm),
|
||||
onConfirm: () => dispatch(deleteMarkedNotifications()),
|
||||
}));
|
||||
},
|
||||
|
||||
onMarkAll() {
|
||||
dispatch(markAllNotifications(true));
|
||||
},
|
||||
|
||||
onMarkNone() {
|
||||
dispatch(markAllNotifications(false));
|
||||
},
|
||||
|
||||
onInvert() {
|
||||
dispatch(markAllNotifications(null));
|
||||
},
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
markNewForDelete: state.getIn(['notifications', 'markNewForDelete']),
|
||||
});
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons));
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Buttons widget for controlling the notification clearing mode.
|
||||
* In idle state, the cleaning mode button is shown. When the mode is active,
|
||||
* a Confirm and Abort buttons are shown in its place.
|
||||
*/
|
||||
|
||||
|
||||
// Package imports //
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
// Mastodon imports //
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
const messages = defineMessages({
|
||||
btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' },
|
||||
btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' },
|
||||
btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' },
|
||||
btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class NotificationPurgeButtons extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onDeleteMarked : PropTypes.func.isRequired,
|
||||
onMarkAll : PropTypes.func.isRequired,
|
||||
onMarkNone : PropTypes.func.isRequired,
|
||||
onInvert : PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
markNewForDelete: PropTypes.bool,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, markNewForDelete } = this.props;
|
||||
|
||||
//className='active'
|
||||
return (
|
||||
<div className='column-header__notif-cleaning-buttons'>
|
||||
<button onClick={this.props.onMarkAll} className={markNewForDelete ? 'active' : ''}>
|
||||
<b>∀</b><br />{intl.formatMessage(messages.btnAll)}
|
||||
</button>
|
||||
|
||||
<button onClick={this.props.onMarkNone} className={!markNewForDelete ? 'active' : ''}>
|
||||
<b>∅</b><br />{intl.formatMessage(messages.btnNone)}
|
||||
</button>
|
||||
|
||||
<button onClick={this.props.onInvert}>
|
||||
<b>¬</b><br />{intl.formatMessage(messages.btnInvert)}
|
||||
</button>
|
||||
|
||||
<button onClick={this.props.onDeleteMarked}>
|
||||
<i className='fa fa-trash' /><br />{intl.formatMessage(messages.btnApply)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
209
app/javascript/glitch/components/list/statuses/index.js
Normal file
209
app/javascript/glitch/components/list/statuses/index.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import PropTypes from 'prop-types';
|
||||
import IntersectionObserverWrapper from 'mastodon/features/ui/util/intersection_observer_wrapper';
|
||||
import { throttle } from 'lodash';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import StatusContainer from 'glitch/components/status/container';
|
||||
import CommonButton from 'glitch/components/common/button';
|
||||
|
||||
const messages = defineMessages({
|
||||
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class ListStatuses extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
scrollKey: PropTypes.string.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
onScrollToBottom: PropTypes.func,
|
||||
onScrollToTop: PropTypes.func,
|
||||
onScroll: PropTypes.func,
|
||||
trackScroll: PropTypes.bool,
|
||||
shouldUpdateScroll: PropTypes.func,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
prepend: PropTypes.node,
|
||||
emptyMessage: PropTypes.node,
|
||||
};
|
||||
static defaultProps = {
|
||||
trackScroll: true,
|
||||
};
|
||||
state = {
|
||||
currentDetail: null,
|
||||
};
|
||||
|
||||
intersectionObserverWrapper = new IntersectionObserverWrapper();
|
||||
|
||||
handleScroll = throttle(() => {
|
||||
if (this.node) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = this.node;
|
||||
const offset = scrollHeight - scrollTop - clientHeight;
|
||||
this._oldScrollPosition = scrollHeight - scrollTop;
|
||||
|
||||
if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
|
||||
this.props.onScrollToBottom();
|
||||
} else if (scrollTop < 100 && this.props.onScrollToTop) {
|
||||
this.props.onScrollToTop();
|
||||
} else if (this.props.onScroll) {
|
||||
this.props.onScroll();
|
||||
}
|
||||
}
|
||||
}, 150, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
componentDidMount () {
|
||||
this.attachScrollListener();
|
||||
this.attachIntersectionObserver();
|
||||
|
||||
// Handle initial scroll posiiton
|
||||
this.handleScroll();
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
// Reset the scroll position when a new toot comes in in order not to
|
||||
// jerk the scrollbar around if you're already scrolled down the page.
|
||||
if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) {
|
||||
if (prevProps.statusIds.first() !== this.props.statusIds.first()) {
|
||||
let newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
|
||||
if (this.node.scrollTop !== newScrollTop) {
|
||||
this.node.scrollTop = newScrollTop;
|
||||
}
|
||||
} else {
|
||||
this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.detachScrollListener();
|
||||
this.detachIntersectionObserver();
|
||||
}
|
||||
|
||||
attachIntersectionObserver () {
|
||||
this.intersectionObserverWrapper.connect({
|
||||
root: this.node,
|
||||
rootMargin: '300% 0px',
|
||||
});
|
||||
}
|
||||
|
||||
detachIntersectionObserver () {
|
||||
this.intersectionObserverWrapper.disconnect();
|
||||
}
|
||||
|
||||
attachScrollListener () {
|
||||
this.node.addEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
detachScrollListener () {
|
||||
this.node.removeEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
handleLoadMore = (e) => {
|
||||
e.preventDefault();
|
||||
this.props.onScrollToBottom();
|
||||
}
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
|
||||
const article = (() => {
|
||||
switch (e.key) {
|
||||
case 'PageDown':
|
||||
return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
|
||||
case 'PageUp':
|
||||
return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
|
||||
case 'End':
|
||||
return this.node.querySelector('[role="feed"] > article:last-of-type');
|
||||
case 'Home':
|
||||
return this.node.querySelector('[role="feed"] > article:first-of-type');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
if (article) {
|
||||
e.preventDefault();
|
||||
article.focus();
|
||||
article.scrollIntoView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSetDetail = (id) => {
|
||||
this.setState({ currentDetail : id });
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
handleKeyDown,
|
||||
handleLoadMore,
|
||||
handleSetDetail,
|
||||
intersectionObserverWrapper,
|
||||
setRef,
|
||||
} = this;
|
||||
const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, intl } = this.props;
|
||||
const { currentDetail } = this.state;
|
||||
|
||||
const loadMore = (
|
||||
<CommonButton
|
||||
className='load-more'
|
||||
disabled={isLoading || statusIds.size > 0 && hasMore}
|
||||
onClick={handleLoadMore}
|
||||
showTitle
|
||||
title={intl.formatMessage(messages.load_more)}
|
||||
/>
|
||||
);
|
||||
let scrollableArea = null;
|
||||
|
||||
if (isLoading || statusIds.size > 0 || !emptyMessage) {
|
||||
scrollableArea = (
|
||||
<div className='scrollable' ref={setRef}>
|
||||
<div role='feed' className='status-list' onKeyDown={handleKeyDown}>
|
||||
{prepend}
|
||||
|
||||
{statusIds.map((statusId, index) => (
|
||||
<StatusContainer
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
index={index}
|
||||
listLength={statusIds.size}
|
||||
detailed={currentDetail === statusId}
|
||||
setDetail={handleSetDetail}
|
||||
intersectionObserverWrapper={intersectionObserverWrapper}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loadMore}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
scrollableArea = (
|
||||
<div className='empty-column-indicator' ref={setRef}>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (trackScroll) {
|
||||
return (
|
||||
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
|
||||
{scrollableArea}
|
||||
</ScrollContainer>
|
||||
);
|
||||
} else {
|
||||
return scrollableArea;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
24
app/javascript/glitch/components/local_settings/container.js
Normal file
24
app/javascript/glitch/components/local_settings/container.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Package imports //
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// Mastodon imports //
|
||||
import { closeModal } from 'mastodon/actions/modal';
|
||||
|
||||
// Our imports //
|
||||
import { changeLocalSetting } from 'glitch/actions/local_settings';
|
||||
import LocalSettings from '.';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
settings: state.get('local_settings'),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onChange (setting, value) {
|
||||
dispatch(changeLocalSetting(setting, value));
|
||||
},
|
||||
onClose () {
|
||||
dispatch(closeModal());
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(LocalSettings);
|
||||
50
app/javascript/glitch/components/local_settings/index.js
Normal file
50
app/javascript/glitch/components/local_settings/index.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Package imports
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
// Our imports
|
||||
import LocalSettingsPage from './page';
|
||||
import LocalSettingsNavigation from './navigation';
|
||||
|
||||
// Stylesheet imports
|
||||
import './style';
|
||||
|
||||
export default class LocalSettings extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
currentIndex: 0,
|
||||
};
|
||||
|
||||
navigateTo = (index) =>
|
||||
this.setState({ currentIndex: +index });
|
||||
|
||||
render () {
|
||||
|
||||
const { navigateTo } = this;
|
||||
const { onChange, onClose, settings } = this.props;
|
||||
const { currentIndex } = this.state;
|
||||
|
||||
return (
|
||||
<div className='glitch modal-root__modal local-settings'>
|
||||
<LocalSettingsNavigation
|
||||
index={currentIndex}
|
||||
onClose={onClose}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
<LocalSettingsPage
|
||||
index={currentIndex}
|
||||
onChange={onChange}
|
||||
settings={settings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package imports
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
// Our imports
|
||||
import LocalSettingsNavigationItem from './item';
|
||||
|
||||
// Stylesheet imports
|
||||
import './style';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
const messages = defineMessages({
|
||||
general: { id: 'settings.general', defaultMessage: 'General' },
|
||||
collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' },
|
||||
media: { id: 'settings.media', defaultMessage: 'Media' },
|
||||
preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' },
|
||||
close: { id: 'settings.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class LocalSettingsNavigation extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
index : PropTypes.number,
|
||||
intl : PropTypes.object.isRequired,
|
||||
onClose : PropTypes.func.isRequired,
|
||||
onNavigate : PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
|
||||
const { index, intl, onClose, onNavigate } = this.props;
|
||||
|
||||
return (
|
||||
<nav className='glitch local-settings__navigation'>
|
||||
<LocalSettingsNavigationItem
|
||||
active={index === 0}
|
||||
index={0}
|
||||
onNavigate={onNavigate}
|
||||
title={intl.formatMessage(messages.general)}
|
||||
/>
|
||||
<LocalSettingsNavigationItem
|
||||
active={index === 1}
|
||||
index={1}
|
||||
onNavigate={onNavigate}
|
||||
title={intl.formatMessage(messages.collapsed)}
|
||||
/>
|
||||
<LocalSettingsNavigationItem
|
||||
active={index === 2}
|
||||
index={2}
|
||||
onNavigate={onNavigate}
|
||||
title={intl.formatMessage(messages.media)}
|
||||
/>
|
||||
<LocalSettingsNavigationItem
|
||||
active={index === 3}
|
||||
href='/settings/preferences'
|
||||
index={3}
|
||||
icon='cog'
|
||||
title={intl.formatMessage(messages.preferences)}
|
||||
/>
|
||||
<LocalSettingsNavigationItem
|
||||
active={index === 4}
|
||||
className='close'
|
||||
index={4}
|
||||
onNavigate={onClose}
|
||||
title={intl.formatMessage(messages.close)}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package imports
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
// Stylesheet imports
|
||||
import './style';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
export default class LocalSettingsPage extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
index: PropTypes.number.isRequired,
|
||||
onNavigate: PropTypes.func,
|
||||
title: PropTypes.string,
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
const { index, onNavigate } = this.props;
|
||||
if (onNavigate) {
|
||||
onNavigate(index);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { handleClick } = this;
|
||||
const {
|
||||
active,
|
||||
className,
|
||||
href,
|
||||
icon,
|
||||
onNavigate,
|
||||
title,
|
||||
} = this.props;
|
||||
|
||||
const finalClassName = classNames('glitch', 'local-settings__navigation__item', {
|
||||
active,
|
||||
}, className);
|
||||
|
||||
const iconElem = icon ? <i className={`fa fa-fw fa-${icon}`} /> : null;
|
||||
|
||||
if (href) return (
|
||||
<a
|
||||
href={href}
|
||||
className={finalClassName}
|
||||
>
|
||||
{iconElem} {title}
|
||||
</a>
|
||||
);
|
||||
else if (onNavigate) return (
|
||||
<a
|
||||
onClick={handleClick}
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
className={finalClassName}
|
||||
>
|
||||
{iconElem} {title}
|
||||
</a>
|
||||
);
|
||||
else return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.local-settings__navigation__item {
|
||||
display: block;
|
||||
padding: 15px 20px;
|
||||
color: inherit;
|
||||
background: $primary-text-color;
|
||||
border-bottom: 1px $ui-primary-color solid;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
transition: background .3s;
|
||||
|
||||
&:hover {
|
||||
background: $ui-secondary-color;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $ui-highlight-color;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
&.close, &.close:hover {
|
||||
background: $error-value-color;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.local-settings__navigation {
|
||||
background: $primary-text-color;
|
||||
color: $ui-base-color;
|
||||
width: 200px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
// Package imports //
|
||||
// Package imports
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
// Our imports //
|
||||
import SettingsItem from './item';
|
||||
// Our imports
|
||||
import LocalSettingsPageItem from './item';
|
||||
|
||||
// Stylesheet imports
|
||||
import './style';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
const messages = defineMessages({
|
||||
layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' },
|
||||
@@ -14,27 +19,21 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class Settings extends React.PureComponent {
|
||||
export default class LocalSettingsPage extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
toggleSetting: PropTypes.func.isRequired,
|
||||
changeSetting: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
index : PropTypes.number,
|
||||
intl : PropTypes.object.isRequired,
|
||||
onChange : PropTypes.func.isRequired,
|
||||
settings : ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
currentIndex: 0,
|
||||
};
|
||||
|
||||
General = () => {
|
||||
const { intl } = this.props;
|
||||
return (
|
||||
<div>
|
||||
pages = [
|
||||
({ intl, onChange, settings }) => (
|
||||
<div className='glitch local-settings__page general'>
|
||||
<h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1>
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['layout']}
|
||||
id='mastodon-settings--layout'
|
||||
options={[
|
||||
@@ -42,180 +41,143 @@ export default class Settings extends React.PureComponent {
|
||||
{ value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
|
||||
{ value: 'single', message: intl.formatMessage(messages.layout_mobile) },
|
||||
]}
|
||||
onChange={this.props.changeSetting}
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.layout' defaultMessage='Layout:' />
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['stretch']}
|
||||
id='mastodon-settings--stretch'
|
||||
onChange={this.props.toggleSetting}
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
|
||||
</SettingsItem>
|
||||
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['navbar_under']}
|
||||
id='mastodon-settings--navbar_under'
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' />
|
||||
</LocalSettingsPageItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CollapsedStatuses = () => {
|
||||
return (
|
||||
<div>
|
||||
),
|
||||
({ onChange, settings }) => (
|
||||
<div className='glitch local-settings__page collapsed'>
|
||||
<h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1>
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['collapsed', 'enabled']}
|
||||
id='mastodon-settings--collapsed-enabled'
|
||||
onChange={this.props.toggleSetting}
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' />
|
||||
</SettingsItem>
|
||||
</LocalSettingsPageItem>
|
||||
<section>
|
||||
<h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2>
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['collapsed', 'auto', 'all']}
|
||||
id='mastodon-settings--collapsed-auto-all'
|
||||
onChange={this.props.toggleSetting}
|
||||
onChange={onChange}
|
||||
dependsOn={[['collapsed', 'enabled']]}
|
||||
>
|
||||
<FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' />
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['collapsed', 'auto', 'notifications']}
|
||||
id='mastodon-settings--collapsed-auto-notifications'
|
||||
onChange={this.props.toggleSetting}
|
||||
onChange={onChange}
|
||||
dependsOn={[['collapsed', 'enabled']]}
|
||||
dependsOnNot={[['collapsed', 'auto', 'all']]}
|
||||
>
|
||||
<FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' />
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['collapsed', 'auto', 'lengthy']}
|
||||
id='mastodon-settings--collapsed-auto-lengthy'
|
||||
onChange={this.props.toggleSetting}
|
||||
onChange={onChange}
|
||||
dependsOn={[['collapsed', 'enabled']]}
|
||||
dependsOnNot={[['collapsed', 'auto', 'all']]}
|
||||
>
|
||||
<FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' />
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['collapsed', 'auto', 'replies']}
|
||||
id='mastodon-settings--collapsed-auto-replies'
|
||||
onChange={this.props.toggleSetting}
|
||||
onChange={onChange}
|
||||
dependsOn={[['collapsed', 'enabled']]}
|
||||
dependsOnNot={[['collapsed', 'auto', 'all']]}
|
||||
>
|
||||
<FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' />
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['collapsed', 'auto', 'media']}
|
||||
id='mastodon-settings--collapsed-auto-media'
|
||||
onChange={this.props.toggleSetting}
|
||||
onChange={onChange}
|
||||
dependsOn={[['collapsed', 'enabled']]}
|
||||
dependsOnNot={[['collapsed', 'auto', 'all']]}
|
||||
>
|
||||
<FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' />
|
||||
</SettingsItem>
|
||||
</LocalSettingsPageItem>
|
||||
</section>
|
||||
<section>
|
||||
<h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2>
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['collapsed', 'backgrounds', 'user_backgrounds']}
|
||||
id='mastodon-settings--collapsed-user-backgrouns'
|
||||
onChange={this.props.toggleSetting}
|
||||
onChange={onChange}
|
||||
dependsOn={[['collapsed', 'enabled']]}
|
||||
>
|
||||
<FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' />
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['collapsed', 'backgrounds', 'preview_images']}
|
||||
id='mastodon-settings--collapsed-preview-images'
|
||||
onChange={this.props.toggleSetting}
|
||||
onChange={onChange}
|
||||
dependsOn={[['collapsed', 'enabled']]}
|
||||
>
|
||||
<FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' />
|
||||
</SettingsItem>
|
||||
</LocalSettingsPageItem>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Media = () => {
|
||||
return (
|
||||
<div>
|
||||
),
|
||||
({ onChange, settings }) => (
|
||||
<div className='glitch local-settings__page media'>
|
||||
<h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1>
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['media', 'letterbox']}
|
||||
id='mastodon-settings--media-letterbox'
|
||||
onChange={this.props.toggleSetting}
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' />
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
settings={this.props.settings}
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['media', 'fullwidth']}
|
||||
id='mastodon-settings--media-fullwidth'
|
||||
onChange={this.props.toggleSetting}
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' />
|
||||
</SettingsItem>
|
||||
</LocalSettingsPageItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
navigateTo = (e) =>
|
||||
this.setState({ currentIndex: +e.currentTarget.getAttribute('data-mastodon-navigation_index') });
|
||||
),
|
||||
];
|
||||
|
||||
render () {
|
||||
const { pages } = this;
|
||||
const { index, intl, onChange, settings } = this.props;
|
||||
const CurrentPage = pages[index] || pages[0];
|
||||
|
||||
const { General, CollapsedStatuses, Media, navigateTo } = this;
|
||||
const { onClose } = this.props;
|
||||
const { currentIndex } = this.state;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal settings-modal'>
|
||||
|
||||
<nav className='settings-modal__navigation'>
|
||||
<a onClick={navigateTo} role='button' data-mastodon-navigation_index='0' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 0 ? ' active' : ''}`}>
|
||||
<FormattedMessage id='settings.general' defaultMessage='General' />
|
||||
</a>
|
||||
<a onClick={navigateTo} role='button' data-mastodon-navigation_index='1' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 1 ? ' active' : ''}`}>
|
||||
<FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' />
|
||||
</a>
|
||||
<a onClick={navigateTo} role='button' data-mastodon-navigation_index='2' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 2 ? ' active' : ''}`}>
|
||||
<FormattedMessage id='settings.media' defaultMessage='Media' />
|
||||
</a>
|
||||
<a href='/settings/preferences' className='settings-modal__navigation-item'>
|
||||
<i className='fa fa-fw fa-cog' /> <FormattedMessage id='settings.preferences' defaultMessage='User preferences' />
|
||||
</a>
|
||||
<a onClick={onClose} role='button' tabIndex='0' className='settings-modal__navigation-close'>
|
||||
<FormattedMessage id='settings.close' defaultMessage='Close' />
|
||||
</a>
|
||||
|
||||
</nav>
|
||||
|
||||
<div className='settings-modal__content'>
|
||||
{
|
||||
[
|
||||
<General />,
|
||||
<CollapsedStatuses />,
|
||||
<Media />,
|
||||
][currentIndex] || <General />
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
return <CurrentPage intl={intl} onChange={onChange} settings={settings} />;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,30 +1,38 @@
|
||||
// Package imports //
|
||||
// Package imports
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
export default class SettingsItem extends React.PureComponent {
|
||||
// Stylesheet imports
|
||||
import './style';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
export default class LocalSettingsPageItem extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
item: PropTypes.array.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
value: PropTypes.string.isRequired,
|
||||
message: PropTypes.object.isRequired,
|
||||
})),
|
||||
children: PropTypes.element.isRequired,
|
||||
dependsOn: PropTypes.array,
|
||||
dependsOnNot: PropTypes.array,
|
||||
children: PropTypes.element.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
item: PropTypes.array.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
value: PropTypes.string.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
})),
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
const { item, onChange } = this.props;
|
||||
onChange(item, e);
|
||||
handleChange = e => {
|
||||
const { target } = e;
|
||||
const { item, onChange, options } = this.props;
|
||||
if (options && options.length > 0) onChange(item, target.value);
|
||||
else onChange(item, target.checked);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { handleChange } = this;
|
||||
const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props;
|
||||
let enabled = true;
|
||||
|
||||
@@ -42,38 +50,41 @@ export default class SettingsItem extends React.PureComponent {
|
||||
if (options && options.length > 0) {
|
||||
const currentValue = settings.getIn(item);
|
||||
const optionElems = options && options.length > 0 && options.map((opt) => (
|
||||
<option key={opt.value} selected={currentValue === opt.value} value={opt.value} >
|
||||
<option
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
>
|
||||
{opt.message}
|
||||
</option>
|
||||
));
|
||||
return (
|
||||
<label htmlFor={id}>
|
||||
<label className='glitch local-settings__page__item' htmlFor={id}>
|
||||
<p>{children}</p>
|
||||
<p>
|
||||
<select
|
||||
id={id}
|
||||
disabled={!enabled}
|
||||
onBlur={this.handleChange}
|
||||
onBlur={handleChange}
|
||||
onChange={handleChange}
|
||||
value={currentValue}
|
||||
>
|
||||
{optionElems}
|
||||
</select>
|
||||
</p>
|
||||
</label>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<label htmlFor={id}>
|
||||
<input
|
||||
id={id}
|
||||
type='checkbox'
|
||||
checked={settings.getIn(item)}
|
||||
onChange={this.handleChange}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
} else return (
|
||||
<label className='glitch local-settings__page__item' htmlFor={id}>
|
||||
<input
|
||||
id={id}
|
||||
type='checkbox'
|
||||
checked={settings.getIn(item)}
|
||||
onChange={handleChange}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.local-settings__page__item {
|
||||
select {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.local-settings__page {
|
||||
display: block;
|
||||
flex: auto;
|
||||
padding: 15px 20px 15px 20px;
|
||||
width: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
34
app/javascript/glitch/components/local_settings/style.scss
Normal file
34
app/javascript/glitch/components/local_settings/style.scss
Normal file
@@ -0,0 +1,34 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.local-settings {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: $ui-secondary-color;
|
||||
color: $ui-base-color;
|
||||
border-radius: 8px;
|
||||
height: 80vh;
|
||||
width: 80vw;
|
||||
max-width: 740px;
|
||||
max-height: 450px;
|
||||
overflow: hidden;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
56
app/javascript/glitch/components/notification/container.js
Normal file
56
app/javascript/glitch/components/notification/container.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
|
||||
`<NotificationContainer>`
|
||||
=========================
|
||||
|
||||
This container connects `<Notification>`s to the Redux store.
|
||||
|
||||
*/
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Imports:
|
||||
--------
|
||||
|
||||
*/
|
||||
|
||||
// Package imports //
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// Mastodon imports //
|
||||
import { makeGetNotification } from '../../../mastodon/selectors';
|
||||
|
||||
// Our imports //
|
||||
import Notification from '.';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
State mapping:
|
||||
--------------
|
||||
|
||||
The `mapStateToProps()` function maps various state properties to the
|
||||
props of our component. We wrap this in `makeMapStateToProps()` so that
|
||||
we only have to call `makeGetNotification()` once instead of every
|
||||
time.
|
||||
|
||||
*/
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getNotification = makeGetNotification();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
notification: getNotification(state, props.notification, props.accountId),
|
||||
settings: state.get('local_settings'),
|
||||
notifCleaning: state.getIn(['notifications', 'cleaningMode']),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
export default connect(makeMapStateToProps)(Notification);
|
||||
124
app/javascript/glitch/components/notification/follow.js
Normal file
124
app/javascript/glitch/components/notification/follow.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
|
||||
`<NotificationFollow>`
|
||||
======================
|
||||
|
||||
This component renders a follow notification.
|
||||
|
||||
__Props:__
|
||||
|
||||
- __`id` (`PropTypes.number.isRequired`) :__
|
||||
This is the id of the notification.
|
||||
|
||||
- __`onDeleteNotification` (`PropTypes.func.isRequired`) :__
|
||||
The function to call when a notification should be
|
||||
dismissed/deleted.
|
||||
|
||||
- __`account` (`PropTypes.object.isRequired`) :__
|
||||
The account associated with the follow notification, ie the account
|
||||
which followed the user.
|
||||
|
||||
- __`intl` (`PropTypes.object.isRequired`) :__
|
||||
Our internationalization object, inserted by `@injectIntl`.
|
||||
|
||||
*/
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Imports:
|
||||
--------
|
||||
|
||||
*/
|
||||
|
||||
// Package imports //
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
// Mastodon imports //
|
||||
import emojify from '../../../mastodon/emoji';
|
||||
import Permalink from '../../../mastodon/components/permalink';
|
||||
import AccountContainer from '../../../mastodon/containers/account_container';
|
||||
|
||||
// Our imports //
|
||||
import NotificationOverlayContainer from '../notification/overlay/container';
|
||||
|
||||
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
|
||||
/*
|
||||
|
||||
Implementation:
|
||||
---------------
|
||||
|
||||
*/
|
||||
|
||||
export default class NotificationFollow extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
id : PropTypes.number.isRequired,
|
||||
account : ImmutablePropTypes.map.isRequired,
|
||||
notification : ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
### `render()`
|
||||
|
||||
This actually renders the component.
|
||||
|
||||
*/
|
||||
|
||||
render () {
|
||||
const { account, notification } = this.props;
|
||||
|
||||
/*
|
||||
|
||||
`link` is a container for the account's `displayName`, which links to
|
||||
the account timeline using a `<Permalink>`.
|
||||
|
||||
*/
|
||||
|
||||
const displayName = account.get('display_name') || account.get('username');
|
||||
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
|
||||
const link = (
|
||||
<Permalink
|
||||
className='notification__display-name'
|
||||
href={account.get('url')}
|
||||
title={account.get('acct')}
|
||||
to={`/accounts/${account.get('id')}`}
|
||||
dangerouslySetInnerHTML={displayNameHTML}
|
||||
/>
|
||||
);
|
||||
|
||||
/*
|
||||
|
||||
We can now render our component.
|
||||
|
||||
*/
|
||||
|
||||
return (
|
||||
<div className='notification notification-follow'>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<i className='fa fa-fw fa-user-plus' />
|
||||
</div>
|
||||
|
||||
<FormattedMessage
|
||||
id='notification.follow'
|
||||
defaultMessage='{name} followed you'
|
||||
values={{ name: link }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AccountContainer id={account.get('id')} withNote={false} />
|
||||
<NotificationOverlayContainer notification={notification} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
// Package imports //
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
// Mastodon imports //
|
||||
import AccountContainer from '../../../mastodon/containers/account_container';
|
||||
import Permalink from '../../../mastodon/components/permalink';
|
||||
import emojify from '../../../mastodon/emoji';
|
||||
|
||||
// Our imports //
|
||||
import StatusContainer from '../../containers/status';
|
||||
import StatusContainer from '../status/container';
|
||||
import NotificationFollow from './follow';
|
||||
import NotificationOverlayContainer from './overlay/container';
|
||||
|
||||
export default class Notification extends ImmutablePureComponent {
|
||||
|
||||
@@ -21,22 +18,12 @@ export default class Notification extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
renderFollow (notification) {
|
||||
const account = notification.get('account');
|
||||
const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
|
||||
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
|
||||
const link = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
|
||||
return (
|
||||
<div className='notification notification-follow'>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<i className='fa fa-fw fa-user-plus' />
|
||||
</div>
|
||||
|
||||
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
|
||||
</div>
|
||||
|
||||
<AccountContainer id={account.get('id')} withNote={false} />
|
||||
</div>
|
||||
<NotificationFollow
|
||||
id={notification.get('id')}
|
||||
account={notification.get('account')}
|
||||
notification={notification}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +31,7 @@ export default class Notification extends ImmutablePureComponent {
|
||||
return (
|
||||
<StatusContainer
|
||||
id={notification.get('status')}
|
||||
notification={notification}
|
||||
withDismiss
|
||||
/>
|
||||
);
|
||||
@@ -56,6 +44,7 @@ export default class Notification extends ImmutablePureComponent {
|
||||
account={notification.get('account')}
|
||||
prepend='favourite'
|
||||
muted
|
||||
notification={notification}
|
||||
withDismiss
|
||||
/>
|
||||
);
|
||||
@@ -68,6 +57,7 @@ export default class Notification extends ImmutablePureComponent {
|
||||
account={notification.get('account')}
|
||||
prepend='reblog'
|
||||
muted
|
||||
notification={notification}
|
||||
withDismiss
|
||||
/>
|
||||
);
|
||||
@@ -76,18 +66,25 @@ export default class Notification extends ImmutablePureComponent {
|
||||
render () {
|
||||
const { notification } = this.props;
|
||||
|
||||
switch(notification.get('type')) {
|
||||
case 'follow':
|
||||
return this.renderFollow(notification);
|
||||
case 'mention':
|
||||
return this.renderMention(notification);
|
||||
case 'favourite':
|
||||
return this.renderFavourite(notification);
|
||||
case 'reblog':
|
||||
return this.renderReblog(notification);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<div class='status'>
|
||||
{(() => {
|
||||
switch (notification.get('type')) {
|
||||
case 'follow':
|
||||
return this.renderFollow(notification);
|
||||
case 'mention':
|
||||
return this.renderMention(notification);
|
||||
case 'favourite':
|
||||
return this.renderFavourite(notification);
|
||||
case 'reblog':
|
||||
return this.renderReblog(notification);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})()}
|
||||
<NotificationOverlayContainer notification={notification} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// <NotificationOverlayContainer>
|
||||
// ==============================
|
||||
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/notification/overlay/container
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// Mastodon imports.
|
||||
import { markNotificationForDelete } from 'mastodon/actions/notifications';
|
||||
|
||||
// Our imports.
|
||||
import NotificationOverlay from './notification_overlay';
|
||||
|
||||
// State mapping
|
||||
// -------------
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
show: state.getIn(['notifications', 'cleaningMode']),
|
||||
});
|
||||
|
||||
// Dispatch mapping
|
||||
// ----------------
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onMarkForDelete(id, yes) {
|
||||
dispatch(markNotificationForDelete(id, yes));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay);
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Notification overlay
|
||||
*/
|
||||
|
||||
|
||||
// Package imports //
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class NotificationOverlay extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
notification : ImmutablePropTypes.map.isRequired,
|
||||
onMarkForDelete : PropTypes.func.isRequired,
|
||||
show : PropTypes.bool.isRequired,
|
||||
intl : PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
onToggleMark = () => {
|
||||
const mark = !this.props.notification.get('markedForDelete');
|
||||
const id = this.props.notification.get('id');
|
||||
this.props.onMarkForDelete(id, mark);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { notification, show, intl } = this.props;
|
||||
|
||||
const active = notification.get('markedForDelete');
|
||||
const label = intl.formatMessage(messages.markForDeletion);
|
||||
|
||||
return show ? (
|
||||
<div
|
||||
aria-label={label}
|
||||
role='checkbox'
|
||||
aria-checked={active}
|
||||
tabIndex={0}
|
||||
className={`notification__dismiss-overlay ${active ? 'active' : ''}`}
|
||||
onClick={this.onToggleMark}
|
||||
>
|
||||
<div className='wrappy'>
|
||||
<div className='ckbox' aria-hidden='true' title={label}>
|
||||
{active ? (<i className='fa fa-check' />) : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
// Package imports //
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
// Mastodon imports //
|
||||
import RelativeTimestamp from '../../../mastodon/components/relative_timestamp';
|
||||
import IconButton from '../../../mastodon/components/icon_button';
|
||||
import DropdownMenu from '../../../mastodon/components/dropdown_menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
||||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
onBlock: PropTypes.func,
|
||||
onReport: PropTypes.func,
|
||||
onMuteConversation: PropTypes.func,
|
||||
me: PropTypes.number.isRequired,
|
||||
withDismiss: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
// evaluate to false. See react-immutable-pure-component for usage.
|
||||
updateOnProps = [
|
||||
'status',
|
||||
'me',
|
||||
'withDismiss',
|
||||
]
|
||||
|
||||
handleReplyClick = () => {
|
||||
this.props.onReply(this.props.status, this.context.router.history);
|
||||
}
|
||||
|
||||
handleFavouriteClick = () => {
|
||||
this.props.onFavourite(this.props.status);
|
||||
}
|
||||
|
||||
handleReblogClick = (e) => {
|
||||
this.props.onReblog(this.props.status, e);
|
||||
}
|
||||
|
||||
handleDeleteClick = () => {
|
||||
this.props.onDelete(this.props.status);
|
||||
}
|
||||
|
||||
handleMentionClick = () => {
|
||||
this.props.onMention(this.props.status.get('account'), this.context.router.history);
|
||||
}
|
||||
|
||||
handleMuteClick = () => {
|
||||
this.props.onMute(this.props.status.get('account'));
|
||||
}
|
||||
|
||||
handleBlockClick = () => {
|
||||
this.props.onBlock(this.props.status.get('account'));
|
||||
}
|
||||
|
||||
handleOpen = () => {
|
||||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
|
||||
}
|
||||
|
||||
handleReport = () => {
|
||||
this.props.onReport(this.props.status);
|
||||
}
|
||||
|
||||
handleConversationMuteClick = () => {
|
||||
this.props.onMuteConversation(this.props.status);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, me, intl, withDismiss } = this.props;
|
||||
const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
|
||||
const mutingConversation = status.get('muted');
|
||||
|
||||
let menu = [];
|
||||
let reblogIcon = 'retweet';
|
||||
let replyIcon;
|
||||
let replyTitle;
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
|
||||
menu.push(null);
|
||||
|
||||
if (withDismiss) {
|
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||
menu.push(null);
|
||||
}
|
||||
|
||||
if (status.getIn(['account', 'id']) === me) {
|
||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
|
||||
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
|
||||
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
|
||||
}
|
||||
|
||||
/*
|
||||
if (status.get('visibility') === 'direct') {
|
||||
reblogIcon = 'envelope';
|
||||
} else if (status.get('visibility') === 'private') {
|
||||
reblogIcon = 'lock';
|
||||
}
|
||||
*/
|
||||
|
||||
if (status.get('in_reply_to_id', null) === null) {
|
||||
replyIcon = 'reply';
|
||||
replyTitle = intl.formatMessage(messages.reply);
|
||||
} else {
|
||||
replyIcon = 'reply-all';
|
||||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton className='status__action-bar-button' title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
|
||||
<IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenu items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
|
||||
</div>
|
||||
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
268
app/javascript/glitch/components/status/action_bar/index.js
Normal file
268
app/javascript/glitch/components/status/action_bar/index.js
Normal file
@@ -0,0 +1,268 @@
|
||||
// <StatusActionBar>
|
||||
// ========
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/status/action_bar
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
// Mastodon imports.
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
|
||||
// Our imports.
|
||||
import CommonButton from 'glitch/components/common/button';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Initial setup
|
||||
// -------------
|
||||
|
||||
// Holds our localization messages.
|
||||
const messages = defineMessages({
|
||||
delete:
|
||||
{ id: 'status.delete', defaultMessage: 'Delete' },
|
||||
mention:
|
||||
{ id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||
mute:
|
||||
{ id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
block:
|
||||
{ id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
reply:
|
||||
{ id: 'status.reply', defaultMessage: 'Reply' },
|
||||
replyAll:
|
||||
{ id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
||||
reblog:
|
||||
{ id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
cannot_reblog:
|
||||
{ id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite:
|
||||
{ id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
open:
|
||||
{ id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
report:
|
||||
{ id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
muteConversation:
|
||||
{ id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
||||
unmuteConversation:
|
||||
{ id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
||||
share:
|
||||
{ id: 'status.share', defaultMessage: 'Share' },
|
||||
more:
|
||||
{ id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
|
||||
export default class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
// Props.
|
||||
static propTypes = {
|
||||
detailed: PropTypes.bool,
|
||||
handler: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
history: PropTypes.object,
|
||||
intl: PropTypes.object.isRequired,
|
||||
me: PropTypes.number,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
// These handle all of our actions.
|
||||
handleReplyClick = () => {
|
||||
const { handler, history, status } = this.props;
|
||||
handler.reply(status, { history }); // hack
|
||||
}
|
||||
handleFavouriteClick = () => {
|
||||
const { handler, status } = this.props;
|
||||
handler.favourite(status);
|
||||
}
|
||||
handleReblogClick = (e) => {
|
||||
const { handler, status } = this.props;
|
||||
handler.reblog(status, e.shiftKey);
|
||||
}
|
||||
handleDeleteClick = () => {
|
||||
const { handler, status } = this.props;
|
||||
handler.delete(status);
|
||||
}
|
||||
handleMentionClick = () => {
|
||||
const { handler, history, status } = this.props;
|
||||
handler.mention(status.get('account'), { history }); // hack
|
||||
}
|
||||
handleMuteClick = () => {
|
||||
const { handler, status } = this.props;
|
||||
handler.mute(status.get('account'));
|
||||
}
|
||||
handleBlockClick = () => {
|
||||
const { handler, status } = this.props;
|
||||
handler.block(status.get('account'));
|
||||
}
|
||||
handleOpen = () => {
|
||||
const { history, status } = this.props;
|
||||
history.push(`/statuses/${status.get('id')}`);
|
||||
}
|
||||
handleReport = () => {
|
||||
const { handler, status } = this.props;
|
||||
handler.report(status);
|
||||
}
|
||||
handleShare = () => {
|
||||
const { status } = this.props;
|
||||
navigator.share({
|
||||
text: status.get('search_index'),
|
||||
url: status.get('url'),
|
||||
});
|
||||
}
|
||||
handleConversationMuteClick = () => {
|
||||
const { handler, status } = this.props;
|
||||
handler.muteConversation(status);
|
||||
}
|
||||
|
||||
// Renders our component.
|
||||
render () {
|
||||
const {
|
||||
handleBlockClick,
|
||||
handleConversationMuteClick,
|
||||
handleDeleteClick,
|
||||
handleFavouriteClick,
|
||||
handleMentionClick,
|
||||
handleMuteClick,
|
||||
handleOpen,
|
||||
handleReblogClick,
|
||||
handleReplyClick,
|
||||
handleReport,
|
||||
handleShare,
|
||||
} = this;
|
||||
const { detailed, intl, me, status } = this.props;
|
||||
const account = status.get('account');
|
||||
const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
|
||||
const reblogTitle = reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog);
|
||||
const mutingConversation = status.get('muted');
|
||||
const anonymousAccess = !me;
|
||||
let menu = [];
|
||||
let replyIcon;
|
||||
let replyTitle;
|
||||
|
||||
// This builds our menu.
|
||||
if (!detailed) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.open),
|
||||
action: handleOpen,
|
||||
});
|
||||
menu.push(null);
|
||||
}
|
||||
menu.push({
|
||||
text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation),
|
||||
action: handleConversationMuteClick,
|
||||
});
|
||||
menu.push(null);
|
||||
if (account.get('id') === me) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.delete),
|
||||
action: handleDeleteClick,
|
||||
});
|
||||
} else {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.get('username'),
|
||||
}),
|
||||
action: handleMentionClick,
|
||||
});
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.mute, {
|
||||
name: account.get('username'),
|
||||
}),
|
||||
action: handleMuteClick,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.block, {
|
||||
name: account.get('username'),
|
||||
}),
|
||||
action: handleBlockClick,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.report, {
|
||||
name: account.get('username'),
|
||||
}),
|
||||
action: handleReport,
|
||||
});
|
||||
}
|
||||
|
||||
// This selects our reply icon.
|
||||
if (status.get('in_reply_to_id', null) === null) {
|
||||
replyIcon = 'reply';
|
||||
replyTitle = intl.formatMessage(messages.reply);
|
||||
} else {
|
||||
replyIcon = 'reply-all';
|
||||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
// Now we can render the component.
|
||||
return (
|
||||
<div className='glitch glitch__status__action-bar'>
|
||||
<CommonButton
|
||||
className='action-bar\button'
|
||||
disabled={anonymousAccess}
|
||||
title={replyTitle}
|
||||
icon={replyIcon}
|
||||
onClick={handleReplyClick}
|
||||
/>
|
||||
<CommonButton
|
||||
className='action-bar\button'
|
||||
disabled={anonymousAccess || reblogDisabled}
|
||||
active={status.get('reblogged')}
|
||||
title={reblogTitle}
|
||||
icon='retweet'
|
||||
onClick={handleReblogClick}
|
||||
/>
|
||||
<CommonButton
|
||||
className='action-bar\button'
|
||||
disabled={anonymousAccess}
|
||||
animate
|
||||
active={status.get('favourited')}
|
||||
title={intl.formatMessage(messages.favourite)}
|
||||
icon='star'
|
||||
onClick={handleFavouriteClick}
|
||||
/>
|
||||
{
|
||||
'share' in navigator ? (
|
||||
<CommonButton
|
||||
className='action-bar\button'
|
||||
disabled={status.get('visibility') !== 'public'}
|
||||
title={intl.formatMessage(messages.share)}
|
||||
icon='share-alt'
|
||||
onClick={handleShare}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
<div className='action-bar\button'>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
disabled={anonymousAccess}
|
||||
icon='ellipsis-h'
|
||||
size={18}
|
||||
direction='right'
|
||||
aria-label={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.glitch__status__action-bar {
|
||||
display: block;
|
||||
height: 1.25em;
|
||||
font-size: 1.25em;
|
||||
line-height: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
// Dropdown style override for centering on the icon
|
||||
.dropdown--active {
|
||||
position: relative;
|
||||
|
||||
.dropdown__content.dropdown__right {
|
||||
left: calc(50% + 3px);
|
||||
right: initial;
|
||||
transform: translate(-50%, 0);
|
||||
top: 22px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 1px;
|
||||
bottom: -2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
212
app/javascript/glitch/components/status/container.js
Normal file
212
app/javascript/glitch/components/status/container.js
Normal file
@@ -0,0 +1,212 @@
|
||||
// <StatusContainer>
|
||||
// =================
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/status/container
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import React from 'react';
|
||||
import {
|
||||
defineMessages,
|
||||
injectIntl,
|
||||
FormattedMessage,
|
||||
} from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router';
|
||||
import { createStructuredSelector } from 'reselect';
|
||||
|
||||
// Mastodon imports.
|
||||
import { blockAccount, muteAccount } from 'mastodon/actions/accounts';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
} from 'mastodon/actions/compose';
|
||||
import {
|
||||
reblog,
|
||||
favourite,
|
||||
unreblog,
|
||||
unfavourite,
|
||||
} from 'mastodon/actions/interactions';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { initReport } from 'mastodon/actions/reports';
|
||||
import {
|
||||
muteStatus,
|
||||
unmuteStatus,
|
||||
deleteStatus,
|
||||
} from 'mastodon/actions/statuses';
|
||||
import { fetchStatusCard } from 'mastodon/actions/cards';
|
||||
|
||||
// Our imports.
|
||||
import Status from '.';
|
||||
import makeStatusSelector from 'glitch/selectors/status';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Initial setup
|
||||
// -------------
|
||||
|
||||
// Localization messages.
|
||||
const messages = defineMessages({
|
||||
blockConfirm : {
|
||||
id : 'confirmations.block.confirm',
|
||||
defaultMessage : 'Block',
|
||||
},
|
||||
deleteConfirm : {
|
||||
id : 'confirmations.delete.confirm',
|
||||
defaultMessage : 'Delete',
|
||||
},
|
||||
deleteMessage : {
|
||||
id : 'confirmations.delete.message',
|
||||
defaultMessage : 'Are you sure you want to delete this status?',
|
||||
},
|
||||
muteConfirm : {
|
||||
id : 'confirmations.mute.confirm',
|
||||
defaultMessage : 'Mute',
|
||||
},
|
||||
});
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// State mapping
|
||||
// -------------
|
||||
|
||||
// We wrap our `mapStateToProps()` function in a
|
||||
// `makeMapStateToProps()` to give us a closure and preserve
|
||||
// `makeGetStatus()`'s value.
|
||||
const makeMapStateToProps = () => {
|
||||
const statusSelector = makeStatusSelector();
|
||||
|
||||
// State mapping.
|
||||
return (state, ownProps) => {
|
||||
let status = statusSelector(state, ownProps.id);
|
||||
let reblogStatus = status.get('reblog', null);
|
||||
let comrade = undefined;
|
||||
let prepend = undefined;
|
||||
|
||||
// Processes reblogs and generates their prepend.
|
||||
if (reblogStatus !== null && typeof reblogStatus === 'object') {
|
||||
comrade = status.get('account');
|
||||
status = reblogStatus;
|
||||
prepend = 'reblogged';
|
||||
}
|
||||
|
||||
// This is what we pass to <Status>.
|
||||
return {
|
||||
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
|
||||
comrade: comrade || ownProps.comrade,
|
||||
deleteModal: state.getIn(['meta', 'delete_modal']),
|
||||
me: state.getIn(['meta', 'me']),
|
||||
prepend: prepend || ownProps.prepend,
|
||||
reblogModal: state.getIn(['meta', 'boost_modal']),
|
||||
settings: state.get('local_settings'),
|
||||
status: status,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Dispatch mapping
|
||||
// ----------------
|
||||
|
||||
const makeMapDispatchToProps = (dispatch) => {
|
||||
const dispatchSelector = createStructuredSelector({
|
||||
handler: ({ intl }) => ({
|
||||
block (account) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.blockConfirm),
|
||||
onConfirm: () => dispatch(blockAccount(account.get('id'))),
|
||||
}));
|
||||
},
|
||||
delete (status) {
|
||||
if (!this.deleteModal) { // TODO: THIS IS BORKN (this refers to handler)
|
||||
dispatch(deleteStatus(status.get('id')));
|
||||
} else {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.deleteMessage),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(deleteStatus(status.get('id'))),
|
||||
}));
|
||||
}
|
||||
},
|
||||
favourite (status) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
}
|
||||
},
|
||||
fetchCard (status) {
|
||||
dispatch(fetchStatusCard(status.get('id')));
|
||||
},
|
||||
mention (account, router) {
|
||||
dispatch(mentionCompose(account, router));
|
||||
},
|
||||
modalReblog (status) {
|
||||
dispatch(reblog(status));
|
||||
},
|
||||
mute (account) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.muteConfirm),
|
||||
onConfirm: () => dispatch(muteAccount(account.get('id'))),
|
||||
}));
|
||||
},
|
||||
muteConversation (status) {
|
||||
if (status.get('muted')) {
|
||||
dispatch(unmuteStatus(status.get('id')));
|
||||
} else {
|
||||
dispatch(muteStatus(status.get('id')));
|
||||
}
|
||||
},
|
||||
openMedia (media, index) {
|
||||
dispatch(openModal('MEDIA', { media, index }));
|
||||
},
|
||||
openVideo (media, time) {
|
||||
dispatch(openModal('VIDEO', { media, time }));
|
||||
},
|
||||
reblog (status, withShift) {
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
if (withShift || !this.reblogModal) { // TODO: THIS IS BORKN (this refers to handler)
|
||||
this.modalReblog(status);
|
||||
} else {
|
||||
dispatch(openModal('BOOST', { status, onReblog: this.modalReblog }));
|
||||
}
|
||||
}
|
||||
},
|
||||
reply (status, router) {
|
||||
dispatch(replyCompose(status, router));
|
||||
},
|
||||
report (status) {
|
||||
dispatch(initReport(status.get('account'), status));
|
||||
},
|
||||
}),
|
||||
});
|
||||
return (_, ownProps) => dispatchSelector(ownProps);
|
||||
};
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Connecting
|
||||
// ----------
|
||||
|
||||
// `connect` will only update when its resultant props change. So
|
||||
// `withRouter` won't get called unless an update is already planned.
|
||||
// This is intended behaviour because we only care about the (mutable)
|
||||
// `history` object.
|
||||
export default injectIntl(
|
||||
connect(makeMapStateToProps, makeMapDispatchToProps)(
|
||||
withRouter(Status)
|
||||
)
|
||||
);
|
||||
@@ -1,239 +0,0 @@
|
||||
// Package imports //
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
// Mastodon imports //
|
||||
import emojify from '../../../mastodon/emoji';
|
||||
import { isRtl } from '../../../mastodon/rtl';
|
||||
import Permalink from '../../../mastodon/components/permalink';
|
||||
|
||||
export default class StatusContent extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
expanded: PropTypes.oneOf([true, false, null]),
|
||||
setExpansion: PropTypes.func,
|
||||
onHeightUpdate: PropTypes.func,
|
||||
media: PropTypes.element,
|
||||
mediaIcon: PropTypes.string,
|
||||
parseClick: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const node = this.node;
|
||||
const links = node.querySelectorAll('a');
|
||||
|
||||
for (var i = 0; i < links.length; ++i) {
|
||||
let link = links[i];
|
||||
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
||||
|
||||
if (mention) {
|
||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||
link.setAttribute('title', mention.get('acct'));
|
||||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
} else {
|
||||
link.addEventListener('click', this.onLinkClick.bind(this), false);
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener');
|
||||
link.setAttribute('title', link.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
if (this.props.onHeightUpdate) {
|
||||
this.props.onHeightUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
onLinkClick = (e) => {
|
||||
if (this.props.expanded === false) {
|
||||
if (this.props.parseClick) this.props.parseClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMentionClick = (mention, e) => {
|
||||
if (this.props.parseClick) {
|
||||
this.props.parseClick(e, `/accounts/${mention.get('id')}`);
|
||||
}
|
||||
}
|
||||
|
||||
onHashtagClick = (hashtag, e) => {
|
||||
hashtag = hashtag.replace(/^#/, '').toLowerCase();
|
||||
|
||||
if (this.props.parseClick) {
|
||||
this.props.parseClick(e, `/timelines/tag/${hashtag}`);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseDown = (e) => {
|
||||
this.startXY = [e.clientX, e.clientY];
|
||||
}
|
||||
|
||||
handleMouseUp = (e) => {
|
||||
const { parseClick } = this.props;
|
||||
|
||||
if (!this.startXY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [ startX, startY ] = this.startXY;
|
||||
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
|
||||
|
||||
if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX + deltaY < 5 && e.button === 0 && parseClick) {
|
||||
parseClick(e);
|
||||
}
|
||||
|
||||
this.startXY = null;
|
||||
}
|
||||
|
||||
handleSpoilerClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.props.setExpansion) {
|
||||
this.props.setExpansion(this.props.expanded ? null : true);
|
||||
} else {
|
||||
this.setState({ hidden: !this.state.hidden });
|
||||
}
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, media, mediaIcon } = this.props;
|
||||
|
||||
const hidden = (
|
||||
this.props.setExpansion ?
|
||||
!this.props.expanded :
|
||||
this.state.hidden
|
||||
);
|
||||
|
||||
const content = { __html: emojify(status.get('content')) };
|
||||
const spoilerContent = {
|
||||
__html: emojify(escapeTextContentForBrowser(
|
||||
status.get('spoiler_text', '')
|
||||
)),
|
||||
};
|
||||
const directionStyle = { direction: 'ltr' };
|
||||
|
||||
if (isRtl(status.get('search_index'))) {
|
||||
directionStyle.direction = 'rtl';
|
||||
}
|
||||
|
||||
if (status.get('spoiler_text').length > 0) {
|
||||
let mentionsPlaceholder = '';
|
||||
|
||||
const mentionLinks = status.get('mentions').map(item => (
|
||||
<Permalink
|
||||
to={`/accounts/${item.get('id')}`}
|
||||
href={item.get('url')}
|
||||
key={item.get('id')}
|
||||
className='mention'
|
||||
>
|
||||
@<span>{item.get('username')}</span>
|
||||
</Permalink>
|
||||
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
|
||||
|
||||
const toggleText = hidden ? [
|
||||
<FormattedMessage
|
||||
id='status.show_more'
|
||||
defaultMessage='Show more'
|
||||
key='0'
|
||||
/>,
|
||||
mediaIcon ? (
|
||||
<i
|
||||
className={
|
||||
`fa fa-fw fa-${mediaIcon} status__content__spoiler-icon`
|
||||
}
|
||||
aria-hidden='true'
|
||||
key='1'
|
||||
/>
|
||||
) : null,
|
||||
] : [
|
||||
<FormattedMessage
|
||||
id='status.show_less'
|
||||
defaultMessage='Show less'
|
||||
key='0'
|
||||
/>,
|
||||
];
|
||||
|
||||
if (hidden) {
|
||||
mentionsPlaceholder = <div>{mentionLinks}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='status__content status__content--with-action' ref={this.setRef}>
|
||||
<p
|
||||
style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={spoilerContent} />
|
||||
{' '}
|
||||
<button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
|
||||
{toggleText}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{mentionsPlaceholder}
|
||||
|
||||
<div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
|
||||
<div
|
||||
style={directionStyle}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
dangerouslySetInnerHTML={content}
|
||||
/>
|
||||
{media}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
} else if (this.props.parseClick) {
|
||||
return (
|
||||
<div
|
||||
ref={this.setRef}
|
||||
className='status__content status__content--with-action'
|
||||
style={directionStyle}
|
||||
>
|
||||
<div
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
dangerouslySetInnerHTML={content}
|
||||
/>
|
||||
{media}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
ref={this.setRef}
|
||||
className='status__content'
|
||||
style={directionStyle}
|
||||
>
|
||||
<div dangerouslySetInnerHTML={content} />
|
||||
{media}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
190
app/javascript/glitch/components/status/content/card/index.js
Normal file
190
app/javascript/glitch/components/status/content/card/index.js
Normal file
@@ -0,0 +1,190 @@
|
||||
// <StatusContentCard>
|
||||
// ========
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/card
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import punycode from 'punycode';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
// Mastodon imports.
|
||||
import emojify from 'mastodon/emoji';
|
||||
|
||||
// Our imports.
|
||||
import CommonLink from 'glitch/components/common/link';
|
||||
import CommonSeparator from 'glitch/components/common/separator';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Initial setup
|
||||
// -------------
|
||||
|
||||
// Reliably gets the hostname from a URL.
|
||||
const getHostname = url => {
|
||||
const parser = document.createElement('a');
|
||||
parser.href = url;
|
||||
return parser.hostname;
|
||||
};
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
export default class Card extends ImmutablePureComponent {
|
||||
|
||||
// Props.
|
||||
static propTypes = {
|
||||
card: ImmutablePropTypes.map.isRequired,
|
||||
fullwidth: PropTypes.bool,
|
||||
letterbox: PropTypes.bool,
|
||||
}
|
||||
|
||||
// Rendering.
|
||||
render () {
|
||||
const { card, fullwidth, letterbox } = this.props;
|
||||
let media = null;
|
||||
let text = null;
|
||||
let author = null;
|
||||
let provider = null;
|
||||
let caption = null;
|
||||
|
||||
// This gets all of our card properties.
|
||||
const authorName = card.get('author_name');
|
||||
const authorUrl = card.get('author_url');
|
||||
const description = card.get('description');
|
||||
const html = card.get('html');
|
||||
const image = card.get('image');
|
||||
const providerName = card.get('provider_name');
|
||||
const providerUrl = card.get('provider_url');
|
||||
const title = card.get('title');
|
||||
const type = card.get('type');
|
||||
const url = card.get('url');
|
||||
|
||||
// Sets our class.
|
||||
const computedClass = classNames('glitch', 'glitch__status__content__card', type, {
|
||||
_fullwidth: fullwidth,
|
||||
_letterbox: letterbox,
|
||||
});
|
||||
|
||||
// A card is required to render.
|
||||
if (!card) return null;
|
||||
|
||||
// This generates our card media (image or video).
|
||||
switch(type) {
|
||||
case 'photo':
|
||||
media = (
|
||||
<CommonLink
|
||||
className='card\media card\photo'
|
||||
href={url}
|
||||
>
|
||||
<img
|
||||
alt={title}
|
||||
src={image}
|
||||
/>
|
||||
</CommonLink>
|
||||
);
|
||||
break;
|
||||
case 'video':
|
||||
media = (
|
||||
<div
|
||||
className='card\media card\video'
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// If we have at least a title or a description, then we can
|
||||
// render some textual contents.
|
||||
if (title || description) {
|
||||
text = (
|
||||
<CommonLink
|
||||
className='card\description'
|
||||
href={url}
|
||||
>
|
||||
{type === 'link' && image ? (
|
||||
<div className='card\thumbnail'>
|
||||
<img
|
||||
alt=''
|
||||
className='card\image'
|
||||
src={image}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{title ? (
|
||||
<h1 className='card\title'>{title}</h1>
|
||||
) : null}
|
||||
{emojify(description)}
|
||||
</CommonLink>
|
||||
);
|
||||
}
|
||||
|
||||
// This creates links or spans (depending on whether a URL was
|
||||
// provided) for the card author and provider.
|
||||
if (authorUrl) {
|
||||
author = (
|
||||
<CommonLink
|
||||
className='card\author card\link'
|
||||
href={authorUrl}
|
||||
>
|
||||
{authorName ? authorName : punycode.toUnicode(getHostname(authorUrl))}
|
||||
</CommonLink>
|
||||
);
|
||||
} else if (authorName) {
|
||||
author = <span className='card\author'>{authorName}</span>;
|
||||
}
|
||||
if (providerUrl) {
|
||||
provider = (
|
||||
<CommonLink
|
||||
className='card\provider card\link'
|
||||
href={providerUrl}
|
||||
>
|
||||
{providerName ? providerName : punycode.toUnicode(getHostname(providerUrl))}
|
||||
</CommonLink>
|
||||
);
|
||||
} else if (providerName) {
|
||||
provider = <span className='card\provider'>{providerName}</span>;
|
||||
}
|
||||
|
||||
// If we have either the author or the provider, then we can
|
||||
// render an attachment.
|
||||
if (author || provider) {
|
||||
caption = (
|
||||
<figcaption className='card\caption'>
|
||||
{author}
|
||||
<CommonSeparator
|
||||
className='card\separator'
|
||||
visible={author && provider}
|
||||
/>
|
||||
{provider}
|
||||
</figcaption>
|
||||
);
|
||||
}
|
||||
|
||||
// Putting the pieces together and returning.
|
||||
return (
|
||||
<figure className={computedClass}>
|
||||
{media}
|
||||
{text}
|
||||
{caption}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
123
app/javascript/glitch/components/status/content/card/style.scss
Normal file
123
app/javascript/glitch/components/status/content/card/style.scss
Normal file
@@ -0,0 +1,123 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.glitch__content__card {
|
||||
display: block;
|
||||
border: thin $glitch-texture-color solid;
|
||||
border-radius: .35em;
|
||||
background: $glitch-darker-color;
|
||||
|
||||
.card\\caption {
|
||||
color: $ui-primary-color;
|
||||
background: $glitch-texture-color;
|
||||
font-size: (1.25em / 1.35); // approx. .925em
|
||||
|
||||
.card\\link { // caption links
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card\\media {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 13.5em;
|
||||
|
||||
/*
|
||||
Our fallback styles letterbox the media, but we'll expand it to
|
||||
fill the container if supported. This won't do anything for
|
||||
`<iframe>`s, but we'll just have to trust them to manage their
|
||||
own content.
|
||||
*/
|
||||
& > * {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
|
||||
@supports (object-fit: cover) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card\\description {
|
||||
color: $ui-secondary-color;
|
||||
background: $ui-base-color;
|
||||
|
||||
.card\\thumbnail {
|
||||
position: relative;
|
||||
float: left;
|
||||
width: 6.75em;
|
||||
height: 100%;
|
||||
background: $glitch-darker-color;
|
||||
|
||||
& > img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
|
||||
@supports (object-fit: cover) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
We have to divide the bottom margin of titles by their font-size to
|
||||
get them to match what we use elsewhere.
|
||||
*/
|
||||
.card\\title {
|
||||
margin-bottom: (.75em * 1.35 / 1.5);
|
||||
font-size: 1.5em;
|
||||
line-height: 1.125; // = 1.35 * (1.25 / 1.5)
|
||||
}
|
||||
}
|
||||
|
||||
&._fullwidth {
|
||||
margin-left: -.75em;
|
||||
width: calc(100% + 1.5em);
|
||||
}
|
||||
|
||||
/*
|
||||
If `letterbox` is specified, then we don't need object-fit (since
|
||||
we essentially just do a scale-down).
|
||||
*/
|
||||
&._letterbox {
|
||||
.card\\description .card\\thumbnail {
|
||||
& > img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: fill;
|
||||
}
|
||||
}
|
||||
|
||||
.card\\media {
|
||||
& > * {
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: fill;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
app/javascript/glitch/components/status/content/gallery/index.js
Normal file
191
app/javascript/glitch/components/status/content/gallery/index.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// <StatusContentGallery>
|
||||
// ======================
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports:
|
||||
// --------
|
||||
|
||||
// Package imports.
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
// Our imports.
|
||||
import StatusContentGalleryItem from './item';
|
||||
import StatusContentGalleryPlayer from './player';
|
||||
import CommonButton from 'glitch/components/common/button';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Initial setup
|
||||
// -------------
|
||||
|
||||
// Holds our localization messages.
|
||||
const messages = defineMessages({
|
||||
hide: { id: 'media_gallery.hide_media', defaultMessage: 'Hide media' },
|
||||
});
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
|
||||
export default class StatusContentGallery extends ImmutablePureComponent {
|
||||
|
||||
// Props and state.
|
||||
static propTypes = {
|
||||
attachments: ImmutablePropTypes.list.isRequired,
|
||||
autoPlayGif: PropTypes.bool,
|
||||
fullwidth: PropTypes.bool,
|
||||
height: PropTypes.number.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
letterbox: PropTypes.bool,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
onOpenVideo: PropTypes.func.isRequired,
|
||||
sensitive: PropTypes.bool,
|
||||
standalone: PropTypes.bool,
|
||||
};
|
||||
state = {
|
||||
visible: !this.props.sensitive,
|
||||
};
|
||||
|
||||
// Handles media clicks.
|
||||
handleMediaClick = index => {
|
||||
const { attachments, onOpenMedia, standalone } = this.props;
|
||||
if (standalone) return;
|
||||
onOpenMedia(attachments, index);
|
||||
}
|
||||
|
||||
// Handles showing and hiding.
|
||||
handleToggle = () => {
|
||||
this.setState({ visible: !this.state.visible });
|
||||
}
|
||||
|
||||
// Handles video clicks.
|
||||
handleVideoClick = time => {
|
||||
const { attachments, onOpenVideo, standalone } = this.props;
|
||||
if (standalone) return;
|
||||
onOpenVideo(attachments.get(0), time);
|
||||
}
|
||||
|
||||
// Renders.
|
||||
render () {
|
||||
const { handleMediaClick, handleToggle, handleVideoClick } = this;
|
||||
const {
|
||||
attachments,
|
||||
autoPlayGif,
|
||||
fullwidth,
|
||||
intl,
|
||||
letterbox,
|
||||
sensitive,
|
||||
} = this.props;
|
||||
const { visible } = this.state;
|
||||
const computedClass = classNames('glitch', 'glitch__status__content__gallery', {
|
||||
_fullwidth: fullwidth,
|
||||
});
|
||||
const useableAttachments = attachments.take(4);
|
||||
let button;
|
||||
let children;
|
||||
let size;
|
||||
|
||||
// This handles hidden media
|
||||
if (!this.state.visible) {
|
||||
button = (
|
||||
<CommonButton
|
||||
active
|
||||
className='gallery\sensitive gallery\curtain'
|
||||
title={intl.formatMessage(messages.hide)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span className='gallery\message'>
|
||||
<strong className='gallery\warning'>
|
||||
{sensitive ? (
|
||||
<FormattedMessage
|
||||
id='status.sensitive_warning'
|
||||
defaultMessage='Sensitive content'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='status.media_hidden'
|
||||
defaultMessage='Media hidden'
|
||||
/>
|
||||
)}
|
||||
</strong>
|
||||
<FormattedMessage
|
||||
defaultMessage='Click to view'
|
||||
id='status.sensitive_toggle'
|
||||
/>
|
||||
</span>
|
||||
</CommonButton>
|
||||
); // No children with hidden media
|
||||
|
||||
// If our media is visible, then we render it alongside the
|
||||
// "eyeball" button.
|
||||
} else {
|
||||
button = (
|
||||
<CommonButton
|
||||
className='gallery\sensitive gallery\button'
|
||||
icon={visible ? 'eye' : 'eye-slash'}
|
||||
title={intl.formatMessage(messages.hide)}
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
);
|
||||
|
||||
// If our first item is a video, we render a player. Otherwise,
|
||||
// we render our images.
|
||||
if (attachments.getIn([0, 'type']) === 'video') {
|
||||
size = 1;
|
||||
children = (
|
||||
<StatusContentGalleryPlayer
|
||||
attachment={attachments.get(0)}
|
||||
autoPlayGif={autoPlayGif}
|
||||
intl={intl}
|
||||
letterbox={letterbox}
|
||||
onClick={handleVideoClick}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
size = useableAttachments.size;
|
||||
children = useableAttachments.map(
|
||||
(attachment, index) => (
|
||||
<StatusContentGalleryItem
|
||||
attachment={attachment}
|
||||
autoPlayGif={autoPlayGif}
|
||||
gallerySize={size}
|
||||
index={index}
|
||||
intl={intl}
|
||||
key={attachment.get('id')}
|
||||
letterbox={letterbox}
|
||||
onClick={handleMediaClick}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Renders the gallery.
|
||||
return (
|
||||
<div
|
||||
className={computedClass}
|
||||
style={{ height: `${this.props.height}px` }}
|
||||
>
|
||||
{button}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user