Compare commits
607 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
f051c2e813 | ||
|
8b9206f7d9 | ||
|
306eb6e9c9 | ||
|
7cfd5b680a | ||
|
c468446f4c | ||
|
cde0476ca2 | ||
|
fcb5a85cdd | ||
|
d7a7baa9a7 | ||
|
ab4f5f5da5 | ||
|
6cf44ca92c | ||
|
99fe89026c | ||
|
889709a2a6 | ||
|
1a33e4042e | ||
|
7d53ee73f3 | ||
|
da5d366230 | ||
|
a78148f763 | ||
|
2ae0fb4419 | ||
|
0439c7d58b | ||
|
144402ec7e | ||
|
3778355454 | ||
|
383114add3 | ||
|
926459fc0a | ||
|
7d7a11250c | ||
|
1d5cbfa356 | ||
|
cc1eccc8bc | ||
|
16f9490d33 | ||
|
bfec9aaee0 | ||
|
e9737c2235 | ||
|
ab165547fd | ||
|
1f7c0ad8d3 | ||
|
a097dd489b | ||
|
777f527d86 | ||
|
e45fed58cb | ||
|
a67ffcbf56 | ||
|
f81dc7a33a | ||
|
6c002cf615 | ||
|
65122798b2 | ||
|
d6bc0e8db4 | ||
|
88801f7554 | ||
|
32d756fb22 | ||
|
f63f0c4625 | ||
|
7cde08e30b | ||
|
c01dd089ff | ||
|
e25170f960 | ||
|
2939e9898b | ||
|
ca50ceeaf0 | ||
|
b11fdc3ae3 | ||
|
babc6a1528 | ||
|
91dc21c469 | ||
|
8f54a8851a | ||
|
4a2ee43e80 | ||
|
7951e7ffd5 | ||
|
10739df458 | ||
|
3ad0496ccb | ||
|
f876a8681d | ||
|
4292cf60ae | ||
|
18b11100e7 | ||
|
312c51b5c8 | ||
|
1e9d2c4b1e | ||
|
a1db2a191b | ||
|
67a31454ad | ||
|
917cf0bf5d | ||
|
205ba00017 | ||
|
5d558c14b4 | ||
|
def1f8c5b3 | ||
|
d6a456dc71 | ||
|
3bddd647e0 | ||
|
75f80bef10 | ||
|
23ebf60b95 | ||
|
131bae89fd | ||
|
d64c454cfe | ||
|
2e71bb031b | ||
|
38bc85e695 | ||
|
a8e30060ae | ||
|
05e964688d | ||
|
b4f09bae1d | ||
|
69643338f6 | ||
|
e8d6f6c8c1 | ||
|
db8a088502 | ||
|
abe3ae1cc2 | ||
|
4a7e3e5082 | ||
|
6d097d559b | ||
|
4447c4c7c8 | ||
|
e5d9009d71 | ||
|
db3d5d811c | ||
|
7a0a13ab53 | ||
|
8a571158c9 | ||
|
cec7e69827 | ||
|
e1ca354956 | ||
|
679aa35e15 | ||
|
57ff221c0f | ||
|
165df323ae | ||
|
61211b509c | ||
|
2fbb38e4b2 | ||
|
be6ae3546f | ||
|
5c7add2176 | ||
|
7ddec6e7c3 | ||
|
11ea7336e9 | ||
|
46fb634c79 | ||
|
f1289ca3c0 | ||
|
989c3f4002 | ||
|
1bfbce7b45 | ||
|
72c3a41bef | ||
|
8b28b82141 | ||
|
18deeb9db5 | ||
|
92bd5f62f6 | ||
|
6917099a6a | ||
|
7b9f8766e8 | ||
|
28a2f79dff | ||
|
819bfb75c6 | ||
|
9f21eb6064 | ||
|
10a9ebae3b | ||
|
00b9ba64c9 | ||
|
a1de2e332d | ||
|
ca7dce4a5a | ||
|
10e6288444 | ||
|
3354dc117b | ||
|
9e6ceb3201 | ||
|
87f76d4095 | ||
|
c3c9879b5e | ||
|
df89cb5488 | ||
|
92638308ee | ||
|
26ec24fa1d | ||
|
53b765f4b1 | ||
|
c318e6e42e | ||
|
1f3c895ffb | ||
|
c100b83b98 | ||
|
7ac55d2674 | ||
|
05cc5636d8 | ||
|
9c493b1ea2 | ||
|
0c600e9db6 | ||
|
9bb1b97d2a | ||
|
3b604d2786 | ||
|
879aa9ad26 | ||
|
52850c51db | ||
|
cc46c6b493 | ||
|
1da73ecade | ||
|
6c28886317 | ||
|
251b04298e | ||
|
98729d50c8 | ||
|
cbcb7e1241 | ||
|
bb033c1d37 | ||
|
db21724a5a | ||
|
aaee8c9b5d | ||
|
312736cd1b | ||
|
5b75f6d0f3 | ||
|
3807b0b171 | ||
|
98b83aca37 | ||
|
2b0b7ff1b8 | ||
|
1bbcd71cd4 | ||
|
18f59df09e | ||
|
febe2449bb | ||
|
1fcb807d91 | ||
|
de154dbd5d | ||
|
e6657d7342 | ||
|
4d300e2507 | ||
|
3125dd8920 | ||
|
86be6d48c9 | ||
|
f79ba2de83 | ||
|
136e18b875 | ||
|
65647a2472 | ||
|
75122e162d | ||
|
c04002b340 | ||
|
9f9e11ce07 | ||
|
8f47f6a7ec | ||
|
91c5426455 | ||
|
8d44281677 | ||
|
777bcfc701 | ||
|
a302e56f9a | ||
|
49834a6e7f | ||
|
8724094ed0 | ||
|
d7dc84439c | ||
|
8b94d283fb | ||
|
e2c2fefc36 | ||
|
9aaf223ae2 | ||
|
eca6110fc4 | ||
|
5418df467d | ||
|
2146ac91a0 | ||
|
3689c119f0 | ||
|
004382e4d0 | ||
|
7376af90f7 | ||
|
3282448878 | ||
|
c3e9ba6a66 | ||
|
0a84ab43d2 | ||
|
60f2da1b2f | ||
|
0bc6da89d2 | ||
|
538d109a82 | ||
|
6e064cf715 | ||
|
26f969665d | ||
|
b191afcb5b | ||
|
de9b6e3a6a | ||
|
b302b9202b | ||
|
3c841c7306 | ||
|
05b13c38b5 | ||
|
f729cfc881 | ||
|
9d42bff285 | ||
|
b891a81008 | ||
|
2d2154ba75 | ||
|
f91b6fa9e1 | ||
|
3caf0cfb03 | ||
|
025f7bb223 | ||
|
80e02b90e4 | ||
|
6d71044c85 | ||
|
c4bc5c8930 | ||
|
c128fcee16 | ||
|
318e63cb79 | ||
|
c30a3d259c | ||
|
587bf6820e | ||
|
e09d3a2c66 | ||
|
806ffbab63 | ||
|
ae7a2957aa | ||
|
4c6809f6ab | ||
|
0542773bca | ||
|
6b67b55cee | ||
|
5ae1b39ec9 | ||
|
aed25932b5 | ||
|
6de079a5af | ||
|
c4ffffbeed | ||
|
642e464670 | ||
|
8bbdd35341 | ||
|
f5c8d64b6d | ||
|
4df38516e6 | ||
|
b27066e154 | ||
|
4284093aa3 | ||
|
76ec907993 | ||
|
668013265c | ||
|
908fcf83c6 | ||
|
6d1066fe61 | ||
|
6e7e97c849 | ||
|
73c142fb94 | ||
|
cda297450f | ||
|
f92cb02b9b | ||
|
2b22c33039 | ||
|
6a5036ab19 | ||
|
e90fcb46e3 | ||
|
f90133d2ad | ||
|
6c60757e99 | ||
|
c8e0ceed56 | ||
|
0c491ea928 | ||
|
a5797139b2 | ||
|
d2ab41aea4 | ||
|
1759cf8336 | ||
|
2c6c912076 | ||
|
46b1f1ec63 | ||
|
d85c566960 | ||
|
8b93f45f3d | ||
|
f114bc7bb7 | ||
|
06dd359239 | ||
|
bf5f8a2449 | ||
|
2ef9f36cf2 | ||
|
f978b06dd1 | ||
|
f406e01fcf | ||
|
2488162733 | ||
|
1357c1cb3d | ||
|
84d2371d6a | ||
|
39cc9fde8a | ||
|
64d109dc0e | ||
|
a910cdd54d | ||
|
0e18bbe3e2 | ||
|
b362de2232 | ||
|
41b4be699f | ||
|
d5da55c6cc | ||
|
d4559402e4 | ||
|
9d9f796130 | ||
|
d236dcded2 | ||
|
00e9dac1d3 | ||
|
f763e844e8 | ||
|
24e692b0cf | ||
|
80c44ed9c1 | ||
|
290ffb63cd | ||
|
d3bd10dfe4 | ||
|
565cd95bca | ||
|
b14b5e3b44 | ||
|
5abf64d647 | ||
|
ec44cff9a2 | ||
|
816284d739 | ||
|
253970cb73 | ||
|
ea91286aaa | ||
|
165498f110 | ||
|
8cfcc52876 | ||
|
470f629b06 | ||
|
d9232959df | ||
|
3114e55c7a | ||
|
e3222feddb | ||
|
58b3f4fd67 | ||
|
2b2797d6a5 | ||
|
fe65acd414 | ||
|
1c1102008f | ||
|
fd01f13b3c | ||
|
1d0321fc45 | ||
|
bdf7d8f8fd | ||
|
96a2a6523b | ||
|
2b13df4a8d | ||
|
66e55d60e1 | ||
|
0768c2825f | ||
|
6ff93845d5 | ||
|
14bd46946d | ||
|
5c2ea4da7d | ||
|
1b447c190e | ||
|
a21bcac9e1 | ||
|
356d3874eb | ||
|
4a2347da41 | ||
|
ff21ff1489 | ||
|
872a35011a | ||
|
157fd07edc | ||
|
18da021529 | ||
|
1e99a2bb03 | ||
|
7e90772c92 | ||
|
93a90cd9c3 | ||
|
5973ca3d11 | ||
|
cc70f28f19 | ||
|
c0555f2db6 | ||
|
4e351baf88 | ||
|
41ef277da3 | ||
|
5b076cbafb | ||
|
3ba6531611 | ||
|
50638174c8 | ||
|
4eba76711b | ||
|
27fc49d745 | ||
|
7e5e33df48 | ||
|
79e41fbd51 | ||
|
66ab0d0d56 | ||
|
ea05fdaa57 | ||
|
f860d15d39 | ||
|
397f7dda5d | ||
|
2d2c81765b | ||
|
7109711b71 | ||
|
26287b6e7d | ||
|
0cfb8dbd20 | ||
|
a7769c2672 | ||
|
1c9c176bec | ||
|
8a4913fde0 | ||
|
054138797f | ||
|
6c82dfcf5f | ||
|
950312bada | ||
|
2cb3dc5e5a | ||
|
4986c727d9 | ||
|
dda9ac9222 | ||
|
9f731b6a27 | ||
|
9f9680412f | ||
|
b107a4820a | ||
|
4495baf451 | ||
|
b503a5e05e | ||
|
898ab66e2e | ||
|
c1aab76da4 | ||
|
7bd559158b | ||
|
50bec5b999 | ||
|
6586408c69 | ||
|
959e4b8198 | ||
|
effdb42f4c | ||
|
9bb885805c | ||
|
8a3745a4df | ||
|
1ff0d5aea6 | ||
|
1c9e21a507 | ||
|
71401659b8 | ||
|
eb3a12bba6 | ||
|
e3ed212b85 | ||
|
7d6c461739 | ||
|
d0122045f4 | ||
|
4cc6684881 | ||
|
03d22fabb7 | ||
|
9af6c52a41 | ||
|
543d06971e | ||
|
34e18eb251 | ||
|
8efa081f21 | ||
|
fca183968e | ||
|
c0a223b480 | ||
|
60577f4c6e | ||
|
5e33445c5f | ||
|
8e34bed7cc | ||
|
75d0903317 | ||
|
b6ee2fb1c6 | ||
|
4f1d863615 | ||
|
7baca3fe4d | ||
|
cefef2c571 | ||
|
cbc50016eb | ||
|
7cee27f517 | ||
|
7161f91313 | ||
|
3373ae02de | ||
|
79a0135869 | ||
|
2112a81e86 | ||
|
8e936b03d5 | ||
|
d74cef45aa | ||
|
e8725d2d98 | ||
|
23677bc51e | ||
|
1e95fbb10b | ||
|
94f96a6e85 | ||
|
5434ad3002 | ||
|
0603971894 | ||
|
82191b3383 | ||
|
4bdb6a0eaf | ||
|
9b53c7d353 | ||
|
cf912e01fd | ||
|
5c78547198 | ||
|
fc90d38893 | ||
|
4b5b953d42 | ||
|
45c7ee39b3 | ||
|
f5dd152e1a | ||
|
95db6cbe28 | ||
|
4a422650bb | ||
|
c7031fd535 | ||
|
82cb34916a | ||
|
ba4c03de71 | ||
|
89dab7c534 | ||
|
f9bd3d8808 | ||
|
115ce90578 | ||
|
1788164352 | ||
|
64cfbbcc55 | ||
|
46d9076e99 | ||
|
12e9f789ab | ||
|
7abbb02824 | ||
|
835d5483fe | ||
|
7944ed6fe5 | ||
|
6b6243a186 | ||
|
a3afea7b9d | ||
|
fc87243c39 | ||
|
05823c325c | ||
|
753115ff57 | ||
|
8504110d45 | ||
|
e9980a9b8b | ||
|
627a85f4e4 | ||
|
f88ca4a206 | ||
|
52119104b9 | ||
|
e8c27767aa | ||
|
954f344cf7 | ||
|
db58235930 | ||
|
e3665c1d67 | ||
|
c41dc5e8e9 | ||
|
d32e0364f9 | ||
|
93577f74e7 | ||
|
fb48cc3b74 | ||
|
e616ffc5d6 | ||
|
29b12f9e0a | ||
|
38dd85daab | ||
|
da2ef4d676 | ||
|
3838e6836d | ||
|
c12125e6b5 | ||
|
0b48973733 | ||
|
8977fde8ed | ||
|
daf90377bd | ||
|
065f372bd1 | ||
|
d68750d7dc | ||
|
93e47c7135 | ||
|
5cda7f6bbb | ||
|
a6ed09441c | ||
|
51dc725794 | ||
|
39533190c2 | ||
|
c7a1b78536 | ||
|
ac2403fb24 | ||
|
367b05d733 | ||
|
0293a7dd49 | ||
|
7dea6a23f7 | ||
|
2c58e7e06a | ||
|
e4f56fa942 | ||
|
882de42bab | ||
|
7b7bf834e9 | ||
|
a05fe70c24 | ||
|
084668c170 | ||
|
2f1b6d4f41 | ||
|
1fd2ac774f | ||
|
3794e4e307 | ||
|
9d9bb1d728 | ||
|
305d0375ab | ||
|
85d1b74ac3 | ||
|
c1be1ac7c6 | ||
|
e177ff305a | ||
|
a6e4f754fc | ||
|
7ac574d9a9 | ||
|
b2e504616a | ||
|
116ab27e08 | ||
|
2c766bd4b4 | ||
|
01e43c3e57 | ||
|
546c4718e7 | ||
|
3ce6ac0ce2 | ||
|
a4313224d9 | ||
|
489bd99803 | ||
|
4f07fb1f0a | ||
|
fdc17bea58 | ||
|
a91c3ef6ce | ||
|
cea28e0c1d | ||
|
f8f15e5697 | ||
|
bcfa49aea7 | ||
|
4286d49ade | ||
|
44f236e889 | ||
|
dbfe1e4be6 | ||
|
49b7896953 | ||
|
3f54fba0d3 | ||
|
7ce4670164 | ||
|
50d3083cbd | ||
|
d42ed78aa4 | ||
|
c4eb63c1d4 | ||
|
f0bdfadab7 | ||
|
8152584cf5 | ||
|
20aa777c58 | ||
|
afded319d2 | ||
|
09218d4c01 | ||
|
cd765f26a9 | ||
|
ff229aa978 | ||
|
1c17b932fe | ||
|
82fd74d101 | ||
|
0320ea4b85 | ||
|
36921be9aa | ||
|
ca3b364aea | ||
|
bf5f55a6bb | ||
|
aabf884c5f | ||
|
17903c6dae | ||
|
c5e03a2e0d | ||
|
448ab18a20 | ||
|
b13e7dda1f | ||
|
8d7fc5da6c | ||
|
9aecc0f48a | ||
|
86574ea524 | ||
|
b6832553ff | ||
|
cff0b03cbb | ||
|
2f21f4cc01 | ||
|
2d69bf4b3a | ||
|
bb4d1eb2e8 | ||
|
096bfbad96 | ||
|
11cbe49ffc | ||
|
dbe00a4156 | ||
|
0400734df7 | ||
|
a39a92bd22 | ||
|
bec47e40f5 | ||
|
d0d799f911 | ||
|
f5c6baf29d | ||
|
c3559d18a3 | ||
|
4a6cc46e81 | ||
|
23fc424b7a | ||
|
b835f4aa1c | ||
|
ea4170785a | ||
|
d14967e1c8 | ||
|
45230c56ab | ||
|
3ab193bc3f | ||
|
93212bc2c4 | ||
|
7bb28bf780 | ||
|
262adb9791 | ||
|
eb47409036 | ||
|
a698b767c1 | ||
|
48b9619439 | ||
|
62292797ec | ||
|
6471a548fe | ||
|
4ea13d8dc9 | ||
|
2463c72006 | ||
|
087b993892 | ||
|
6d5ef89356 | ||
|
98c3a5e9c3 | ||
|
6d26bfd147 | ||
|
0405ef977a | ||
|
159203a7bc | ||
|
1828df9bc0 | ||
|
abf664b560 | ||
|
829d189f44 | ||
|
3731230c6d | ||
|
e4671adc25 | ||
|
c003e70758 | ||
|
562044f36a | ||
|
0160d1d9b5 | ||
|
aff22bfdb5 | ||
|
4b357ecf98 | ||
|
d427df4a8a | ||
|
76a717d549 | ||
|
87aabeb7af | ||
|
77045d0886 | ||
|
bc1dba22c5 | ||
|
e7723ab662 | ||
|
e7cd6da938 | ||
|
5ff2762fcf | ||
|
53fb7b1aa8 | ||
|
a59d10d3df | ||
|
9467b900a2 | ||
|
0a6b5e2c17 | ||
|
d991869498 | ||
|
dd03118098 | ||
|
57304ac375 | ||
|
bab4904492 | ||
|
d88d148d89 | ||
|
3692051561 | ||
|
50aaf4667f | ||
|
7e35650398 | ||
|
c49f6290eb | ||
|
fa1cc2d05a | ||
|
f10feb2e70 | ||
|
e8ff4c8e56 | ||
|
7060bdf04b | ||
|
e0a197650a | ||
|
c913bdfc98 | ||
|
bfb6cc5f2c | ||
|
ac4f53a3a2 | ||
|
1c84d505c8 | ||
|
909d0d5e88 | ||
|
e5565a7e4a | ||
|
19615a2c37 | ||
|
c1a0633a12 | ||
|
de50eff6ac | ||
|
f8f40f15da | ||
|
61db14bcbe | ||
|
720d1f8f3d | ||
|
b746a931a5 | ||
|
abb8f5837e | ||
|
a9e40a3d80 | ||
|
17122df80d | ||
|
852c82435d | ||
|
4b0e3eb1fb | ||
|
19cfe64455 | ||
|
04bbc57690 | ||
|
8698cd3281 | ||
|
1d2175f73c |
5
.babelrc
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"presets": ["es2015", "react"],
|
||||
"plugins": ["transform-object-rest-spread"]
|
||||
"plugins": [
|
||||
"transform-decorators-legacy",
|
||||
"transform-object-rest-spread"
|
||||
]
|
||||
}
|
||||
|
18
.codeclimate.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
engines:
|
||||
duplication:
|
||||
enabled: true
|
||||
config:
|
||||
languages:
|
||||
- ruby
|
||||
- javascript
|
||||
rubocop:
|
||||
enabled: true
|
||||
eslint:
|
||||
enabled: true
|
||||
ratings:
|
||||
paths:
|
||||
- "**.rb"
|
||||
- "**.js"
|
||||
exclude_paths:
|
||||
- spec/
|
||||
- vendor/asset
|
@@ -6,20 +6,38 @@ DB_USER=postgres
|
||||
DB_NAME=postgres
|
||||
DB_PASS=
|
||||
DB_PORT=5432
|
||||
NEO4J_HOST=neo4j
|
||||
NEO4J_PORT=7474
|
||||
|
||||
# Federation
|
||||
LOCAL_DOMAIN=example.com
|
||||
LOCAL_HTTPS=true
|
||||
|
||||
# Application secrets
|
||||
# Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose)
|
||||
PAPERCLIP_SECRET=
|
||||
SECRET_KEY_BASE=
|
||||
|
||||
# Registrations
|
||||
# Single user mode will disable registrations and redirect frontpage to the first profile
|
||||
# SINGLE_USER_MODE=true
|
||||
# Prevent registrations with following e-mail domains
|
||||
# EMAIL_DOMAIN_BLACKLIST=example1.com|example2.de|etc
|
||||
|
||||
# E-mail configuration
|
||||
SMTP_SERVER=smtp.mailgun.org
|
||||
SMTP_PORT=587
|
||||
SMTP_LOGIN=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_ADDRESS=notifications@example.com
|
||||
|
||||
# Optional asset host for multi-server setups
|
||||
# CDN_HOST=assets.example.com
|
||||
|
||||
# S3 (optional)
|
||||
# S3_ENABLED=true
|
||||
# S3_BUCKET=
|
||||
# AWS_ACCESS_KEY_ID=
|
||||
# AWS_SECRET_ACCESS_KEY=
|
||||
# S3_REGION=
|
||||
|
||||
# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
|
||||
# S3_CLOUDFRONT_HOST=
|
||||
|
3
.env.test
Normal file
@@ -0,0 +1,3 @@
|
||||
# Federation
|
||||
LOCAL_DOMAIN=cb6e6126.ngrok.io
|
||||
LOCAL_HTTPS=true
|
34
.eslintrc
@@ -15,7 +15,37 @@
|
||||
"sourceType": "module",
|
||||
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"arrowFunctions": true,
|
||||
"jsx": true,
|
||||
"destructuring": true,
|
||||
"modules": true,
|
||||
"spread": true
|
||||
}
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"no-cond-assign": 2,
|
||||
"no-console": 1,
|
||||
"no-irregular-whitespace": 2,
|
||||
"no-unreachable": 2,
|
||||
"valid-typeof": 2,
|
||||
"consistent-return": 2,
|
||||
"dot-notation": 2,
|
||||
"eqeqeq": 2,
|
||||
"no-fallthrough": 2,
|
||||
"no-unused-expressions": 2,
|
||||
"strict": 0,
|
||||
"no-catch-shadow": 2,
|
||||
"indent": [1, 2],
|
||||
"brace-style": 1,
|
||||
"comma-spacing": [1, {"before": false, "after": true}],
|
||||
"comma-style": [1, "last"],
|
||||
"no-mixed-spaces-and-tabs": 1,
|
||||
"no-nested-ternary": 1,
|
||||
"no-trailing-spaces": 1,
|
||||
"react/wrap-multilines": 2,
|
||||
"react/self-closing-comp": 2,
|
||||
"react/prop-types": 2,
|
||||
"react/no-multi-comp": 0
|
||||
}
|
||||
}
|
||||
|
5
.gitignore
vendored
@@ -19,6 +19,9 @@ coverage
|
||||
public/system
|
||||
public/assets
|
||||
.env
|
||||
.env.*
|
||||
.env.production
|
||||
node_modules/
|
||||
neo4j/
|
||||
|
||||
# Ignore Vagrant files
|
||||
.vagrant/
|
||||
|
84
.rubocop.yml
@@ -1,14 +1,90 @@
|
||||
Rails:
|
||||
Enabled: true
|
||||
|
||||
Metrics/LineLength:
|
||||
Enabled: false
|
||||
|
||||
Style/PerlBackrefs:
|
||||
AutoCorrect: false
|
||||
|
||||
Style/ClassAndModuleChildren:
|
||||
Enabled: false
|
||||
|
||||
Documentation:
|
||||
Metrics/BlockNesting:
|
||||
Max: 2
|
||||
|
||||
Metrics/LineLength:
|
||||
AllowURI: true
|
||||
Enabled: false
|
||||
|
||||
Metrics/MethodLength:
|
||||
CountComments: false
|
||||
Max: 10
|
||||
|
||||
Metrics/AbcSize:
|
||||
Max: 100
|
||||
|
||||
Metrics/BlockNesting:
|
||||
Max: 3
|
||||
|
||||
Metrics/ClassLength:
|
||||
CountComments: false
|
||||
Max: 200
|
||||
|
||||
Metrics/CyclomaticComplexity:
|
||||
Max: 15
|
||||
|
||||
Metrics/MethodLength:
|
||||
Max: 55
|
||||
|
||||
Metrics/ModuleLength:
|
||||
CountComments: false
|
||||
Max: 200
|
||||
|
||||
Metrics/PerceivedComplexity:
|
||||
Max: 10
|
||||
|
||||
Metrics/ParameterLists:
|
||||
Max: 4
|
||||
CountKeywordArgs: true
|
||||
|
||||
Style/AccessModifierIndentation:
|
||||
EnforcedStyle: indent
|
||||
|
||||
Style/CollectionMethods:
|
||||
Enabled: true
|
||||
PreferredMethods:
|
||||
find_all: 'select'
|
||||
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
|
||||
Style/DoubleNegation:
|
||||
Enabled: true
|
||||
|
||||
Style/FrozenStringLiteralComment:
|
||||
Enabled: true
|
||||
|
||||
Style/SpaceInsideHashLiteralBraces:
|
||||
EnforcedStyle: space
|
||||
|
||||
Style/TrailingCommaInLiteral:
|
||||
EnforcedStyleForMultiline: 'comma'
|
||||
|
||||
Style/RegexpLiteral:
|
||||
Enabled: false
|
||||
|
||||
Style/Lambda:
|
||||
Enabled: false
|
||||
|
||||
Rails/HasAndBelongsToMany:
|
||||
Enabled: false
|
||||
|
||||
AllCops:
|
||||
TargetRubyVersion: 2.3
|
||||
Exclude:
|
||||
- 'spec/**/*'
|
||||
- 'db/**/*'
|
||||
- 'app/views/**/*'
|
||||
- 'config/**/*'
|
||||
- 'bin/*'
|
||||
- 'Rakefile'
|
||||
- 'node_modules/**/*'
|
||||
- 'Vagrantfile'
|
||||
|
@@ -1 +1 @@
|
||||
ruby-2.2.4
|
||||
2.3.1
|
||||
|
@@ -11,14 +11,12 @@ env:
|
||||
- LOCAL_DOMAIN=cb6e6126.ngrok.io
|
||||
- LOCAL_HTTPS=true
|
||||
- RAILS_ENV=test
|
||||
- NEO4J_HOST=localhost
|
||||
- NEO4J_PORT=7575
|
||||
|
||||
addons:
|
||||
postgresql: 9.4
|
||||
|
||||
rvm:
|
||||
- 2.2.4
|
||||
- 2.3.1
|
||||
|
||||
services:
|
||||
- redis-server
|
||||
|
@@ -1,4 +1,4 @@
|
||||
FROM ruby:2.2.4
|
||||
FROM ruby:2.3.1
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
|
@@ -1,17 +0,0 @@
|
||||
FROM neo4j:latest
|
||||
|
||||
ENV NEO4J_AUTH=none
|
||||
|
||||
RUN cd /var/lib/neo4j/plugins \
|
||||
&& wget http://products.graphaware.com/download/framework-server-community/graphaware-server-community-all-3.0.6.43.jar \
|
||||
&& wget http://products.graphaware.com/download/noderank/graphaware-noderank-3.0.6.43.3.jar
|
||||
RUN echo "dbms.unmanaged_extension_classes=com.graphaware.server=/graphaware" >> /var/lib/neo4j/conf/neo4j.conf
|
||||
RUN echo 'com.graphaware.runtime.enabled=true\n\
|
||||
com.graphaware.module.NR.1=com.graphaware.module.noderank.NodeRankModuleBootstrapper\n\
|
||||
com.graphaware.module.NR.maxTopRankNodes=10\n\
|
||||
com.graphaware.module.NR.dampingFactor=0.85\n\
|
||||
com.graphaware.module.NR.propertyKey=nodeRank\n'\
|
||||
>> /var/lib/neo4j/conf/neo4j.conf
|
||||
RUN echo 'com.graphaware.runtime.stats.disabled=true\n\
|
||||
com.graphaware.server.stats.disabled=true\n'\
|
||||
>> /var/lib/neo4j/conf/neo4j.conf
|
24
Gemfile
@@ -1,10 +1,12 @@
|
||||
source 'https://rubygems.org'
|
||||
# frozen_string_literal: true
|
||||
|
||||
gem 'rails', '5.0.0.1'
|
||||
source 'https://rubygems.org'
|
||||
ruby '2.3.1'
|
||||
|
||||
gem 'rails', '~> 5.0.1.0'
|
||||
gem 'sass-rails', '~> 5.0'
|
||||
gem 'uglifier', '>= 1.3.0'
|
||||
gem 'coffee-rails', '~> 4.1.0'
|
||||
gem 'therubyracer', platforms: :ruby
|
||||
gem 'jquery-rails'
|
||||
gem 'jbuilder', '~> 2.0'
|
||||
gem 'sdoc', '~> 0.4.0', group: :doc
|
||||
@@ -12,11 +14,14 @@ gem 'puma'
|
||||
|
||||
gem 'hamlit-rails'
|
||||
gem 'pg'
|
||||
gem 'pghero'
|
||||
gem 'dotenv-rails'
|
||||
gem 'font-awesome-rails'
|
||||
gem 'best_in_place', '~> 3.0.1'
|
||||
|
||||
gem 'paperclip', '~> 4.3'
|
||||
gem 'paperclip', '~> 5.1'
|
||||
gem 'paperclip-av-transcoder'
|
||||
gem 'aws-sdk', '>= 2.0'
|
||||
|
||||
gem 'http'
|
||||
gem 'httplog'
|
||||
@@ -37,18 +42,23 @@ gem 'htmlentities'
|
||||
gem 'simple_form'
|
||||
gem 'will_paginate'
|
||||
gem 'rack-attack'
|
||||
gem 'rack-cors', require: 'rack/cors'
|
||||
gem 'sidekiq'
|
||||
gem 'ledermann-rails-settings'
|
||||
gem 'neography'
|
||||
gem 'rails-settings-cached'
|
||||
gem 'pg_search'
|
||||
gem 'simple-navigation'
|
||||
gem 'statsd-instrument'
|
||||
|
||||
gem 'react-rails'
|
||||
gem 'browserify-rails'
|
||||
gem 'autoprefixer-rails'
|
||||
|
||||
group :development, :test do
|
||||
gem 'rspec-rails'
|
||||
gem 'pry-rails'
|
||||
gem 'fuubar'
|
||||
gem 'fabrication'
|
||||
gem 'i18n-tasks', '~> 0.9.6'
|
||||
end
|
||||
|
||||
group :test do
|
||||
@@ -69,4 +79,6 @@ end
|
||||
group :production do
|
||||
gem 'rails_12factor'
|
||||
gem 'lograge'
|
||||
gem 'redis-rails'
|
||||
gem 'rack-timeout-puma'
|
||||
end
|
||||
|
218
Gemfile.lock
@@ -1,54 +1,68 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (5.0.0.1)
|
||||
actionpack (= 5.0.0.1)
|
||||
actioncable (5.0.1)
|
||||
actionpack (= 5.0.1)
|
||||
nio4r (~> 1.2)
|
||||
websocket-driver (~> 0.6.1)
|
||||
actionmailer (5.0.0.1)
|
||||
actionpack (= 5.0.0.1)
|
||||
actionview (= 5.0.0.1)
|
||||
activejob (= 5.0.0.1)
|
||||
actionmailer (5.0.1)
|
||||
actionpack (= 5.0.1)
|
||||
actionview (= 5.0.1)
|
||||
activejob (= 5.0.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (5.0.0.1)
|
||||
actionview (= 5.0.0.1)
|
||||
activesupport (= 5.0.0.1)
|
||||
actionpack (5.0.1)
|
||||
actionview (= 5.0.1)
|
||||
activesupport (= 5.0.1)
|
||||
rack (~> 2.0)
|
||||
rack-test (~> 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
||||
actionview (5.0.0.1)
|
||||
activesupport (= 5.0.0.1)
|
||||
actionview (5.0.1)
|
||||
activesupport (= 5.0.1)
|
||||
builder (~> 3.1)
|
||||
erubis (~> 2.7.0)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
||||
active_record_query_trace (1.5.3)
|
||||
activejob (5.0.0.1)
|
||||
activesupport (= 5.0.0.1)
|
||||
activejob (5.0.1)
|
||||
activesupport (= 5.0.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (5.0.0.1)
|
||||
activesupport (= 5.0.0.1)
|
||||
activerecord (5.0.0.1)
|
||||
activemodel (= 5.0.0.1)
|
||||
activesupport (= 5.0.0.1)
|
||||
activemodel (5.0.1)
|
||||
activesupport (= 5.0.1)
|
||||
activerecord (5.0.1)
|
||||
activemodel (= 5.0.1)
|
||||
activesupport (= 5.0.1)
|
||||
arel (~> 7.0)
|
||||
activesupport (5.0.0.1)
|
||||
activesupport (5.0.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (~> 0.7)
|
||||
minitest (~> 5.1)
|
||||
tzinfo (~> 1.1)
|
||||
addressable (2.4.0)
|
||||
arel (7.1.1)
|
||||
addressable (2.5.0)
|
||||
public_suffix (~> 2.0, >= 2.0.2)
|
||||
arel (7.1.4)
|
||||
ast (2.3.0)
|
||||
autoprefixer-rails (6.5.0.2)
|
||||
execjs
|
||||
av (0.9.0)
|
||||
cocaine (~> 0.5.3)
|
||||
aws-sdk (2.6.28)
|
||||
aws-sdk-resources (= 2.6.28)
|
||||
aws-sdk-core (2.6.28)
|
||||
aws-sigv4 (~> 1.0)
|
||||
jmespath (~> 1.0)
|
||||
aws-sdk-resources (2.6.28)
|
||||
aws-sdk-core (= 2.6.28)
|
||||
aws-sigv4 (1.0.0)
|
||||
babel-source (5.8.35)
|
||||
babel-transpiler (0.7.0)
|
||||
babel-source (>= 4.0, < 6)
|
||||
execjs (~> 2.0)
|
||||
bcrypt (3.1.11)
|
||||
best_in_place (3.0.3)
|
||||
actionpack (>= 3.2)
|
||||
railties (>= 3.2)
|
||||
better_errors (2.1.1)
|
||||
coderay (>= 1.0.0)
|
||||
erubis (>= 2.6.6)
|
||||
@@ -62,8 +76,7 @@ GEM
|
||||
bullet (5.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.10.0)
|
||||
climate_control (0.0.3)
|
||||
activesupport (>= 3.0)
|
||||
climate_control (0.1.0)
|
||||
cocaine (0.5.8)
|
||||
climate_control (>= 0.0.3, < 1.0)
|
||||
coderay (1.1.1)
|
||||
@@ -75,8 +88,8 @@ GEM
|
||||
execjs
|
||||
coffee-script-source (1.10.0)
|
||||
colorize (0.8.1)
|
||||
concurrent-ruby (1.0.2)
|
||||
connection_pool (2.2.0)
|
||||
concurrent-ruby (1.0.4)
|
||||
connection_pool (2.2.1)
|
||||
crack (0.4.3)
|
||||
safe_yaml (~> 1.0.0)
|
||||
debug_inspector (0.0.2)
|
||||
@@ -88,7 +101,7 @@ GEM
|
||||
warden (~> 1.2.3)
|
||||
diff-lcs (1.2.5)
|
||||
docile (1.1.5)
|
||||
domain_name (0.5.20160826)
|
||||
domain_name (0.5.20161129)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
doorkeeper (4.2.0)
|
||||
railties (>= 4.2)
|
||||
@@ -96,8 +109,11 @@ GEM
|
||||
dotenv-rails (2.1.1)
|
||||
dotenv (= 2.1.1)
|
||||
railties (>= 4.0, < 5.1)
|
||||
easy_translate (0.5.0)
|
||||
json
|
||||
thread
|
||||
thread_safe
|
||||
erubis (2.7.0)
|
||||
excon (0.53.0)
|
||||
execjs (2.7.0)
|
||||
fabrication (2.15.2)
|
||||
fast_blank (1.0.0)
|
||||
@@ -108,7 +124,7 @@ GEM
|
||||
ruby-progressbar (~> 1.4)
|
||||
globalid (0.3.7)
|
||||
activesupport (>= 4.1.0)
|
||||
goldfinger (1.1.0)
|
||||
goldfinger (1.1.2)
|
||||
addressable (~> 2.4)
|
||||
http (~> 2.0)
|
||||
nokogiri (~> 1.6)
|
||||
@@ -122,9 +138,10 @@ GEM
|
||||
hamlit (>= 1.2.0)
|
||||
railties (>= 4.0.1)
|
||||
hashdiff (0.3.0)
|
||||
highline (1.7.8)
|
||||
hiredis (0.6.1)
|
||||
htmlentities (4.3.4)
|
||||
http (2.0.3)
|
||||
http (2.1.0)
|
||||
addressable (~> 2.3)
|
||||
http-cookie (~> 1.0)
|
||||
http-form_data (~> 1.0.1)
|
||||
@@ -136,9 +153,20 @@ GEM
|
||||
httplog (0.3.2)
|
||||
colorize
|
||||
i18n (0.7.0)
|
||||
i18n-tasks (0.9.6)
|
||||
activesupport (>= 4.0.2)
|
||||
ast (>= 2.1.0)
|
||||
easy_translate (>= 0.5.0)
|
||||
erubis
|
||||
highline (>= 1.7.3)
|
||||
i18n
|
||||
parser (>= 2.2.3.0)
|
||||
term-ansicolor (>= 1.3.2)
|
||||
terminal-table (>= 1.5.1)
|
||||
jbuilder (2.6.0)
|
||||
activesupport (>= 3.0.0, < 5.1)
|
||||
multi_json (~> 1.2)
|
||||
jmespath (1.3.1)
|
||||
jquery-rails (4.1.1)
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
@@ -146,11 +174,8 @@ GEM
|
||||
json (1.8.3)
|
||||
launchy (2.4.3)
|
||||
addressable (~> 2.3)
|
||||
ledermann-rails-settings (2.4.2)
|
||||
activerecord (>= 3.1)
|
||||
letter_opener (1.4.1)
|
||||
launchy (~> 2.2)
|
||||
libv8 (3.16.14.15)
|
||||
link_header (0.0.8)
|
||||
lograge (0.4.1)
|
||||
actionpack (>= 4, < 5.1)
|
||||
@@ -164,39 +189,37 @@ GEM
|
||||
mime-types (3.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2016.0521)
|
||||
mimemagic (0.3.0)
|
||||
mimemagic (0.3.2)
|
||||
mini_portile2 (2.1.0)
|
||||
minitest (5.9.1)
|
||||
minitest (5.10.1)
|
||||
multi_json (1.12.1)
|
||||
neography (1.8.0)
|
||||
excon (>= 0.33.0)
|
||||
json (>= 1.7.7)
|
||||
multi_json (>= 1.3.2)
|
||||
os (>= 0.9.6)
|
||||
rake (>= 0.8.7)
|
||||
rubyzip (>= 1.0.0)
|
||||
nio4r (1.2.1)
|
||||
nokogiri (1.6.8.1)
|
||||
mini_portile2 (~> 2.1.0)
|
||||
oj (2.17.3)
|
||||
orm_adapter (0.5.0)
|
||||
os (0.9.6)
|
||||
ostatus2 (1.0.2)
|
||||
addressable (~> 2.4)
|
||||
http (~> 2.0)
|
||||
nokogiri (~> 1.6)
|
||||
paperclip (4.3.7)
|
||||
activemodel (>= 3.2.0)
|
||||
activesupport (>= 3.2.0)
|
||||
paperclip (5.1.0)
|
||||
activemodel (>= 4.2.0)
|
||||
activesupport (>= 4.2.0)
|
||||
cocaine (~> 0.5.5)
|
||||
mime-types
|
||||
mimemagic (= 0.3.0)
|
||||
mimemagic (~> 0.3.0)
|
||||
paperclip-av-transcoder (0.6.4)
|
||||
av (~> 0.9.0)
|
||||
paperclip (>= 2.5.2)
|
||||
parser (2.3.1.2)
|
||||
ast (~> 2.2)
|
||||
pg (0.18.4)
|
||||
pg_search (1.0.6)
|
||||
activerecord (>= 3.1)
|
||||
activesupport (>= 3.1)
|
||||
arel
|
||||
pghero (1.6.2)
|
||||
activerecord
|
||||
powerpack (0.1.1)
|
||||
pry (0.10.4)
|
||||
coderay (~> 1.1.0)
|
||||
@@ -204,33 +227,40 @@ GEM
|
||||
slop (~> 3.4)
|
||||
pry-rails (0.3.4)
|
||||
pry (>= 0.9.10)
|
||||
public_suffix (2.0.4)
|
||||
puma (3.6.0)
|
||||
rabl (0.13.0)
|
||||
rabl (0.13.1)
|
||||
activesupport (>= 2.3.14)
|
||||
rack (2.0.1)
|
||||
rack-attack (5.0.1)
|
||||
rack
|
||||
rack-cors (0.4.0)
|
||||
rack-protection (1.5.3)
|
||||
rack
|
||||
rack-test (0.6.3)
|
||||
rack (>= 1.0)
|
||||
rails (5.0.0.1)
|
||||
actioncable (= 5.0.0.1)
|
||||
actionmailer (= 5.0.0.1)
|
||||
actionpack (= 5.0.0.1)
|
||||
actionview (= 5.0.0.1)
|
||||
activejob (= 5.0.0.1)
|
||||
activemodel (= 5.0.0.1)
|
||||
activerecord (= 5.0.0.1)
|
||||
activesupport (= 5.0.0.1)
|
||||
rack-timeout (0.4.2)
|
||||
rack-timeout-puma (0.0.1)
|
||||
rack-timeout (~> 0.2, >= 0.2.0)
|
||||
rails (5.0.1)
|
||||
actioncable (= 5.0.1)
|
||||
actionmailer (= 5.0.1)
|
||||
actionpack (= 5.0.1)
|
||||
actionview (= 5.0.1)
|
||||
activejob (= 5.0.1)
|
||||
activemodel (= 5.0.1)
|
||||
activerecord (= 5.0.1)
|
||||
activesupport (= 5.0.1)
|
||||
bundler (>= 1.3.0, < 2.0)
|
||||
railties (= 5.0.0.1)
|
||||
railties (= 5.0.1)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-dom-testing (2.0.1)
|
||||
activesupport (>= 4.2.0, < 6.0)
|
||||
nokogiri (~> 1.6.0)
|
||||
rails-html-sanitizer (1.0.3)
|
||||
loofah (~> 2.0)
|
||||
rails-settings-cached (0.6.5)
|
||||
rails (>= 4.2.0)
|
||||
rails_12factor (0.0.3)
|
||||
rails_serve_static_assets
|
||||
rails_stdout_logging
|
||||
@@ -238,14 +268,14 @@ GEM
|
||||
rails (> 3.1)
|
||||
rails_serve_static_assets (0.0.5)
|
||||
rails_stdout_logging (0.0.5)
|
||||
railties (5.0.0.1)
|
||||
actionpack (= 5.0.0.1)
|
||||
activesupport (= 5.0.0.1)
|
||||
railties (5.0.1)
|
||||
actionpack (= 5.0.1)
|
||||
activesupport (= 5.0.1)
|
||||
method_source
|
||||
rake (>= 0.8.7)
|
||||
thor (>= 0.18.1, < 2.0)
|
||||
rainbow (2.1.0)
|
||||
rake (11.3.0)
|
||||
rake (12.0.0)
|
||||
rdoc (4.2.2)
|
||||
json (~> 1.4)
|
||||
react-rails (1.8.2)
|
||||
@@ -255,8 +285,23 @@ GEM
|
||||
execjs
|
||||
railties (>= 3.2)
|
||||
tilt
|
||||
redis (3.3.1)
|
||||
ref (2.0.0)
|
||||
redis (3.3.2)
|
||||
redis-actionpack (5.0.0)
|
||||
actionpack (>= 4.0.0, < 6)
|
||||
redis-rack (~> 2.0.0.pre)
|
||||
redis-store (~> 1.2.0.pre)
|
||||
redis-activesupport (5.0.1)
|
||||
activesupport (>= 3, < 6)
|
||||
redis-store (~> 1.2.0)
|
||||
redis-rack (2.0.0)
|
||||
rack (~> 2.0)
|
||||
redis-store (~> 1.2.0)
|
||||
redis-rails (5.0.1)
|
||||
redis-actionpack (~> 5.0.0)
|
||||
redis-activesupport (~> 5.0.0)
|
||||
redis-store (~> 1.2.0)
|
||||
redis-store (1.2.0)
|
||||
redis (>= 2.2)
|
||||
responders (2.3.0)
|
||||
railties (>= 4.2.0, < 5.1)
|
||||
rspec (3.5.0)
|
||||
@@ -290,7 +335,6 @@ GEM
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (~> 1.0, >= 1.0.1)
|
||||
ruby-progressbar (1.8.1)
|
||||
rubyzip (1.2.0)
|
||||
safe_yaml (1.0.4)
|
||||
sass (3.4.22)
|
||||
sass-rails (5.0.6)
|
||||
@@ -302,11 +346,13 @@ GEM
|
||||
sdoc (0.4.1)
|
||||
json (~> 1.7, >= 1.7.7)
|
||||
rdoc (~> 4.0)
|
||||
sidekiq (4.2.1)
|
||||
sidekiq (4.2.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
connection_pool (~> 2.2, >= 2.2.0)
|
||||
rack-protection (~> 1.5)
|
||||
rack-protection (>= 1.5.0)
|
||||
redis (~> 3.2, >= 3.2.1)
|
||||
simple-navigation (4.0.3)
|
||||
activesupport (>= 2.3.2)
|
||||
simple_form (3.2.1)
|
||||
actionpack (> 4, < 5.1)
|
||||
activemodel (> 4, < 5.1)
|
||||
@@ -316,20 +362,24 @@ GEM
|
||||
simplecov-html (~> 0.10.0)
|
||||
simplecov-html (0.10.0)
|
||||
slop (3.6.0)
|
||||
sprockets (3.7.0)
|
||||
sprockets (3.7.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (> 1, < 3)
|
||||
sprockets-rails (3.1.1)
|
||||
sprockets-rails (3.2.0)
|
||||
actionpack (>= 4.0)
|
||||
activesupport (>= 4.0)
|
||||
sprockets (>= 3.0.0)
|
||||
statsd-instrument (2.1.2)
|
||||
temple (0.7.7)
|
||||
therubyracer (0.12.2)
|
||||
libv8 (~> 3.16.14.0)
|
||||
ref
|
||||
thor (0.19.1)
|
||||
term-ansicolor (1.4.0)
|
||||
tins (~> 1.0)
|
||||
terminal-table (1.7.0)
|
||||
unicode-display_width (~> 1.1)
|
||||
thor (0.19.4)
|
||||
thread (0.2.2)
|
||||
thread_safe (0.3.5)
|
||||
tilt (2.0.5)
|
||||
tins (1.12.0)
|
||||
tzinfo (1.2.2)
|
||||
thread_safe (~> 0.1)
|
||||
uglifier (3.0.1)
|
||||
@@ -356,6 +406,9 @@ PLATFORMS
|
||||
DEPENDENCIES
|
||||
active_record_query_trace
|
||||
addressable
|
||||
autoprefixer-rails
|
||||
aws-sdk (>= 2.0)
|
||||
best_in_place (~> 3.0.1)
|
||||
better_errors
|
||||
binding_of_caller
|
||||
browserify-rails
|
||||
@@ -374,40 +427,49 @@ DEPENDENCIES
|
||||
htmlentities
|
||||
http
|
||||
httplog
|
||||
i18n-tasks (~> 0.9.6)
|
||||
jbuilder (~> 2.0)
|
||||
jquery-rails
|
||||
ledermann-rails-settings
|
||||
letter_opener
|
||||
link_header
|
||||
lograge
|
||||
neography
|
||||
nokogiri
|
||||
oj
|
||||
ostatus2
|
||||
paperclip (~> 4.3)
|
||||
paperclip (~> 5.1)
|
||||
paperclip-av-transcoder
|
||||
pg
|
||||
pg_search
|
||||
pghero
|
||||
pry-rails
|
||||
puma
|
||||
rabl
|
||||
rack-attack
|
||||
rails (= 5.0.0.1)
|
||||
rack-cors
|
||||
rack-timeout-puma
|
||||
rails (~> 5.0.1.0)
|
||||
rails-settings-cached
|
||||
rails_12factor
|
||||
rails_autolink
|
||||
react-rails
|
||||
redis (~> 3.2)
|
||||
redis-rails
|
||||
rspec-rails
|
||||
rspec-sidekiq
|
||||
rubocop
|
||||
sass-rails (~> 5.0)
|
||||
sdoc (~> 0.4.0)
|
||||
sidekiq
|
||||
simple-navigation
|
||||
simple_form
|
||||
simplecov
|
||||
therubyracer
|
||||
statsd-instrument
|
||||
uglifier (>= 1.3.0)
|
||||
webmock
|
||||
will_paginate
|
||||
|
||||
RUBY VERSION
|
||||
ruby 2.3.1p112
|
||||
|
||||
BUNDLED WITH
|
||||
1.13.0
|
||||
1.13.6
|
||||
|
2
Procfile
Normal file
@@ -0,0 +1,2 @@
|
||||
web: bundle exec puma -C config/puma.rb
|
||||
worker: bundle exec sidekiq -q default -q mailers -q push
|
91
README.md
@@ -1,17 +1,19 @@
|
||||
Mastodon
|
||||
========
|
||||
|
||||
[][travis]
|
||||
[][code_climate]
|
||||
[][travis]
|
||||
[][code_climate]
|
||||
|
||||
[travis]: https://travis-ci.org/Gargron/mastodon
|
||||
[code_climate]: https://codeclimate.com/github/Gargron/mastodon
|
||||
[travis]: https://travis-ci.org/tootsuite/mastodon
|
||||
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
|
||||
|
||||
Mastodon is a federated microblogging engine. An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
|
||||
Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
|
||||
|
||||
An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
|
||||
|
||||
Click on the screenshot to watch a demo of the UI:
|
||||
|
||||
[][youtube_demo]
|
||||
[][youtube_demo]
|
||||
|
||||
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
|
||||
|
||||
@@ -21,12 +23,14 @@ If you would like, you can [support the development of this project on Patreon][
|
||||
|
||||
[patreon]: https://www.patreon.com/user?u=619786
|
||||
|
||||
**Current status of the project is early development**
|
||||
|
||||
## Resources
|
||||
|
||||
- [API overview](https://github.com/Gargron/mastodon/wiki/API)
|
||||
- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL)
|
||||
- [List of Mastodon instances](https://github.com/tootsuite/mastodon/wiki/List-of-Mastodon-instances)
|
||||
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
|
||||
- [API overview](https://github.com/tootsuite/mastodon/wiki/API)
|
||||
- [How to use the API via cURL/oAuth](https://github.com/tootsuite/mastodon/wiki/Testing-with-cURL)
|
||||
- [Frequently Asked Questions](https://github.com/tootsuite/mastodon/wiki/FAQ)
|
||||
- [List of apps](https://github.com/tootsuite/mastodon/wiki/Apps)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -49,16 +53,16 @@ If you would like, you can [support the development of this project on Patreon][
|
||||
|
||||
- `LOCAL_DOMAIN` should be the domain/hostname of your instance. This is **absolutely required** as it is used for generating unique IDs for everything federation-related
|
||||
- `LOCAL_HTTPS` set it to `true` if HTTPS works on your website. This is used to generate canonical URLs, which is also important when generating and parsing federation-related IDs
|
||||
- `HUB_URL` should be the URL of the PubsubHubbub service that your instance is going to use. By default it is the open service of Superfeedr
|
||||
|
||||
Consult the example configuration file, `.env.production.sample` for the full list. Among other things you need to set details for the SMTP server you are going to use.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Ruby
|
||||
- Node.js
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
- Neo4J (optional)
|
||||
- GraphAware NodeRank
|
||||
- Nginx
|
||||
|
||||
## Running with Docker and Docker-Compose
|
||||
|
||||
@@ -87,8 +91,8 @@ The container has two volumes, for the assets and for user uploads. The default
|
||||
- `rake mastodon:media:clear` removes uploads that have not been attached to any status after a while, you would want to run this from a periodic cronjob
|
||||
- `rake mastodon:push:clear` unsubscribes from PuSH notifications for remote users that have no local followers. You may not want to actually do that, to keep a fuller footprint of the fediverse or in case your users will soon re-follow
|
||||
- `rake mastodon:push:refresh` re-subscribes PuSH for expiring remote users, this should be run periodically from a cronjob and quite often as the expiration time depends on the particular hub of the remote user
|
||||
- `rake mastodon:feeds:clear` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
|
||||
- `rake mastodon:graphs:sync` re-imports all follow relationships into Neo4J. Only for troubleshooting
|
||||
- `rake mastodon:feeds:clear_all` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
|
||||
- `rake mastodon:feeds:clear` removes timelines of users who haven't signed in lately, which allows to save RAM and improve message distribution. This is required to be run periodically so that when they login again the regeneration process will trigger
|
||||
|
||||
Running any of these tasks via docker-compose would look like this:
|
||||
|
||||
@@ -110,6 +114,61 @@ And finally,
|
||||
|
||||
Which will re-create the updated containers, leaving databases and data as is. Depending on what files have been updated, you might need to re-run migrations and asset compilation.
|
||||
|
||||
### Contributing
|
||||
## Deployment without Docker
|
||||
|
||||
Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](https://github.com/tootsuite/mastodon/wiki/Production-guide) for examples, configuration and instructions.
|
||||
|
||||
## Deployment on Heroku (experimental)
|
||||
|
||||
[](https://heroku.com/deploy)
|
||||
|
||||
Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. It should be noted this has limited testing and could have unpredictable results.
|
||||
|
||||
1. Click the above button.
|
||||
2. Fill in the options requested.
|
||||
* You can use a .herokuapp.com domain, which will be simple to set up, or you can use a custom domain. If you want a custom domain and HTTPS, you will need to upgrade to a paid plan (to use Heroku's SSL features), or set up [CloudFlare](https://cloudflare.com) who offer free "Flexible SSL" (note: CloudFlare have some undefined limits on WebSockets. So far, no one has reported hitting concurrent connection limits).
|
||||
* You will want Amazon S3 for file storage. The only exception is for development purposes, where you may not care if files are not saaved. Follow a guide online for creating a free Amazon S3 bucket and Access Key, then enter the details.
|
||||
* If you want your Mastodon to be able to send emails, configure SMTP settings here (or later). Consider using [Mailgun](https://mailgun.com) or similar, who offer free plans that should suit your interests.
|
||||
3. Deploy! The app should be set up, with a working web interface and database. You can change settings and manage versions from the Heroku dashboard.
|
||||
|
||||
## Development with Vagrant
|
||||
|
||||
A quick way to get a development environment up and running is with Vagrant. You will need recent versions of [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) installed.
|
||||
|
||||
Install the latest version for your operating systems, and then run:
|
||||
|
||||
vagrant plugin install vagrant-hostsupdater
|
||||
|
||||
This is optional, but will update your 'hosts' file when you start the virtual machine, allowing you to access the site at http://mastodon.dev (instead of http://localhost:3000).
|
||||
|
||||
To create and provision a new virtual machine for Mastodon development:
|
||||
|
||||
git clone git@github.com:tootsuite/mastodon.git
|
||||
cd mastodon
|
||||
vagrant up
|
||||
|
||||
Running `vagrant up` for the first time will run provisioning, which will:
|
||||
|
||||
- Download the Ubuntu 14.04 base image, if there isn't already a copy on your machine
|
||||
- Create a new VirtualBox virtual machine from that image
|
||||
- Run the provisioning script (located inside the Vagrantfile), which installs the system packages, Ruby gems, and JS modules required for Mastodon
|
||||
|
||||
Once this has completed, the virtual machine will start a rails process. You can then access your development site at http://mastodon.dev (or at http://localhost:3000 if you haven't installed vagrants-hostupdater). Any changes you make should be reflected on the server instantly. To set environment variables, copy `.env.production.sample` to `.env.vagrant` and make changes as required.
|
||||
|
||||
When you are finished with your session, run `vagrant halt` to stop the VM. Next time, running `vagrant up` should boot the VM, and skip provisioning.
|
||||
|
||||
If you no longer need your environment, or if things have gone terribly wrong, running `vagrant destroy` will delete the virtual machine (after which, running `vagrant up` will create a new one, and run provisioning).
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. This section may be updated with more details in the future.
|
||||
|
||||
**IRC channel**: #mastodon on irc.freenode.net
|
||||
|
||||
## Extra credits
|
||||
|
||||
- The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis
|
||||
- The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo)
|
||||
|
||||

|
||||
|
109
Vagrantfile
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
|
||||
$provision = <<SCRIPT
|
||||
|
||||
cd /vagrant # This is where the host folder/repo is mounted
|
||||
|
||||
# Add the yarn repo + yarn repo keys
|
||||
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
|
||||
sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main'
|
||||
|
||||
# Add repo for NodeJS
|
||||
curl -sL https://deb.nodesource.com/setup_4.x | sudo bash -
|
||||
|
||||
# Add firewall rule to redirect 80 to 3000 and save
|
||||
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000
|
||||
echo iptables-persistent iptables-persistent/autosave_v4 boolean true | sudo debconf-set-selections
|
||||
echo iptables-persistent iptables-persistent/autosave_v6 boolean true | sudo debconf-set-selections
|
||||
sudo apt-get install iptables-persistent -y
|
||||
|
||||
# Add packages to build and run Mastodon
|
||||
sudo apt-get install \
|
||||
git-core \
|
||||
g++ \
|
||||
libpq-dev \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
imagemagick \
|
||||
nodejs \
|
||||
redis-server \
|
||||
redis-tools \
|
||||
postgresql \
|
||||
postgresql-contrib \
|
||||
yarn \
|
||||
libreadline-dev \
|
||||
-y
|
||||
|
||||
# Install rbenv
|
||||
git clone https://github.com/rbenv/rbenv.git ~/.rbenv
|
||||
cd ~/.rbenv && src/configure && make -C src
|
||||
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile
|
||||
echo 'eval "$(rbenv init -)"' >> ~/.bash_profile
|
||||
|
||||
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
|
||||
|
||||
export PATH="$HOME/.rbenv/bin::$PATH"
|
||||
eval "$(rbenv init -)"
|
||||
|
||||
echo "Compiling Ruby 2.3.1: warning, this takes a while!!!"
|
||||
rbenv install 2.3.1
|
||||
rbenv global 2.3.1
|
||||
|
||||
cd /vagrant
|
||||
|
||||
# Configure database
|
||||
sudo -u postgres createuser -U postgres vagrant -s
|
||||
sudo -u postgres createdb -U postgres mastodon_development
|
||||
|
||||
# Install gems and node modules
|
||||
gem install bundler
|
||||
bundle install
|
||||
yarn install
|
||||
|
||||
# Build Mastodon
|
||||
bundle exec rails db:setup
|
||||
bundle exec rails assets:precompile
|
||||
|
||||
SCRIPT
|
||||
|
||||
$start = <<SCRIPT
|
||||
|
||||
cd /vagrant
|
||||
export $(cat ".env.vagrant" | xargs)
|
||||
rails s -d -b 0.0.0.0
|
||||
|
||||
SCRIPT
|
||||
|
||||
VAGRANTFILE_API_VERSION = "2"
|
||||
|
||||
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
|
||||
config.vm.box = "ubuntu/trusty64"
|
||||
|
||||
config.vm.provider :virtualbox do |vb|
|
||||
vb.name = "mastodon"
|
||||
vb.customize ["modifyvm", :id, "--memory", "1024"]
|
||||
end
|
||||
|
||||
config.vm.hostname = "mastodon.dev"
|
||||
|
||||
# This uses the vagrant-hostsupdater plugin, and lets you
|
||||
# access the development site at http://mastodon.dev.
|
||||
# To install:
|
||||
# $ vagrant plugin install hostsupdater
|
||||
if defined?(VagrantPlugins::HostsUpdater)
|
||||
config.vm.network :private_network, ip: "192.168.42.42"
|
||||
config.hostsupdater.remove_on_suspend = false
|
||||
end
|
||||
|
||||
# Otherwise, you can access the site at http://localhost:3000
|
||||
config.vm.network :forwarded_port, guest: 80, host: 3000
|
||||
|
||||
# Full provisioning script, only runs on first 'vagrant up' or with 'vagrant provision'
|
||||
config.vm.provision :shell, inline: $provision, privileged: false
|
||||
|
||||
# Start up script, runs on every 'vagrant up'
|
||||
config.vm.provision :shell, inline: $start, run: 'always', privileged: false
|
||||
|
||||
end
|
91
app.json
Normal file
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"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",
|
||||
"env": {
|
||||
"HEROKU": {
|
||||
"description": "Leave this as true",
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"LOCAL_DOMAIN": {
|
||||
"description": "The domain that your Mastodon instance will run on (this can be appname.herokuapp.com or a custom domain)",
|
||||
"required": true
|
||||
},
|
||||
"LOCAL_HTTPS": {
|
||||
"description": "Will your domain support HTTPS? (Automatic for herokuapp, requires manual configuration for custom domains)",
|
||||
"value": "false",
|
||||
"required": true
|
||||
},
|
||||
"PAPERCLIP_SECRET": {
|
||||
"description": "The secret key for storing media files",
|
||||
"generator": "secret"
|
||||
},
|
||||
"SECRET_KEY_BASE": {
|
||||
"description": "The secret key base",
|
||||
"generator": "secret"
|
||||
},
|
||||
"SINGLE_USER_MODE": {
|
||||
"description": "Should the instance run in single user mode? (Disable registrations, redirect to front page)",
|
||||
"value": "false",
|
||||
"required": true
|
||||
},
|
||||
"S3_ENABLED": {
|
||||
"description": "Should Mastodon use Amazon S3 for storage? This is highly recommended, as Heroku does not have persistent file storage (files will be lost).",
|
||||
"value": "true",
|
||||
"required": false
|
||||
},
|
||||
"S3_BUCKET": {
|
||||
"description": "Amazon S3 Bucket",
|
||||
"required": false
|
||||
},
|
||||
"S3_REGION": {
|
||||
"description": "Amazon S3 region that the bucket is located in",
|
||||
"required": false
|
||||
},
|
||||
"AWS_ACCESS_KEY_ID": {
|
||||
"description": "Amazon S3 Access Key",
|
||||
"required": false
|
||||
},
|
||||
"AWS_SECRET_ACCESS_KEY": {
|
||||
"description": "Amazon S3 Secret Key",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_SERVER": {
|
||||
"description": "Hostname for SMTP server, if you want to enable email",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PORT": {
|
||||
"description": "Port for SMTP server",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_LOGIN": {
|
||||
"description": "Username for SMTP server",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PASSWORD": {
|
||||
"description": "Password for SMTP server",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_DOMAIN": {
|
||||
"description": "Domain for SMTP server. Will default to instance domain if blank.",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"buildpacks": [
|
||||
{
|
||||
"url": "heroku/nodejs"
|
||||
},
|
||||
{
|
||||
"url": "heroku/ruby"
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"postdeploy": "bundle exec rails db:migrate && bundle exec rails db:seed"
|
||||
},
|
||||
"addons": [
|
||||
"heroku-postgresql",
|
||||
"heroku-redis"
|
||||
]
|
||||
}
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 874 KiB |
BIN
app/assets/images/boost_sprite.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 20 KiB |
BIN
app/assets/images/mastodon-getting-started.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
app/assets/images/mastodon.jpg
Normal file
After Width: | Height: | Size: 131 KiB |
BIN
app/assets/images/mastodon_small.jpg
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
app/assets/images/screenshot.png
Normal file
After Width: | Height: | Size: 244 KiB |
Before Width: | Height: | Size: 180 B After Width: | Height: | Size: 174 B |
@@ -1,2 +1,8 @@
|
||||
//= require jquery
|
||||
//= require jquery_ujs
|
||||
//= require extras
|
||||
//= require best_in_place
|
||||
|
||||
$(function () {
|
||||
$(".best_in_place").best_in_place();
|
||||
});
|
||||
|
@@ -5,6 +5,11 @@ window.React = require('react');
|
||||
window.ReactDOM = require('react-dom');
|
||||
window.Perf = require('react-addons-perf');
|
||||
|
||||
if (!window.Intl) {
|
||||
require('intl');
|
||||
require('intl/locale-data/jsonp/en.js');
|
||||
}
|
||||
|
||||
//= require_tree ./components
|
||||
|
||||
window.Mastodon = require('./components/containers/mastodon');
|
||||
|
@@ -1,9 +1,6 @@
|
||||
import api from '../api'
|
||||
import axios from 'axios';
|
||||
import api, { getLinks } from '../api'
|
||||
import Immutable from 'immutable';
|
||||
|
||||
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
|
||||
|
||||
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
|
||||
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
|
||||
export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL';
|
||||
@@ -32,33 +29,70 @@ export const ACCOUNT_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_TIMELINE_EXPAND_REQUEST'
|
||||
export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS';
|
||||
export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_FAIL';
|
||||
|
||||
export function setAccountSelf(account) {
|
||||
return {
|
||||
type: ACCOUNT_SET_SELF,
|
||||
account: account
|
||||
};
|
||||
};
|
||||
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
|
||||
export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
|
||||
export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST';
|
||||
export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS';
|
||||
export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL';
|
||||
|
||||
export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
|
||||
export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
|
||||
export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST';
|
||||
export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
|
||||
export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL';
|
||||
|
||||
export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
|
||||
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
|
||||
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
|
||||
export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS';
|
||||
export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST';
|
||||
export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
|
||||
export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL';
|
||||
|
||||
export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
|
||||
export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
|
||||
export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
|
||||
|
||||
export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
|
||||
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
|
||||
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
|
||||
|
||||
export function fetchAccount(id) {
|
||||
return (dispatch, getState) => {
|
||||
const boundApi = api(getState);
|
||||
|
||||
dispatch(fetchAccountRequest(id));
|
||||
|
||||
axios.all([boundApi.get(`/api/v1/accounts/${id}`), boundApi.get(`/api/v1/accounts/relationships?id=${id}`)]).then(values => {
|
||||
dispatch(fetchAccountSuccess(values[0].data, values[1].data[0]));
|
||||
api(getState).get(`/api/v1/accounts/${id}`).then(response => {
|
||||
dispatch(fetchAccountSuccess(response.data));
|
||||
dispatch(fetchRelationships([id]));
|
||||
}).catch(error => {
|
||||
dispatch(fetchAccountFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountTimeline(id) {
|
||||
export function fetchAccountTimeline(id, replace = false) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchAccountTimelineRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/statuses`).then(response => {
|
||||
dispatch(fetchAccountTimelineSuccess(id, response.data));
|
||||
const ids = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
|
||||
const newestId = ids.size > 0 ? ids.first() : null;
|
||||
|
||||
let params = '';
|
||||
|
||||
if (newestId !== null && !replace) {
|
||||
params = `?since_id=${newestId}`;
|
||||
}
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
|
||||
dispatch(fetchAccountTimelineSuccess(id, response.data, replace));
|
||||
}).catch(error => {
|
||||
dispatch(fetchAccountTimelineFail(id, error));
|
||||
});
|
||||
@@ -82,23 +116,22 @@ export function expandAccountTimeline(id) {
|
||||
export function fetchAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_FETCH_REQUEST,
|
||||
id: id
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountSuccess(account, relationship) {
|
||||
export function fetchAccountSuccess(account) {
|
||||
return {
|
||||
type: ACCOUNT_FETCH_SUCCESS,
|
||||
account: account,
|
||||
relationship: relationship
|
||||
account
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountFail(id, error) {
|
||||
return {
|
||||
type: ACCOUNT_FETCH_FAIL,
|
||||
id: id,
|
||||
error: error
|
||||
id,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
@@ -129,88 +162,89 @@ export function unfollowAccount(id) {
|
||||
export function followAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_FOLLOW_REQUEST,
|
||||
id: id
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function followAccountSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_FOLLOW_SUCCESS,
|
||||
relationship: relationship
|
||||
relationship
|
||||
};
|
||||
};
|
||||
|
||||
export function followAccountFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_FOLLOW_FAIL,
|
||||
error: error
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function unfollowAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_UNFOLLOW_REQUEST,
|
||||
id: id
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function unfollowAccountSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_UNFOLLOW_SUCCESS,
|
||||
relationship: relationship
|
||||
relationship
|
||||
};
|
||||
};
|
||||
|
||||
export function unfollowAccountFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_UNFOLLOW_FAIL,
|
||||
error: error
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountTimelineRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_TIMELINE_FETCH_REQUEST,
|
||||
id: id
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountTimelineSuccess(id, statuses) {
|
||||
export function fetchAccountTimelineSuccess(id, statuses, replace) {
|
||||
return {
|
||||
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||
id: id,
|
||||
statuses: statuses
|
||||
id,
|
||||
statuses,
|
||||
replace
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountTimelineFail(id, error) {
|
||||
return {
|
||||
type: ACCOUNT_TIMELINE_FETCH_FAIL,
|
||||
id: id,
|
||||
error: error
|
||||
id,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function expandAccountTimelineRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_TIMELINE_EXPAND_REQUEST,
|
||||
id: id
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function expandAccountTimelineSuccess(id, statuses) {
|
||||
return {
|
||||
type: ACCOUNT_TIMELINE_EXPAND_SUCCESS,
|
||||
id: id,
|
||||
statuses: statuses
|
||||
id,
|
||||
statuses
|
||||
};
|
||||
};
|
||||
|
||||
export function expandAccountTimelineFail(id, error) {
|
||||
return {
|
||||
type: ACCOUNT_TIMELINE_EXPAND_FAIL,
|
||||
id: id,
|
||||
error: error
|
||||
id,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
@@ -219,7 +253,8 @@ export function blockAccount(id) {
|
||||
dispatch(blockAccountRequest(id));
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
|
||||
dispatch(blockAccountSuccess(response.data));
|
||||
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
|
||||
dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
|
||||
}).catch(error => {
|
||||
dispatch(blockAccountFail(id, error));
|
||||
});
|
||||
@@ -241,41 +276,384 @@ export function unblockAccount(id) {
|
||||
export function blockAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_BLOCK_REQUEST,
|
||||
id: id
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function blockAccountSuccess(relationship) {
|
||||
export function blockAccountSuccess(relationship, statuses) {
|
||||
return {
|
||||
type: ACCOUNT_BLOCK_SUCCESS,
|
||||
relationship: relationship
|
||||
relationship,
|
||||
statuses
|
||||
};
|
||||
};
|
||||
|
||||
export function blockAccountFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_BLOCK_FAIL,
|
||||
error: error
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function unblockAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_UNBLOCK_REQUEST,
|
||||
id: id
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function unblockAccountSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_UNBLOCK_SUCCESS,
|
||||
relationship: relationship
|
||||
relationship
|
||||
};
|
||||
};
|
||||
|
||||
export function unblockAccountFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_UNBLOCK_FAIL,
|
||||
error: error
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowers(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFollowersRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFollowersFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowersRequest(id) {
|
||||
return {
|
||||
type: FOLLOWERS_FETCH_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowersSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FOLLOWERS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowersFail(id, error) {
|
||||
return {
|
||||
type: FOLLOWERS_FETCH_FAIL,
|
||||
id,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowers(id) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'followers', id, 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowersRequest(id));
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowersFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowersRequest(id) {
|
||||
return {
|
||||
type: FOLLOWERS_EXPAND_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowersSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FOLLOWERS_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowersFail(id, error) {
|
||||
return {
|
||||
type: FOLLOWERS_EXPAND_FAIL,
|
||||
id,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowing(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFollowingRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFollowingFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowingRequest(id) {
|
||||
return {
|
||||
type: FOLLOWING_FETCH_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowingSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FOLLOWING_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowingFail(id, error) {
|
||||
return {
|
||||
type: FOLLOWING_FETCH_FAIL,
|
||||
id,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowing(id) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'following', id, 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowingRequest(id));
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowingFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowingRequest(id) {
|
||||
return {
|
||||
type: FOLLOWING_EXPAND_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowingSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FOLLOWING_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowingFail(id, error) {
|
||||
return {
|
||||
type: FOLLOWING_EXPAND_FAIL,
|
||||
id,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationships(account_ids) {
|
||||
return (dispatch, getState) => {
|
||||
if (account_ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchRelationshipsRequest(account_ids));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
||||
dispatch(fetchRelationshipsSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchRelationshipsFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationshipsRequest(ids) {
|
||||
return {
|
||||
type: RELATIONSHIPS_FETCH_REQUEST,
|
||||
ids
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationshipsSuccess(relationships) {
|
||||
return {
|
||||
type: RELATIONSHIPS_FETCH_SUCCESS,
|
||||
relationships
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationshipsFail(error) {
|
||||
return {
|
||||
type: RELATIONSHIPS_FETCH_FAIL,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowRequests() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFollowRequestsRequest());
|
||||
|
||||
api(getState).get('/api/v1/follow_requests').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null))
|
||||
}).catch(error => dispatch(fetchFollowRequestsFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowRequestsRequest() {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_FETCH_REQUEST
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowRequestsSuccess(accounts, next) {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_FETCH_SUCCESS,
|
||||
accounts,
|
||||
next
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowRequestsFail(error) {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_FETCH_FAIL,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowRequests() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'follow_requests', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowRequestsRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null))
|
||||
}).catch(error => dispatch(expandFollowRequestsFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowRequestsRequest() {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_EXPAND_REQUEST
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowRequestsSuccess(accounts, next) {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_EXPAND_SUCCESS,
|
||||
accounts,
|
||||
next
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowRequestsFail(error) {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_EXPAND_FAIL,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function authorizeFollowRequest(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(authorizeFollowRequestRequest(id));
|
||||
|
||||
api(getState)
|
||||
.post(`/api/v1/follow_requests/${id}/authorize`)
|
||||
.then(response => dispatch(authorizeFollowRequestSuccess(id)))
|
||||
.catch(error => dispatch(authorizeFollowRequestFail(id, error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function authorizeFollowRequestRequest(id) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_AUTHORIZE_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function authorizeFollowRequestSuccess(id) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function authorizeFollowRequestFail(id, error) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
|
||||
id,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export function rejectFollowRequest(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(rejectFollowRequestRequest(id));
|
||||
|
||||
api(getState)
|
||||
.post(`/api/v1/follow_requests/${id}/reject`)
|
||||
.then(response => dispatch(rejectFollowRequestSuccess(id)))
|
||||
.catch(error => dispatch(rejectFollowRequestFail(id, error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function rejectFollowRequestRequest(id) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_REJECT_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function rejectFollowRequestSuccess(id) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_REJECT_SUCCESS,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function rejectFollowRequestFail(id, error) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_REJECT_FAIL,
|
||||
id,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
24
app/assets/javascripts/components/actions/alerts.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||
export const ALERT_DISMISS = 'ALERT_DISMISS';
|
||||
export const ALERT_CLEAR = 'ALERT_CLEAR';
|
||||
|
||||
export function dismissAlert(alert) {
|
||||
return {
|
||||
type: ALERT_DISMISS,
|
||||
alert
|
||||
};
|
||||
};
|
||||
|
||||
export function clearAlert() {
|
||||
return {
|
||||
type: ALERT_CLEAR
|
||||
};
|
||||
};
|
||||
|
||||
export function showAlert(title, message) {
|
||||
return {
|
||||
type: ALERT_SHOW,
|
||||
title,
|
||||
message
|
||||
};
|
||||
};
|
@@ -1,17 +1,31 @@
|
||||
import api from '../api'
|
||||
|
||||
import { updateTimeline } from './timelines';
|
||||
|
||||
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
||||
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
||||
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
||||
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
||||
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
||||
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
||||
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
|
||||
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
|
||||
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
||||
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
||||
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
||||
|
||||
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
||||
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
||||
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
|
||||
|
||||
export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
|
||||
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
|
||||
|
||||
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
||||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
||||
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
|
||||
|
||||
export function changeCompose(text) {
|
||||
return {
|
||||
type: COMPOSE_CHANGE,
|
||||
@@ -19,10 +33,16 @@ export function changeCompose(text) {
|
||||
};
|
||||
};
|
||||
|
||||
export function replyCompose(status) {
|
||||
return {
|
||||
type: COMPOSE_REPLY,
|
||||
status: status
|
||||
export function replyCompose(status, router) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: COMPOSE_REPLY,
|
||||
status: status
|
||||
});
|
||||
|
||||
if (!getState().getIn(['compose', 'mounted'])) {
|
||||
router.push('/statuses/new');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -32,6 +52,13 @@ export function cancelReplyCompose() {
|
||||
};
|
||||
};
|
||||
|
||||
export function mentionCompose(account) {
|
||||
return {
|
||||
type: COMPOSE_MENTION,
|
||||
account: account
|
||||
};
|
||||
};
|
||||
|
||||
export function submitCompose() {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(submitComposeRequest());
|
||||
@@ -39,9 +66,18 @@ export function submitCompose() {
|
||||
api(getState).post('/api/v1/statuses', {
|
||||
status: getState().getIn(['compose', 'text'], ''),
|
||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
|
||||
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
|
||||
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||
visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public')
|
||||
}).then(function (response) {
|
||||
dispatch(submitComposeSuccess(response.data));
|
||||
dispatch(submitComposeSuccess({ ...response.data }));
|
||||
|
||||
// To make the app more responsive, immediately get the status into the columns
|
||||
dispatch(updateTimeline('home', { ...response.data }));
|
||||
|
||||
if (response.data.in_reply_to_id === null && !getState().getIn(['compose', 'private']) && !getState().getIn(['compose', 'unlisted'])) {
|
||||
dispatch(updateTimeline('public', { ...response.data }));
|
||||
}
|
||||
}).catch(function (error) {
|
||||
dispatch(submitComposeFail(error));
|
||||
});
|
||||
@@ -121,3 +157,77 @@ export function undoUploadCompose(media_id) {
|
||||
media_id: media_id
|
||||
};
|
||||
};
|
||||
|
||||
export function clearComposeSuggestions() {
|
||||
return {
|
||||
type: COMPOSE_SUGGESTIONS_CLEAR
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchComposeSuggestions(token) {
|
||||
return (dispatch, getState) => {
|
||||
api(getState).get('/api/v1/accounts/search', {
|
||||
params: {
|
||||
q: token,
|
||||
resolve: false,
|
||||
limit: 4
|
||||
}
|
||||
}).then(response => {
|
||||
dispatch(readyComposeSuggestions(token, response.data));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function readyComposeSuggestions(token, accounts) {
|
||||
return {
|
||||
type: COMPOSE_SUGGESTIONS_READY,
|
||||
token,
|
||||
accounts
|
||||
};
|
||||
};
|
||||
|
||||
export function selectComposeSuggestion(position, token, accountId) {
|
||||
return (dispatch, getState) => {
|
||||
const completion = getState().getIn(['accounts', accountId, 'acct']);
|
||||
|
||||
dispatch({
|
||||
type: COMPOSE_SUGGESTION_SELECT,
|
||||
position,
|
||||
token,
|
||||
completion
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function mountCompose() {
|
||||
return {
|
||||
type: COMPOSE_MOUNT
|
||||
};
|
||||
};
|
||||
|
||||
export function unmountCompose() {
|
||||
return {
|
||||
type: COMPOSE_UNMOUNT
|
||||
};
|
||||
};
|
||||
|
||||
export function changeComposeSensitivity(checked) {
|
||||
return {
|
||||
type: COMPOSE_SENSITIVITY_CHANGE,
|
||||
checked
|
||||
};
|
||||
};
|
||||
|
||||
export function changeComposeVisibility(checked) {
|
||||
return {
|
||||
type: COMPOSE_VISIBILITY_CHANGE,
|
||||
checked
|
||||
};
|
||||
};
|
||||
|
||||
export function changeComposeListability(checked) {
|
||||
return {
|
||||
type: COMPOSE_LISTABILITY_CHANGE,
|
||||
checked
|
||||
};
|
||||
};
|
||||
|
83
app/assets/javascripts/components/actions/favourites.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import api, { getLinks } from '../api'
|
||||
|
||||
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
|
||||
export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
|
||||
export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL';
|
||||
|
||||
export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
|
||||
export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
|
||||
export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL';
|
||||
|
||||
export function fetchFavouritedStatuses() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFavouritedStatusesRequest());
|
||||
|
||||
api(getState).get('/api/v1/favourites').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFavouritedStatusesFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritedStatusesRequest() {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_FETCH_REQUEST
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritedStatusesSuccess(statuses, next) {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_FETCH_SUCCESS,
|
||||
statuses,
|
||||
next
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritedStatusesFail(error) {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_FETCH_FAIL,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFavouritedStatuses() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFavouritedStatusesRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandFavouritedStatusesFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFavouritedStatusesRequest() {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_EXPAND_REQUEST
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFavouritedStatusesSuccess(statuses, next) {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
|
||||
statuses,
|
||||
next
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFavouritedStatusesFail(error) {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_EXPAND_FAIL,
|
||||
error
|
||||
};
|
||||
};
|
@@ -1,48 +0,0 @@
|
||||
import api from '../api'
|
||||
|
||||
export const FOLLOW_CHANGE = 'FOLLOW_CHANGE';
|
||||
export const FOLLOW_SUBMIT_REQUEST = 'FOLLOW_SUBMIT_REQUEST';
|
||||
export const FOLLOW_SUBMIT_SUCCESS = 'FOLLOW_SUBMIT_SUCCESS';
|
||||
export const FOLLOW_SUBMIT_FAIL = 'FOLLOW_SUBMIT_FAIL';
|
||||
|
||||
export function changeFollow(text) {
|
||||
return {
|
||||
type: FOLLOW_CHANGE,
|
||||
text: text
|
||||
};
|
||||
};
|
||||
|
||||
export function submitFollow(router) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(submitFollowRequest());
|
||||
|
||||
api(getState).post('/api/v1/follows', {
|
||||
uri: getState().getIn(['follow', 'text'])
|
||||
}).then(function (response) {
|
||||
dispatch(submitFollowSuccess(response.data));
|
||||
router.push(`/accounts/${response.data.id}`);
|
||||
}).catch(function (error) {
|
||||
dispatch(submitFollowFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function submitFollowRequest() {
|
||||
return {
|
||||
type: FOLLOW_SUBMIT_REQUEST
|
||||
};
|
||||
};
|
||||
|
||||
export function submitFollowSuccess(account) {
|
||||
return {
|
||||
type: FOLLOW_SUBMIT_SUCCESS,
|
||||
account: account
|
||||
};
|
||||
};
|
||||
|
||||
export function submitFollowFail(error) {
|
||||
return {
|
||||
type: FOLLOW_SUBMIT_FAIL,
|
||||
error: error
|
||||
};
|
||||
};
|
@@ -16,6 +16,14 @@ export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
|
||||
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
|
||||
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
|
||||
|
||||
export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
|
||||
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
|
||||
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
|
||||
|
||||
export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
|
||||
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
|
||||
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
|
||||
|
||||
export function reblog(status) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(reblogRequest(status));
|
||||
@@ -157,3 +165,71 @@ export function unfavouriteFail(status, error) {
|
||||
error: error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogs(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchReblogsRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
|
||||
dispatch(fetchReblogsSuccess(id, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchReblogsFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogsRequest(id) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogsSuccess(id, accounts) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogsFail(id, error) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_FAIL,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavourites(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFavouritesRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
|
||||
dispatch(fetchFavouritesSuccess(id, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFavouritesFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesRequest(id) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesSuccess(id, accounts) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesFail(id, error) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_FAIL,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
@@ -1,8 +0,0 @@
|
||||
export const ACCESS_TOKEN_SET = 'ACCESS_TOKEN_SET';
|
||||
|
||||
export function setAccessToken(token) {
|
||||
return {
|
||||
type: ACCESS_TOKEN_SET,
|
||||
token: token
|
||||
};
|
||||
};
|
15
app/assets/javascripts/components/actions/modal.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const MEDIA_OPEN = 'MEDIA_OPEN';
|
||||
export const MODAL_CLOSE = 'MODAL_CLOSE';
|
||||
|
||||
export function openMedia(url) {
|
||||
return {
|
||||
type: MEDIA_OPEN,
|
||||
url: url
|
||||
};
|
||||
};
|
||||
|
||||
export function closeModal() {
|
||||
return {
|
||||
type: MODAL_CLOSE
|
||||
};
|
||||
};
|
@@ -1,24 +1,137 @@
|
||||
export const NOTIFICATION_SHOW = 'NOTIFICATION_SHOW';
|
||||
export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS';
|
||||
export const NOTIFICATION_CLEAR = 'NOTIFICATION_CLEAR';
|
||||
import api, { getLinks } from '../api'
|
||||
import Immutable from 'immutable';
|
||||
import IntlMessageFormat from 'intl-messageformat';
|
||||
|
||||
export function dismissNotification(notification) {
|
||||
return {
|
||||
type: NOTIFICATION_DISMISS,
|
||||
notification: notification
|
||||
import { fetchRelationships } from './accounts';
|
||||
|
||||
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
|
||||
|
||||
export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
|
||||
export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
|
||||
export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL';
|
||||
|
||||
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
|
||||
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
|
||||
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
|
||||
|
||||
const fetchRelatedRelationships = (dispatch, notifications) => {
|
||||
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
|
||||
|
||||
if (accountIds > 0) {
|
||||
dispatch(fetchRelationships(accountIds));
|
||||
}
|
||||
};
|
||||
|
||||
export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||
return (dispatch, getState) => {
|
||||
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
|
||||
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
|
||||
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_UPDATE,
|
||||
notification,
|
||||
account: notification.account,
|
||||
status: notification.status,
|
||||
meta: playSound ? { sound: 'boop' } : undefined
|
||||
});
|
||||
|
||||
fetchRelatedRelationships(dispatch, [notification]);
|
||||
|
||||
// Desktop notifications
|
||||
if (typeof window.Notification !== 'undefined' && showAlert) {
|
||||
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
|
||||
const body = $('<p>').html(notification.status ? notification.status.content : '').text();
|
||||
|
||||
new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function clearNotifications() {
|
||||
return {
|
||||
type: NOTIFICATION_CLEAR
|
||||
export function refreshNotifications() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(refreshNotificationsRequest());
|
||||
|
||||
const params = {};
|
||||
const ids = getState().getIn(['notifications', 'items']);
|
||||
|
||||
if (ids.size > 0) {
|
||||
params.since_id = ids.first().get('id');
|
||||
}
|
||||
|
||||
api(getState).get('/api/v1/notifications', { params }).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(refreshNotificationsSuccess(response.data, next ? next.uri : null));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
}).catch(error => {
|
||||
dispatch(refreshNotificationsFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function showNotification(title, message) {
|
||||
export function refreshNotificationsRequest() {
|
||||
return {
|
||||
type: NOTIFICATION_SHOW,
|
||||
title: title,
|
||||
message: message
|
||||
type: NOTIFICATIONS_REFRESH_REQUEST
|
||||
};
|
||||
};
|
||||
|
||||
export function refreshNotificationsSuccess(notifications, next) {
|
||||
return {
|
||||
type: NOTIFICATIONS_REFRESH_SUCCESS,
|
||||
notifications,
|
||||
accounts: notifications.map(item => item.account),
|
||||
statuses: notifications.map(item => item.status).filter(status => !!status),
|
||||
next
|
||||
};
|
||||
};
|
||||
|
||||
export function refreshNotificationsFail(error) {
|
||||
return {
|
||||
type: NOTIFICATIONS_REFRESH_FAIL,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function expandNotifications() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['notifications', 'next'], null);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandNotificationsRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
}).catch(error => {
|
||||
dispatch(expandNotificationsFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandNotificationsRequest() {
|
||||
return {
|
||||
type: NOTIFICATIONS_EXPAND_REQUEST
|
||||
};
|
||||
};
|
||||
|
||||
export function expandNotificationsSuccess(notifications, next) {
|
||||
return {
|
||||
type: NOTIFICATIONS_EXPAND_SUCCESS,
|
||||
notifications,
|
||||
accounts: notifications.map(item => item.account),
|
||||
statuses: notifications.map(item => item.status).filter(status => !!status),
|
||||
next
|
||||
};
|
||||
};
|
||||
|
||||
export function expandNotificationsFail(error) {
|
||||
return {
|
||||
type: NOTIFICATIONS_EXPAND_FAIL,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
51
app/assets/javascripts/components/actions/search.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import api from '../api'
|
||||
|
||||
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
|
||||
export const SEARCH_SUGGESTIONS_CLEAR = 'SEARCH_SUGGESTIONS_CLEAR';
|
||||
export const SEARCH_SUGGESTIONS_READY = 'SEARCH_SUGGESTIONS_READY';
|
||||
export const SEARCH_RESET = 'SEARCH_RESET';
|
||||
|
||||
export function changeSearch(value) {
|
||||
return {
|
||||
type: SEARCH_CHANGE,
|
||||
value
|
||||
};
|
||||
};
|
||||
|
||||
export function clearSearchSuggestions() {
|
||||
return {
|
||||
type: SEARCH_SUGGESTIONS_CLEAR
|
||||
};
|
||||
};
|
||||
|
||||
export function readySearchSuggestions(value, accounts) {
|
||||
return {
|
||||
type: SEARCH_SUGGESTIONS_READY,
|
||||
value,
|
||||
accounts
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSearchSuggestions(value) {
|
||||
return (dispatch, getState) => {
|
||||
if (getState().getIn(['search', 'loaded_value']) === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
api(getState).get('/api/v1/accounts/search', {
|
||||
params: {
|
||||
q: value,
|
||||
resolve: true,
|
||||
limit: 4
|
||||
}
|
||||
}).then(response => {
|
||||
dispatch(readySearchSuggestions(value, response.data));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function resetSearch() {
|
||||
return {
|
||||
type: SEARCH_RESET
|
||||
};
|
||||
};
|
19
app/assets/javascripts/components/actions/settings.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const SETTING_CHANGE = 'SETTING_CHANGE';
|
||||
|
||||
export function changeSetting(key, value) {
|
||||
return {
|
||||
type: SETTING_CHANGE,
|
||||
key,
|
||||
value
|
||||
};
|
||||
};
|
||||
|
||||
export function saveSettings() {
|
||||
return (_, getState) => {
|
||||
axios.put('/api/web/settings', {
|
||||
data: getState().get('settings').toJS()
|
||||
});
|
||||
};
|
||||
};
|
@@ -1,5 +1,6 @@
|
||||
import api from '../api';
|
||||
import axios from 'axios';
|
||||
import api from '../api';
|
||||
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
|
||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
||||
@@ -9,6 +10,10 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
|
||||
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
|
||||
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
|
||||
|
||||
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
|
||||
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
|
||||
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
|
||||
|
||||
export function fetchStatusRequest(id) {
|
||||
return {
|
||||
type: STATUS_FETCH_REQUEST,
|
||||
@@ -18,12 +23,11 @@ export function fetchStatusRequest(id) {
|
||||
|
||||
export function fetchStatus(id) {
|
||||
return (dispatch, getState) => {
|
||||
const boundApi = api(getState);
|
||||
|
||||
dispatch(fetchStatusRequest(id));
|
||||
|
||||
axios.all([boundApi.get(`/api/v1/statuses/${id}`), boundApi.get(`/api/v1/statuses/${id}/context`)]).then(values => {
|
||||
dispatch(fetchStatusSuccess(values[0].data, values[1].data));
|
||||
api(getState).get(`/api/v1/statuses/${id}`).then(response => {
|
||||
dispatch(fetchStatusSuccess(response.data));
|
||||
dispatch(fetchContext(id));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error));
|
||||
});
|
||||
@@ -52,6 +56,7 @@ export function deleteStatus(id) {
|
||||
|
||||
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
|
||||
dispatch(deleteStatusSuccess(id));
|
||||
dispatch(deleteFromTimelines(id));
|
||||
}).catch(error => {
|
||||
dispatch(deleteStatusFail(id, error));
|
||||
});
|
||||
@@ -79,3 +84,40 @@ export function deleteStatusFail(id, error) {
|
||||
error: error
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContext(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchContextRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
|
||||
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
|
||||
}).catch(error => {
|
||||
dispatch(fetchContextFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContextRequest(id) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_REQUEST,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContextSuccess(id, ancestors, descendants) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_SUCCESS,
|
||||
id,
|
||||
ancestors,
|
||||
descendants,
|
||||
statuses: ancestors.concat(descendants)
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContextFail(id, error) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_FAIL,
|
||||
id,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
17
app/assets/javascripts/components/actions/store.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import Immutable from 'immutable';
|
||||
|
||||
export const STORE_HYDRATE = 'STORE_HYDRATE';
|
||||
|
||||
const convertState = rawState =>
|
||||
Immutable.fromJS(rawState, (k, v) =>
|
||||
Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
|
||||
Number.isNaN(x * 1) ? x : x * 1));
|
||||
|
||||
export function hydrateStore(rawState) {
|
||||
const state = convertState(rawState);
|
||||
|
||||
return {
|
||||
type: STORE_HYDRATE,
|
||||
state
|
||||
};
|
||||
};
|
@@ -1,37 +0,0 @@
|
||||
import api from '../api';
|
||||
|
||||
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
|
||||
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
|
||||
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
|
||||
|
||||
export function fetchSuggestions() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchSuggestionsRequest());
|
||||
|
||||
api(getState).get('/api/v1/accounts/suggestions').then(response => {
|
||||
dispatch(fetchSuggestionsSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchSuggestionsFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSuggestionsRequest() {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_REQUEST
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSuggestionsSuccess(suggestions) {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_SUCCESS,
|
||||
suggestions: suggestions
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSuggestionsFail(error) {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_FAIL,
|
||||
error: error
|
||||
};
|
||||
};
|
@@ -1,4 +1,5 @@
|
||||
import api from '../api'
|
||||
import Immutable from 'immutable';
|
||||
|
||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
||||
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
||||
@@ -11,6 +12,8 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
|
||||
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
|
||||
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
|
||||
|
||||
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
|
||||
|
||||
export function refreshTimelineSuccess(timeline, statuses) {
|
||||
return {
|
||||
type: TIMELINE_REFRESH_SUCCESS,
|
||||
@@ -20,32 +23,61 @@ export function refreshTimelineSuccess(timeline, statuses) {
|
||||
};
|
||||
|
||||
export function updateTimeline(timeline, status) {
|
||||
return {
|
||||
type: TIMELINE_UPDATE,
|
||||
timeline: timeline,
|
||||
status: status
|
||||
return (dispatch, getState) => {
|
||||
const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
|
||||
|
||||
dispatch({
|
||||
type: TIMELINE_UPDATE,
|
||||
timeline,
|
||||
status,
|
||||
references
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function deleteFromTimelines(id) {
|
||||
return {
|
||||
type: TIMELINE_DELETE,
|
||||
id: id
|
||||
return (dispatch, getState) => {
|
||||
const accountId = getState().getIn(['statuses', id, 'account']);
|
||||
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
|
||||
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
|
||||
|
||||
dispatch({
|
||||
type: TIMELINE_DELETE,
|
||||
id,
|
||||
accountId,
|
||||
references,
|
||||
reblogOf
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function refreshTimelineRequest(timeline) {
|
||||
export function refreshTimelineRequest(timeline, id) {
|
||||
return {
|
||||
type: TIMELINE_REFRESH_REQUEST,
|
||||
timeline: timeline
|
||||
timeline,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export function refreshTimeline(timeline) {
|
||||
export function refreshTimeline(timeline, id = null) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(refreshTimelineRequest(timeline));
|
||||
dispatch(refreshTimelineRequest(timeline, id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${timeline}`).then(function (response) {
|
||||
const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
|
||||
const newestId = ids.size > 0 ? ids.first() : null;
|
||||
|
||||
let params = '';
|
||||
let path = timeline;
|
||||
|
||||
if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded'])) {
|
||||
params = `?since_id=${newestId}`;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
path = `${path}/${id}`
|
||||
}
|
||||
|
||||
api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
|
||||
dispatch(refreshTimelineSuccess(timeline, response.data));
|
||||
}).catch(function (error) {
|
||||
dispatch(refreshTimelineFail(timeline, error));
|
||||
@@ -56,18 +88,29 @@ export function refreshTimeline(timeline) {
|
||||
export function refreshTimelineFail(timeline, error) {
|
||||
return {
|
||||
type: TIMELINE_REFRESH_FAIL,
|
||||
timeline: timeline,
|
||||
error: error
|
||||
timeline,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function expandTimeline(timeline) {
|
||||
export function expandTimeline(timeline, id = null) {
|
||||
return (dispatch, getState) => {
|
||||
const lastId = getState().getIn(['timelines', timeline]).last();
|
||||
const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last();
|
||||
|
||||
if (!lastId) {
|
||||
// If timeline is empty, don't try to load older posts since there are none
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandTimelineRequest(timeline));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${timeline}?max_id=${lastId}`).then(response => {
|
||||
let path = timeline;
|
||||
|
||||
if (id) {
|
||||
path = `${path}/${id}`
|
||||
}
|
||||
|
||||
api(getState).get(`/api/v1/timelines/${path}?max_id=${lastId}`).then(response => {
|
||||
dispatch(expandTimelineSuccess(timeline, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(expandTimelineFail(timeline, error));
|
||||
@@ -78,22 +121,30 @@ export function expandTimeline(timeline) {
|
||||
export function expandTimelineRequest(timeline) {
|
||||
return {
|
||||
type: TIMELINE_EXPAND_REQUEST,
|
||||
timeline: timeline
|
||||
timeline
|
||||
};
|
||||
};
|
||||
|
||||
export function expandTimelineSuccess(timeline, statuses) {
|
||||
return {
|
||||
type: TIMELINE_EXPAND_SUCCESS,
|
||||
timeline: timeline,
|
||||
statuses: statuses
|
||||
timeline,
|
||||
statuses
|
||||
};
|
||||
};
|
||||
|
||||
export function expandTimelineFail(timeline, error) {
|
||||
return {
|
||||
type: TIMELINE_EXPAND_FAIL,
|
||||
timeline: timeline,
|
||||
error: error
|
||||
timeline,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export function scrollTopTimeline(timeline, top) {
|
||||
return {
|
||||
type: TIMELINE_SCROLL_TOP,
|
||||
timeline,
|
||||
top
|
||||
};
|
||||
};
|
||||
|
@@ -1,4 +1,15 @@
|
||||
import axios from 'axios';
|
||||
import LinkHeader from 'http-link-header';
|
||||
|
||||
export const getLinks = response => {
|
||||
const value = response.headers.link;
|
||||
|
||||
if (!value) {
|
||||
return { refs: [] };
|
||||
}
|
||||
|
||||
return LinkHeader.parse(value);
|
||||
};
|
||||
|
||||
export default getState => axios.create({
|
||||
headers: {
|
||||
@@ -6,6 +17,10 @@ export default getState => axios.create({
|
||||
},
|
||||
|
||||
transformResponse: [function (data) {
|
||||
return JSON.parse(data);
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch(Exception) {
|
||||
return data;
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
115
app/assets/javascripts/components/components/account.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Avatar from './avatar';
|
||||
import DisplayName from './display_name';
|
||||
import Permalink from './permalink';
|
||||
import IconButton from './icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock' }
|
||||
});
|
||||
|
||||
const outerStyle = {
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid #363c4b'
|
||||
};
|
||||
|
||||
const itemStyle = {
|
||||
flex: '1 1 auto',
|
||||
display: 'block',
|
||||
color: '#9baec8',
|
||||
overflow: 'hidden',
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px'
|
||||
};
|
||||
|
||||
const noteStyle = {
|
||||
paddingTop: '5px',
|
||||
fontSize: '12px',
|
||||
color: '#616b86'
|
||||
};
|
||||
|
||||
const buttonsStyle = {
|
||||
padding: '10px',
|
||||
height: '18px'
|
||||
};
|
||||
|
||||
const Account = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
me: React.PropTypes.number.isRequired,
|
||||
onFollow: React.PropTypes.func.isRequired,
|
||||
onBlock: React.PropTypes.func.isRequired,
|
||||
withNote: React.PropTypes.bool,
|
||||
intl: React.PropTypes.object.isRequired
|
||||
},
|
||||
|
||||
getDefaultProps () {
|
||||
return {
|
||||
withNote: true
|
||||
};
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleFollow () {
|
||||
this.props.onFollow(this.props.account);
|
||||
},
|
||||
|
||||
handleBlock () {
|
||||
this.props.onBlock(this.props.account);
|
||||
},
|
||||
|
||||
render () {
|
||||
const { account, me, withNote, intl } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
let note, buttons;
|
||||
|
||||
if (account.get('note').length > 0 && withNote) {
|
||||
note = <div style={noteStyle}>{account.get('note')}</div>;
|
||||
}
|
||||
|
||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
|
||||
if (requested) {
|
||||
buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
|
||||
} else if (blocking) {
|
||||
buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
|
||||
} else {
|
||||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Permalink key={account.get('id')} style={itemStyle} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
|
||||
<div style={{ float: 'left', marginLeft: '12px', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Permalink>
|
||||
|
||||
<div style={buttonsStyle}>
|
||||
{buttons}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(Account);
|
@@ -0,0 +1,202 @@
|
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
let word;
|
||||
|
||||
let left = str.slice(0, caretPosition).search(/\S+$/);
|
||||
let right = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 2 || word[0] !== '@') {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase().slice(1);
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
|
||||
const AutosuggestTextarea = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
value: React.PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
disabled: React.PropTypes.bool,
|
||||
fileDropDate: React.PropTypes.instanceOf(Date),
|
||||
placeholder: React.PropTypes.string,
|
||||
onSuggestionSelected: React.PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: React.PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
|
||||
onChange: React.PropTypes.func.isRequired,
|
||||
onKeyUp: React.PropTypes.func,
|
||||
onKeyDown: React.PropTypes.func
|
||||
},
|
||||
|
||||
getInitialState () {
|
||||
return {
|
||||
isFileDragging: false,
|
||||
fileDraggingDate: undefined,
|
||||
suggestionsHidden: false,
|
||||
selectedSuggestion: 0,
|
||||
lastToken: null,
|
||||
tokenStart: 0
|
||||
};
|
||||
},
|
||||
|
||||
onChange (e) {
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
|
||||
|
||||
if (token != null && this.state.lastToken !== token) {
|
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
|
||||
this.props.onSuggestionsFetchRequested(token);
|
||||
} else if (token === null) {
|
||||
this.setState({ lastToken: null });
|
||||
this.props.onSuggestionsClearRequested();
|
||||
}
|
||||
|
||||
this.props.onChange(e);
|
||||
},
|
||||
|
||||
onKeyDown (e) {
|
||||
const { suggestions, disabled } = this.props;
|
||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
||||
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
if (!suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ suggestionsHidden: true });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
// Select suggestion
|
||||
if (this.state.lastToken != null && suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (e.defaultPrevented || !this.props.onKeyDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onKeyDown(e);
|
||||
},
|
||||
|
||||
onBlur () {
|
||||
this.setState({ suggestionsHidden: true });
|
||||
},
|
||||
|
||||
onSuggestionClick (suggestion, e) {
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
},
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
|
||||
const fileDropDate = nextProps.fileDropDate;
|
||||
const { isFileDragging, fileDraggingDate } = this.state;
|
||||
|
||||
/*
|
||||
* We can't detect drop events, because they might not be on the textarea (the app allows dropping anywhere in the
|
||||
* window). Instead, on-drop, we notify this textarea to stop its hover effect by passing in a prop with the
|
||||
* drop-date.
|
||||
*/
|
||||
if (isFileDragging && fileDraggingDate && fileDropDate // if dragging when props updated, and dates aren't undefined
|
||||
&& fileDropDate > fileDraggingDate) { // and if the drop date is now greater than when we started dragging
|
||||
// then we should stop dragging
|
||||
this.setState({
|
||||
isFileDragging: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setTextarea (c) {
|
||||
this.textarea = c;
|
||||
},
|
||||
|
||||
onDragEnter () {
|
||||
this.setState({
|
||||
isFileDragging: true,
|
||||
fileDraggingDate: new Date()
|
||||
})
|
||||
},
|
||||
|
||||
onDragExit () {
|
||||
this.setState({
|
||||
isFileDragging: false
|
||||
})
|
||||
},
|
||||
|
||||
render () {
|
||||
const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props;
|
||||
const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state;
|
||||
const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea';
|
||||
|
||||
return (
|
||||
<div className='autosuggest-textarea'>
|
||||
<textarea
|
||||
ref={this.setTextarea}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onBlur={this.onBlur}
|
||||
onDragEnter={this.onDragEnter}
|
||||
onDragExit={this.onDragExit}
|
||||
/>
|
||||
|
||||
<div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
|
||||
{suggestions.map((suggestion, i) => (
|
||||
<div key={suggestion} className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`} onClick={this.onSuggestionClick.bind(this, suggestion)}>
|
||||
<AutosuggestAccountContainer id={suggestion} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default AutosuggestTextarea;
|
@@ -4,14 +4,15 @@ const Avatar = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
src: React.PropTypes.string.isRequired,
|
||||
size: React.PropTypes.number.isRequired
|
||||
size: React.PropTypes.number.isRequired,
|
||||
style: React.PropTypes.object
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div style={{ width: `${this.props.size}px`, height: `${this.props.size}px` }}>
|
||||
<div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}>
|
||||
<img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} />
|
||||
</div>
|
||||
);
|
||||
|
@@ -7,7 +7,14 @@ const Button = React.createClass({
|
||||
onClick: React.PropTypes.func,
|
||||
disabled: React.PropTypes.bool,
|
||||
block: React.PropTypes.bool,
|
||||
secondary: React.PropTypes.bool
|
||||
secondary: React.PropTypes.bool,
|
||||
size: React.PropTypes.number,
|
||||
},
|
||||
|
||||
getDefaultProps () {
|
||||
return {
|
||||
size: 36
|
||||
};
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
@@ -20,7 +27,7 @@ const Button = React.createClass({
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
fontFamily: 'Roboto',
|
||||
fontFamily: 'inherit',
|
||||
display: this.props.block ? 'block' : 'inline-block',
|
||||
width: this.props.block ? '100%' : 'auto',
|
||||
position: 'relative',
|
||||
@@ -32,16 +39,16 @@ const Button = React.createClass({
|
||||
fontWeight: '500',
|
||||
letterSpacing: '0',
|
||||
textTransform: 'uppercase',
|
||||
padding: '0 16px',
|
||||
height: '36px',
|
||||
padding: `0 ${this.props.size / 2.25}px`,
|
||||
height: `${this.props.size}px`,
|
||||
cursor: 'pointer',
|
||||
lineHeight: '36px',
|
||||
lineHeight: `${this.props.size}px`,
|
||||
borderRadius: '4px',
|
||||
textDecoration: 'none'
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={style}>
|
||||
<button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ ...style, ...this.props.style }}>
|
||||
{this.props.text || this.props.children}
|
||||
</button>
|
||||
);
|
||||
|
@@ -0,0 +1,41 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const outerStyle = {
|
||||
padding: '15px',
|
||||
fontSize: '16px',
|
||||
background: '#2f3441',
|
||||
flex: '0 0 auto',
|
||||
cursor: 'pointer',
|
||||
color: '#2b90d9'
|
||||
};
|
||||
|
||||
const iconStyle = {
|
||||
display: 'inline-block',
|
||||
marginRight: '5px'
|
||||
};
|
||||
|
||||
const ColumnBackButton = React.createClass({
|
||||
|
||||
contextTypes: {
|
||||
router: React.PropTypes.object
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleClick () {
|
||||
this.context.router.goBack();
|
||||
},
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div onClick={this.handleClick} style={outerStyle} className='column-back-button'>
|
||||
<i className='fa fa-fw fa-chevron-left' style={iconStyle} />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default ColumnBackButton;
|
@@ -0,0 +1,60 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
|
||||
const iconStyle = {
|
||||
fontSize: '16px',
|
||||
padding: '15px',
|
||||
position: 'absolute',
|
||||
right: '0',
|
||||
top: '-48px',
|
||||
cursor: 'pointer'
|
||||
};
|
||||
|
||||
const ColumnCollapsable = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
icon: React.PropTypes.string.isRequired,
|
||||
fullHeight: React.PropTypes.number.isRequired,
|
||||
children: React.PropTypes.node,
|
||||
onCollapse: React.PropTypes.func
|
||||
},
|
||||
|
||||
getInitialState () {
|
||||
return {
|
||||
collapsed: true
|
||||
};
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleToggleCollapsed () {
|
||||
const currentState = this.state.collapsed;
|
||||
|
||||
this.setState({ collapsed: !currentState });
|
||||
|
||||
if (!currentState && this.props.onCollapse) {
|
||||
this.props.onCollapse();
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { icon, fullHeight, children } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div>
|
||||
|
||||
<Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
|
||||
{({ opacity, height }) =>
|
||||
<div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
</Motion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default ColumnCollapsable;
|
@@ -1,5 +1,7 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
|
||||
import emojify from '../emoji';
|
||||
|
||||
const DisplayName = React.createClass({
|
||||
|
||||
@@ -10,15 +12,12 @@ const DisplayName = React.createClass({
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
let displayName = this.props.account.get('display_name');
|
||||
|
||||
if (displayName.length === 0) {
|
||||
displayName = this.props.account.get('username');
|
||||
}
|
||||
const displayName = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name');
|
||||
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
|
||||
|
||||
return (
|
||||
<span style={{ display: 'block', maxWidth: '100%', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
|
||||
<strong style={{ fontWeight: 'bold' }}>{displayName}</strong> <span style={{ fontSize: '14px' }}>@{this.props.account.get('acct')}</span>
|
||||
<span style={{ display: 'block', maxWidth: '100%', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }} className='display-name'>
|
||||
<strong style={{ fontWeight: '500' }} dangerouslySetInnerHTML={displayNameHTML} /> <span style={{ fontSize: '14px' }}>@{this.props.account.get('acct')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@@ -1,13 +1,15 @@
|
||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||
|
||||
const DropdownMenu = ({ icon, items, size }) => {
|
||||
const DropdownMenu = ({ icon, items, size, direction }) => {
|
||||
const directionClass = (direction == "left") ? "dropdown__left" : "dropdown__right";
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }}>
|
||||
<i className={`fa fa-fw fa-${icon}`} style={{ verticalAlign: 'middle' }} />
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent style={{ lineHeight: '18px' }}>
|
||||
<DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
|
||||
<ul>
|
||||
{items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
|
||||
if (typeof action === 'function') {
|
||||
|
@@ -1,19 +1,26 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
|
||||
const IconButton = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
title: React.PropTypes.string.isRequired,
|
||||
icon: React.PropTypes.string.isRequired,
|
||||
onClick: React.PropTypes.func.isRequired,
|
||||
onClick: React.PropTypes.func,
|
||||
size: React.PropTypes.number,
|
||||
active: React.PropTypes.bool
|
||||
active: React.PropTypes.bool,
|
||||
style: React.PropTypes.object,
|
||||
activeStyle: React.PropTypes.object,
|
||||
disabled: React.PropTypes.bool,
|
||||
animate: React.PropTypes.bool
|
||||
},
|
||||
|
||||
getDefaultProps () {
|
||||
return {
|
||||
size: 18,
|
||||
active: false
|
||||
active: false,
|
||||
disabled: false,
|
||||
animate: false
|
||||
};
|
||||
},
|
||||
|
||||
@@ -21,23 +28,42 @@ const IconButton = React.createClass({
|
||||
|
||||
handleClick (e) {
|
||||
e.preventDefault();
|
||||
this.props.onClick();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!this.props.disabled) {
|
||||
this.props.onClick();
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
let style = {
|
||||
display: 'inline-block',
|
||||
border: 'none',
|
||||
padding: '0',
|
||||
background: 'transparent',
|
||||
fontSize: `${this.props.size}px`,
|
||||
width: `${this.props.size}px`,
|
||||
width: `${this.props.size * 1.28571429}px`,
|
||||
height: `${this.props.size}px`,
|
||||
lineHeight: `${this.props.size}px`
|
||||
lineHeight: `${this.props.size}px`,
|
||||
...this.props.style
|
||||
};
|
||||
|
||||
if (this.props.active) {
|
||||
style = { ...style, ...this.props.activeStyle };
|
||||
}
|
||||
|
||||
return (
|
||||
<a href='#' title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}>
|
||||
<i className={`fa fa-fw fa-${this.props.icon}`}></i>
|
||||
</a>
|
||||
<Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
|
||||
{({ rotate }) =>
|
||||
<button
|
||||
aria-label={this.props.title}
|
||||
title={this.props.title}
|
||||
className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`}
|
||||
onClick={this.handleClick}
|
||||
style={style}>
|
||||
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
|
||||
</button>
|
||||
}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
|
78
app/assets/javascripts/components/components/lightbox.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from './icon_button';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
import { injectIntl } from 'react-intl';
|
||||
|
||||
const overlayStyle = {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignContent: 'center',
|
||||
flexDirection: 'row',
|
||||
zIndex: '9999'
|
||||
};
|
||||
|
||||
const dialogStyle = {
|
||||
color: '#282c37',
|
||||
boxShadow: '0 0 30px rgba(0, 0, 0, 0.8)',
|
||||
margin: 'auto',
|
||||
position: 'relative'
|
||||
};
|
||||
|
||||
const closeStyle = {
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px'
|
||||
};
|
||||
|
||||
const Lightbox = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
isVisible: React.PropTypes.bool,
|
||||
onOverlayClicked: React.PropTypes.func,
|
||||
onCloseClicked: React.PropTypes.func,
|
||||
intl: React.PropTypes.object.isRequired,
|
||||
children: React.PropTypes.node
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentDidMount () {
|
||||
this._listener = e => {
|
||||
if (e.key === 'Escape') {
|
||||
this.props.onCloseClicked();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keyup', this._listener);
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('keyup', this._listener);
|
||||
},
|
||||
|
||||
render () {
|
||||
const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}>
|
||||
{({ backgroundOpacity, opacity, y }) =>
|
||||
<div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex'}} onClick={onOverlayClicked}>
|
||||
<div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }}>
|
||||
<IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(Lightbox);
|
@@ -1,13 +1,17 @@
|
||||
const LoadingIndicator = () => {
|
||||
const style = {
|
||||
textAlign: 'center',
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
color: '#616b86',
|
||||
paddingTop: '120px'
|
||||
};
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
return <div style={style}>Loading...</div>;
|
||||
const style = {
|
||||
textAlign: 'center',
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
color: '#616b86',
|
||||
paddingTop: '120px'
|
||||
};
|
||||
|
||||
const LoadingIndicator = () => (
|
||||
<div style={style}>
|
||||
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LoadingIndicator;
|
||||
|
@@ -1,74 +1,166 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from './icon_button';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
|
||||
});
|
||||
|
||||
const outerStyle = {
|
||||
marginTop: '8px',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative'
|
||||
};
|
||||
|
||||
const spoilerStyle = {
|
||||
background: '#000',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column'
|
||||
};
|
||||
|
||||
const spoilerSpanStyle = {
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
};
|
||||
|
||||
const spoilerSubSpanStyle = {
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
};
|
||||
|
||||
const spoilerButtonStyle = {
|
||||
position: 'absolute',
|
||||
top: '6px',
|
||||
left: '8px',
|
||||
zIndex: '100'
|
||||
};
|
||||
|
||||
const MediaGallery = React.createClass({
|
||||
|
||||
getInitialState () {
|
||||
return {
|
||||
visible: false
|
||||
};
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
sensitive: React.PropTypes.bool,
|
||||
media: ImmutablePropTypes.list.isRequired,
|
||||
height: React.PropTypes.number.isRequired
|
||||
height: React.PropTypes.number.isRequired,
|
||||
onOpenMedia: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleClick (url, e) {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.props.onOpenMedia(url);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
},
|
||||
|
||||
handleOpen () {
|
||||
this.setState({ visible: !this.state.visible });
|
||||
},
|
||||
|
||||
render () {
|
||||
var children = this.props.media.take(4);
|
||||
var size = children.size;
|
||||
const { media, intl, sensitive } = this.props;
|
||||
|
||||
children = children.map((attachment, i) => {
|
||||
let width = 50;
|
||||
let height = 100;
|
||||
let top = 'auto';
|
||||
let left = 'auto';
|
||||
let bottom = 'auto';
|
||||
let right = 'auto';
|
||||
let children;
|
||||
|
||||
if (size === 1) {
|
||||
width = 100;
|
||||
}
|
||||
|
||||
if (size === 4 || (size === 3 && i > 0)) {
|
||||
height = 50;
|
||||
}
|
||||
if (sensitive && !this.state.visible) {
|
||||
children = (
|
||||
<div style={spoilerStyle} onClick={this.handleOpen}>
|
||||
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
|
||||
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const size = media.take(4).size;
|
||||
|
||||
if (size === 2) {
|
||||
if (i === 0) {
|
||||
right = '2px';
|
||||
} else {
|
||||
left = '2px';
|
||||
}
|
||||
} else if (size === 3) {
|
||||
if (i === 0) {
|
||||
right = '2px';
|
||||
} else if (i > 0) {
|
||||
left = '2px';
|
||||
children = media.take(4).map((attachment, i) => {
|
||||
let width = 50;
|
||||
let height = 100;
|
||||
let top = 'auto';
|
||||
let left = 'auto';
|
||||
let bottom = 'auto';
|
||||
let right = 'auto';
|
||||
|
||||
if (size === 1) {
|
||||
width = 100;
|
||||
}
|
||||
|
||||
if (i === 1) {
|
||||
bottom = '2px';
|
||||
} else if (i > 1) {
|
||||
top = '2px';
|
||||
}
|
||||
} else if (size === 4) {
|
||||
if (i === 0 || i === 2) {
|
||||
right = '2px';
|
||||
if (size === 4 || (size === 3 && i > 0)) {
|
||||
height = 50;
|
||||
}
|
||||
|
||||
if (i === 1 || i === 3) {
|
||||
left = '2px';
|
||||
if (size === 2) {
|
||||
if (i === 0) {
|
||||
right = '2px';
|
||||
} else {
|
||||
left = '2px';
|
||||
}
|
||||
} else if (size === 3) {
|
||||
if (i === 0) {
|
||||
right = '2px';
|
||||
} else if (i > 0) {
|
||||
left = '2px';
|
||||
}
|
||||
|
||||
if (i === 1) {
|
||||
bottom = '2px';
|
||||
} else if (i > 1) {
|
||||
top = '2px';
|
||||
}
|
||||
} else if (size === 4) {
|
||||
if (i === 0 || i === 2) {
|
||||
right = '2px';
|
||||
}
|
||||
|
||||
if (i === 1 || i === 3) {
|
||||
left = '2px';
|
||||
}
|
||||
|
||||
if (i < 2) {
|
||||
bottom = '2px';
|
||||
} else {
|
||||
top = '2px';
|
||||
}
|
||||
}
|
||||
|
||||
if (i < 2) {
|
||||
bottom = '2px';
|
||||
} else {
|
||||
top = '2px';
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
|
||||
<a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return <a key={attachment.get('id')} href={attachment.get('url')} target='_blank' style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', textDecoration: 'none', border: 'none', display: 'block', width: `${width}%`, height: `${height}%`, background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover', cursor: 'zoom-in' }} />;
|
||||
});
|
||||
let spoilerButton;
|
||||
|
||||
if (sensitive) {
|
||||
spoilerButton = (
|
||||
<div style={spoilerButtonStyle} >
|
||||
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '8px', overflow: 'hidden', width: '100%', height: `${this.props.height}px`, boxSizing: 'border-box' }}>
|
||||
<div style={{ ...outerStyle, height: `${this.props.height}px` }}>
|
||||
{spoilerButton}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -76,4 +168,4 @@ const MediaGallery = React.createClass({
|
||||
|
||||
});
|
||||
|
||||
export default MediaGallery;
|
||||
export default injectIntl(MediaGallery);
|
||||
|
@@ -0,0 +1,17 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const style = {
|
||||
textAlign: 'center',
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
color: '#616b86',
|
||||
paddingTop: '120px'
|
||||
};
|
||||
|
||||
const MissingIndicator = () => (
|
||||
<div style={style}>
|
||||
<FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MissingIndicator;
|
27
app/assets/javascripts/components/components/permalink.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
const Permalink = React.createClass({
|
||||
|
||||
contextTypes: {
|
||||
router: React.PropTypes.object
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
href: React.PropTypes.string.isRequired,
|
||||
to: React.PropTypes.string.isRequired
|
||||
},
|
||||
|
||||
handleClick (e) {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.push(this.props.to);
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { href, children, ...other } = this.props;
|
||||
|
||||
return <a href={href} onClick={this.handleClick} {...other}>{children}</a>;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default Permalink;
|
@@ -1,59 +1,18 @@
|
||||
import moment from 'moment';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import { injectIntl, FormattedRelative } from 'react-intl';
|
||||
|
||||
moment.updateLocale('en', {
|
||||
relativeTime : {
|
||||
future: "in %s",
|
||||
past: "%s",
|
||||
s: "%ds",
|
||||
m: "1m",
|
||||
mm: "%dm",
|
||||
h: "1h",
|
||||
hh: "%dh",
|
||||
d: "1d",
|
||||
dd: "%dd",
|
||||
M: "1mo",
|
||||
MM: "%dmo",
|
||||
y: "1y",
|
||||
yy: "%dy"
|
||||
}
|
||||
});
|
||||
const RelativeTimestamp = ({ intl, timestamp }) => {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
const RelativeTimestamp = React.createClass({
|
||||
return (
|
||||
<time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}>
|
||||
<FormattedRelative value={date} />
|
||||
</time>
|
||||
);
|
||||
};
|
||||
|
||||
getInitialState () {
|
||||
return {
|
||||
text: ''
|
||||
};
|
||||
},
|
||||
RelativeTimestamp.propTypes = {
|
||||
intl: React.PropTypes.object.isRequired,
|
||||
timestamp: React.PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
propTypes: {
|
||||
timestamp: React.PropTypes.string.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this._updateMomentText();
|
||||
this.interval = setInterval(this._updateMomentText, 60000);
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
clearInterval(this.interval);
|
||||
},
|
||||
|
||||
_updateMomentText () {
|
||||
this.setState({ text: moment(this.props.timestamp).fromNow() });
|
||||
},
|
||||
|
||||
render () {
|
||||
return (
|
||||
<span>
|
||||
{this.state.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default RelativeTimestamp;
|
||||
export default injectIntl(RelativeTimestamp);
|
||||
|
@@ -1,12 +1,24 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Avatar from './avatar';
|
||||
import RelativeTimestamp from './relative_timestamp';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import DisplayName from './display_name';
|
||||
import MediaGallery from './media_gallery';
|
||||
import VideoPlayer from './video_player';
|
||||
import StatusContent from './status_content';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import Avatar from './avatar';
|
||||
import RelativeTimestamp from './relative_timestamp';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import DisplayName from './display_name';
|
||||
import MediaGallery from './media_gallery';
|
||||
import VideoPlayer from './video_player';
|
||||
import StatusContent from './status_content';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import emojify from '../emoji';
|
||||
import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
|
||||
|
||||
const outerStyle = {
|
||||
padding: '8px 10px',
|
||||
paddingLeft: '68px',
|
||||
position: 'relative',
|
||||
minHeight: '48px',
|
||||
borderBottom: '1px solid #363c4b',
|
||||
cursor: 'default'
|
||||
};
|
||||
|
||||
const Status = React.createClass({
|
||||
|
||||
@@ -15,13 +27,16 @@ const Status = React.createClass({
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
wrapped: React.PropTypes.bool,
|
||||
onReply: React.PropTypes.func,
|
||||
onFavourite: React.PropTypes.func,
|
||||
onReblog: React.PropTypes.func,
|
||||
onDelete: React.PropTypes.func,
|
||||
me: React.PropTypes.number
|
||||
onOpenMedia: React.PropTypes.func,
|
||||
onBlock: React.PropTypes.func,
|
||||
me: React.PropTypes.number,
|
||||
muted: React.PropTypes.bool
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
@@ -36,13 +51,15 @@ const Status = React.createClass({
|
||||
e.preventDefault();
|
||||
this.context.router.push(`/accounts/${id}`);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
},
|
||||
|
||||
render () {
|
||||
let media = '';
|
||||
let { status, ...other } = this.props;
|
||||
const { status, now, ...other } = this.props;
|
||||
|
||||
if (status === null) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
let displayName = status.getIn(['account', 'display_name']);
|
||||
@@ -51,11 +68,13 @@ const Status = React.createClass({
|
||||
displayName = status.getIn(['account', 'username']);
|
||||
}
|
||||
|
||||
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
|
||||
|
||||
return (
|
||||
<div style={{ cursor: 'pointer' }} onClick={this.handleClick}>
|
||||
<div style={{ cursor: 'default' }}>
|
||||
<div style={{ marginLeft: '68px', color: '#616b86', padding: '8px 0', paddingBottom: '2px', fontSize: '14px', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', 'left': '-26px'}}><i className='fa fa-fw fa-retweet'></i></div>
|
||||
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'><strong style={{ color: '#616b86'}}>{displayName}</strong></a> reblogged
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} reblogged' values={{ name: <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong style={{ color: '#616b86'}} dangerouslySetInnerHTML={displayNameHTML} /></a> }} />
|
||||
</div>
|
||||
|
||||
<Status {...other} wrapped={true} status={status.get('reblog')} />
|
||||
@@ -63,23 +82,23 @@ const Status = React.createClass({
|
||||
);
|
||||
}
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
if (status.get('media_attachments').size > 0 && !this.props.muted) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />;
|
||||
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} />;
|
||||
} else {
|
||||
media = <MediaGallery media={status.get('media_attachments')} height={110} />;
|
||||
media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 10px', paddingLeft: '68px', position: 'relative', minHeight: '48px', borderBottom: '1px solid #363c4b', cursor: 'pointer' }} onClick={this.handleClick}>
|
||||
<div className={this.props.muted ? 'muted' : ''} style={outerStyle}>
|
||||
<div style={{ fontSize: '15px' }}>
|
||||
<div style={{ float: 'right', fontSize: '14px' }}>
|
||||
<a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }} target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
<a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }} target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} now={now} /></a>
|
||||
</div>
|
||||
|
||||
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}>
|
||||
<div style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}>
|
||||
<div className='status__avatar' style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}>
|
||||
<Avatar src={status.getIn(['account', 'avatar'])} size={48} />
|
||||
</div>
|
||||
|
||||
@@ -87,7 +106,7 @@ const Status = React.createClass({
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StatusContent status={status} />
|
||||
<StatusContent status={status} onClick={this.handleClick} />
|
||||
|
||||
{media}
|
||||
|
||||
|
@@ -1,21 +1,39 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from './icon_button';
|
||||
import DropdownMenu from './dropdown_menu';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from './icon_button';
|
||||
import DropdownMenu from './dropdown_menu';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
mention: { id: 'status.mention', defaultMessage: 'Mention' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block' },
|
||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand' }
|
||||
});
|
||||
|
||||
const StatusActionBar = React.createClass({
|
||||
|
||||
contextTypes: {
|
||||
router: React.PropTypes.object
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onReply: React.PropTypes.func,
|
||||
onFavourite: React.PropTypes.func,
|
||||
onReblog: React.PropTypes.func,
|
||||
onDelete: React.PropTypes.func
|
||||
onDelete: React.PropTypes.func,
|
||||
onMention: React.PropTypes.func,
|
||||
onBlock: React.PropTypes.func
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleReplyClick () {
|
||||
this.props.onReply(this.props.status);
|
||||
this.props.onReply(this.props.status, this.context.router);
|
||||
},
|
||||
|
||||
handleFavouriteClick () {
|
||||
@@ -30,22 +48,39 @@ const StatusActionBar = React.createClass({
|
||||
this.props.onDelete(this.props.status);
|
||||
},
|
||||
|
||||
handleMentionClick () {
|
||||
this.props.onMention(this.props.status.get('account'), this.context.router);
|
||||
},
|
||||
|
||||
handleBlockClick () {
|
||||
this.props.onBlock(this.props.status.get('account'));
|
||||
},
|
||||
|
||||
handleOpen () {
|
||||
this.context.router.push(`/statuses/${this.props.status.get('id')}`);
|
||||
},
|
||||
|
||||
render () {
|
||||
const { status, me } = this.props;
|
||||
const { status, me, intl } = this.props;
|
||||
let menu = [];
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
|
||||
|
||||
if (status.getIn(['account', 'id']) === me) {
|
||||
menu.push({ text: 'Delete', action: this.handleDeleteClick });
|
||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
|
||||
menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
||||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton title='Reply' icon='reply' onClick={this.handleReplyClick} /></div>
|
||||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={this.handleReblogClick} /></div>
|
||||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div>
|
||||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
|
||||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
|
||||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
|
||||
|
||||
<div onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}>
|
||||
<DropdownMenu items={menu} icon='ellipsis-h' size={18} />
|
||||
<div style={{ width: '18px', height: '18px', float: 'left' }}>
|
||||
<DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -53,4 +88,4 @@ const StatusActionBar = React.createClass({
|
||||
|
||||
});
|
||||
|
||||
export default StatusActionBar;
|
||||
export default injectIntl(StatusActionBar);
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import emojify from '../emoji';
|
||||
|
||||
const StatusContent = React.createClass({
|
||||
|
||||
@@ -8,7 +9,8 @@ const StatusContent = React.createClass({
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
status: ImmutablePropTypes.map.isRequired
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onClick: React.PropTypes.func
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
@@ -23,11 +25,14 @@ const StatusContent = React.createClass({
|
||||
|
||||
if (mention) {
|
||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||
} 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.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener');
|
||||
link.addEventListener('click', this.onNormalClick, false);
|
||||
}
|
||||
|
||||
link.addEventListener('click', this.onNormalClick, false);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -36,8 +41,15 @@ const StatusContent = React.createClass({
|
||||
e.preventDefault();
|
||||
this.context.router.push(`/accounts/${mention.get('id')}`);
|
||||
}
|
||||
},
|
||||
|
||||
e.stopPropagation();
|
||||
onHashtagClick (hashtag, e) {
|
||||
hashtag = hashtag.replace(/^#/, '').toLowerCase();
|
||||
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.push(`/timelines/tag/${hashtag}`);
|
||||
}
|
||||
},
|
||||
|
||||
onNormalClick (e) {
|
||||
@@ -45,8 +57,11 @@ const StatusContent = React.createClass({
|
||||
},
|
||||
|
||||
render () {
|
||||
const content = { __html: this.props.status.get('content') };
|
||||
return <div className='status__content' dangerouslySetInnerHTML={content} />;
|
||||
const { status, onClick } = this.props;
|
||||
|
||||
const content = { __html: emojify(status.get('content')) };
|
||||
|
||||
return <div className='status__content' style={{ cursor: 'pointer' }} dangerouslySetInnerHTML={content} onClick={onClick} />;
|
||||
},
|
||||
|
||||
});
|
||||
|
@@ -1,17 +1,23 @@
|
||||
import Status from './status';
|
||||
import Status from './status';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import StatusContainer from '../containers/status_container';
|
||||
|
||||
const StatusList = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
statuses: ImmutablePropTypes.list.isRequired,
|
||||
onReply: React.PropTypes.func,
|
||||
onReblog: React.PropTypes.func,
|
||||
onFavourite: React.PropTypes.func,
|
||||
onDelete: React.PropTypes.func,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
onScrollToBottom: React.PropTypes.func,
|
||||
me: React.PropTypes.number
|
||||
onScrollToTop: React.PropTypes.func,
|
||||
onScroll: React.PropTypes.func,
|
||||
trackScroll: React.PropTypes.bool
|
||||
},
|
||||
|
||||
getDefaultProps () {
|
||||
return {
|
||||
trackScroll: true
|
||||
};
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
@@ -19,23 +25,49 @@ const StatusList = React.createClass({
|
||||
handleScroll (e) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
|
||||
if (scrollTop === scrollHeight - clientHeight) {
|
||||
this._oldScrollPosition = scrollHeight - scrollTop;
|
||||
|
||||
if (scrollTop === scrollHeight - clientHeight && this.props.onScrollToBottom) {
|
||||
this.props.onScrollToBottom();
|
||||
} else if (scrollTop < 100 && this.props.onScrollToTop) {
|
||||
this.props.onScrollToTop();
|
||||
} else if (this.props.onScroll) {
|
||||
this.props.onScroll();
|
||||
}
|
||||
},
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) {
|
||||
const node = ReactDOM.findDOMNode(this);
|
||||
|
||||
if (node.scrollTop > 0) {
|
||||
node.scrollTop = node.scrollHeight - this._oldScrollPosition;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { statuses, onScrollToBottom, ...other } = this.props;
|
||||
const { statusIds, onScrollToBottom, trackScroll } = this.props;
|
||||
|
||||
return (
|
||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto', overflowX: 'hidden' }} className='scrollable' onScroll={this.handleScroll}>
|
||||
const scrollableArea = (
|
||||
<div className='scrollable' onScroll={this.handleScroll}>
|
||||
<div>
|
||||
{statuses.map((status) => {
|
||||
return <Status key={status.get('id')} {...other} status={status} />;
|
||||
{statusIds.map((statusId) => {
|
||||
return <StatusContainer key={statusId} id={statusId} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (trackScroll) {
|
||||
return (
|
||||
<ScrollContainer scrollKey='status-list'>
|
||||
{scrollableArea}
|
||||
</ScrollContainer>
|
||||
);
|
||||
} else {
|
||||
return scrollableArea;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
@@ -1,12 +1,60 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from './icon_button';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from './icon_button';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }
|
||||
});
|
||||
|
||||
const videoStyle = {
|
||||
position: 'relative',
|
||||
zIndex: '1',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
|
||||
const muteStyle = {
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
opacity: '0.8',
|
||||
zIndex: '5'
|
||||
};
|
||||
|
||||
const spoilerStyle = {
|
||||
marginTop: '8px',
|
||||
background: '#000',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column'
|
||||
};
|
||||
|
||||
const spoilerSpanStyle = {
|
||||
display: 'block',
|
||||
fontSize: '14px'
|
||||
};
|
||||
|
||||
const spoilerSubSpanStyle = {
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
};
|
||||
|
||||
const VideoPlayer = React.createClass({
|
||||
propTypes: {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
width: React.PropTypes.number,
|
||||
height: React.PropTypes.number
|
||||
height: React.PropTypes.number,
|
||||
sensitive: React.PropTypes.bool
|
||||
},
|
||||
|
||||
getDefaultProps () {
|
||||
@@ -18,6 +66,7 @@ const VideoPlayer = React.createClass({
|
||||
|
||||
getInitialState () {
|
||||
return {
|
||||
visible: false,
|
||||
muted: true
|
||||
};
|
||||
},
|
||||
@@ -28,15 +77,48 @@ const VideoPlayer = React.createClass({
|
||||
this.setState({ muted: !this.state.muted });
|
||||
},
|
||||
|
||||
handleVideoClick (e) {
|
||||
e.stopPropagation();
|
||||
|
||||
const node = ReactDOM.findDOMNode(this).querySelector('video');
|
||||
|
||||
if (node.paused) {
|
||||
node.play();
|
||||
} else {
|
||||
node.pause();
|
||||
}
|
||||
},
|
||||
|
||||
handleOpen () {
|
||||
this.setState({ visible: true });
|
||||
},
|
||||
|
||||
render () {
|
||||
const { media, intl, width, height, sensitive } = this.props;
|
||||
|
||||
if (sensitive && !this.state.visible) {
|
||||
return (
|
||||
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
|
||||
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
|
||||
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
</div>
|
||||
);
|
||||
} else if (!sensitive && !this.state.visible) {
|
||||
return (
|
||||
<div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${this.props.width}px`, height: `${this.props.height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: '10px', left: '10px', opacity: '0.8' }}><IconButton title='Toggle sound' icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
|
||||
<video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={{ width: '100%', height: '100%' }} />
|
||||
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
|
||||
<div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div>
|
||||
<video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default VideoPlayer;
|
||||
export default injectIntl(VideoPlayer);
|
||||
|
@@ -0,0 +1,40 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { makeGetAccount } from '../selectors';
|
||||
import Account from '../components/account';
|
||||
import {
|
||||
followAccount,
|
||||
unfollowAccount,
|
||||
blockAccount,
|
||||
unblockAccount
|
||||
} from '../actions/accounts';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: getAccount(state, props.id),
|
||||
me: state.getIn(['meta', 'me'])
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onFollow (account) {
|
||||
if (account.getIn(['relationship', 'following'])) {
|
||||
dispatch(unfollowAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(followAccount(account.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onBlock (account) {
|
||||
if (account.getIn(['relationship', 'blocking'])) {
|
||||
dispatch(unblockAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(blockAccount(account.get('id')));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(Account);
|
@@ -1,65 +1,94 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from '../store/configureStore';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from '../store/configureStore';
|
||||
import {
|
||||
refreshTimelineSuccess,
|
||||
updateTimeline,
|
||||
deleteFromTimelines,
|
||||
refreshTimeline
|
||||
} from '../actions/timelines';
|
||||
import { setAccessToken } from '../actions/meta';
|
||||
import { setAccountSelf } from '../actions/accounts';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
} from '../actions/timelines';
|
||||
import { updateNotifications } from '../actions/notifications';
|
||||
import createBrowserHistory from 'history/lib/createBrowserHistory';
|
||||
import {
|
||||
applyRouterMiddleware,
|
||||
useRouterHistory,
|
||||
Router,
|
||||
Route,
|
||||
hashHistory,
|
||||
IndexRedirect,
|
||||
IndexRoute
|
||||
} from 'react-router';
|
||||
import UI from '../features/ui';
|
||||
import Account from '../features/account';
|
||||
import Status from '../features/status';
|
||||
import GettingStarted from '../features/getting_started';
|
||||
import PublicTimeline from '../features/public_timeline';
|
||||
import AccountTimeline from '../features/account_timeline';
|
||||
import HomeTimeline from '../features/home_timeline';
|
||||
import MentionsTimeline from '../features/mentions_timeline';
|
||||
import Compose from '../features/compose';
|
||||
} from 'react-router';
|
||||
import { useScroll } from 'react-router-scroll';
|
||||
import UI from '../features/ui';
|
||||
import Account from '../features/account';
|
||||
import Status from '../features/status';
|
||||
import GettingStarted from '../features/getting_started';
|
||||
import PublicTimeline from '../features/public_timeline';
|
||||
import AccountTimeline from '../features/account_timeline';
|
||||
import HomeTimeline from '../features/home_timeline';
|
||||
import MentionsTimeline from '../features/mentions_timeline';
|
||||
import Compose from '../features/compose';
|
||||
import Followers from '../features/followers';
|
||||
import Following from '../features/following';
|
||||
import Reblogs from '../features/reblogs';
|
||||
import Favourites from '../features/favourites';
|
||||
import HashtagTimeline from '../features/hashtag_timeline';
|
||||
import Notifications from '../features/notifications';
|
||||
import FollowRequests from '../features/follow_requests';
|
||||
import GenericNotFound from '../features/generic_not_found';
|
||||
import FavouritedStatuses from '../features/favourited_statuses';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import en from 'react-intl/locale-data/en';
|
||||
import de from 'react-intl/locale-data/de';
|
||||
import es from 'react-intl/locale-data/es';
|
||||
import fr from 'react-intl/locale-data/fr';
|
||||
import pt from 'react-intl/locale-data/pt';
|
||||
import hu from 'react-intl/locale-data/hu';
|
||||
import uk from 'react-intl/locale-data/uk';
|
||||
import getMessagesForLocale from '../locales';
|
||||
import { hydrateStore } from '../actions/store';
|
||||
|
||||
const store = configureStore();
|
||||
|
||||
store.dispatch(hydrateStore(window.INITIAL_STATE));
|
||||
|
||||
const browserHistory = useRouterHistory(createBrowserHistory)({
|
||||
basename: '/web'
|
||||
});
|
||||
|
||||
addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]);
|
||||
|
||||
const Mastodon = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
token: React.PropTypes.string.isRequired,
|
||||
timelines: React.PropTypes.object,
|
||||
account: React.PropTypes.string
|
||||
locale: React.PropTypes.string.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount() {
|
||||
store.dispatch(setAccessToken(this.props.token));
|
||||
store.dispatch(setAccountSelf(JSON.parse(this.props.account)));
|
||||
const { locale } = this.props;
|
||||
|
||||
if (typeof App !== 'undefined') {
|
||||
this.subscription = App.cable.subscriptions.create('TimelineChannel', {
|
||||
|
||||
received (data) {
|
||||
switch(data.type) {
|
||||
case 'update':
|
||||
return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
|
||||
case 'delete':
|
||||
return store.dispatch(deleteFromTimelines(data.id));
|
||||
case 'merge':
|
||||
case 'unmerge':
|
||||
return store.dispatch(refreshTimeline('home'));
|
||||
case 'block':
|
||||
return store.dispatch(refreshTimeline('mentions'));
|
||||
case 'update':
|
||||
store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
|
||||
break;
|
||||
case 'delete':
|
||||
store.dispatch(deleteFromTimelines(data.id));
|
||||
break;
|
||||
case 'notification':
|
||||
store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// Desktop notifications
|
||||
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
@@ -69,22 +98,41 @@ const Mastodon = React.createClass({
|
||||
},
|
||||
|
||||
render () {
|
||||
const { locale } = this.props;
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Router history={hashHistory}>
|
||||
<Route path='/' component={UI}>
|
||||
<IndexRoute component={GettingStarted} />
|
||||
<Route path='/statuses/new' component={Compose} />
|
||||
<Route path='/statuses/home' component={HomeTimeline} />
|
||||
<Route path='/statuses/mentions' component={MentionsTimeline} />
|
||||
<Route path='/statuses/all' component={PublicTimeline} />
|
||||
<Route path='/statuses/:statusId' component={Status} />
|
||||
<Route path='/accounts/:accountId' component={Account}>
|
||||
<IndexRoute component={AccountTimeline} />
|
||||
<IntlProvider locale={locale} messages={getMessagesForLocale(locale)}>
|
||||
<Provider store={store}>
|
||||
<Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
|
||||
<Route path='/' component={UI}>
|
||||
<IndexRedirect to="/getting-started" />
|
||||
|
||||
<Route path='getting-started' component={GettingStarted} />
|
||||
<Route path='timelines/home' component={HomeTimeline} />
|
||||
<Route path='timelines/mentions' component={MentionsTimeline} />
|
||||
<Route path='timelines/public' component={PublicTimeline} />
|
||||
<Route path='timelines/tag/:id' component={HashtagTimeline} />
|
||||
|
||||
<Route path='notifications' component={Notifications} />
|
||||
<Route path='favourites' component={FavouritedStatuses} />
|
||||
|
||||
<Route path='statuses/new' component={Compose} />
|
||||
<Route path='statuses/:statusId' component={Status} />
|
||||
<Route path='statuses/:statusId/reblogs' component={Reblogs} />
|
||||
<Route path='statuses/:statusId/favourites' component={Favourites} />
|
||||
|
||||
<Route path='accounts/:accountId' component={Account}>
|
||||
<IndexRoute component={AccountTimeline} />
|
||||
<Route path='followers' component={Followers} />
|
||||
<Route path='following' component={Following} />
|
||||
</Route>
|
||||
|
||||
<Route path='follow_requests' component={FollowRequests} />
|
||||
<Route path='*' component={GenericNotFound} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Router>
|
||||
</Provider>
|
||||
</Router>
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,111 @@
|
||||
import { connect } from 'react-redux';
|
||||
import Status from '../components/status';
|
||||
import { makeGetStatus } from '../selectors';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose
|
||||
} from '../actions/compose';
|
||||
import {
|
||||
reblog,
|
||||
favourite,
|
||||
unreblog,
|
||||
unfavourite
|
||||
} from '../actions/interactions';
|
||||
import { blockAccount } from '../actions/accounts';
|
||||
import { deleteStatus } from '../actions/statuses';
|
||||
import { openMedia } from '../actions/modal';
|
||||
import { createSelector } from 'reselect'
|
||||
import { isMobile } from '../is_mobile'
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
statusBase: state.getIn(['statuses', props.id]),
|
||||
me: state.getIn(['meta', 'me'])
|
||||
});
|
||||
|
||||
const makeMapStateToPropsInner = () => {
|
||||
const getStatus = (() => {
|
||||
return createSelector(
|
||||
[
|
||||
(_, base) => base,
|
||||
(state, base) => (base ? state.getIn(['accounts', base.get('account')]) : null),
|
||||
(state, base) => (base ? state.getIn(['statuses', base.get('reblog')], null) : null)
|
||||
],
|
||||
|
||||
(base, account, reblog) => (base ? base.set('account', account).set('reblog', reblog) : null)
|
||||
);
|
||||
})();
|
||||
|
||||
const mapStateToProps = (state, { statusBase }) => ({
|
||||
status: getStatus(state, statusBase)
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const makeMapStateToPropsLast = () => {
|
||||
const getStatus = (() => {
|
||||
return createSelector(
|
||||
[
|
||||
(_, status) => status,
|
||||
(state, status) => (status ? state.getIn(['accounts', status.getIn(['reblog', 'account'])], null) : null)
|
||||
],
|
||||
|
||||
(status, reblogAccount) => (status && status.get('reblog') ? status.setIn(['reblog', 'account'], reblogAccount) : status)
|
||||
);
|
||||
})();
|
||||
|
||||
const mapStateToProps = (state, { status }) => ({
|
||||
status: getStatus(state, status)
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
|
||||
onReply (status, router) {
|
||||
dispatch(replyCompose(status, router));
|
||||
},
|
||||
|
||||
onReblog (status) {
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
dispatch(reblog(status));
|
||||
}
|
||||
},
|
||||
|
||||
onFavourite (status) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
}
|
||||
},
|
||||
|
||||
onDelete (status) {
|
||||
dispatch(deleteStatus(status.get('id')));
|
||||
},
|
||||
|
||||
onMention (account, router) {
|
||||
dispatch(mentionCompose(account));
|
||||
if (isMobile(window.innerWidth)) {
|
||||
router.push('/statuses/new');
|
||||
}
|
||||
},
|
||||
|
||||
onOpenMedia (url) {
|
||||
dispatch(openMedia(url));
|
||||
},
|
||||
|
||||
onBlock (account) {
|
||||
dispatch(blockAccount(account.get('id')));
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(
|
||||
connect(makeMapStateToPropsInner)(
|
||||
connect(makeMapStateToPropsLast)(Status)
|
||||
)
|
||||
);
|
9
app/assets/javascripts/components/emoji.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import emojione from 'emojione';
|
||||
|
||||
emojione.imageType = 'png';
|
||||
emojione.sprites = false;
|
||||
emojione.imagePathPNG = '/emoji/';
|
||||
|
||||
export default function emojify(text) {
|
||||
return emojione.toImage(text);
|
||||
};
|
@@ -1,61 +1,94 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import DropdownMenu from '../../../components/dropdown_menu';
|
||||
import DropdownMenu from '../../../components/dropdown_menu';
|
||||
import { Link } from 'react-router';
|
||||
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
mention: { id: 'account.mention', defaultMessage: 'Mention' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block' }
|
||||
});
|
||||
|
||||
const outerStyle = {
|
||||
borderTop: '1px solid #363c4b',
|
||||
borderBottom: '1px solid #363c4b',
|
||||
lineHeight: '36px',
|
||||
overflow: 'hidden',
|
||||
flex: '0 0 auto',
|
||||
display: 'flex'
|
||||
};
|
||||
|
||||
const outerDropdownStyle = {
|
||||
padding: '10px',
|
||||
flex: '1 1 auto'
|
||||
};
|
||||
|
||||
const outerLinksStyle = {
|
||||
flex: '1 1 auto',
|
||||
display: 'flex',
|
||||
lineHeight: '18px'
|
||||
};
|
||||
|
||||
const ActionBar = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
me: React.PropTypes.number.isRequired,
|
||||
onFollow: React.PropTypes.func.isRequired,
|
||||
onBlock: React.PropTypes.func.isRequired
|
||||
onFollow: React.PropTypes.func,
|
||||
onBlock: React.PropTypes.func.isRequired,
|
||||
onMention: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
const { account, me } = this.props;
|
||||
const { account, me, intl } = this.props;
|
||||
|
||||
let menu = [];
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.mention), action: this.props.onMention });
|
||||
|
||||
if (account.get('id') === me) {
|
||||
menu.push({ text: 'Edit profile', href: '/settings/profile' });
|
||||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
|
||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||
menu.push({ text: 'Unblock', action: this.props.onBlock });
|
||||
menu.push({ text: intl.formatMessage(messages.unblock), action: this.props.onBlock });
|
||||
} else if (account.getIn(['relationship', 'following'])) {
|
||||
menu.push({ text: 'Unfollow', action: this.props.onFollow });
|
||||
menu.push({ text: 'Block', action: this.props.onBlock });
|
||||
menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
|
||||
} else {
|
||||
menu.push({ text: 'Follow', action: this.props.onFollow });
|
||||
menu.push({ text: 'Block', action: this.props.onBlock });
|
||||
menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto', display: 'flex' }}>
|
||||
<div style={{ flex: '1 1 auto', display: 'flex', lineHeight: '18px' }}>
|
||||
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px', paddingRight: '5px' }}>
|
||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Posts</span>
|
||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('statuses_count')}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px 5px' }}>
|
||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Follows</span>
|
||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('following_count')}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ overflow: 'hidden', width: '80px', padding: '10px 5px', borderRight: '1px solid #363c4b' }}>
|
||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Followers</span>
|
||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('followers_count')}</span>
|
||||
</div>
|
||||
<div style={outerStyle}>
|
||||
<div style={outerDropdownStyle}>
|
||||
<DropdownMenu items={menu} icon='bars' size={24} direction="right" />
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '10px', flex: '1 1 auto' }}>
|
||||
<DropdownMenu items={menu} icon='bars' size={24} />
|
||||
<div style={outerLinksStyle}>
|
||||
<Link to={`/accounts/${account.get('id')}`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', borderLeft: '1px solid #363c4b', padding: '10px', paddingRight: '5px' }}>
|
||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}><FormattedMessage id='account.posts' defaultMessage='Posts' /></span>
|
||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}><FormattedNumber value={account.get('statuses_count')} /></span>
|
||||
</Link>
|
||||
|
||||
<Link to={`/accounts/${account.get('id')}/following`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', borderLeft: '1px solid #363c4b', padding: '10px 5px' }}>
|
||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
|
||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}><FormattedNumber value={account.get('following_count')} /></span>
|
||||
</Link>
|
||||
|
||||
<Link to={`/accounts/${account.get('id')}/followers`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', padding: '10px 5px', borderLeft: '1px solid #363c4b' }}>
|
||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
|
||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}><FormattedNumber value={account.get('followers_count')} /></span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default ActionBar;
|
||||
export default injectIntl(ActionBar);
|
||||
|
@@ -1,44 +1,81 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import emojify from '../../../emoji';
|
||||
import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
|
||||
});
|
||||
|
||||
const Header = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
me: React.PropTypes.number.isRequired
|
||||
me: React.PropTypes.number.isRequired,
|
||||
onFollow: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
const { account, me } = this.props;
|
||||
const { account, me, intl } = this.props;
|
||||
|
||||
let displayName = account.get('display_name');
|
||||
let info = '';
|
||||
let info = '';
|
||||
let actionBtn = '';
|
||||
let lockedIcon = '';
|
||||
|
||||
if (displayName.length === 0) {
|
||||
displayName = account.get('username');
|
||||
}
|
||||
|
||||
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
|
||||
info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}>Follows you</span>
|
||||
info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>
|
||||
}
|
||||
|
||||
if (me !== account.get('id')) {
|
||||
if (account.getIn(['relationship', 'requested'])) {
|
||||
actionBtn = (
|
||||
<div style={{ position: 'absolute', top: '10px', left: '20px' }}>
|
||||
<IconButton size={26} disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
actionBtn = (
|
||||
<div style={{ position: 'absolute', top: '10px', left: '20px' }}>
|
||||
<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} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.get('locked')) {
|
||||
lockedIcon = <i className='fa fa-lock' />;
|
||||
}
|
||||
|
||||
const content = { __html: emojify(account.get('note')) };
|
||||
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
|
||||
|
||||
return (
|
||||
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}>
|
||||
<div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '30px 10px' }}>
|
||||
<div className='account__header' style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', backgroundPosition: 'center', position: 'relative' }}>
|
||||
<div style={{ background: 'rgba(47, 52, 65, 0.9)', padding: '20px 10px' }}>
|
||||
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
|
||||
<div style={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}>
|
||||
<div className='account__header__avatar' style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
|
||||
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
|
||||
</div>
|
||||
|
||||
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }}>{displayName}</span>
|
||||
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
|
||||
</a>
|
||||
|
||||
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '15px' }}>@{account.get('acct')}</span>
|
||||
<p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p>
|
||||
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#489fde', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
|
||||
<div style={{ color: '#d9e1e8', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
|
||||
|
||||
{info}
|
||||
{actionBtn}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -46,4 +83,4 @@ const Header = React.createClass({
|
||||
|
||||
});
|
||||
|
||||
export default Header;
|
||||
export default injectIntl(Header);
|
||||
|
@@ -10,22 +10,35 @@ import {
|
||||
fetchAccountTimeline,
|
||||
expandAccountTimeline
|
||||
} from '../../actions/accounts';
|
||||
import { mentionCompose } from '../../actions/compose';
|
||||
import Header from './components/header';
|
||||
import {
|
||||
getAccountTimeline,
|
||||
getAccount
|
||||
makeGetAccount
|
||||
} from '../../selectors';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import ActionBar from './components/action_bar';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
import { isMobile } from '../../is_mobile'
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: getAccount(state, Number(props.params.accountId)),
|
||||
me: state.getIn(['timelines', 'me'])
|
||||
});
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: getAccount(state, Number(props.params.accountId)),
|
||||
me: state.getIn(['meta', 'me'])
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const Account = React.createClass({
|
||||
|
||||
contextTypes: {
|
||||
router: React.PropTypes.object
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
@@ -39,7 +52,7 @@ const Account = React.createClass({
|
||||
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
|
||||
},
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
|
||||
}
|
||||
@@ -61,6 +74,13 @@ const Account = React.createClass({
|
||||
}
|
||||
},
|
||||
|
||||
handleMention () {
|
||||
this.props.dispatch(mentionCompose(this.props.account));
|
||||
if (isMobile(window.innerWidth)) {
|
||||
this.context.router.push('/statuses/new');
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { account, me } = this.props;
|
||||
|
||||
@@ -74,9 +94,9 @@ const Account = React.createClass({
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<Header account={account} me={me} />
|
||||
|
||||
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} />
|
||||
<ColumnBackButton />
|
||||
<Header account={account} me={me} onFollow={this.handleFollow} />
|
||||
<ActionBar account={account} me={me} onBlock={this.handleBlock} onMention={this.handleMention} />
|
||||
|
||||
{this.props.children}
|
||||
</Column>
|
||||
@@ -85,4 +105,4 @@ const Account = React.createClass({
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Account);
|
||||
export default connect(makeMapStateToProps)(Account);
|
||||
|
@@ -1,24 +1,16 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { getAccountTimeline } from '../../selectors';
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import {
|
||||
fetchAccountTimeline,
|
||||
expandAccountTimeline
|
||||
} from '../../actions/accounts';
|
||||
import { deleteStatus } from '../../actions/statuses';
|
||||
import { replyCompose } from '../../actions/compose';
|
||||
import {
|
||||
favourite,
|
||||
reblog,
|
||||
unreblog,
|
||||
unfavourite
|
||||
} from '../../actions/interactions';
|
||||
import StatusList from '../../components/status_list';
|
||||
} from '../../actions/accounts';
|
||||
import StatusList from '../../components/status_list';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
statuses: getAccountTimeline(state, Number(props.params.accountId)),
|
||||
me: state.getIn(['timelines', 'me'])
|
||||
statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]),
|
||||
me: state.getIn(['meta', 'me'])
|
||||
});
|
||||
|
||||
const AccountTimeline = React.createClass({
|
||||
@@ -26,7 +18,8 @@ const AccountTimeline = React.createClass({
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
statuses: ImmutablePropTypes.list
|
||||
statusIds: ImmutablePropTypes.list,
|
||||
me: React.PropTypes.number.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
@@ -41,38 +34,18 @@ const AccountTimeline = React.createClass({
|
||||
}
|
||||
},
|
||||
|
||||
handleReply (status) {
|
||||
this.props.dispatch(replyCompose(status));
|
||||
},
|
||||
|
||||
handleReblog (status) {
|
||||
if (status.get('reblogged')) {
|
||||
this.props.dispatch(unreblog(status));
|
||||
} else {
|
||||
this.props.dispatch(reblog(status));
|
||||
}
|
||||
},
|
||||
|
||||
handleFavourite (status) {
|
||||
if (status.get('favourited')) {
|
||||
this.props.dispatch(unfavourite(status));
|
||||
} else {
|
||||
this.props.dispatch(favourite(status));
|
||||
}
|
||||
},
|
||||
|
||||
handleDelete (status) {
|
||||
this.props.dispatch(deleteStatus(status.get('id')));
|
||||
},
|
||||
|
||||
handleScrollToBottom () {
|
||||
this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
|
||||
},
|
||||
|
||||
render () {
|
||||
const { statuses, me } = this.props;
|
||||
const { statusIds, me } = this.props;
|
||||
|
||||
return <StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} onDelete={this.handleDelete} />
|
||||
if (!statusIds) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
|
||||
}
|
||||
|
||||
});
|
||||
|
@@ -0,0 +1,11 @@
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
|
||||
const AutosuggestAccount = ({ account }) => (
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AutosuggestAccount;
|
@@ -0,0 +1,168 @@
|
||||
import CharacterCounter from './character_counter';
|
||||
import Button from '../../../components/button';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ReplyIndicator from './reply_indicator';
|
||||
import UploadButton from './upload_button';
|
||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||
import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container';
|
||||
import { debounce } from 'react-decoration';
|
||||
import UploadButtonContainer from '../containers/upload_button_container';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import Toggle from 'react-toggle';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
|
||||
publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }
|
||||
});
|
||||
|
||||
const ComposeForm = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
intl: React.PropTypes.object.isRequired,
|
||||
text: React.PropTypes.string.isRequired,
|
||||
suggestion_token: React.PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
sensitive: React.PropTypes.bool,
|
||||
unlisted: React.PropTypes.bool,
|
||||
private: React.PropTypes.bool,
|
||||
fileDropDate: React.PropTypes.instanceOf(Date),
|
||||
is_submitting: React.PropTypes.bool,
|
||||
is_uploading: React.PropTypes.bool,
|
||||
in_reply_to: ImmutablePropTypes.map,
|
||||
media_count: React.PropTypes.number,
|
||||
me: React.PropTypes.number,
|
||||
onChange: React.PropTypes.func.isRequired,
|
||||
onSubmit: React.PropTypes.func.isRequired,
|
||||
onCancelReply: React.PropTypes.func.isRequired,
|
||||
onClearSuggestions: React.PropTypes.func.isRequired,
|
||||
onFetchSuggestions: React.PropTypes.func.isRequired,
|
||||
onSuggestionSelected: React.PropTypes.func.isRequired,
|
||||
onChangeSensitivity: React.PropTypes.func.isRequired,
|
||||
onChangeVisibility: React.PropTypes.func.isRequired,
|
||||
onChangeListability: React.PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleChange (e) {
|
||||
this.props.onChange(e.target.value);
|
||||
},
|
||||
|
||||
handleKeyDown (e) {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
this.props.onSubmit();
|
||||
}
|
||||
},
|
||||
|
||||
handleSubmit () {
|
||||
this.props.onSubmit();
|
||||
},
|
||||
|
||||
onSuggestionsClearRequested () {
|
||||
this.props.onClearSuggestions();
|
||||
},
|
||||
|
||||
@debounce(500)
|
||||
onSuggestionsFetchRequested (token) {
|
||||
this.props.onFetchSuggestions(token);
|
||||
},
|
||||
|
||||
onSuggestionSelected (tokenStart, token, value) {
|
||||
this.props.onSuggestionSelected(tokenStart, token, value);
|
||||
},
|
||||
|
||||
handleChangeSensitivity (e) {
|
||||
this.props.onChangeSensitivity(e.target.checked);
|
||||
},
|
||||
|
||||
handleChangeVisibility (e) {
|
||||
this.props.onChangeVisibility(e.target.checked);
|
||||
},
|
||||
|
||||
handleChangeListability (e) {
|
||||
this.props.onChangeListability(e.target.checked);
|
||||
},
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) {
|
||||
// If replying to zero or one users, places the cursor at the end of the textbox.
|
||||
// If replying to more than one user, selects any usernames past the first;
|
||||
// this provides a convenient shortcut to drop everyone else from the conversation.
|
||||
const selectionStart = this.props.text.search(/\s/) + 1;
|
||||
const selectionEnd = this.props.text.length;
|
||||
|
||||
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
|
||||
this.autosuggestTextarea.textarea.focus();
|
||||
}
|
||||
},
|
||||
|
||||
setAutosuggestTextarea (c) {
|
||||
this.autosuggestTextarea = c;
|
||||
},
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
let replyArea = '';
|
||||
const disabled = this.props.is_submitting || this.props.is_uploading;
|
||||
|
||||
if (this.props.in_reply_to) {
|
||||
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
|
||||
}
|
||||
|
||||
let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '10px' }}>
|
||||
{replyArea}
|
||||
|
||||
<AutosuggestTextarea
|
||||
ref={this.setAutosuggestTextarea}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
disabled={disabled}
|
||||
fileDropDate={this.props.fileDropDate}
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
||||
<div style={{ float: 'right' }}><Button text={intl.formatMessage(messages.publish)} onClick={this.handleSubmit} disabled={disabled} /></div>
|
||||
<div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div>
|
||||
<UploadButtonContainer style={{ paddingTop: '4px' }} />
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', marginTop: '10px', borderTop: '1px solid #282c37', paddingTop: '10px' }}>
|
||||
<Toggle checked={this.props.private} onChange={this.handleChangeVisibility} />
|
||||
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
|
||||
</label>
|
||||
|
||||
<Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}>
|
||||
{({ opacity, height }) =>
|
||||
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
|
||||
<Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />
|
||||
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display in public timeline' /></span>
|
||||
</label>
|
||||
}
|
||||
</Motion>
|
||||
|
||||
<Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(this.props.media_count === 0 ? 0 : 100), height: spring(this.props.media_count === 0 ? 0 : 39.5) }}>
|
||||
{({ opacity, height }) =>
|
||||
<label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
|
||||
<Toggle checked={this.props.sensitive} onChange={this.handleChangeSensitivity} />
|
||||
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark content as sensitive' /></span>
|
||||
</label>
|
||||
}
|
||||
</Motion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(ComposeForm);
|
@@ -0,0 +1,75 @@
|
||||
import { Link } from 'react-router';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
|
||||
});
|
||||
|
||||
const outerStyle = {
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflowY: 'hidden'
|
||||
};
|
||||
|
||||
const innerStyle = {
|
||||
boxSizing: 'border-box',
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflowY: 'auto',
|
||||
flexGrow: '1'
|
||||
};
|
||||
|
||||
const tabStyle = {
|
||||
display: 'block',
|
||||
flex: '1 1 auto',
|
||||
padding: '15px',
|
||||
paddingBottom: '13px',
|
||||
color: '#9baec8',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center',
|
||||
fontSize: '16px',
|
||||
borderBottom: '2px solid transparent'
|
||||
};
|
||||
|
||||
const tabActiveStyle = {
|
||||
color: '#2b90d9',
|
||||
borderBottom: '2px solid #2b90d9'
|
||||
};
|
||||
|
||||
const Drawer = ({ children, withHeader, intl }) => {
|
||||
let header = '';
|
||||
|
||||
if (withHeader) {
|
||||
header = (
|
||||
<div className='drawer__header'>
|
||||
<Link title={intl.formatMessage(messages.start)} style={tabStyle} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
|
||||
<Link title={intl.formatMessage(messages.public)} style={tabStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
|
||||
<a title={intl.formatMessage(messages.preferences)} style={tabStyle} href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
|
||||
<a title={intl.formatMessage(messages.logout)} style={tabStyle} href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='drawer' style={outerStyle}>
|
||||
{header}
|
||||
|
||||
<div className='drawer__inner' style={innerStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Drawer.propTypes = {
|
||||
withHeader: React.PropTypes.bool,
|
||||
children: React.PropTypes.node,
|
||||
intl: React.PropTypes.object
|
||||
};
|
||||
|
||||
export default injectIntl(Drawer);
|
@@ -0,0 +1,32 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import Permalink from '../../../components/permalink';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const NavigationBar = React.createClass({
|
||||
propTypes: {
|
||||
account: ImmutablePropTypes.map.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div style={{ padding: '10px', display: 'flex', flexShrink: '0', cursor: 'default' }}>
|
||||
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Permalink>
|
||||
|
||||
<div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
|
||||
<strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
|
||||
<a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default NavigationBar;
|
@@ -0,0 +1,59 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import emojify from '../../../emoji';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }
|
||||
});
|
||||
|
||||
const ReplyIndicator = React.createClass({
|
||||
|
||||
contextTypes: {
|
||||
router: React.PropTypes.object
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onCancel: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleClick () {
|
||||
this.props.onCancel();
|
||||
},
|
||||
|
||||
handleAccountClick (e) {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
const content = { __html: emojify(this.props.status.get('content')) };
|
||||
|
||||
return (
|
||||
<div style={{ background: '#9baec8', padding: '10px' }}>
|
||||
<div style={{ overflow: 'hidden', marginBottom: '5px' }}>
|
||||
<div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
|
||||
|
||||
<a href={this.props.status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#282c37', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
|
||||
<div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={this.props.status.getIn(['account', 'avatar'])} /></div>
|
||||
<DisplayName account={this.props.status.get('account')} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(ReplyIndicator);
|
@@ -0,0 +1,133 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
import AutosuggestAccountContainer from '../containers/autosuggest_account_container';
|
||||
import { debounce } from 'react-decoration';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }
|
||||
});
|
||||
|
||||
const getSuggestionValue = suggestion => suggestion.value;
|
||||
|
||||
const renderSuggestion = suggestion => {
|
||||
if (suggestion.type === 'account') {
|
||||
return <AutosuggestAccountContainer id={suggestion.id} />;
|
||||
} else {
|
||||
return <span>#{suggestion.id}</span>
|
||||
}
|
||||
};
|
||||
|
||||
const renderSectionTitle = section => (
|
||||
<strong><FormattedMessage id={`search.${section.title}`} defaultMessage={section.title} /></strong>
|
||||
);
|
||||
|
||||
const getSectionSuggestions = section => section.items;
|
||||
|
||||
const outerStyle = {
|
||||
padding: '10px',
|
||||
lineHeight: '20px',
|
||||
position: 'relative'
|
||||
};
|
||||
|
||||
const inputStyle = {
|
||||
boxSizing: 'border-box',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
padding: '10px',
|
||||
paddingRight: '30px',
|
||||
fontFamily: 'inherit',
|
||||
background: '#282c37',
|
||||
color: '#9baec8',
|
||||
fontSize: '14px',
|
||||
margin: '0'
|
||||
};
|
||||
|
||||
const iconStyle = {
|
||||
position: 'absolute',
|
||||
top: '18px',
|
||||
right: '20px',
|
||||
color: '#9baec8',
|
||||
fontSize: '18px',
|
||||
pointerEvents: 'none'
|
||||
};
|
||||
|
||||
const Search = React.createClass({
|
||||
|
||||
contextTypes: {
|
||||
router: React.PropTypes.object
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
suggestions: React.PropTypes.array.isRequired,
|
||||
value: React.PropTypes.string.isRequired,
|
||||
onChange: React.PropTypes.func.isRequired,
|
||||
onClear: React.PropTypes.func.isRequired,
|
||||
onFetch: React.PropTypes.func.isRequired,
|
||||
onReset: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
onChange (_, { newValue }) {
|
||||
if (typeof newValue !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onChange(newValue);
|
||||
},
|
||||
|
||||
onSuggestionsClearRequested () {
|
||||
this.props.onClear();
|
||||
},
|
||||
|
||||
@debounce(500)
|
||||
onSuggestionsFetchRequested ({ value }) {
|
||||
value = value.replace('#', '');
|
||||
this.props.onFetch(value.trim());
|
||||
},
|
||||
|
||||
onSuggestionSelected (_, { suggestion }) {
|
||||
if (suggestion.type === 'account') {
|
||||
this.context.router.push(`/accounts/${suggestion.id}`);
|
||||
} else {
|
||||
this.context.router.push(`/timelines/tag/${suggestion.id}`);
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const inputProps = {
|
||||
placeholder: this.props.intl.formatMessage(messages.placeholder),
|
||||
value: this.props.value,
|
||||
onChange: this.onChange,
|
||||
style: inputStyle
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<Autosuggest
|
||||
multiSection={true}
|
||||
suggestions={this.props.suggestions}
|
||||
focusFirstSuggestion={true}
|
||||
focusInputOnSuggestionClick={false}
|
||||
alwaysRenderSuggestions={false}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
getSuggestionValue={getSuggestionValue}
|
||||
renderSuggestion={renderSuggestion}
|
||||
renderSectionTitle={renderSectionTitle}
|
||||
getSectionSuggestions={getSectionSuggestions}
|
||||
inputProps={inputProps}
|
||||
/>
|
||||
|
||||
<div style={iconStyle}><i className='fa fa-search' /></div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(Search);
|
@@ -1,123 +0,0 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const outerStyle = {
|
||||
marginBottom: '10px',
|
||||
borderTop: '1px solid #616b86',
|
||||
position: 'relative'
|
||||
};
|
||||
|
||||
const headerStyle = {
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'block',
|
||||
padding: '10px',
|
||||
color: '#9baec8',
|
||||
background: '#454b5e',
|
||||
width: '120px',
|
||||
marginTop: '-18px'
|
||||
};
|
||||
|
||||
const itemStyle = {
|
||||
display: 'block',
|
||||
padding: '10px',
|
||||
color: '#9baec8',
|
||||
overflow: 'hidden',
|
||||
textDecoration: 'none'
|
||||
};
|
||||
|
||||
const displayNameStyle = {
|
||||
display: 'block',
|
||||
fontWeight: '500',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
};
|
||||
|
||||
const acctStyle = {
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
};
|
||||
|
||||
const nextStyle = {
|
||||
fontWeight: '400',
|
||||
color: '#2b90d9'
|
||||
};
|
||||
|
||||
const SuggestionsBox = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
accounts: ImmutablePropTypes.list.isRequired,
|
||||
perWindow: React.PropTypes.number
|
||||
},
|
||||
|
||||
getInitialState () {
|
||||
return {
|
||||
index: 0
|
||||
};
|
||||
},
|
||||
|
||||
getDefaultProps () {
|
||||
return {
|
||||
perWindow: 2
|
||||
};
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleNextClick (e) {
|
||||
e.preventDefault();
|
||||
|
||||
let newIndex = this.state.index + 1;
|
||||
|
||||
if (this.props.accounts.skip(this.props.perWindow * newIndex).size === 0) {
|
||||
newIndex = 0;
|
||||
}
|
||||
|
||||
this.setState({ index: newIndex });
|
||||
},
|
||||
|
||||
render () {
|
||||
const { accounts, perWindow } = this.props;
|
||||
|
||||
if (accounts.size === 0) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
let nextLink = '';
|
||||
|
||||
if (accounts.size > perWindow) {
|
||||
nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}>Next</a>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<strong style={headerStyle}>
|
||||
Who to follow {nextLink}
|
||||
</strong>
|
||||
|
||||
{accounts.skip(perWindow * this.state.index).take(perWindow).map(account => {
|
||||
let displayName = account.get('display_name');
|
||||
|
||||
if (displayName.length === 0) {
|
||||
displayName = account.get('username');
|
||||
}
|
||||
|
||||
return (
|
||||
<Link key={account.get('id')} style={itemStyle} to={`/accounts/${account.get('id')}`}>
|
||||
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
|
||||
<strong style={displayNameStyle}>{displayName}</strong>
|
||||
<span style={acctStyle}>{account.get('acct')}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default SuggestionsBox;
|
@@ -0,0 +1,48 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
upload: { id: 'upload_button.label', defaultMessage: 'Add media' }
|
||||
});
|
||||
|
||||
const UploadButton = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
disabled: React.PropTypes.bool,
|
||||
onSelectFile: React.PropTypes.func.isRequired,
|
||||
style: React.PropTypes.object,
|
||||
resetFileKey: React.PropTypes.number,
|
||||
intl: React.PropTypes.object.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleChange (e) {
|
||||
if (e.target.files.length > 0) {
|
||||
this.props.onSelectFile(e.target.files);
|
||||
}
|
||||
},
|
||||
|
||||
handleClick () {
|
||||
this.fileElement.click();
|
||||
},
|
||||
|
||||
setRef (c) {
|
||||
this.fileElement = c;
|
||||
},
|
||||
|
||||
render () {
|
||||
const { intl, resetFileKey, disabled } = this.props;
|
||||
|
||||
return (
|
||||
<div style={this.props.style}>
|
||||
<IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} />
|
||||
<input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(UploadButton);
|
@@ -0,0 +1,44 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }
|
||||
});
|
||||
|
||||
const UploadForm = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
media: ImmutablePropTypes.list.isRequired,
|
||||
is_uploading: React.PropTypes.bool,
|
||||
onRemoveFile: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
const { intl, media } = this.props;
|
||||
|
||||
if (!media.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uploads = media.map(attachment => (
|
||||
<div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'>
|
||||
<div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
|
||||
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}>
|
||||
{uploads}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(UploadForm);
|
@@ -0,0 +1,15 @@
|
||||
import { connect } from 'react-redux';
|
||||
import AutosuggestAccount from '../components/autosuggest_account';
|
||||
import { makeGetAccount } from '../../../selectors';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
account: getAccount(state, id)
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default connect(makeMapStateToProps)(AutosuggestAccount);
|
@@ -0,0 +1,79 @@
|
||||
import { connect } from 'react-redux';
|
||||
import ComposeForm from '../components/compose_form';
|
||||
import {
|
||||
changeCompose,
|
||||
submitCompose,
|
||||
cancelReplyCompose,
|
||||
clearComposeSuggestions,
|
||||
fetchComposeSuggestions,
|
||||
selectComposeSuggestion,
|
||||
changeComposeSensitivity,
|
||||
changeComposeVisibility,
|
||||
changeComposeListability
|
||||
} from '../../../actions/compose';
|
||||
import { makeGetStatus } from '../../../selectors';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
const mapStateToProps = function (state, props) {
|
||||
return {
|
||||
text: state.getIn(['compose', 'text']),
|
||||
suggestion_token: state.getIn(['compose', 'suggestion_token']),
|
||||
suggestions: state.getIn(['compose', 'suggestions']),
|
||||
sensitive: state.getIn(['compose', 'sensitive']),
|
||||
unlisted: state.getIn(['compose', 'unlisted']),
|
||||
private: state.getIn(['compose', 'private']),
|
||||
fileDropDate: state.getIn(['compose', 'fileDropDate']),
|
||||
is_submitting: state.getIn(['compose', 'is_submitting']),
|
||||
is_uploading: state.getIn(['compose', 'is_uploading']),
|
||||
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
|
||||
media_count: state.getIn(['compose', 'media_attachments']).size,
|
||||
me: state.getIn(['compose', 'me'])
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = function (dispatch) {
|
||||
return {
|
||||
onChange (text) {
|
||||
dispatch(changeCompose(text));
|
||||
},
|
||||
|
||||
onSubmit () {
|
||||
dispatch(submitCompose());
|
||||
},
|
||||
|
||||
onCancelReply () {
|
||||
dispatch(cancelReplyCompose());
|
||||
},
|
||||
|
||||
onClearSuggestions () {
|
||||
dispatch(clearComposeSuggestions());
|
||||
},
|
||||
|
||||
onFetchSuggestions (token) {
|
||||
dispatch(fetchComposeSuggestions(token));
|
||||
},
|
||||
|
||||
onSuggestionSelected (position, token, accountId) {
|
||||
dispatch(selectComposeSuggestion(position, token, accountId));
|
||||
},
|
||||
|
||||
onChangeSensitivity (checked) {
|
||||
dispatch(changeComposeSensitivity(checked));
|
||||
},
|
||||
|
||||
onChangeVisibility (checked) {
|
||||
dispatch(changeComposeVisibility(checked));
|
||||
},
|
||||
|
||||
onChangeListability (checked) {
|
||||
dispatch(changeComposeListability(checked));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm);
|
@@ -1,8 +1,10 @@
|
||||
import { connect } from 'react-redux';
|
||||
import NavigationBar from '../components/navigation_bar';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: state.getIn(['timelines', 'accounts', state.getIn(['timelines', 'me'])])
|
||||
});
|
||||
const mapStateToProps = (state, props) => {
|
||||
return {
|
||||
account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(NavigationBar);
|
@@ -0,0 +1,35 @@
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
changeSearch,
|
||||
clearSearchSuggestions,
|
||||
fetchSearchSuggestions,
|
||||
resetSearch
|
||||
} from '../../../actions/search';
|
||||
import Search from '../components/search';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
suggestions: state.getIn(['search', 'suggestions']),
|
||||
value: state.getIn(['search', 'value'])
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
onChange (value) {
|
||||
dispatch(changeSearch(value));
|
||||
},
|
||||
|
||||
onClear () {
|
||||
dispatch(clearSearchSuggestions());
|
||||
},
|
||||
|
||||
onFetch (value) {
|
||||
dispatch(fetchSearchSuggestions(value));
|
||||
},
|
||||
|
||||
onReset () {
|
||||
dispatch(resetSearch());
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Search);
|
@@ -1,9 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { getSuggestions } from '../../../selectors';
|
||||
import SuggestionsBox from '../components/suggestions_box';
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
accounts: getSuggestions(state)
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(SuggestionsBox);
|
@@ -0,0 +1,18 @@
|
||||
import { connect } from 'react-redux';
|
||||
import UploadButton from '../components/upload_button';
|
||||
import { uploadCompose } from '../../../actions/compose';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
|
||||
resetFileKey: state.getIn(['compose', 'resetFileKey'])
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
onSelectFile (files) {
|
||||
dispatch(uploadCompose(files));
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
|
@@ -0,0 +1,17 @@
|
||||
import { connect } from 'react-redux';
|
||||
import UploadForm from '../components/upload_form';
|
||||
import { undoUploadCompose } from '../../../actions/compose';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
onRemoveFile (media_id) {
|
||||
dispatch(undoUploadCompose(media_id));
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);
|
@@ -1,36 +1,36 @@
|
||||
import Drawer from '../ui/components/drawer';
|
||||
import ComposeFormContainer from '../ui/containers/compose_form_container';
|
||||
import FollowFormContainer from '../ui/containers/follow_form_container';
|
||||
import UploadFormContainer from '../ui/containers/upload_form_container';
|
||||
import NavigationContainer from '../ui/containers/navigation_container';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import SuggestionsContainer from './containers/suggestions_container';
|
||||
import { fetchSuggestions } from '../../actions/suggestions';
|
||||
import { connect } from 'react-redux';
|
||||
import Drawer from './components/drawer';
|
||||
import ComposeFormContainer from './containers/compose_form_container';
|
||||
import UploadFormContainer from './containers/upload_form_container';
|
||||
import NavigationContainer from './containers/navigation_container';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import SearchContainer from './containers/search_container';
|
||||
import { connect } from 'react-redux';
|
||||
import { mountCompose, unmountCompose } from '../../actions/compose';
|
||||
|
||||
const Compose = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
dispatch: React.PropTypes.func.isRequired
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
withHeader: React.PropTypes.bool
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentDidMount () {
|
||||
this.props.dispatch(fetchSuggestions());
|
||||
this.props.dispatch(mountCompose());
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
this.props.dispatch(unmountCompose());
|
||||
},
|
||||
|
||||
render () {
|
||||
return (
|
||||
<Drawer>
|
||||
<div style={{ flex: '1 1 auto' }}>
|
||||
<NavigationContainer />
|
||||
<ComposeFormContainer />
|
||||
<UploadFormContainer />
|
||||
</div>
|
||||
|
||||
<SuggestionsContainer />
|
||||
<FollowFormContainer />
|
||||
<Drawer withHeader={this.props.withHeader}>
|
||||
<SearchContainer />
|
||||
<NavigationContainer />
|
||||
<ComposeFormContainer />
|
||||
<UploadFormContainer />
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
@@ -0,0 +1,63 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
|
||||
import Column from '../ui/components/column';
|
||||
import StatusList from '../../components/status_list';
|
||||
import ColumnBackButton from '../public_timeline/components/column_back_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.favourites', defaultMessage: 'Favourites' }
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
|
||||
loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
|
||||
me: state.getIn(['meta', 'me'])
|
||||
});
|
||||
|
||||
const Favourites = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
loaded: React.PropTypes.bool,
|
||||
intl: React.PropTypes.object.isRequired,
|
||||
me: React.PropTypes.number.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchFavouritedStatuses());
|
||||
},
|
||||
|
||||
handleScrollToBottom () {
|
||||
this.props.dispatch(expandFavouritedStatuses());
|
||||
},
|
||||
|
||||
render () {
|
||||
const { statusIds, loaded, intl, me } = this.props;
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column icon='star' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButton />
|
||||
<StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Favourites));
|
@@ -0,0 +1,61 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import { fetchFavourites } from '../../actions/interactions';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import AccountContainer from '../../containers/account_container';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)])
|
||||
});
|
||||
|
||||
const Favourites = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
|
||||
},
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
|
||||
this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { accountIds } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<ScrollContainer scrollKey='favourites'>
|
||||
<div className='scrollable'>
|
||||
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Favourites);
|
@@ -0,0 +1,61 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Permalink from '../../../components/permalink';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import emojify from '../../../emoji';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
|
||||
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }
|
||||
});
|
||||
|
||||
const outerStyle = {
|
||||
padding: '14px 10px'
|
||||
};
|
||||
|
||||
const panelStyle = {
|
||||
background: '#2f3441',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
borderTop: '1px solid #363c4b',
|
||||
borderBottom: '1px solid #363c4b',
|
||||
padding: '10px 0'
|
||||
};
|
||||
|
||||
const btnStyle = {
|
||||
flex: '1 1 auto',
|
||||
textAlign: 'center'
|
||||
};
|
||||
|
||||
const AccountAuthorize = ({ intl, account, onAuthorize, onReject }) => {
|
||||
const content = { __html: emojify(account.get('note')) };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={outerStyle}>
|
||||
<Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
|
||||
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={48} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Permalink>
|
||||
|
||||
<div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
|
||||
</div>
|
||||
|
||||
<div style={panelStyle}>
|
||||
<div style={btnStyle}><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div>
|
||||
<div style={btnStyle}><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
AccountAuthorize.propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onAuthorize: React.PropTypes.func.isRequired,
|
||||
onReject: React.PropTypes.func.isRequired,
|
||||
intl: React.PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default injectIntl(AccountAuthorize);
|
@@ -0,0 +1,26 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { makeGetAccount } from '../../../selectors';
|
||||
import AccountAuthorize from '../components/account_authorize';
|
||||
import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: getAccount(state, props.id)
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
||||
onAuthorize (account) {
|
||||
dispatch(authorizeFollowRequest(id));
|
||||
},
|
||||
|
||||
onReject (account) {
|
||||
dispatch(rejectFollowRequest(id));
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize);
|
@@ -0,0 +1,66 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import Column from '../ui/components/column';
|
||||
import AccountAuthorizeContainer from './containers/account_authorize_container';
|
||||
import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
accountIds: state.getIn(['user_lists', 'follow_requests', 'items'])
|
||||
});
|
||||
|
||||
const FollowRequests = React.createClass({
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
intl: React.PropTypes.object.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchFollowRequests());
|
||||
},
|
||||
|
||||
handleScroll (e) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
|
||||
if (scrollTop === scrollHeight - clientHeight) {
|
||||
this.props.dispatch(expandFollowRequests());
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { intl, accountIds } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column icon='users' heading={intl.formatMessage(messages.heading)}>
|
||||
<ScrollContainer scrollKey='follow_requests'>
|
||||
<div className='scrollable' onScroll={this.handleScroll}>
|
||||
{accountIds.map(id =>
|
||||
<AccountAuthorizeContainer key={id} id={id} />
|
||||
)}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(FollowRequests));
|
@@ -0,0 +1,64 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import {
|
||||
fetchFollowers,
|
||||
expandFollowers
|
||||
} from '../../actions/accounts';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import AccountContainer from '../../containers/account_container';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items'])
|
||||
});
|
||||
|
||||
const Followers = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchFollowers(Number(this.props.params.accountId)));
|
||||
},
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId)));
|
||||
}
|
||||
},
|
||||
|
||||
handleScroll (e) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
|
||||
if (scrollTop === scrollHeight - clientHeight) {
|
||||
this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { accountIds } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollContainer scrollKey='followers'>
|
||||
<div className='scrollable' onScroll={this.handleScroll}>
|
||||
<div>
|
||||
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Followers);
|
@@ -0,0 +1,64 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import {
|
||||
fetchFollowing,
|
||||
expandFollowing
|
||||
} from '../../actions/accounts';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import AccountContainer from '../../containers/account_container';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items'])
|
||||
});
|
||||
|
||||
const Following = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchFollowing(Number(this.props.params.accountId)));
|
||||
},
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId)));
|
||||
}
|
||||
},
|
||||
|
||||
handleScroll (e) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
|
||||
if (scrollTop === scrollHeight - clientHeight) {
|
||||
this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const { accountIds } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollContainer scrollKey='following'>
|
||||
<div className='scrollable' onScroll={this.handleScroll}>
|
||||
<div>
|
||||
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Following);
|
@@ -0,0 +1,10 @@
|
||||
import Column from '../ui/components/column';
|
||||
import MissingIndicator from '../../components/missing_indicator';
|
||||
|
||||
const GenericNotFound = () => (
|
||||
<Column>
|
||||
<MissingIndicator />
|
||||
</Column>
|
||||
);
|
||||
|
||||
export default GenericNotFound;
|
@@ -1,19 +1,55 @@
|
||||
import Column from '../ui/components/column';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnLink from '../ui/components/column_link';
|
||||
import { Link } from 'react-router';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
me: state.getIn(['accounts', state.getIn(['meta', 'me'])])
|
||||
});
|
||||
|
||||
const GettingStarted = ({ intl, me }) => {
|
||||
let followRequests = '';
|
||||
|
||||
if (me.get('locked')) {
|
||||
followRequests = <ColumnLink icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />;
|
||||
}
|
||||
|
||||
const GettingStarted = () => {
|
||||
return (
|
||||
<Column>
|
||||
<div className='static-content'>
|
||||
<h1>Getting started</h1>
|
||||
<p>Mastodon is still in development and one of the lacking areas at the moment is user discovery.</p>
|
||||
<p>You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form in the bottom of the sidebar.</p>
|
||||
<p>If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.</p>
|
||||
<p>The developer of this project can be followed as Gargron@mastodon.social</p>
|
||||
<p>Also <Link to='/statuses/all' style={{ color: '#2b90d9', textDecoration: 'none' }}>check out the public timeline for a start</Link>!</p>
|
||||
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
|
||||
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
|
||||
<ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
|
||||
{followRequests}
|
||||
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
|
||||
</div>
|
||||
|
||||
<div className='scrollable optionally-scrollable'>
|
||||
<div className='static-content getting-started'>
|
||||
<p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
|
||||
<p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
|
||||
<p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p>
|
||||
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a style={{ color: '#616b86'}} href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></p>
|
||||
</div>
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default GettingStarted;
|
||||
GettingStarted.propTypes = {
|
||||
intl: React.PropTypes.object.isRequired,
|
||||
me: ImmutablePropTypes.map.isRequired
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(GettingStarted));
|
||||
|
@@ -0,0 +1,80 @@
|
||||
import { connect } from 'react-redux';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
import Column from '../ui/components/column';
|
||||
import {
|
||||
refreshTimeline,
|
||||
updateTimeline,
|
||||
deleteFromTimelines
|
||||
} from '../../actions/timelines';
|
||||
import ColumnBackButton from '../public_timeline/components/column_back_button';
|
||||
|
||||
const HashtagTimeline = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
params: React.PropTypes.object.isRequired,
|
||||
dispatch: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
_subscribe (dispatch, id) {
|
||||
if (typeof App !== 'undefined') {
|
||||
this.subscription = App.cable.subscriptions.create({
|
||||
channel: 'HashtagChannel',
|
||||
tag: id
|
||||
}, {
|
||||
|
||||
received (data) {
|
||||
switch(data.type) {
|
||||
case 'update':
|
||||
return dispatch(updateTimeline('tag', JSON.parse(data.message)));
|
||||
case 'delete':
|
||||
return dispatch(deleteFromTimelines(data.id));
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_unsubscribe () {
|
||||
if (typeof this.subscription !== 'undefined') {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
},
|
||||
|
||||
componentWillMount () {
|
||||
const { dispatch } = this.props;
|
||||
const { id } = this.props.params;
|
||||
|
||||
dispatch(refreshTimeline('tag', id));
|
||||
this._subscribe(dispatch, id);
|
||||
},
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.id !== this.props.params.id) {
|
||||
this.props.dispatch(refreshTimeline('tag', nextProps.params.id));
|
||||
this._unsubscribe();
|
||||
this._subscribe(this.props.dispatch, nextProps.params.id);
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount () {
|
||||
this._unsubscribe();
|
||||
},
|
||||
|
||||
render () {
|
||||
const { id } = this.props.params;
|
||||
|
||||
return (
|
||||
<Column icon='hashtag' heading={id}>
|
||||
<ColumnBackButton />
|
||||
<StatusListContainer type='tag' id={id} />
|
||||
</Column>
|
||||
);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect()(HashtagTimeline);
|
@@ -0,0 +1,68 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ColumnCollapsable from '../../../components/column_collapsable';
|
||||
import SettingToggle from '../../notifications/components/setting_toggle';
|
||||
import SettingText from './setting_text';
|
||||
|
||||
const messages = defineMessages({
|
||||
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' }
|
||||
});
|
||||
|
||||
const outerStyle = {
|
||||
background: '#373b4a',
|
||||
padding: '15px'
|
||||
};
|
||||
|
||||
const sectionStyle = {
|
||||
cursor: 'default',
|
||||
display: 'block',
|
||||
fontWeight: '500',
|
||||
color: '#9baec8',
|
||||
marginBottom: '10px'
|
||||
};
|
||||
|
||||
const rowStyle = {
|
||||
|
||||
};
|
||||
|
||||
const ColumnSettings = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
onChange: React.PropTypes.func.isRequired,
|
||||
onSave: React.PropTypes.func.isRequired,
|
||||
intl: React.PropTypes.object.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
const { settings, onChange, onSave, intl } = this.props;
|
||||
|
||||
return (
|
||||
<ColumnCollapsable icon='sliders' fullHeight={209} onCollapse={onSave}>
|
||||
<div style={outerStyle}>
|
||||
<span style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
|
||||
|
||||
<div style={rowStyle}>
|
||||
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} />
|
||||
</div>
|
||||
|
||||
<div style={rowStyle}>
|
||||
<SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
|
||||
</div>
|
||||
|
||||
<span style={sectionStyle}><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
|
||||
|
||||
<div style={rowStyle}>
|
||||
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
|
||||
</div>
|
||||
</div>
|
||||
</ColumnCollapsable>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(ColumnSettings);
|