Compare commits
701 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd8f6e486 | ||
|
|
c02cbd9645 | ||
|
|
65b863ee54 | ||
|
|
21252f9a4d | ||
|
|
54141ba674 | ||
|
|
ba086ac8b7 | ||
|
|
c852911801 | ||
|
|
9dee2cf53b | ||
|
|
fa622ebffc | ||
|
|
6db4c3a894 | ||
|
|
f4e319d230 | ||
|
|
63758c2771 | ||
|
|
93b7ad2b14 | ||
|
|
8c072977f5 | ||
|
|
d93e5a84d1 | ||
|
|
4dc89cb0cc | ||
|
|
34da679752 | ||
|
|
ce44c05e83 | ||
|
|
adf5e1ca97 | ||
|
|
ebc498945b | ||
|
|
b76828df7e | ||
|
|
17f700475c | ||
|
|
ca52e9a019 | ||
|
|
d4adf6fa66 | ||
|
|
f224eb8601 | ||
|
|
8f62bc97ce | ||
|
|
25269bcc73 | ||
|
|
47a5aaabfb | ||
|
|
6454395864 | ||
|
|
224d61e8c5 | ||
|
|
235f6197d0 | ||
|
|
3ab619994f | ||
|
|
cd5b2079bd | ||
|
|
9b1a32455a | ||
|
|
a1d6cf99c2 | ||
|
|
d1c34cfca7 | ||
|
|
f97b6a7dde | ||
|
|
925da123d9 | ||
|
|
f99cbdce33 | ||
|
|
16778b8c92 | ||
|
|
75f274d967 | ||
|
|
30163f0340 | ||
|
|
c3a2561121 | ||
|
|
0f65b53f3d | ||
|
|
591db6e20b | ||
|
|
8f6d54a920 | ||
|
|
ae28bd4a54 | ||
|
|
64aac6092d | ||
|
|
21b8c7e120 | ||
|
|
ed02cfbabb | ||
|
|
0b509439f6 | ||
|
|
81ab6569b5 | ||
|
|
80a40ebaa6 | ||
|
|
b1b643c794 | ||
|
|
2c4523e19b | ||
|
|
94b0d4f3cf | ||
|
|
1a46b092fc | ||
|
|
d81e9b0430 | ||
|
|
3f70e47c90 | ||
|
|
c7ac0b74a7 | ||
|
|
42c71789f9 | ||
|
|
2fcc8ae64a | ||
|
|
6de36e75af | ||
|
|
840648c316 | ||
|
|
2b9d3c18e1 | ||
|
|
ca5feb4920 | ||
|
|
54ca0dbd38 | ||
|
|
9f41fe60df | ||
|
|
74746c8bc3 | ||
|
|
e4759ae9f2 | ||
|
|
ca4ac732b8 | ||
|
|
1b9a807cbb | ||
|
|
8fba9be113 | ||
|
|
fcbfd7eccd | ||
|
|
414d39f883 | ||
|
|
5e82006fb3 | ||
|
|
692d3fb4de | ||
|
|
1d63e23984 | ||
|
|
137d57c5dd | ||
|
|
29df250942 | ||
|
|
764df93203 | ||
|
|
c68f2fe461 | ||
|
|
37c44e47fc | ||
|
|
9aa59fe215 | ||
|
|
c8d76796f5 | ||
|
|
99c2b75dd4 | ||
|
|
a51f0d53e5 | ||
|
|
93e91879c7 | ||
|
|
8bec292ec4 | ||
|
|
a615eecd36 | ||
|
|
ffec25da9c | ||
|
|
70e37e84e4 | ||
|
|
ce1abdf273 | ||
|
|
d0e09c512c | ||
|
|
9ee80ebb7f | ||
|
|
3fbdfc5f8d | ||
|
|
bc1bce1ec8 | ||
|
|
1f72e631a2 | ||
|
|
4d0f6f07c0 | ||
|
|
992587f836 | ||
|
|
8bc71df896 | ||
|
|
dcab937030 | ||
|
|
fad6e645fb | ||
|
|
9fb0d287bf | ||
|
|
75c4ba2d0f | ||
|
|
3c722be96f | ||
|
|
47593053d0 | ||
|
|
efb4a55b9b | ||
|
|
33df4233c9 | ||
|
|
db38b379fe | ||
|
|
cb42d24c96 | ||
|
|
a692f93571 | ||
|
|
df49194953 | ||
|
|
083988cb44 | ||
|
|
f83eaad496 | ||
|
|
47e7a10d6e | ||
|
|
b94be27834 | ||
|
|
234b5780c3 | ||
|
|
7e766d76d0 | ||
|
|
6eb9d73d80 | ||
|
|
ccc2c940bf | ||
|
|
7db4d0ac84 | ||
|
|
837ef6f2e5 | ||
|
|
bce103b199 | ||
|
|
bd8f0d0d94 | ||
|
|
4207152390 | ||
|
|
6777a465ea | ||
|
|
810d6febb5 | ||
|
|
f1f6bced01 | ||
|
|
59dee76b34 | ||
|
|
ccfdb7eb39 | ||
|
|
1de85c0b1d | ||
|
|
1d5dfb4563 | ||
|
|
8f064ae97b | ||
|
|
4025bcedaf | ||
|
|
954da93bf8 | ||
|
|
e7d94b8d6f | ||
|
|
2350947f65 | ||
|
|
07d398e2e7 | ||
|
|
d5b9540449 | ||
|
|
36b72e8141 | ||
|
|
4931c8e913 | ||
|
|
1ec32f8cd1 | ||
|
|
ae5ca5756d | ||
|
|
ed910b0227 | ||
|
|
6eaf3c6b39 | ||
|
|
e2049175d6 | ||
|
|
62498f3653 | ||
|
|
4c6a4302df | ||
|
|
f407c88327 | ||
|
|
6893e72593 | ||
|
|
e5a7937177 | ||
|
|
04347f1b2d | ||
|
|
03be23c73a | ||
|
|
f9335244f8 | ||
|
|
79b97a18d2 | ||
|
|
56e2aeee77 | ||
|
|
d46b486633 | ||
|
|
eeaa3816e1 | ||
|
|
9bbce5730d | ||
|
|
160958715e | ||
|
|
9e08d9da26 | ||
|
|
23fa6ff325 | ||
|
|
a1c481e65a | ||
|
|
5011c9cd2d | ||
|
|
ebb0b94f73 | ||
|
|
b355f03448 | ||
|
|
de4195be7e | ||
|
|
b3a4cf8ee6 | ||
|
|
64f11e6b6c | ||
|
|
5c22c7fc80 | ||
|
|
8410e6f8c1 | ||
|
|
525fd7c51f | ||
|
|
32bf2e99c8 | ||
|
|
db19f64b2b | ||
|
|
ab133a7036 | ||
|
|
f24d09b5c6 | ||
|
|
b6538bb306 | ||
|
|
4da6833d6b | ||
|
|
3f03914c94 | ||
|
|
fa68a20841 | ||
|
|
686c3f6a2f | ||
|
|
30e057c647 | ||
|
|
d70b0d32da | ||
|
|
50b5fd7711 | ||
|
|
f6be51b86d | ||
|
|
fe75b4a776 | ||
|
|
edf7113b54 | ||
|
|
943f7e14c4 | ||
|
|
1ceaa396f2 | ||
|
|
ae77d184ba | ||
|
|
f4031b9754 | ||
|
|
fab6eed917 | ||
|
|
c0383f5a0d | ||
|
|
c8845e6213 | ||
|
|
a2af9c07de | ||
|
|
e37cccfe7f | ||
|
|
dd65629836 | ||
|
|
8d48707d9b | ||
|
|
fbeb488ec5 | ||
|
|
14e9bea12f | ||
|
|
bc6a643f5c | ||
|
|
1c558d3ecc | ||
|
|
c35ecbea89 | ||
|
|
d73e1ee753 | ||
|
|
b9b7d0cb70 | ||
|
|
b24ab069cd | ||
|
|
15df856915 | ||
|
|
9ac871517d | ||
|
|
af3afe5940 | ||
|
|
2672410743 | ||
|
|
7902dd201f | ||
|
|
ae6addeb2d | ||
|
|
f55439e33e | ||
|
|
cc5fc18f5f | ||
|
|
21e0eebada | ||
|
|
f9bdbef16f | ||
|
|
382f6959fc | ||
|
|
85d6de7b00 | ||
|
|
33b93124d6 | ||
|
|
d0919fdfb2 | ||
|
|
c36f3485f0 | ||
|
|
d94015fcff | ||
|
|
30f9200fc7 | ||
|
|
d6b1c1ce40 | ||
|
|
91bff783b7 | ||
|
|
9c39acfbb0 | ||
|
|
a27f7e2781 | ||
|
|
aaafbd1ae5 | ||
|
|
498ef7a4a3 | ||
|
|
2e0274b598 | ||
|
|
227a8644f1 | ||
|
|
c2da14925e | ||
|
|
00d448105e | ||
|
|
011805f577 | ||
|
|
1c4c41107a | ||
|
|
60f710f2bd | ||
|
|
0933cdf285 | ||
|
|
a3c836541c | ||
|
|
a3fccbc3c3 | ||
|
|
5516000740 | ||
|
|
8c890fa64a | ||
|
|
154ad2b402 | ||
|
|
3d527546d7 | ||
|
|
1d67cc0e44 | ||
|
|
815dbdb082 | ||
|
|
fe98b0664a | ||
|
|
86145ca975 | ||
|
|
ae8d0513c3 | ||
|
|
bba4786df2 | ||
|
|
0bf267a662 | ||
|
|
db2b7b0b24 | ||
|
|
f19b9c8de8 | ||
|
|
449d21b88c | ||
|
|
3f2c8dcc2a | ||
|
|
9a9b0a6847 | ||
|
|
1ab0f0ccb4 | ||
|
|
a787f89440 | ||
|
|
f5416a987f | ||
|
|
4a67301146 | ||
|
|
e683e2d6b4 | ||
|
|
b5cce6c276 | ||
|
|
597c7c4bca | ||
|
|
1bd0e1a32e | ||
|
|
4b2cbf8858 | ||
|
|
be4b531072 | ||
|
|
7daac542f6 | ||
|
|
1234acd2dd | ||
|
|
a7d3d8ffa1 | ||
|
|
a5df8c8dcf | ||
|
|
e81266e795 | ||
|
|
11af9ccfa5 | ||
|
|
625ceaad31 | ||
|
|
3d5991ff8f | ||
|
|
f808157670 | ||
|
|
2f196614fc | ||
|
|
d8266319f4 | ||
|
|
ee74122ce1 | ||
|
|
b1f5aa4058 | ||
|
|
70e345518c | ||
|
|
7a079b286c | ||
|
|
448e960121 | ||
|
|
0a32b86f23 | ||
|
|
21f8511396 | ||
|
|
9f7e64eead | ||
|
|
7f50fc4f70 | ||
|
|
299f8ecdac | ||
|
|
5771b29d19 | ||
|
|
fca2117d2b | ||
|
|
eccad71d6c | ||
|
|
4ceb77115e | ||
|
|
e2c2321634 | ||
|
|
a1332a906b | ||
|
|
7d9b672877 | ||
|
|
ff5f991980 | ||
|
|
e6ad3ef0ff | ||
|
|
91610578a8 | ||
|
|
704da6e9e9 | ||
|
|
1ac67cb1b3 | ||
|
|
bc4cb4f871 | ||
|
|
bbf7bb176c | ||
|
|
a4c03a6496 | ||
|
|
ee3cf05acc | ||
|
|
cdb4c1651b | ||
|
|
311f29d9d6 | ||
|
|
eb54bf9e5b | ||
|
|
df61b9309d | ||
|
|
b1b9faeae6 | ||
|
|
158662519d | ||
|
|
81c21681ce | ||
|
|
fd8bf5d2cb | ||
|
|
ad74eea50d | ||
|
|
0ec92f95d4 | ||
|
|
98ff2fd8ab | ||
|
|
8a22e60438 | ||
|
|
0b282fb812 | ||
|
|
8a171389e1 | ||
|
|
cb3be41ead | ||
|
|
8c417d949e | ||
|
|
f1f0a6b358 | ||
|
|
eb5f34b779 | ||
|
|
33e09ca6d6 | ||
|
|
7784bbe702 | ||
|
|
be9e64eabf | ||
|
|
0793253b0b | ||
|
|
07a53907a7 | ||
|
|
29e9329eb3 | ||
|
|
ec0dccf438 | ||
|
|
45146818d7 | ||
|
|
5ab9a9d898 | ||
|
|
a43ddace3e | ||
|
|
8069b664b0 | ||
|
|
187505d0ba | ||
|
|
580caa9ef1 | ||
|
|
f75dc4ca65 | ||
|
|
e91b3ec707 | ||
|
|
d8f0379931 | ||
|
|
e8b880deae | ||
|
|
4372f468ee | ||
|
|
6aecebf294 | ||
|
|
e2e2d57f37 | ||
|
|
c2d596b223 | ||
|
|
b02e29829e | ||
|
|
a39aa9c61d | ||
|
|
7926a1f7bb | ||
|
|
66a96b1ed2 | ||
|
|
a286be473a | ||
|
|
6fc8c494a3 | ||
|
|
d0130e4ab9 | ||
|
|
284e65f7d3 | ||
|
|
96cb283170 | ||
|
|
3d5b8c16b7 | ||
|
|
f5ee848ab0 | ||
|
|
8d6fbe1769 | ||
|
|
1e044c6c75 | ||
|
|
a2ebae2d5b | ||
|
|
7e055e01c5 | ||
|
|
d72e4ee84e | ||
|
|
8ef654d71f | ||
|
|
f1ea9cbd91 | ||
|
|
6fb8361be8 | ||
|
|
5ec054623e | ||
|
|
9dc47dbd33 | ||
|
|
9506f7448f | ||
|
|
7e69341dcd | ||
|
|
710af4b28c | ||
|
|
6bc76a9573 | ||
|
|
1448f8b8e7 | ||
|
|
edfd3967ab | ||
|
|
4d9d864df7 | ||
|
|
dae9d369ec | ||
|
|
9dd4a59226 | ||
|
|
1687d3657f | ||
|
|
557dde29c7 | ||
|
|
7129e6e4cb | ||
|
|
387bdadbe2 | ||
|
|
63d48a0ed9 | ||
|
|
636b31bc3c | ||
|
|
3f21f63a42 | ||
|
|
2baa4c9b13 | ||
|
|
04f361eb72 | ||
|
|
0c40e2dddf | ||
|
|
8f77b478e4 | ||
|
|
922e84826b | ||
|
|
744e0613f0 | ||
|
|
39279cf66c | ||
|
|
24a0507d64 | ||
|
|
5f0249f4ec | ||
|
|
1069924c1f | ||
|
|
b83bc6c2e9 | ||
|
|
9a23cf8921 | ||
|
|
f1a068367a | ||
|
|
bad3d4e29d | ||
|
|
0a7d18b525 | ||
|
|
b2660b7d12 | ||
|
|
1ce4a4dab8 | ||
|
|
ee59a2b5c8 | ||
|
|
edb53404a2 | ||
|
|
1e5d451cb1 | ||
|
|
b124ada186 | ||
|
|
e81aa395c4 | ||
|
|
76edd571bd | ||
|
|
2b216c6cef | ||
|
|
cf05ad54a9 | ||
|
|
0bf928ad84 | ||
|
|
0df14fa2b5 | ||
|
|
6e80ace6de | ||
|
|
df508e8027 | ||
|
|
3989a0d9f9 | ||
|
|
1ec4a9539e | ||
|
|
9798e1e588 | ||
|
|
8a755831bf | ||
|
|
40cda06e3e | ||
|
|
a7c4969c79 | ||
|
|
11dee669b1 | ||
|
|
7fda914d1a | ||
|
|
c7efbcfb80 | ||
|
|
85aa569eea | ||
|
|
2fe8d9ca00 | ||
|
|
56b91a0175 | ||
|
|
c047611421 | ||
|
|
8b989b71cc | ||
|
|
0eaa6defa0 | ||
|
|
d5c4215f82 | ||
|
|
7ae8733e93 | ||
|
|
be8723ab9c | ||
|
|
518bd00135 | ||
|
|
f823aaadef | ||
|
|
a32b7bd37a | ||
|
|
a117ae25a8 | ||
|
|
63ae563b5a | ||
|
|
80c93e23ac | ||
|
|
8b8dee956c | ||
|
|
6bb2dd0584 | ||
|
|
a3fbb64a0e | ||
|
|
1b5d7c1659 | ||
|
|
1a7b576ec6 | ||
|
|
1d78baee0d | ||
|
|
709805ae02 | ||
|
|
919f33f377 | ||
|
|
21c4c1d9d4 | ||
|
|
5cbad06de4 | ||
|
|
a9207d87b9 | ||
|
|
cb731dbecd | ||
|
|
a91d0f39bc | ||
|
|
7d40cd92f8 | ||
|
|
0a35966465 | ||
|
|
ee0c293c26 | ||
|
|
a87f9e627b | ||
|
|
52022fe58d | ||
|
|
86b0f589c9 | ||
|
|
1893b76977 | ||
|
|
2f536423de | ||
|
|
546d21116f | ||
|
|
4f00241488 | ||
|
|
1e7589f758 | ||
|
|
98811332d8 | ||
|
|
d28bf7bddd | ||
|
|
9fa29ca898 | ||
|
|
29df37d430 | ||
|
|
738ee9620e | ||
|
|
f67f425b79 | ||
|
|
e14412a676 | ||
|
|
a150616b79 | ||
|
|
0c2b7f242f | ||
|
|
3591fe8aef | ||
|
|
4fa3fa2a79 | ||
|
|
b2b3f3b95d | ||
|
|
f919b59c8b | ||
|
|
070296f9d1 | ||
|
|
6821353360 | ||
|
|
186ca3d106 | ||
|
|
c5d91cb5d5 | ||
|
|
e0c009557d | ||
|
|
b3f8402849 | ||
|
|
f746e39fbe | ||
|
|
62110bddd8 | ||
|
|
a371f223c2 | ||
|
|
392c769b37 | ||
|
|
b9ec4f6efb | ||
|
|
a3c6209cca | ||
|
|
94b0bc4228 | ||
|
|
c515b505f1 | ||
|
|
71f583c9eb | ||
|
|
b5a869eb32 | ||
|
|
7e49c957ad | ||
|
|
28762f3446 | ||
|
|
f1afbe4e5f | ||
|
|
8236658581 | ||
|
|
54db7ea0be | ||
|
|
44189faab2 | ||
|
|
a0db82bed4 | ||
|
|
c55d6deef6 | ||
|
|
21431c9107 | ||
|
|
8da8937e4a | ||
|
|
3b08cf6955 | ||
|
|
ec4752909e | ||
|
|
5a92aca19d | ||
|
|
baf5d0f8a3 | ||
|
|
394c1cfda0 | ||
|
|
c9ea04ffd2 | ||
|
|
8983ad20de | ||
|
|
b26e18025e | ||
|
|
3ffa889aa8 | ||
|
|
469eaa23a8 | ||
|
|
8ae6e49ad2 | ||
|
|
0485ea918b | ||
|
|
748d1590c1 | ||
|
|
e910f7f3fe | ||
|
|
d29f363528 | ||
|
|
13adf8e4df | ||
|
|
7085f0bee6 | ||
|
|
2853b6f109 | ||
|
|
b8ef8890ee | ||
|
|
8d6bbd7511 | ||
|
|
e03b7ce445 | ||
|
|
f15c14775a | ||
|
|
b59a1136a2 | ||
|
|
813d8c5857 | ||
|
|
16e590563d | ||
|
|
ee17eb98a3 | ||
|
|
7cb376d6f4 | ||
|
|
5a31a7b3d3 | ||
|
|
969276b57f | ||
|
|
01bbd1f316 | ||
|
|
f79dbbe4ff | ||
|
|
bc8fa638c1 | ||
|
|
4ba28a08a3 | ||
|
|
e23af6eea4 | ||
|
|
40fc83843b | ||
|
|
ec974b1235 | ||
|
|
80535b09f1 | ||
|
|
651886be58 | ||
|
|
b4b8e0dd12 | ||
|
|
ad7dbae939 | ||
|
|
e6047f2b65 | ||
|
|
befac23d6f | ||
|
|
d7b31e291b | ||
|
|
97ddc82356 | ||
|
|
ccbfacbfb0 | ||
|
|
935a50cb3c | ||
|
|
35e1ac9db1 | ||
|
|
8160483872 | ||
|
|
a174c68f6c | ||
|
|
3ed844da92 | ||
|
|
5ca5b362fe | ||
|
|
1b2a6c6cb6 | ||
|
|
62bff49f14 | ||
|
|
9e139f0278 | ||
|
|
d88481ec52 | ||
|
|
b3cc71032e | ||
|
|
6ac351dc7e | ||
|
|
c5f0a2db2e | ||
|
|
7bffcdee75 | ||
|
|
20127b40e3 | ||
|
|
309c9c3902 | ||
|
|
0fb314023c | ||
|
|
afd0fcb99c | ||
|
|
f496a7d54b | ||
|
|
ae6c7c6c5e | ||
|
|
0c6efc0307 | ||
|
|
6284485970 | ||
|
|
f14358e751 | ||
|
|
aa6d9e74d3 | ||
|
|
f4917dffbf | ||
|
|
94e937141b | ||
|
|
b009ebe2a4 | ||
|
|
b1fb1831df | ||
|
|
97acd3f93b | ||
|
|
a874890ab3 | ||
|
|
01e9777f54 | ||
|
|
fba419ada0 | ||
|
|
45c8251e48 | ||
|
|
9558393e7b | ||
|
|
841f177db8 | ||
|
|
4c8715ed2e | ||
|
|
adf8a5c9a3 | ||
|
|
5bd72597d5 | ||
|
|
4fc1918e0f | ||
|
|
380891875e | ||
|
|
8556622e2b | ||
|
|
2ec92021e1 | ||
|
|
892c90f93c | ||
|
|
d61fcbbb9d | ||
|
|
1b62a7e2a1 | ||
|
|
6de95d9783 | ||
|
|
3ecb456db2 | ||
|
|
d330f9b62f | ||
|
|
0955d2ab6c | ||
|
|
cf676ea6b2 | ||
|
|
db9bc24742 | ||
|
|
35d6e64c3a | ||
|
|
2ff275e4f4 | ||
|
|
9a80510376 | ||
|
|
2164c082d4 | ||
|
|
f13af3c13f | ||
|
|
f1c7009166 | ||
|
|
7e25c9f213 | ||
|
|
35f153c46b | ||
|
|
f42d1c8f63 | ||
|
|
56b3e88786 | ||
|
|
f72f5d9315 | ||
|
|
1d771d3d56 | ||
|
|
ddc5e52f7c | ||
|
|
2e369a143c | ||
|
|
b174ad7a52 | ||
|
|
477ad15038 | ||
|
|
0a4ae61b2c | ||
|
|
5d87b917d8 | ||
|
|
d05b7c6329 | ||
|
|
717e72150b | ||
|
|
e37abbd397 | ||
|
|
50da33eee1 | ||
|
|
714ea51990 | ||
|
|
548c18dd51 | ||
|
|
1db036d465 | ||
|
|
35a3df35ea | ||
|
|
42b9823bc2 | ||
|
|
8ba7fdce90 | ||
|
|
cf4a452dd8 | ||
|
|
a33dc7403f | ||
|
|
2366fe4bab | ||
|
|
37dd456a56 | ||
|
|
764b90d535 | ||
|
|
6092313eba | ||
|
|
d57c84ec00 | ||
|
|
0323ef9943 | ||
|
|
dc95cd75b1 | ||
|
|
d89b792375 | ||
|
|
af6769b792 | ||
|
|
63f4dce499 | ||
|
|
ef9761e032 | ||
|
|
a777d81d6b | ||
|
|
0baebd1659 | ||
|
|
fca0bad4e1 | ||
|
|
f378a6c18f | ||
|
|
5c07ae7607 | ||
|
|
881df0e14f | ||
|
|
ff0fe6eb3a | ||
|
|
26afa00520 | ||
|
|
1bfa6f6c9d | ||
|
|
e1437e6670 | ||
|
|
13aca1cceb | ||
|
|
4818ba5960 | ||
|
|
3b754956e7 | ||
|
|
ad8f2cbed1 | ||
|
|
5388d4f92a | ||
|
|
53435cf05f | ||
|
|
0893e412d7 | ||
|
|
40f449c002 | ||
|
|
1e411acbaf | ||
|
|
7d9c21143f | ||
|
|
fbd5325031 | ||
|
|
3ba68857bd | ||
|
|
eaa5304036 | ||
|
|
61c7c55ddc | ||
|
|
ced0e85ddb | ||
|
|
97a5a8ae28 | ||
|
|
e61a6238f2 | ||
|
|
a4ae8570ea | ||
|
|
8a20215673 | ||
|
|
67203ccb64 | ||
|
|
ac079e6240 | ||
|
|
c761bf4b90 | ||
|
|
0cca90b889 | ||
|
|
772e96e52b | ||
|
|
74fe4b7cb7 | ||
|
|
c5acf64f54 | ||
|
|
e10e37ec62 | ||
|
|
63f0bd0495 | ||
|
|
cc4bfa7b45 | ||
|
|
121a8ba6dc | ||
|
|
12c6ff22d8 | ||
|
|
c6143ba990 | ||
|
|
e448cbf6ad | ||
|
|
bcccec1ea6 | ||
|
|
6e1842d2b3 | ||
|
|
8c3d3060d1 | ||
|
|
c7040e46b9 | ||
|
|
bebc107082 | ||
|
|
6a91151017 | ||
|
|
9e96152788 | ||
|
|
7e78139563 | ||
|
|
bd8b403780 | ||
|
|
caa9a324b0 | ||
|
|
3739684164 | ||
|
|
adc1a2e9a5 | ||
|
|
872aa6216b | ||
|
|
c97833b1fc | ||
|
|
c0b128d547 | ||
|
|
ee61fb5274 | ||
|
|
c87673c651 | ||
|
|
d2fd8e1947 | ||
|
|
f058bfa376 | ||
|
|
ec102c550d | ||
|
|
340c2e4ff6 | ||
|
|
f07b00f1e9 | ||
|
|
b813e692b2 | ||
|
|
f23bc46d43 | ||
|
|
c3e1cbfe90 | ||
|
|
fd81422202 |
@ -1,13 +1,13 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
import RESTAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class ApiKey extends RestAdapter {
|
||||
jsonMode = true;
|
||||
export default RESTAdapter.extend({
|
||||
jsonMode: true,
|
||||
|
||||
basePath() {
|
||||
return "/admin/api/";
|
||||
}
|
||||
},
|
||||
|
||||
apiNameFor() {
|
||||
return "key";
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default function buildPluginAdapter(pluginName) {
|
||||
return class extends RestAdapter {
|
||||
return RestAdapter.extend({
|
||||
pathFor(store, type, findArgs) {
|
||||
return (
|
||||
"/admin/plugins/" + pluginName + super.pathFor(store, type, findArgs)
|
||||
"/admin/plugins/" + pluginName + this._super(store, type, findArgs)
|
||||
);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class CustomizationBase extends RestAdapter {
|
||||
export default RestAdapter.extend({
|
||||
basePath() {
|
||||
return "/admin/customize/";
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class EmailStyle extends RestAdapter {
|
||||
export default RestAdapter.extend({
|
||||
pathFor() {
|
||||
return "/admin/customize/email_style";
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class Embedding extends RestAdapter {
|
||||
export default RestAdapter.extend({
|
||||
pathFor() {
|
||||
return "/admin/customize/embedding";
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class StaffActionLog extends RestAdapter {
|
||||
export default RestAdapter.extend({
|
||||
basePath() {
|
||||
return "/admin/logs/";
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class TagGroup extends RestAdapter {
|
||||
jsonMode = true;
|
||||
}
|
||||
export default RestAdapter.extend({
|
||||
jsonMode: true,
|
||||
});
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class Theme extends RestAdapter {
|
||||
jsonMode = true;
|
||||
export default RestAdapter.extend({
|
||||
basePath() {
|
||||
return "/admin/";
|
||||
}
|
||||
},
|
||||
|
||||
afterFindAll(results) {
|
||||
let map = {};
|
||||
@ -21,5 +20,7 @@ export default class Theme extends RestAdapter {
|
||||
theme.set("parentThemes", mappedParents);
|
||||
});
|
||||
return results;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
jsonMode: true,
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
import RESTAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class WebHookEvent extends RestAdapter {
|
||||
export default RESTAdapter.extend({
|
||||
basePath() {
|
||||
return "/admin/api/";
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
import RESTAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class WebHook extends RestAdapter {
|
||||
export default RESTAdapter.extend({
|
||||
basePath() {
|
||||
return "/admin/api/";
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -2,7 +2,7 @@ import Helper from "@ember/component/helper";
|
||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
|
||||
export default class DispositionIcon extends Helper {
|
||||
export default Helper.extend({
|
||||
compute([disposition]) {
|
||||
if (!disposition) {
|
||||
return null;
|
||||
@ -24,5 +24,5 @@ export default class DispositionIcon extends Helper {
|
||||
}
|
||||
}
|
||||
return htmlSafe(iconHTML(icon, { title }));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -7,17 +7,19 @@ const GENERAL_ATTRIBUTES = [
|
||||
"release_notes_link",
|
||||
];
|
||||
|
||||
export default class AdminDashboard extends EmberObject {
|
||||
static fetch() {
|
||||
const AdminDashboard = EmberObject.extend({});
|
||||
|
||||
AdminDashboard.reopenClass({
|
||||
fetch() {
|
||||
return ajax("/admin/dashboard.json").then((json) => {
|
||||
const model = AdminDashboard.create();
|
||||
model.set("version_check", json.version_check);
|
||||
|
||||
return model;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
static fetchGeneral() {
|
||||
fetchGeneral() {
|
||||
return ajax("/admin/dashboard/general.json").then((json) => {
|
||||
const model = AdminDashboard.create();
|
||||
|
||||
@ -32,13 +34,15 @@ export default class AdminDashboard extends EmberObject {
|
||||
|
||||
return model;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
static fetchProblems() {
|
||||
fetchProblems() {
|
||||
return ajax("/admin/dashboard/problems.json").then((json) => {
|
||||
const model = AdminDashboard.create(json);
|
||||
model.set("loaded", true);
|
||||
return model;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default AdminDashboard;
|
||||
|
||||
@ -10,30 +10,14 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { propertyNotEqual } from "discourse/lib/computed";
|
||||
import { userPath } from "discourse/lib/url";
|
||||
|
||||
export default class AdminUser extends User {
|
||||
static find(user_id) {
|
||||
return ajax(`/admin/users/${user_id}.json`).then((result) => {
|
||||
result.loadedDetails = true;
|
||||
return AdminUser.create(result);
|
||||
});
|
||||
}
|
||||
const wrapAdmin = (user) => (user ? AdminUser.create(user) : null);
|
||||
|
||||
static findAll(query, userFilter) {
|
||||
return ajax(`/admin/users/list/${query}.json`, {
|
||||
data: userFilter,
|
||||
}).then((users) => users.map((u) => AdminUser.create(u)));
|
||||
}
|
||||
const AdminUser = User.extend({
|
||||
adminUserView: true,
|
||||
customGroups: filter("groups", (g) => !g.automatic && Group.create(g)),
|
||||
automaticGroups: filter("groups", (g) => g.automatic && Group.create(g)),
|
||||
|
||||
adminUserView = true;
|
||||
|
||||
@filter("groups", (g) => !g.automatic && Group.create(g)) customGroups;
|
||||
@filter("groups", (g) => g.automatic && Group.create(g)) automaticGroups;
|
||||
@or("active", "staged") canViewProfile;
|
||||
@gt("bounce_score", 0) canResetBounceScore;
|
||||
@propertyNotEqual("originalTrustLevel", "trust_level") dirty;
|
||||
@lt("trust_level", 4) canLockTrustLevel;
|
||||
@not("staff") canSuspend;
|
||||
@not("staff") canSilence;
|
||||
canViewProfile: or("active", "staged"),
|
||||
|
||||
@discourseComputed("bounce_score", "reset_bounce_score_after")
|
||||
bounceScore(bounce_score, reset_bounce_score_after) {
|
||||
@ -44,7 +28,7 @@ export default class AdminUser extends User {
|
||||
} else {
|
||||
return bounce_score;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("bounce_score")
|
||||
bounceScoreExplanation(bounce_score) {
|
||||
@ -55,12 +39,14 @@ export default class AdminUser extends User {
|
||||
} else {
|
||||
return I18n.t("admin.user.bounce_score_explanation.threshold_reached");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed
|
||||
bounceLink() {
|
||||
return getURL("/admin/email/bounced");
|
||||
}
|
||||
},
|
||||
|
||||
canResetBounceScore: gt("bounce_score", 0),
|
||||
|
||||
resetBounceScore() {
|
||||
return ajax(`/admin/users/${this.id}/reset_bounce_score`, {
|
||||
@ -71,14 +57,14 @@ export default class AdminUser extends User {
|
||||
reset_bounce_score_after: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
groupAdded(added) {
|
||||
return ajax(`/admin/users/${this.id}/groups`, {
|
||||
type: "POST",
|
||||
data: { group_id: added.id },
|
||||
}).then(() => this.groups.pushObject(added));
|
||||
}
|
||||
},
|
||||
|
||||
groupRemoved(groupId) {
|
||||
return ajax(`/admin/users/${this.id}/groups/${groupId}`, {
|
||||
@ -89,13 +75,13 @@ export default class AdminUser extends User {
|
||||
this.set("primary_group_id", null);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deleteAllPosts() {
|
||||
return ajax(`/admin/users/${this.get("id")}/delete_posts_batch`, {
|
||||
type: "PUT",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
revokeAdmin() {
|
||||
return ajax(`/admin/users/${this.id}/revoke_admin`, {
|
||||
@ -111,7 +97,7 @@ export default class AdminUser extends User {
|
||||
can_delete_all_posts: resp.can_delete_all_posts,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
grantAdmin(data) {
|
||||
return ajax(`/admin/users/${this.id}/grant_admin`, {
|
||||
@ -128,7 +114,7 @@ export default class AdminUser extends User {
|
||||
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
revokeModeration() {
|
||||
return ajax(`/admin/users/${this.id}/revoke_moderation`, {
|
||||
@ -144,7 +130,7 @@ export default class AdminUser extends User {
|
||||
});
|
||||
})
|
||||
.catch(popupAjaxError);
|
||||
}
|
||||
},
|
||||
|
||||
grantModeration() {
|
||||
return ajax(`/admin/users/${this.id}/grant_moderation`, {
|
||||
@ -160,7 +146,7 @@ export default class AdminUser extends User {
|
||||
});
|
||||
})
|
||||
.catch(popupAjaxError);
|
||||
}
|
||||
},
|
||||
|
||||
disableSecondFactor() {
|
||||
return ajax(`/admin/users/${this.id}/disable_second_factor`, {
|
||||
@ -170,7 +156,7 @@ export default class AdminUser extends User {
|
||||
this.set("second_factor_enabled", false);
|
||||
})
|
||||
.catch(popupAjaxError);
|
||||
}
|
||||
},
|
||||
|
||||
approve(approvedBy) {
|
||||
return ajax(`/admin/users/${this.id}/approve`, {
|
||||
@ -182,76 +168,83 @@ export default class AdminUser extends User {
|
||||
approved_by: approvedBy,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setOriginalTrustLevel() {
|
||||
this.set("originalTrustLevel", this.trust_level);
|
||||
}
|
||||
},
|
||||
|
||||
dirty: propertyNotEqual("originalTrustLevel", "trust_level"),
|
||||
|
||||
saveTrustLevel() {
|
||||
return ajax(`/admin/users/${this.id}/trust_level`, {
|
||||
type: "PUT",
|
||||
data: { level: this.trust_level },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
restoreTrustLevel() {
|
||||
this.set("trust_level", this.originalTrustLevel);
|
||||
}
|
||||
},
|
||||
|
||||
lockTrustLevel(locked) {
|
||||
return ajax(`/admin/users/${this.id}/trust_level_lock`, {
|
||||
type: "PUT",
|
||||
data: { locked: !!locked },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
canLockTrustLevel: lt("trust_level", 4),
|
||||
|
||||
canSuspend: not("staff"),
|
||||
canSilence: not("staff"),
|
||||
|
||||
@discourseComputed("suspended_till", "suspended_at")
|
||||
suspendDuration(suspendedTill, suspendedAt) {
|
||||
suspendedAt = moment(suspendedAt);
|
||||
suspendedTill = moment(suspendedTill);
|
||||
return suspendedAt.format("L") + " - " + suspendedTill.format("L");
|
||||
}
|
||||
},
|
||||
|
||||
suspend(data) {
|
||||
return ajax(`/admin/users/${this.id}/suspend`, {
|
||||
type: "PUT",
|
||||
data,
|
||||
}).then((result) => this.setProperties(result.suspension));
|
||||
}
|
||||
},
|
||||
|
||||
unsuspend() {
|
||||
return ajax(`/admin/users/${this.id}/unsuspend`, {
|
||||
type: "PUT",
|
||||
}).then((result) => this.setProperties(result.suspension));
|
||||
}
|
||||
},
|
||||
|
||||
logOut() {
|
||||
return ajax("/admin/users/" + this.id + "/log_out", {
|
||||
type: "POST",
|
||||
data: { username_or_email: this.username },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
impersonate() {
|
||||
return ajax("/admin/impersonate", {
|
||||
type: "POST",
|
||||
data: { username_or_email: this.username },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
activate() {
|
||||
return ajax(`/admin/users/${this.id}/activate`, {
|
||||
type: "PUT",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
return ajax(`/admin/users/${this.id}/deactivate`, {
|
||||
type: "PUT",
|
||||
data: { context: document.location.pathname },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
unsilence() {
|
||||
this.set("silencingUser", true);
|
||||
@ -261,7 +254,7 @@ export default class AdminUser extends User {
|
||||
})
|
||||
.then((result) => this.setProperties(result.unsilence))
|
||||
.finally(() => this.set("silencingUser", false));
|
||||
}
|
||||
},
|
||||
|
||||
silence(data) {
|
||||
this.set("silencingUser", true);
|
||||
@ -272,20 +265,20 @@ export default class AdminUser extends User {
|
||||
})
|
||||
.then((result) => this.setProperties(result.silence))
|
||||
.finally(() => this.set("silencingUser", false));
|
||||
}
|
||||
},
|
||||
|
||||
sendActivationEmail() {
|
||||
return ajax(userPath("action/send_activation_email"), {
|
||||
type: "POST",
|
||||
data: { username: this.username },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
anonymize() {
|
||||
return ajax(`/admin/users/${this.id}/anonymize.json`, {
|
||||
type: "PUT",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
destroy(formData) {
|
||||
return ajax(`/admin/users/${this.id}.json`, {
|
||||
@ -302,14 +295,14 @@ export default class AdminUser extends User {
|
||||
.catch(() => {
|
||||
this.find(this.id).then((u) => this.setProperties(u));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
merge(formData) {
|
||||
return ajax(`/admin/users/${this.id}/merge.json`, {
|
||||
type: "POST",
|
||||
data: formData,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadDetails() {
|
||||
if (this.loadedDetails) {
|
||||
@ -320,29 +313,23 @@ export default class AdminUser extends User {
|
||||
const userProperties = Object.assign(result, { loadedDetails: true });
|
||||
this.setProperties(userProperties);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("tl3_requirements")
|
||||
tl3Requirements(requirements) {
|
||||
if (requirements) {
|
||||
return this.store.createRecord("tl3Requirements", requirements);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("suspended_by")
|
||||
suspendedBy(user) {
|
||||
return user ? AdminUser.create(user) : null;
|
||||
}
|
||||
suspendedBy: wrapAdmin,
|
||||
|
||||
@discourseComputed("silenced_by")
|
||||
silencedBy(user) {
|
||||
return user ? AdminUser.create(user) : null;
|
||||
}
|
||||
silencedBy: wrapAdmin,
|
||||
|
||||
@discourseComputed("approved_by")
|
||||
approvedBy(user) {
|
||||
return user ? AdminUser.create(user) : null;
|
||||
}
|
||||
approvedBy: wrapAdmin,
|
||||
|
||||
deleteSSORecord() {
|
||||
return ajax(`/admin/users/${this.id}/sso_record.json`, {
|
||||
@ -352,5 +339,22 @@ export default class AdminUser extends User {
|
||||
this.set("single_sign_on_record", null);
|
||||
})
|
||||
.catch(popupAjaxError);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
AdminUser.reopenClass({
|
||||
find(user_id) {
|
||||
return ajax(`/admin/users/${user_id}.json`).then((result) => {
|
||||
result.loadedDetails = true;
|
||||
return AdminUser.create(result);
|
||||
});
|
||||
},
|
||||
|
||||
findAll(query, userFilter) {
|
||||
return ajax(`/admin/users/list/${query}.json`, {
|
||||
data: userFilter,
|
||||
}).then((users) => users.map((u) => AdminUser.create(u)));
|
||||
},
|
||||
});
|
||||
|
||||
export default AdminUser;
|
||||
|
||||
@ -1,26 +1,24 @@
|
||||
import { computed } from "@ember/object";
|
||||
import AdminUser from "admin/models/admin-user";
|
||||
import RestModel from "discourse/models/rest";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { computed } from "@ember/object";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import { fmt } from "discourse/lib/computed";
|
||||
|
||||
export default class ApiKey extends RestModel {
|
||||
@fmt("truncated_key", "%@...") truncatedKey;
|
||||
|
||||
@computed("_user")
|
||||
get user() {
|
||||
return this._user;
|
||||
}
|
||||
|
||||
set user(value) {
|
||||
if (value && !(value instanceof AdminUser)) {
|
||||
this.set("_user", AdminUser.create(value));
|
||||
} else {
|
||||
this.set("_user", value);
|
||||
}
|
||||
return this._user;
|
||||
}
|
||||
const ApiKey = RestModel.extend({
|
||||
user: computed("_user", {
|
||||
get() {
|
||||
return this._user;
|
||||
},
|
||||
set(key, value) {
|
||||
if (value && !(value instanceof AdminUser)) {
|
||||
this.set("_user", AdminUser.create(value));
|
||||
} else {
|
||||
this.set("_user", value);
|
||||
}
|
||||
return this._user;
|
||||
},
|
||||
}),
|
||||
|
||||
@discourseComputed("description")
|
||||
shortDescription(description) {
|
||||
@ -28,28 +26,32 @@ export default class ApiKey extends RestModel {
|
||||
return description;
|
||||
}
|
||||
return `${description.substring(0, 40)}...`;
|
||||
}
|
||||
},
|
||||
|
||||
truncatedKey: fmt("truncated_key", "%@..."),
|
||||
|
||||
revoke() {
|
||||
return ajax(`${this.basePath}/revoke`, {
|
||||
type: "POST",
|
||||
}).then((result) => this.setProperties(result.api_key));
|
||||
}
|
||||
},
|
||||
|
||||
undoRevoke() {
|
||||
return ajax(`${this.basePath}/undo-revoke`, {
|
||||
type: "POST",
|
||||
}).then((result) => this.setProperties(result.api_key));
|
||||
}
|
||||
},
|
||||
|
||||
createProperties() {
|
||||
return this.getProperties("description", "username", "scopes");
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed()
|
||||
basePath() {
|
||||
return this.store
|
||||
.adapterFor("api-key")
|
||||
.pathFor(this.store, "api-key", this.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default ApiKey;
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { not } from "@ember/object/computed";
|
||||
import EmberObject from "@ember/object";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import { not } from "@ember/object/computed";
|
||||
|
||||
export default class BackupStatus extends EmberObject {
|
||||
@not("restoreEnabled") restoreDisabled;
|
||||
export default EmberObject.extend({
|
||||
restoreDisabled: not("restoreEnabled"),
|
||||
|
||||
@discourseComputed("allowRestore", "isOperationRunning")
|
||||
restoreEnabled(allowRestore, isOperationRunning) {
|
||||
return allowRestore && !isOperationRunning;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -2,12 +2,25 @@ import EmberObject from "@ember/object";
|
||||
import MessageBus from "message-bus-client";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
export default class Backup extends EmberObject {
|
||||
static find() {
|
||||
return ajax("/admin/backups.json");
|
||||
}
|
||||
const Backup = EmberObject.extend({
|
||||
destroy() {
|
||||
return ajax("/admin/backups/" + this.filename, { type: "DELETE" });
|
||||
},
|
||||
|
||||
static start(withUploads) {
|
||||
restore() {
|
||||
return ajax("/admin/backups/" + this.filename + "/restore", {
|
||||
type: "POST",
|
||||
data: { client_id: MessageBus.clientId },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Backup.reopenClass({
|
||||
find() {
|
||||
return ajax("/admin/backups.json");
|
||||
},
|
||||
|
||||
start(withUploads) {
|
||||
if (withUploads === undefined) {
|
||||
withUploads = true;
|
||||
}
|
||||
@ -18,28 +31,19 @@ export default class Backup extends EmberObject {
|
||||
client_id: MessageBus.clientId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
static cancel() {
|
||||
cancel() {
|
||||
return ajax("/admin/backups/cancel.json", {
|
||||
type: "DELETE",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
static rollback() {
|
||||
rollback() {
|
||||
return ajax("/admin/backups/rollback.json", {
|
||||
type: "POST",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
destroy() {
|
||||
return ajax("/admin/backups/" + this.filename, { type: "DELETE" });
|
||||
}
|
||||
|
||||
restore() {
|
||||
return ajax("/admin/backups/" + this.filename + "/restore", {
|
||||
type: "POST",
|
||||
data: { client_id: MessageBus.clientId },
|
||||
});
|
||||
}
|
||||
}
|
||||
export default Backup;
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import { observes, on } from "@ember-decorators/object";
|
||||
import discourseComputed, {
|
||||
observes,
|
||||
on,
|
||||
} from "discourse-common/utils/decorators";
|
||||
import EmberObject from "@ember/object";
|
||||
import I18n from "I18n";
|
||||
import { propertyNotEqual } from "discourse/lib/computed";
|
||||
|
||||
export default class ColorSchemeColor extends EmberObject {
|
||||
// Whether the current value is different than Discourse's default color scheme.
|
||||
@propertyNotEqual("hex", "default_hex") overridden;
|
||||
const ColorSchemeColor = EmberObject.extend({
|
||||
@on("init")
|
||||
startTrackingChanges() {
|
||||
this.set("originals", { hex: this.hex || "FFFFFF" });
|
||||
|
||||
// force changed property to be recalculated
|
||||
this.notifyPropertyChange("hex");
|
||||
}
|
||||
},
|
||||
|
||||
// Whether value has changed since it was last saved.
|
||||
@discourseComputed("hex")
|
||||
@ -26,23 +26,26 @@ export default class ColorSchemeColor extends EmberObject {
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Whether the current value is different than Discourse's default color scheme.
|
||||
overridden: propertyNotEqual("hex", "default_hex"),
|
||||
|
||||
// Whether the saved value is different than Discourse's default color scheme.
|
||||
@discourseComputed("default_hex", "hex")
|
||||
savedIsOverriden(defaultHex) {
|
||||
return this.originals.hex !== defaultHex;
|
||||
}
|
||||
},
|
||||
|
||||
revert() {
|
||||
this.set("hex", this.default_hex);
|
||||
}
|
||||
},
|
||||
|
||||
undo() {
|
||||
if (this.originals) {
|
||||
this.set("hex", this.originals.hex);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("name")
|
||||
translatedName(name) {
|
||||
@ -51,7 +54,7 @@ export default class ColorSchemeColor extends EmberObject {
|
||||
} else {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("name")
|
||||
description(name) {
|
||||
@ -60,7 +63,7 @@ export default class ColorSchemeColor extends EmberObject {
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
brightness returns a number between 0 (darkest) to 255 (brightest).
|
||||
@ -87,17 +90,19 @@ export default class ColorSchemeColor extends EmberObject {
|
||||
1000
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@observes("hex")
|
||||
hexValueChanged() {
|
||||
if (this.hex) {
|
||||
this.set("hex", this.hex.toString().replace(/[^0-9a-fA-F]/g, ""));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("hex")
|
||||
valid(hex) {
|
||||
return hex.match(/^([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/) !== null;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default ColorSchemeColor;
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { not } from "@ember/object/computed";
|
||||
import { A } from "@ember/array";
|
||||
import ArrayProxy from "@ember/array/proxy";
|
||||
import ColorSchemeColor from "admin/models/color-scheme-color";
|
||||
@ -6,56 +5,26 @@ import EmberObject from "@ember/object";
|
||||
import I18n from "I18n";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import { not } from "@ember/object/computed";
|
||||
|
||||
class ColorSchemes extends ArrayProxy {}
|
||||
|
||||
export default class ColorScheme extends EmberObject {
|
||||
static findAll() {
|
||||
const colorSchemes = ColorSchemes.create({ content: [], loading: true });
|
||||
return ajax("/admin/color_schemes").then((all) => {
|
||||
all.forEach((colorScheme) => {
|
||||
colorSchemes.pushObject(
|
||||
ColorScheme.create({
|
||||
id: colorScheme.id,
|
||||
name: colorScheme.name,
|
||||
is_base: colorScheme.is_base,
|
||||
theme_id: colorScheme.theme_id,
|
||||
theme_name: colorScheme.theme_name,
|
||||
base_scheme_id: colorScheme.base_scheme_id,
|
||||
user_selectable: colorScheme.user_selectable,
|
||||
colors: colorScheme.colors.map((c) => {
|
||||
return ColorSchemeColor.create({
|
||||
name: c.name,
|
||||
hex: c.hex,
|
||||
default_hex: c.default_hex,
|
||||
is_advanced: c.is_advanced,
|
||||
});
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
return colorSchemes;
|
||||
});
|
||||
}
|
||||
|
||||
@not("id") newRecord;
|
||||
const ColorScheme = EmberObject.extend({
|
||||
init() {
|
||||
super.init(...arguments);
|
||||
this._super(...arguments);
|
||||
|
||||
this.startTrackingChanges();
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed
|
||||
description() {
|
||||
return "" + this.name;
|
||||
}
|
||||
},
|
||||
|
||||
startTrackingChanges() {
|
||||
this.set("originals", {
|
||||
name: this.name,
|
||||
user_selectable: this.user_selectable,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
schemeJson() {
|
||||
const buffer = [];
|
||||
@ -64,7 +33,7 @@ export default class ColorScheme extends EmberObject {
|
||||
});
|
||||
|
||||
return [`"${this.name}": {`, buffer.join(",\n"), "}"].join("\n");
|
||||
}
|
||||
},
|
||||
|
||||
copy() {
|
||||
const newScheme = ColorScheme.create({
|
||||
@ -78,7 +47,7 @@ export default class ColorScheme extends EmberObject {
|
||||
);
|
||||
});
|
||||
return newScheme;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"name",
|
||||
@ -101,7 +70,7 @@ export default class ColorScheme extends EmberObject {
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("changed")
|
||||
disableSave(changed) {
|
||||
@ -110,7 +79,9 @@ export default class ColorScheme extends EmberObject {
|
||||
}
|
||||
|
||||
return !changed || this.saving || this.colors.any((c) => !c.get("valid"));
|
||||
}
|
||||
},
|
||||
|
||||
newRecord: not("id"),
|
||||
|
||||
save(opts) {
|
||||
if (this.is_base || this.disableSave) {
|
||||
@ -153,7 +124,7 @@ export default class ColorScheme extends EmberObject {
|
||||
this.setProperties({ savingStatus: I18n.t("saved"), saving: false });
|
||||
this.notifyPropertyChange("description");
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateUserSelectable(value) {
|
||||
if (!this.id) {
|
||||
@ -166,11 +137,45 @@ export default class ColorScheme extends EmberObject {
|
||||
dataType: "json",
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.id) {
|
||||
return ajax(`/admin/color_schemes/${this.id}`, { type: "DELETE" });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const ColorSchemes = ArrayProxy.extend({});
|
||||
|
||||
ColorScheme.reopenClass({
|
||||
findAll() {
|
||||
const colorSchemes = ColorSchemes.create({ content: [], loading: true });
|
||||
return ajax("/admin/color_schemes").then((all) => {
|
||||
all.forEach((colorScheme) => {
|
||||
colorSchemes.pushObject(
|
||||
ColorScheme.create({
|
||||
id: colorScheme.id,
|
||||
name: colorScheme.name,
|
||||
is_base: colorScheme.is_base,
|
||||
theme_id: colorScheme.theme_id,
|
||||
theme_name: colorScheme.theme_name,
|
||||
base_scheme_id: colorScheme.base_scheme_id,
|
||||
user_selectable: colorScheme.user_selectable,
|
||||
colors: colorScheme.colors.map((c) => {
|
||||
return ColorSchemeColor.create({
|
||||
name: c.name,
|
||||
hex: c.hex,
|
||||
default_hex: c.default_hex,
|
||||
is_advanced: c.is_advanced,
|
||||
});
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
return colorSchemes;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default ColorScheme;
|
||||
|
||||
@ -3,8 +3,10 @@ import EmberObject from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import getURL from "discourse-common/lib/get-url";
|
||||
|
||||
export default class EmailLog extends EmberObject {
|
||||
static create(attrs) {
|
||||
const EmailLog = EmberObject.extend({});
|
||||
|
||||
EmailLog.reopenClass({
|
||||
create(attrs) {
|
||||
attrs = attrs || {};
|
||||
|
||||
if (attrs.user) {
|
||||
@ -15,10 +17,10 @@ export default class EmailLog extends EmberObject {
|
||||
attrs.post_url = getURL(attrs.post_url);
|
||||
}
|
||||
|
||||
return super.create(attrs);
|
||||
}
|
||||
return this._super(attrs);
|
||||
},
|
||||
|
||||
static findAll(filter, offset) {
|
||||
findAll(filter, offset) {
|
||||
filter = filter || {};
|
||||
offset = offset || 0;
|
||||
|
||||
@ -28,5 +30,7 @@ export default class EmailLog extends EmberObject {
|
||||
return ajax(`/admin/email/${status}.json?offset=${offset}`, {
|
||||
data: filter,
|
||||
}).then((logs) => logs.map((log) => EmailLog.create(log)));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default EmailLog;
|
||||
|
||||
@ -1,21 +1,25 @@
|
||||
import EmberObject from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
export default class EmailPreview extends EmberObject {
|
||||
static findDigest(username, lastSeenAt) {
|
||||
return ajax("/admin/email/preview-digest.json", {
|
||||
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username },
|
||||
}).then((result) => EmailPreview.create(result));
|
||||
}
|
||||
|
||||
static sendDigest(username, lastSeenAt, email) {
|
||||
return ajax("/admin/email/send-digest.json", {
|
||||
type: "POST",
|
||||
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username, email },
|
||||
});
|
||||
}
|
||||
}
|
||||
const EmailPreview = EmberObject.extend({});
|
||||
|
||||
export function oneWeekAgo() {
|
||||
return moment().locale("en").subtract(7, "days").format("YYYY-MM-DD");
|
||||
}
|
||||
|
||||
EmailPreview.reopenClass({
|
||||
findDigest(username, lastSeenAt) {
|
||||
return ajax("/admin/email/preview-digest.json", {
|
||||
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username },
|
||||
}).then((result) => EmailPreview.create(result));
|
||||
},
|
||||
|
||||
sendDigest(username, lastSeenAt, email) {
|
||||
return ajax("/admin/email/send-digest.json", {
|
||||
type: "POST",
|
||||
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username, email },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default EmailPreview;
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import EmberObject from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
export default class EmailSettings extends EmberObject {
|
||||
static find() {
|
||||
const EmailSettings = EmberObject.extend({});
|
||||
|
||||
EmailSettings.reopenClass({
|
||||
find() {
|
||||
return ajax("/admin/email.json").then(function (settings) {
|
||||
return EmailSettings.create(settings);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default EmailSettings;
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import RestModel from "discourse/models/rest";
|
||||
|
||||
export default class EmailStyle extends RestModel {
|
||||
changed = false;
|
||||
export default RestModel.extend({
|
||||
changed: false,
|
||||
|
||||
setField(fieldName, value) {
|
||||
this.set(`${fieldName}`, value);
|
||||
this.set("changed", true);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -2,12 +2,12 @@ import RestModel from "discourse/models/rest";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { getProperties } from "@ember/object";
|
||||
|
||||
export default class EmailTemplate extends RestModel {
|
||||
export default RestModel.extend({
|
||||
revert() {
|
||||
return ajax(`/admin/customize/email_templates/${this.id}`, {
|
||||
type: "DELETE",
|
||||
}).then((result) =>
|
||||
getProperties(result.email_template, "subject", "body", "can_revert")
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -2,9 +2,9 @@ import I18n from "I18n";
|
||||
import RestModel from "discourse/models/rest";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
|
||||
export default class FlagType extends RestModel {
|
||||
export default RestModel.extend({
|
||||
@discourseComputed("id")
|
||||
name(id) {
|
||||
return I18n.t(`admin.flags.summary.action_type_${id}`, { count: 1 });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,51 +1,53 @@
|
||||
import RestModel from "discourse/models/rest";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
export default class FormTemplate extends RestModel {
|
||||
static createTemplate(data) {
|
||||
export default class FormTemplate extends RestModel {}
|
||||
|
||||
FormTemplate.reopenClass({
|
||||
createTemplate(data) {
|
||||
return ajax("/admin/customize/form-templates.json", {
|
||||
type: "POST",
|
||||
data,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
static updateTemplate(id, data) {
|
||||
updateTemplate(id, data) {
|
||||
return ajax(`/admin/customize/form-templates/${id}.json`, {
|
||||
type: "PUT",
|
||||
data,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
static createOrUpdateTemplate(data) {
|
||||
createOrUpdateTemplate(data) {
|
||||
if (data.id) {
|
||||
return this.updateTemplate(data.id, data);
|
||||
} else {
|
||||
return this.createTemplate(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
static deleteTemplate(id) {
|
||||
deleteTemplate(id) {
|
||||
return ajax(`/admin/customize/form-templates/${id}.json`, {
|
||||
type: "DELETE",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
static findAll() {
|
||||
findAll() {
|
||||
return ajax(`/admin/customize/form-templates.json`).then((model) => {
|
||||
return model.form_templates.sort((a, b) => a.id - b.id);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
static findById(id) {
|
||||
findById(id) {
|
||||
return ajax(`/admin/customize/form-templates/${id}.json`).then((model) => {
|
||||
return model.form_template;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
static validateTemplate(data) {
|
||||
validateTemplate(data) {
|
||||
return ajax(`/admin/customize/form-templates/preview.json`, {
|
||||
type: "GET",
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -2,8 +2,10 @@ import AdminUser from "admin/models/admin-user";
|
||||
import EmberObject from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
export default class IncomingEmail extends EmberObject {
|
||||
static create(attrs) {
|
||||
const IncomingEmail = EmberObject.extend({});
|
||||
|
||||
IncomingEmail.reopenClass({
|
||||
create(attrs) {
|
||||
attrs = attrs || {};
|
||||
|
||||
if (attrs.user) {
|
||||
@ -11,17 +13,17 @@ export default class IncomingEmail extends EmberObject {
|
||||
}
|
||||
|
||||
return this._super(attrs);
|
||||
}
|
||||
},
|
||||
|
||||
static find(id) {
|
||||
find(id) {
|
||||
return ajax(`/admin/email/incoming/${id}.json`);
|
||||
}
|
||||
},
|
||||
|
||||
static findByBounced(id) {
|
||||
findByBounced(id) {
|
||||
return ajax(`/admin/email/incoming_from_bounced/${id}.json`);
|
||||
}
|
||||
},
|
||||
|
||||
static findAll(filter, offset) {
|
||||
findAll(filter, offset) {
|
||||
filter = filter || {};
|
||||
offset = offset || 0;
|
||||
|
||||
@ -33,9 +35,11 @@ export default class IncomingEmail extends EmberObject {
|
||||
}).then((incomings) =>
|
||||
incomings.map((incoming) => IncomingEmail.create(incoming))
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
static loadRawEmail(id) {
|
||||
loadRawEmail(id) {
|
||||
return ajax(`/admin/email/incoming/${id}/raw.json`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default IncomingEmail;
|
||||
|
||||
@ -4,15 +4,7 @@ import EmberObject from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
|
||||
export default class Permalink extends EmberObject {
|
||||
static findAll(filter) {
|
||||
return ajax("/admin/permalinks.json", { data: { filter } }).then(function (
|
||||
permalinks
|
||||
) {
|
||||
return permalinks.map((p) => Permalink.create(p));
|
||||
});
|
||||
}
|
||||
|
||||
const Permalink = EmberObject.extend({
|
||||
save() {
|
||||
return ajax("/admin/permalinks.json", {
|
||||
type: "POST",
|
||||
@ -22,21 +14,33 @@ export default class Permalink extends EmberObject {
|
||||
permalink_type_value: this.permalink_type_value,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("category_id")
|
||||
category(category_id) {
|
||||
return Category.findById(category_id);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("external_url")
|
||||
linkIsExternal(external_url) {
|
||||
return !DiscourseURL.isInternal(external_url);
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
return ajax("/admin/permalinks/" + this.id + ".json", {
|
||||
type: "DELETE",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Permalink.reopenClass({
|
||||
findAll(filter) {
|
||||
return ajax("/admin/permalinks.json", { data: { filter } }).then(function (
|
||||
permalinks
|
||||
) {
|
||||
return permalinks.map((p) => Permalink.create(p));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default Permalink;
|
||||
|
||||
@ -19,188 +19,12 @@ import round from "discourse/lib/round";
|
||||
// and you want to ensure cache is reset
|
||||
export const SCHEMA_VERSION = 4;
|
||||
|
||||
export default class Report extends EmberObject {
|
||||
static groupingForDatapoints(count) {
|
||||
if (count < DAILY_LIMIT_DAYS) {
|
||||
return "daily";
|
||||
}
|
||||
|
||||
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
|
||||
return "weekly";
|
||||
}
|
||||
|
||||
if (count >= WEEKLY_LIMIT_DAYS) {
|
||||
return "monthly";
|
||||
}
|
||||
}
|
||||
|
||||
static unitForDatapoints(count) {
|
||||
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
|
||||
return "week";
|
||||
} else if (count >= WEEKLY_LIMIT_DAYS) {
|
||||
return "month";
|
||||
} else {
|
||||
return "day";
|
||||
}
|
||||
}
|
||||
|
||||
static unitForGrouping(grouping) {
|
||||
switch (grouping) {
|
||||
case "monthly":
|
||||
return "month";
|
||||
case "weekly":
|
||||
return "week";
|
||||
default:
|
||||
return "day";
|
||||
}
|
||||
}
|
||||
|
||||
static collapse(model, data, grouping) {
|
||||
grouping = grouping || Report.groupingForDatapoints(data.length);
|
||||
|
||||
if (grouping === "daily") {
|
||||
return data;
|
||||
} else if (grouping === "weekly" || grouping === "monthly") {
|
||||
const isoKind = grouping === "weekly" ? "isoWeek" : "month";
|
||||
const kind = grouping === "weekly" ? "week" : "month";
|
||||
const startMoment = moment(model.start_date, "YYYY-MM-DD");
|
||||
|
||||
let currentIndex = 0;
|
||||
let currentStart = startMoment.clone().startOf(isoKind);
|
||||
let currentEnd = startMoment.clone().endOf(isoKind);
|
||||
const transformedData = [
|
||||
{
|
||||
x: currentStart.format("YYYY-MM-DD"),
|
||||
y: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let appliedAverage = false;
|
||||
data.forEach((d) => {
|
||||
const date = moment(d.x, "YYYY-MM-DD");
|
||||
|
||||
if (
|
||||
!date.isSame(currentStart) &&
|
||||
!date.isBetween(currentStart, currentEnd)
|
||||
) {
|
||||
if (model.average) {
|
||||
transformedData[currentIndex].y = applyAverage(
|
||||
transformedData[currentIndex].y,
|
||||
currentStart,
|
||||
currentEnd
|
||||
);
|
||||
|
||||
appliedAverage = true;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
currentStart = currentStart.add(1, kind).startOf(isoKind);
|
||||
currentEnd = currentEnd.add(1, kind).endOf(isoKind);
|
||||
} else {
|
||||
appliedAverage = false;
|
||||
}
|
||||
|
||||
if (transformedData[currentIndex]) {
|
||||
transformedData[currentIndex].y += d.y;
|
||||
} else {
|
||||
transformedData[currentIndex] = {
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (model.average && !appliedAverage) {
|
||||
transformedData[currentIndex].y = applyAverage(
|
||||
transformedData[currentIndex].y,
|
||||
currentStart,
|
||||
moment(model.end_date).subtract(1, "day") // remove 1 day as model end date is at 00:00 of next day
|
||||
);
|
||||
}
|
||||
|
||||
return transformedData;
|
||||
}
|
||||
|
||||
// ensure we return something if grouping is unknown
|
||||
return data;
|
||||
}
|
||||
|
||||
static fillMissingDates(report, options = {}) {
|
||||
const dataField = options.dataField || "data";
|
||||
const filledField = options.filledField || "data";
|
||||
const startDate = options.startDate || "start_date";
|
||||
const endDate = options.endDate || "end_date";
|
||||
|
||||
if (Array.isArray(report[dataField])) {
|
||||
const startDateFormatted = moment
|
||||
.utc(report[startDate])
|
||||
.locale("en")
|
||||
.format("YYYY-MM-DD");
|
||||
const endDateFormatted = moment
|
||||
.utc(report[endDate])
|
||||
.locale("en")
|
||||
.format("YYYY-MM-DD");
|
||||
|
||||
if (report.modes[0] === "stacked_chart") {
|
||||
report[filledField] = report[dataField].map((rep) => {
|
||||
return {
|
||||
req: rep.req,
|
||||
label: rep.label,
|
||||
color: rep.color,
|
||||
data: fillMissingDates(
|
||||
JSON.parse(JSON.stringify(rep.data)),
|
||||
startDateFormatted,
|
||||
endDateFormatted
|
||||
),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
report[filledField] = fillMissingDates(
|
||||
JSON.parse(JSON.stringify(report[dataField])),
|
||||
startDateFormatted,
|
||||
endDateFormatted
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static find(type, startDate, endDate, categoryId, groupId) {
|
||||
return ajax("/admin/reports/" + type, {
|
||||
data: {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
category_id: categoryId,
|
||||
group_id: groupId,
|
||||
},
|
||||
}).then((json) => {
|
||||
// don’t fill for large multi column tables
|
||||
// which are not date based
|
||||
const modes = json.report.modes;
|
||||
if (modes.length !== 1 && modes[0] !== "table") {
|
||||
Report.fillMissingDates(json.report);
|
||||
}
|
||||
|
||||
const model = Report.create({ type });
|
||||
model.setProperties(json.report);
|
||||
|
||||
if (json.report.related_report) {
|
||||
// TODO: fillMissingDates if xaxis is date
|
||||
const related = Report.create({
|
||||
type: json.report.related_report.type,
|
||||
});
|
||||
related.setProperties(json.report.related_report);
|
||||
model.set("relatedReport", related);
|
||||
}
|
||||
|
||||
return model;
|
||||
});
|
||||
}
|
||||
|
||||
average = false;
|
||||
percent = false;
|
||||
higher_is_better = true;
|
||||
description_link = null;
|
||||
description = null;
|
||||
const Report = EmberObject.extend({
|
||||
average: false,
|
||||
percent: false,
|
||||
higher_is_better: true,
|
||||
description_link: null,
|
||||
description: null,
|
||||
|
||||
@discourseComputed("type", "start_date", "end_date")
|
||||
reportUrl(type, start_date, end_date) {
|
||||
@ -211,7 +35,7 @@ export default class Report extends EmberObject {
|
||||
return getURL(
|
||||
`/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
valueAt(numDaysAgo) {
|
||||
if (this.data) {
|
||||
@ -225,7 +49,7 @@ export default class Report extends EmberObject {
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
|
||||
valueFor(startDaysAgo, endDaysAgo) {
|
||||
if (this.data) {
|
||||
@ -246,46 +70,46 @@ export default class Report extends EmberObject {
|
||||
}
|
||||
return round(sum, -2);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("data", "average")
|
||||
todayCount() {
|
||||
return this.valueAt(0);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("data", "average")
|
||||
yesterdayCount() {
|
||||
return this.valueAt(1);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("data", "average")
|
||||
sevenDaysAgoCount() {
|
||||
return this.valueAt(7);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("data", "average")
|
||||
thirtyDaysAgoCount() {
|
||||
return this.valueAt(30);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("data", "average")
|
||||
lastSevenDaysCount() {
|
||||
return this.averageCount(7, this.valueFor(1, 7));
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("data", "average")
|
||||
lastThirtyDaysCount() {
|
||||
return this.averageCount(30, this.valueFor(1, 30));
|
||||
}
|
||||
},
|
||||
|
||||
averageCount(count, value) {
|
||||
return this.average ? value / count : value;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("yesterdayCount", "higher_is_better")
|
||||
yesterdayTrend(yesterdayCount, higherIsBetter) {
|
||||
return this._computeTrend(this.valueAt(2), yesterdayCount, higherIsBetter);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("lastSevenDaysCount", "higher_is_better")
|
||||
sevenDaysTrend(lastSevenDaysCount, higherIsBetter) {
|
||||
@ -294,39 +118,39 @@ export default class Report extends EmberObject {
|
||||
lastSevenDaysCount,
|
||||
higherIsBetter
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("data")
|
||||
currentTotal(data) {
|
||||
return data.reduce((cur, pair) => cur + pair.y, 0);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("data", "currentTotal")
|
||||
currentAverage(data, total) {
|
||||
return makeArray(data).length === 0
|
||||
? 0
|
||||
: parseFloat((total / parseFloat(data.length)).toFixed(1));
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("trend", "higher_is_better")
|
||||
trendIcon(trend, higherIsBetter) {
|
||||
return this._iconForTrend(trend, higherIsBetter);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("sevenDaysTrend", "higher_is_better")
|
||||
sevenDaysTrendIcon(sevenDaysTrend, higherIsBetter) {
|
||||
return this._iconForTrend(sevenDaysTrend, higherIsBetter);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("thirtyDaysTrend", "higher_is_better")
|
||||
thirtyDaysTrendIcon(thirtyDaysTrend, higherIsBetter) {
|
||||
return this._iconForTrend(thirtyDaysTrend, higherIsBetter);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("yesterdayTrend", "higher_is_better")
|
||||
yesterdayTrendIcon(yesterdayTrend, higherIsBetter) {
|
||||
return this._iconForTrend(yesterdayTrend, higherIsBetter);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"prev_period",
|
||||
@ -337,7 +161,7 @@ export default class Report extends EmberObject {
|
||||
trend(prev, currentTotal, currentAverage, higherIsBetter) {
|
||||
const total = this.average ? currentAverage : currentTotal;
|
||||
return this._computeTrend(prev, total, higherIsBetter);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"prev30Days",
|
||||
@ -356,7 +180,7 @@ export default class Report extends EmberObject {
|
||||
lastThirtyDaysCount,
|
||||
higherIsBetter
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("type")
|
||||
method(type) {
|
||||
@ -365,7 +189,7 @@ export default class Report extends EmberObject {
|
||||
} else {
|
||||
return "sum";
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
percentChangeString(val1, val2) {
|
||||
const change = this._computeChange(val1, val2);
|
||||
@ -377,7 +201,7 @@ export default class Report extends EmberObject {
|
||||
} else {
|
||||
return change.toFixed(0) + "%";
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("prev_period", "currentTotal", "currentAverage")
|
||||
trendTitle(prev, currentTotal, currentAverage) {
|
||||
@ -400,7 +224,7 @@ export default class Report extends EmberObject {
|
||||
prev,
|
||||
current,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
changeTitle(valAtT1, valAtT2, prevPeriodString) {
|
||||
const change = this.percentChangeString(valAtT1, valAtT2);
|
||||
@ -410,12 +234,12 @@ export default class Report extends EmberObject {
|
||||
}
|
||||
title += `Was ${number(valAtT1)} ${prevPeriodString}.`;
|
||||
return title;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("yesterdayCount")
|
||||
yesterdayCountTitle(yesterdayCount) {
|
||||
return this.changeTitle(this.valueAt(2), yesterdayCount, "two days ago");
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("lastSevenDaysCount")
|
||||
sevenDaysCountTitle(lastSevenDaysCount) {
|
||||
@ -424,12 +248,12 @@ export default class Report extends EmberObject {
|
||||
lastSevenDaysCount,
|
||||
"two weeks ago"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("prev30Days", "prev_period")
|
||||
canDisplayTrendIcon(prev30Days, prev_period) {
|
||||
return prev30Days ?? prev_period;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("prev30Days", "prev_period", "lastThirtyDaysCount")
|
||||
thirtyDaysCountTitle(prev30Days, prev_period, lastThirtyDaysCount) {
|
||||
@ -438,12 +262,12 @@ export default class Report extends EmberObject {
|
||||
lastThirtyDaysCount,
|
||||
"in the previous 30 day period"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("data")
|
||||
sortedData(data) {
|
||||
return this.xAxisIsDate ? data.toArray().reverse() : data.toArray();
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("data")
|
||||
xAxisIsDate() {
|
||||
@ -451,7 +275,7 @@ export default class Report extends EmberObject {
|
||||
return false;
|
||||
}
|
||||
return this.data && this.data[0].x.match(/\d{4}-\d{1,2}-\d{1,2}/);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("labels")
|
||||
computedLabels(labels) {
|
||||
@ -535,7 +359,7 @@ export default class Report extends EmberObject {
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_userLabel(properties, row) {
|
||||
const username = row[properties.username];
|
||||
@ -564,7 +388,7 @@ export default class Report extends EmberObject {
|
||||
value: username,
|
||||
formattedValue: username ? formattedValue() : "—",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_topicLabel(properties, row) {
|
||||
const topicTitle = row[properties.title];
|
||||
@ -579,7 +403,7 @@ export default class Report extends EmberObject {
|
||||
value: topicTitle,
|
||||
formattedValue: topicTitle ? formattedValue() : "—",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_postLabel(properties, row) {
|
||||
const postTitle = row[properties.truncated_raw];
|
||||
@ -595,21 +419,21 @@ export default class Report extends EmberObject {
|
||||
? `<a href='${href}'>${escapeExpression(postTitle)}</a>`
|
||||
: "—",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_secondsLabel(value) {
|
||||
return {
|
||||
value: toNumber(value),
|
||||
formattedValue: durationTiny(value),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_percentLabel(value) {
|
||||
return {
|
||||
value: toNumber(value),
|
||||
formattedValue: value ? `${value}%` : "—",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_numberLabel(value, options = {}) {
|
||||
const formatNumbers = isEmpty(options.formatNumbers)
|
||||
@ -622,21 +446,21 @@ export default class Report extends EmberObject {
|
||||
value: toNumber(value),
|
||||
formattedValue: value ? formattedValue() : "—",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_bytesLabel(value) {
|
||||
return {
|
||||
value: toNumber(value),
|
||||
formattedValue: I18n.toHumanSize(value),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_dateLabel(value, date, format = "LL") {
|
||||
return {
|
||||
value,
|
||||
formattedValue: value ? date.format(format) : "—",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_textLabel(value) {
|
||||
const escaped = escapeExpression(value);
|
||||
@ -645,7 +469,7 @@ export default class Report extends EmberObject {
|
||||
value,
|
||||
formattedValue: value ? escaped : "—",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_linkLabel(properties, row) {
|
||||
const property = properties[0];
|
||||
@ -660,11 +484,11 @@ export default class Report extends EmberObject {
|
||||
value,
|
||||
formattedValue: value ? formattedValue(value, row[properties[1]]) : "—",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_computeChange(valAtT1, valAtT2) {
|
||||
return ((valAtT2 - valAtT1) / valAtT1) * 100;
|
||||
}
|
||||
},
|
||||
|
||||
_computeTrend(valAtT1, valAtT2, higherIsBetter) {
|
||||
const change = this._computeChange(valAtT1, valAtT2);
|
||||
@ -680,7 +504,7 @@ export default class Report extends EmberObject {
|
||||
} else if (change < -2) {
|
||||
return higherIsBetter ? "trending-down" : "trending-up";
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_iconForTrend(trend, higherIsBetter) {
|
||||
switch (trend) {
|
||||
@ -695,8 +519,8 @@ export default class Report extends EmberObject {
|
||||
default:
|
||||
return "minus";
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const WEEKLY_LIMIT_DAYS = 365;
|
||||
export const DAILY_LIMIT_DAYS = 34;
|
||||
@ -705,3 +529,183 @@ function applyAverage(value, start, end) {
|
||||
const count = end.diff(start, "day") + 1; // 1 to include start
|
||||
return parseFloat((value / count).toFixed(2));
|
||||
}
|
||||
|
||||
Report.reopenClass({
|
||||
groupingForDatapoints(count) {
|
||||
if (count < DAILY_LIMIT_DAYS) {
|
||||
return "daily";
|
||||
}
|
||||
|
||||
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
|
||||
return "weekly";
|
||||
}
|
||||
|
||||
if (count >= WEEKLY_LIMIT_DAYS) {
|
||||
return "monthly";
|
||||
}
|
||||
},
|
||||
|
||||
unitForDatapoints(count) {
|
||||
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
|
||||
return "week";
|
||||
} else if (count >= WEEKLY_LIMIT_DAYS) {
|
||||
return "month";
|
||||
} else {
|
||||
return "day";
|
||||
}
|
||||
},
|
||||
|
||||
unitForGrouping(grouping) {
|
||||
switch (grouping) {
|
||||
case "monthly":
|
||||
return "month";
|
||||
case "weekly":
|
||||
return "week";
|
||||
default:
|
||||
return "day";
|
||||
}
|
||||
},
|
||||
|
||||
collapse(model, data, grouping) {
|
||||
grouping = grouping || Report.groupingForDatapoints(data.length);
|
||||
|
||||
if (grouping === "daily") {
|
||||
return data;
|
||||
} else if (grouping === "weekly" || grouping === "monthly") {
|
||||
const isoKind = grouping === "weekly" ? "isoWeek" : "month";
|
||||
const kind = grouping === "weekly" ? "week" : "month";
|
||||
const startMoment = moment(model.start_date, "YYYY-MM-DD");
|
||||
|
||||
let currentIndex = 0;
|
||||
let currentStart = startMoment.clone().startOf(isoKind);
|
||||
let currentEnd = startMoment.clone().endOf(isoKind);
|
||||
const transformedData = [
|
||||
{
|
||||
x: currentStart.format("YYYY-MM-DD"),
|
||||
y: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let appliedAverage = false;
|
||||
data.forEach((d) => {
|
||||
const date = moment(d.x, "YYYY-MM-DD");
|
||||
|
||||
if (
|
||||
!date.isSame(currentStart) &&
|
||||
!date.isBetween(currentStart, currentEnd)
|
||||
) {
|
||||
if (model.average) {
|
||||
transformedData[currentIndex].y = applyAverage(
|
||||
transformedData[currentIndex].y,
|
||||
currentStart,
|
||||
currentEnd
|
||||
);
|
||||
|
||||
appliedAverage = true;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
currentStart = currentStart.add(1, kind).startOf(isoKind);
|
||||
currentEnd = currentEnd.add(1, kind).endOf(isoKind);
|
||||
} else {
|
||||
appliedAverage = false;
|
||||
}
|
||||
|
||||
if (transformedData[currentIndex]) {
|
||||
transformedData[currentIndex].y += d.y;
|
||||
} else {
|
||||
transformedData[currentIndex] = {
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (model.average && !appliedAverage) {
|
||||
transformedData[currentIndex].y = applyAverage(
|
||||
transformedData[currentIndex].y,
|
||||
currentStart,
|
||||
moment(model.end_date).subtract(1, "day") // remove 1 day as model end date is at 00:00 of next day
|
||||
);
|
||||
}
|
||||
|
||||
return transformedData;
|
||||
}
|
||||
|
||||
// ensure we return something if grouping is unknown
|
||||
return data;
|
||||
},
|
||||
|
||||
fillMissingDates(report, options = {}) {
|
||||
const dataField = options.dataField || "data";
|
||||
const filledField = options.filledField || "data";
|
||||
const startDate = options.startDate || "start_date";
|
||||
const endDate = options.endDate || "end_date";
|
||||
|
||||
if (Array.isArray(report[dataField])) {
|
||||
const startDateFormatted = moment
|
||||
.utc(report[startDate])
|
||||
.locale("en")
|
||||
.format("YYYY-MM-DD");
|
||||
const endDateFormatted = moment
|
||||
.utc(report[endDate])
|
||||
.locale("en")
|
||||
.format("YYYY-MM-DD");
|
||||
|
||||
if (report.modes[0] === "stacked_chart") {
|
||||
report[filledField] = report[dataField].map((rep) => {
|
||||
return {
|
||||
req: rep.req,
|
||||
label: rep.label,
|
||||
color: rep.color,
|
||||
data: fillMissingDates(
|
||||
JSON.parse(JSON.stringify(rep.data)),
|
||||
startDateFormatted,
|
||||
endDateFormatted
|
||||
),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
report[filledField] = fillMissingDates(
|
||||
JSON.parse(JSON.stringify(report[dataField])),
|
||||
startDateFormatted,
|
||||
endDateFormatted
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
find(type, startDate, endDate, categoryId, groupId) {
|
||||
return ajax("/admin/reports/" + type, {
|
||||
data: {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
category_id: categoryId,
|
||||
group_id: groupId,
|
||||
},
|
||||
}).then((json) => {
|
||||
// don’t fill for large multi column tables
|
||||
// which are not date based
|
||||
const modes = json.report.modes;
|
||||
if (modes.length !== 1 && modes[0] !== "table") {
|
||||
Report.fillMissingDates(json.report);
|
||||
}
|
||||
|
||||
const model = Report.create({ type });
|
||||
model.setProperties(json.report);
|
||||
|
||||
if (json.report.related_report) {
|
||||
// TODO: fillMissingDates if xaxis is date
|
||||
const related = Report.create({
|
||||
type: json.report.related_report.type,
|
||||
});
|
||||
related.setProperties(json.report.related_report);
|
||||
model.set("relatedReport", related);
|
||||
}
|
||||
|
||||
return model;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default Report;
|
||||
|
||||
@ -3,8 +3,21 @@ import I18n from "I18n";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
|
||||
export default class ScreenedEmail extends EmberObject {
|
||||
static findAll() {
|
||||
const ScreenedEmail = EmberObject.extend({
|
||||
@discourseComputed("action")
|
||||
actionName(action) {
|
||||
return I18n.t("admin.logs.screened_actions." + action);
|
||||
},
|
||||
|
||||
clearBlock() {
|
||||
return ajax("/admin/logs/screened_emails/" + this.id, {
|
||||
type: "DELETE",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
ScreenedEmail.reopenClass({
|
||||
findAll() {
|
||||
return ajax("/admin/logs/screened_emails.json").then(function (
|
||||
screened_emails
|
||||
) {
|
||||
@ -12,16 +25,7 @@ export default class ScreenedEmail extends EmberObject {
|
||||
return ScreenedEmail.create(b);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@discourseComputed("action")
|
||||
actionName(action) {
|
||||
return I18n.t("admin.logs.screened_actions." + action);
|
||||
}
|
||||
|
||||
clearBlock() {
|
||||
return ajax("/admin/logs/screened_emails/" + this.id, {
|
||||
type: "DELETE",
|
||||
});
|
||||
}
|
||||
}
|
||||
export default ScreenedEmail;
|
||||
|
||||
@ -1,28 +1,21 @@
|
||||
import { equal } from "@ember/object/computed";
|
||||
import EmberObject from "@ember/object";
|
||||
import I18n from "I18n";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import { equal } from "@ember/object/computed";
|
||||
|
||||
export default class ScreenedIpAddress extends EmberObject {
|
||||
static findAll(filter) {
|
||||
return ajax("/admin/logs/screened_ip_addresses.json", {
|
||||
data: { filter },
|
||||
}).then((screened_ips) =>
|
||||
screened_ips.map((b) => ScreenedIpAddress.create(b))
|
||||
);
|
||||
}
|
||||
|
||||
@equal("action_name", "block") isBlocked;
|
||||
const ScreenedIpAddress = EmberObject.extend({
|
||||
@discourseComputed("action_name")
|
||||
actionName(actionName) {
|
||||
return I18n.t(`admin.logs.screened_ips.actions.${actionName}`);
|
||||
}
|
||||
},
|
||||
|
||||
isBlocked: equal("action_name", "block"),
|
||||
|
||||
@discourseComputed("ip_address")
|
||||
isRange(ipAddress) {
|
||||
return ipAddress.indexOf("/") > 0;
|
||||
}
|
||||
},
|
||||
|
||||
save() {
|
||||
return ajax(
|
||||
@ -37,11 +30,23 @@ export default class ScreenedIpAddress extends EmberObject {
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
return ajax("/admin/logs/screened_ip_addresses/" + this.id + ".json", {
|
||||
type: "DELETE",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
ScreenedIpAddress.reopenClass({
|
||||
findAll(filter) {
|
||||
return ajax("/admin/logs/screened_ip_addresses.json", {
|
||||
data: { filter },
|
||||
}).then((screened_ips) =>
|
||||
screened_ips.map((b) => ScreenedIpAddress.create(b))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default ScreenedIpAddress;
|
||||
|
||||
@ -3,8 +3,15 @@ import I18n from "I18n";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
|
||||
export default class ScreenedUrl extends EmberObject {
|
||||
static findAll() {
|
||||
const ScreenedUrl = EmberObject.extend({
|
||||
@discourseComputed("action")
|
||||
actionName(action) {
|
||||
return I18n.t("admin.logs.screened_actions." + action);
|
||||
},
|
||||
});
|
||||
|
||||
ScreenedUrl.reopenClass({
|
||||
findAll() {
|
||||
return ajax("/admin/logs/screened_urls.json").then(function (
|
||||
screened_urls
|
||||
) {
|
||||
@ -12,10 +19,7 @@ export default class ScreenedUrl extends EmberObject {
|
||||
return ScreenedUrl.create(b);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@discourseComputed("action")
|
||||
actionName(action) {
|
||||
return I18n.t("admin.logs.screened_actions." + action);
|
||||
}
|
||||
}
|
||||
export default ScreenedUrl;
|
||||
|
||||
@ -4,8 +4,22 @@ import Setting from "admin/mixins/setting-object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
|
||||
export default class SiteSetting extends EmberObject.extend(Setting) {
|
||||
static findAll() {
|
||||
const SiteSetting = EmberObject.extend(Setting, {
|
||||
@discourseComputed("setting")
|
||||
staffLogFilter(setting) {
|
||||
if (!setting) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
subject: setting,
|
||||
action_name: "change_site_setting",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
SiteSetting.reopenClass({
|
||||
findAll() {
|
||||
return ajax("/admin/site_settings").then(function (settings) {
|
||||
// Group the results by category
|
||||
const categories = {};
|
||||
@ -24,9 +38,9 @@ export default class SiteSetting extends EmberObject.extend(Setting) {
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
static update(key, value, opts = {}) {
|
||||
update(key, value, opts = {}) {
|
||||
const data = {};
|
||||
data[key] = value;
|
||||
|
||||
@ -35,17 +49,7 @@ export default class SiteSetting extends EmberObject.extend(Setting) {
|
||||
}
|
||||
|
||||
return ajax(`/admin/site_settings/${key}`, { type: "PUT", data });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@discourseComputed("setting")
|
||||
staffLogFilter(setting) {
|
||||
if (!setting) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
subject: setting,
|
||||
action_name: "change_site_setting",
|
||||
};
|
||||
}
|
||||
}
|
||||
export default SiteSetting;
|
||||
|
||||
@ -2,10 +2,10 @@ import RestModel from "discourse/models/rest";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { getProperties } from "@ember/object";
|
||||
|
||||
export default class SiteText extends RestModel {
|
||||
export default RestModel.extend({
|
||||
revert(locale) {
|
||||
return ajax(`/admin/customize/site_texts/${this.id}?locale=${locale}`, {
|
||||
type: "DELETE",
|
||||
}).then((result) => getProperties(result.site_text, "value", "can_revert"));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -11,36 +11,13 @@ function format(label, value, escape = true) {
|
||||
: "";
|
||||
}
|
||||
|
||||
export default class StaffActionLog extends RestModel {
|
||||
static munge(json) {
|
||||
if (json.acting_user) {
|
||||
json.acting_user = AdminUser.create(json.acting_user);
|
||||
}
|
||||
if (json.target_user) {
|
||||
json.target_user = AdminUser.create(json.target_user);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
static findAll(data) {
|
||||
return ajax("/admin/logs/staff_action_logs.json", { data }).then(
|
||||
(result) => {
|
||||
return {
|
||||
staff_action_logs: result.staff_action_logs.map((s) =>
|
||||
StaffActionLog.create(s)
|
||||
),
|
||||
user_history_actions: result.user_history_actions,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
showFullDetails = false;
|
||||
const StaffActionLog = RestModel.extend({
|
||||
showFullDetails: false,
|
||||
|
||||
@discourseComputed("action_name")
|
||||
actionName(actionName) {
|
||||
return I18n.t(`admin.logs.staff_actions.actions.${actionName}`);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"email",
|
||||
@ -95,15 +72,42 @@ export default class StaffActionLog extends RestModel {
|
||||
|
||||
const formatted = lines.filter((l) => l.length > 0).join("<br/>");
|
||||
return formatted.length > 0 ? formatted + "<br/>" : "";
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("details")
|
||||
useModalForDetails(details) {
|
||||
return details && details.length > 100;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("action_name")
|
||||
useCustomModalForDetails(actionName) {
|
||||
return ["change_theme", "delete_theme"].includes(actionName);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
StaffActionLog.reopenClass({
|
||||
munge(json) {
|
||||
if (json.acting_user) {
|
||||
json.acting_user = AdminUser.create(json.acting_user);
|
||||
}
|
||||
if (json.target_user) {
|
||||
json.target_user = AdminUser.create(json.target_user);
|
||||
}
|
||||
return json;
|
||||
},
|
||||
|
||||
findAll(data) {
|
||||
return ajax("/admin/logs/staff_action_logs.json", { data }).then(
|
||||
(result) => {
|
||||
return {
|
||||
staff_action_logs: result.staff_action_logs.map((s) =>
|
||||
StaffActionLog.create(s)
|
||||
),
|
||||
user_history_actions: result.user_history_actions,
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default StaffActionLog;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import EmberObject from "@ember/object";
|
||||
import Setting from "admin/mixins/setting-object";
|
||||
|
||||
export default class ThemeSettings extends EmberObject.extend(Setting) {}
|
||||
export default EmberObject.extend(Setting, {});
|
||||
|
||||
@ -13,13 +13,11 @@ export const THEMES = "themes";
|
||||
export const COMPONENTS = "components";
|
||||
const SETTINGS_TYPE_ID = 5;
|
||||
|
||||
class Theme extends RestModel {
|
||||
@or("default", "user_selectable") isActive;
|
||||
@gt("remote_theme.commits_behind", 0) isPendingUpdates;
|
||||
@gt("editedFields.length", 0) hasEditedFields;
|
||||
@gt("parent_themes.length", 0) hasParents;
|
||||
|
||||
changed = false;
|
||||
const Theme = RestModel.extend({
|
||||
isActive: or("default", "user_selectable"),
|
||||
isPendingUpdates: gt("remote_theme.commits_behind", 0),
|
||||
hasEditedFields: gt("editedFields.length", 0),
|
||||
hasParents: gt("parent_themes.length", 0),
|
||||
|
||||
@discourseComputed("theme_fields.[]")
|
||||
targets() {
|
||||
@ -47,7 +45,7 @@ class Theme extends RestModel {
|
||||
target["error"] = this.hasError(target.name);
|
||||
return target;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("theme_fields.[]")
|
||||
fieldNames() {
|
||||
@ -86,7 +84,7 @@ class Theme extends RestModel {
|
||||
],
|
||||
extra_scss: scss_fields,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"fieldNames",
|
||||
@ -120,7 +118,7 @@ class Theme extends RestModel {
|
||||
});
|
||||
});
|
||||
return hash;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("theme_fields")
|
||||
themeFields(fields) {
|
||||
@ -136,7 +134,7 @@ class Theme extends RestModel {
|
||||
}
|
||||
});
|
||||
return hash;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("theme_fields", "theme_fields.[]")
|
||||
uploads(fields) {
|
||||
@ -146,32 +144,32 @@ class Theme extends RestModel {
|
||||
return fields.filter(
|
||||
(f) => f.target === "common" && f.type_id === THEME_UPLOAD_VAR
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("theme_fields", "theme_fields.@each.error")
|
||||
isBroken(fields) {
|
||||
return (
|
||||
fields && fields.any((field) => field.error && field.error.length > 0)
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("theme_fields.[]")
|
||||
editedFields(fields) {
|
||||
return fields.filter(
|
||||
(field) => !isBlank(field.value) && field.type_id !== SETTINGS_TYPE_ID
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("remote_theme.last_error_text")
|
||||
remoteError(errorText) {
|
||||
if (errorText && errorText.length > 0) {
|
||||
return errorText;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getKey(field) {
|
||||
return `${field.target} ${field.name}`;
|
||||
}
|
||||
},
|
||||
|
||||
hasEdited(target, name) {
|
||||
if (name) {
|
||||
@ -182,27 +180,27 @@ class Theme extends RestModel {
|
||||
(field) => field.target === target && !isEmpty(field.value)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
hasError(target, name) {
|
||||
return this.theme_fields
|
||||
.filter((f) => f.target === target && (!name || name === f.name))
|
||||
.any((f) => f.error);
|
||||
}
|
||||
},
|
||||
|
||||
getError(target, name) {
|
||||
let themeFields = this.themeFields;
|
||||
let key = this.getKey({ target, name });
|
||||
let field = themeFields[key];
|
||||
return field ? field.error : "";
|
||||
}
|
||||
},
|
||||
|
||||
getField(target, name) {
|
||||
let themeFields = this.themeFields;
|
||||
let key = this.getKey({ target, name });
|
||||
let field = themeFields[key];
|
||||
return field ? field.value : "";
|
||||
}
|
||||
},
|
||||
|
||||
removeField(field) {
|
||||
this.set("changed", true);
|
||||
@ -211,7 +209,7 @@ class Theme extends RestModel {
|
||||
field.value = null;
|
||||
|
||||
return this.saveChanges("theme_fields");
|
||||
}
|
||||
},
|
||||
|
||||
setField(target, name, value, upload_id, type_id) {
|
||||
this.set("changed", true);
|
||||
@ -251,25 +249,25 @@ class Theme extends RestModel {
|
||||
this.notifyPropertyChange("theme_fields.[]");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("childThemes.[]")
|
||||
child_theme_ids(childThemes) {
|
||||
if (childThemes) {
|
||||
return childThemes.map((theme) => get(theme, "id"));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("recentlyInstalled", "component", "hasParents")
|
||||
warnUnassignedComponent(recent, component, hasParents) {
|
||||
return recent && component && !hasParents;
|
||||
}
|
||||
},
|
||||
|
||||
removeChildTheme(theme) {
|
||||
const childThemes = this.childThemes;
|
||||
childThemes.removeObject(theme);
|
||||
return this.saveChanges("child_theme_ids");
|
||||
}
|
||||
},
|
||||
|
||||
addChildTheme(theme) {
|
||||
let childThemes = this.childThemes;
|
||||
@ -280,7 +278,7 @@ class Theme extends RestModel {
|
||||
childThemes.removeObject(theme);
|
||||
childThemes.pushObject(theme);
|
||||
return this.saveChanges("child_theme_ids");
|
||||
}
|
||||
},
|
||||
|
||||
addParentTheme(theme) {
|
||||
let parentThemes = this.parentThemes;
|
||||
@ -289,36 +287,38 @@ class Theme extends RestModel {
|
||||
this.set("parentThemes", parentThemes);
|
||||
}
|
||||
parentThemes.addObject(theme);
|
||||
}
|
||||
},
|
||||
|
||||
checkForUpdates() {
|
||||
return this.save({ remote_check: true }).then(() =>
|
||||
this.set("changed", false)
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
updateToLatest() {
|
||||
return this.save({ remote_update: true }).then(() =>
|
||||
this.set("changed", false)
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
changed: false,
|
||||
|
||||
saveChanges() {
|
||||
const hash = this.getProperties.apply(this, arguments);
|
||||
return this.save(hash)
|
||||
.finally(() => this.set("changed", false))
|
||||
.catch(popupAjaxError);
|
||||
}
|
||||
},
|
||||
|
||||
saveSettings(name, value) {
|
||||
const settings = {};
|
||||
settings[name] = value;
|
||||
return this.save({ settings });
|
||||
}
|
||||
},
|
||||
|
||||
saveTranslation(name, value) {
|
||||
return this.save({ translations: { [name]: value } });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default Theme;
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
import EmberObject from "@ember/object";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
|
||||
export default class Tl3Requirements extends EmberObject {
|
||||
export default EmberObject.extend({
|
||||
@discourseComputed("days_visited", "time_period")
|
||||
days_visited_percent(daysVisited, timePeriod) {
|
||||
return Math.round((daysVisited * 100) / timePeriod);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("min_days_visited", "time_period")
|
||||
min_days_visited_percent(minDaysVisited, timePeriod) {
|
||||
return Math.round((minDaysVisited * 100) / timePeriod);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("num_topics_replied_to", "min_topics_replied_to")
|
||||
capped_topics_replied_to(numReplied, minReplied) {
|
||||
return numReplied > minReplied;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"days_visited",
|
||||
@ -71,5 +71,5 @@ export default class Tl3Requirements extends EmberObject {
|
||||
silenced: this.get("penalty_counts.silenced") === 0,
|
||||
suspended: this.get("penalty_counts.suspended") === 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -2,8 +2,14 @@ import EmberObject from "@ember/object";
|
||||
import RestModel from "discourse/models/rest";
|
||||
import { i18n } from "discourse/lib/computed";
|
||||
|
||||
export default class UserField extends RestModel {
|
||||
static fieldTypes() {
|
||||
const UserField = RestModel.extend();
|
||||
|
||||
const UserFieldType = EmberObject.extend({
|
||||
name: i18n("id", "admin.user_fields.field_types.%@"),
|
||||
});
|
||||
|
||||
UserField.reopenClass({
|
||||
fieldTypes() {
|
||||
if (!this._fieldTypes) {
|
||||
this._fieldTypes = [
|
||||
UserFieldType.create({ id: "text" }),
|
||||
@ -14,13 +20,11 @@ export default class UserField extends RestModel {
|
||||
}
|
||||
|
||||
return this._fieldTypes;
|
||||
}
|
||||
},
|
||||
|
||||
static fieldTypeById(id) {
|
||||
fieldTypeById(id) {
|
||||
return this.fieldTypes().findBy("id", id);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
class UserFieldType extends EmberObject {
|
||||
@i18n("id", "admin.user_fields.field_types.%@") name;
|
||||
}
|
||||
export default UserField;
|
||||
|
||||
@ -2,39 +2,43 @@ import EmberObject from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
|
||||
export default class VersionCheck extends EmberObject {
|
||||
static find() {
|
||||
return ajax("/admin/version_check").then((json) =>
|
||||
VersionCheck.create(json)
|
||||
);
|
||||
}
|
||||
|
||||
const VersionCheck = EmberObject.extend({
|
||||
@discourseComputed("updated_at")
|
||||
noCheckPerformed(updatedAt) {
|
||||
return updatedAt === null;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("missing_versions_count")
|
||||
upToDate(missingVersionsCount) {
|
||||
return missingVersionsCount === 0 || missingVersionsCount === null;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("missing_versions_count")
|
||||
behindByOneVersion(missingVersionsCount) {
|
||||
return missingVersionsCount === 1;
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("installed_sha")
|
||||
gitLink(installedSHA) {
|
||||
if (installedSHA) {
|
||||
return `https://github.com/discourse/discourse/commits/${installedSHA}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("installed_sha")
|
||||
shortSha(installedSHA) {
|
||||
if (installedSHA) {
|
||||
return installedSHA.slice(0, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
VersionCheck.reopenClass({
|
||||
find() {
|
||||
return ajax("/admin/version_check").then((json) =>
|
||||
VersionCheck.create(json)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default VersionCheck;
|
||||
|
||||
@ -2,8 +2,34 @@ import EmberObject from "@ember/object";
|
||||
import I18n from "I18n";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
export default class WatchedWord extends EmberObject {
|
||||
static findAll() {
|
||||
const WatchedWord = EmberObject.extend({
|
||||
save() {
|
||||
return ajax(
|
||||
"/admin/customize/watched_words" +
|
||||
(this.id ? "/" + this.id : "") +
|
||||
".json",
|
||||
{
|
||||
type: this.id ? "PUT" : "POST",
|
||||
data: {
|
||||
word: this.word,
|
||||
replacement: this.replacement,
|
||||
action_key: this.action,
|
||||
case_sensitive: this.isCaseSensitive,
|
||||
},
|
||||
dataType: "json",
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
return ajax("/admin/customize/watched_words/" + this.id + ".json", {
|
||||
type: "DELETE",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
WatchedWord.reopenClass({
|
||||
findAll() {
|
||||
return ajax("/admin/customize/watched_words.json").then((list) => {
|
||||
const actions = {};
|
||||
|
||||
@ -24,29 +50,7 @@ export default class WatchedWord extends EmberObject {
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
save() {
|
||||
return ajax(
|
||||
"/admin/customize/watched_words" +
|
||||
(this.id ? "/" + this.id : "") +
|
||||
".json",
|
||||
{
|
||||
type: this.id ? "PUT" : "POST",
|
||||
data: {
|
||||
word: this.word,
|
||||
replacement: this.replacement,
|
||||
action_key: this.action,
|
||||
case_sensitive: this.isCaseSensitive,
|
||||
},
|
||||
dataType: "json",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
return ajax("/admin/customize/watched_words/" + this.id + ".json", {
|
||||
type: "DELETE",
|
||||
});
|
||||
}
|
||||
}
|
||||
export default WatchedWord;
|
||||
|
||||
@ -1,34 +1,33 @@
|
||||
import { computed } from "@ember/object";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import { observes } from "@ember-decorators/object";
|
||||
import discourseComputed, { observes } from "discourse-common/utils/decorators";
|
||||
import Category from "discourse/models/category";
|
||||
import Group from "discourse/models/group";
|
||||
import RestModel from "discourse/models/rest";
|
||||
import Site from "discourse/models/site";
|
||||
import { isEmpty } from "@ember/utils";
|
||||
|
||||
export default class WebHook extends RestModel {
|
||||
content_type = 1; // json
|
||||
last_delivery_status = 1; // inactive
|
||||
wildcard_web_hook = false;
|
||||
verify_certificate = true;
|
||||
active = false;
|
||||
web_hook_event_types = null;
|
||||
groupsFilterInName = null;
|
||||
export default RestModel.extend({
|
||||
content_type: 1, // json
|
||||
last_delivery_status: 1, // inactive
|
||||
wildcard_web_hook: false,
|
||||
verify_certificate: true,
|
||||
active: false,
|
||||
web_hook_event_types: null,
|
||||
groupsFilterInName: null,
|
||||
|
||||
@computed("wildcard_web_hook")
|
||||
get wildcard() {
|
||||
return this.wildcard_web_hook ? "wildcard" : "individual";
|
||||
}
|
||||
|
||||
set wildcard(value) {
|
||||
this.set("wildcard_web_hook", value === "wildcard");
|
||||
}
|
||||
@discourseComputed("wildcard_web_hook")
|
||||
webhookType: {
|
||||
get(wildcard) {
|
||||
return wildcard ? "wildcard" : "individual";
|
||||
},
|
||||
set(value) {
|
||||
this.set("wildcard_web_hook", value === "wildcard");
|
||||
},
|
||||
},
|
||||
|
||||
@discourseComputed("category_ids")
|
||||
categories(categoryIds) {
|
||||
return Category.findByIds(categoryIds);
|
||||
}
|
||||
},
|
||||
|
||||
@observes("group_ids")
|
||||
updateGroupsFilter() {
|
||||
@ -42,11 +41,11 @@ export default class WebHook extends RestModel {
|
||||
return groupNames;
|
||||
}, [])
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
groupFinder(term) {
|
||||
return Group.findAll({ term, ignore_automatic: false });
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("wildcard_web_hook", "web_hook_event_types.[]")
|
||||
description(isWildcardWebHook, types) {
|
||||
@ -58,7 +57,7 @@ export default class WebHook extends RestModel {
|
||||
});
|
||||
|
||||
return isWildcardWebHook ? "*" : desc;
|
||||
}
|
||||
},
|
||||
|
||||
createProperties() {
|
||||
const types = this.web_hook_event_types;
|
||||
@ -93,9 +92,9 @@ export default class WebHook extends RestModel {
|
||||
return groupIds;
|
||||
}, []),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
updateProperties() {
|
||||
return this.createProperties();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -10,8 +10,8 @@ import { htmlSafe } from "@ember/template";
|
||||
// A service that can act as a bridge between the front end Discourse application
|
||||
// and the admin application. Use this if you need front end code to access admin
|
||||
// modules. Inject it optionally, and if it exists go to town!
|
||||
export default class AdminToolsService extends Service {
|
||||
@service dialog;
|
||||
export default Service.extend({
|
||||
dialog: service(),
|
||||
|
||||
showActionLogs(target, filters) {
|
||||
const controller = getOwner(target).lookup(
|
||||
@ -20,15 +20,15 @@ export default class AdminToolsService extends Service {
|
||||
target.transitionToRoute("adminLogs.staffActionLogs").then(() => {
|
||||
controller.changeFilters(filters);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
checkSpammer(userId) {
|
||||
return AdminUser.find(userId).then((au) => this.spammerDetails(au));
|
||||
}
|
||||
},
|
||||
|
||||
deleteUser(id, formData) {
|
||||
return AdminUser.find(id).then((user) => user.destroy(formData));
|
||||
}
|
||||
},
|
||||
|
||||
spammerDetails(adminUser) {
|
||||
return {
|
||||
@ -37,7 +37,7 @@ export default class AdminToolsService extends Service {
|
||||
adminUser.get("can_be_deleted") &&
|
||||
adminUser.get("can_delete_all_posts"),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_showControlModal(type, user, opts) {
|
||||
opts = opts || {};
|
||||
@ -67,15 +67,15 @@ export default class AdminToolsService extends Service {
|
||||
|
||||
controller.finishedSetup();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
showSilenceModal(user, opts) {
|
||||
this._showControlModal("silence", user, opts);
|
||||
}
|
||||
},
|
||||
|
||||
showSuspendModal(user, opts) {
|
||||
this._showControlModal("suspend", user, opts);
|
||||
}
|
||||
},
|
||||
|
||||
_deleteSpammer(adminUser) {
|
||||
// Try loading the email if the site supports it
|
||||
@ -131,5 +131,5 @@ export default class AdminToolsService extends Service {
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -2,7 +2,7 @@ export const POPULAR_THEMES = [
|
||||
{
|
||||
name: "Graceful",
|
||||
value: "https://github.com/discourse/graceful",
|
||||
preview: "https://discourse.theme-creator.io/theme/awesomerobot/graceful",
|
||||
preview: "https://theme-creator.discourse.org/theme/awesomerobot/graceful",
|
||||
description: "A light and graceful theme for Discourse.",
|
||||
meta_url:
|
||||
"https://meta.discourse.org/t/a-graceful-theme-for-discourse/93040",
|
||||
@ -10,7 +10,8 @@ export const POPULAR_THEMES = [
|
||||
{
|
||||
name: "Material Design Theme",
|
||||
value: "https://github.com/discourse/material-design-stock-theme",
|
||||
preview: "https://discourse.theme-creator.io/theme/tshenry/material-design",
|
||||
preview:
|
||||
"https://theme-creator.discourse.org/theme/tshenry/material-design",
|
||||
description:
|
||||
"Inspired by Material Design, this theme comes with several color palettes (incl. a dark one).",
|
||||
meta_url: "https://meta.discourse.org/t/material-design-stock-theme/47142",
|
||||
@ -18,7 +19,7 @@ export const POPULAR_THEMES = [
|
||||
{
|
||||
name: "Minima",
|
||||
value: "https://github.com/discourse/minima",
|
||||
preview: "https://discourse.theme-creator.io/theme/awesomerobot/minima",
|
||||
preview: "https://theme-creator.discourse.org/theme/awesomerobot/minima",
|
||||
description: "A minimal theme with reduced UI elements and focus on text.",
|
||||
meta_url:
|
||||
"https://meta.discourse.org/t/minima-a-minimal-theme-for-discourse/108178",
|
||||
@ -26,7 +27,7 @@ export const POPULAR_THEMES = [
|
||||
{
|
||||
name: "Sam's Simple Theme",
|
||||
value: "https://github.com/discourse/discourse-simple-theme",
|
||||
preview: "https://discourse.theme-creator.io/theme/sam/simple",
|
||||
preview: "https://theme-creator.discourse.org/theme/sam/simple",
|
||||
description:
|
||||
"Simplified front page design with classic colors and typography.",
|
||||
meta_url:
|
||||
@ -35,8 +36,6 @@ export const POPULAR_THEMES = [
|
||||
{
|
||||
name: "Brand Header",
|
||||
value: "https://github.com/discourse/discourse-brand-header",
|
||||
preview:
|
||||
"https://discourse.theme-creator.io/theme/vinothkannans/brand-header",
|
||||
description:
|
||||
"Add an extra top header with your logo, navigation links and social icons.",
|
||||
meta_url: "https://meta.discourse.org/t/brand-header-theme-component/77977",
|
||||
@ -46,7 +45,7 @@ export const POPULAR_THEMES = [
|
||||
name: "Custom Header Links",
|
||||
value: "https://github.com/discourse/discourse-custom-header-links",
|
||||
preview:
|
||||
"https://discourse.theme-creator.io/theme/awesomerobot/custom-header-links",
|
||||
"https://theme-creator.discourse.org/theme/Johani/custom-header-links",
|
||||
description: "Easily add custom text-based links to the header.",
|
||||
meta_url: "https://meta.discourse.org/t/custom-header-links/90588",
|
||||
component: true,
|
||||
@ -62,7 +61,7 @@ export const POPULAR_THEMES = [
|
||||
name: "Category Banners",
|
||||
value: "https://github.com/discourse/discourse-category-banners",
|
||||
preview:
|
||||
"https://discourse.theme-creator.io/theme/awesomerobot/discourse-category-banners",
|
||||
"https://theme-creator.discourse.org/theme/awesomerobot/discourse-category-banners",
|
||||
description:
|
||||
"Show banners on category pages using your existing category details.",
|
||||
meta_url: "https://meta.discourse.org/t/discourse-category-banners/86241",
|
||||
@ -71,7 +70,7 @@ export const POPULAR_THEMES = [
|
||||
{
|
||||
name: "Kanban Board",
|
||||
value: "https://github.com/discourse/discourse-kanban-theme",
|
||||
preview: "https://discourse.theme-creator.io/theme/david/kanban",
|
||||
preview: "https://theme-creator.discourse.org/theme/david/kanban",
|
||||
description: "Display and organize topics using a Kanban board interface.",
|
||||
meta_url:
|
||||
"https://meta.discourse.org/t/kanban-board-theme-component/118164",
|
||||
@ -85,19 +84,10 @@ export const POPULAR_THEMES = [
|
||||
meta_url: "https://meta.discourse.org/t/hamburger-theme-selector/61210",
|
||||
component: true,
|
||||
},
|
||||
{
|
||||
name: "Sidebar Theme Toggle",
|
||||
value: "https://github.com/discourse/discourse-sidebar-theme-toggle",
|
||||
description:
|
||||
"Displays a theme selector in the sidebar menu’s footer provided there is more than one user-selectable theme.",
|
||||
meta_url: "https://meta.discourse.org/t/sidebar-theme-toggle/242802",
|
||||
component: true,
|
||||
},
|
||||
{
|
||||
name: "Header Submenus",
|
||||
value: "https://github.com/discourse/discourse-header-submenus",
|
||||
preview:
|
||||
"https://discourse.theme-creator.io/theme/awesomerobot/header-submenus",
|
||||
preview: "https://theme-creator.discourse.org/theme/Johani/header-submenus",
|
||||
description: "Lets you build a header menu with submenus (dropdowns).",
|
||||
meta_url: "https://meta.discourse.org/t/header-submenus/94584",
|
||||
component: true,
|
||||
@ -114,7 +104,7 @@ export const POPULAR_THEMES = [
|
||||
{
|
||||
name: "Easy Responsive Footer",
|
||||
value: "https://github.com/discourse/Discourse-easy-footer",
|
||||
preview: "https://discourse.theme-creator.io/theme/Johani/easy-footer",
|
||||
preview: "https://theme-creator.discourse.org/theme/Johani/easy-footer",
|
||||
description: "Add a fully responsive footer without writing any HTML.",
|
||||
meta_url: "https://meta.discourse.org/t/easy-responsive-footer/95818",
|
||||
component: true,
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
name: require("./package").name,
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "discourse-ensure-deprecation-order",
|
||||
"version": "1.0.0",
|
||||
"description": "A dummy addon which ensures ember-cli-deprecation-workflow is loaded before @ember/jquery",
|
||||
"author": "Discourse",
|
||||
"license": "GPL-2.0-only",
|
||||
"keywords": [
|
||||
"ember-addon"
|
||||
],
|
||||
"ember-addon": {
|
||||
"before": "@ember/jquery",
|
||||
"after": "ember-cli-deprecation-workflow"
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { TextArea } from "@ember/legacy-built-in-components";
|
||||
import TextArea from "@ember/component/text-area";
|
||||
|
||||
export default TextArea.extend({
|
||||
attributeBindings: ["aria-label"],
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { observes, on } from "discourse-common/utils/decorators";
|
||||
import { TextArea } from "@ember/legacy-built-in-components";
|
||||
import TextArea from "@ember/component/text-area";
|
||||
import autosize from "discourse/lib/autosize";
|
||||
import { schedule } from "@ember/runloop";
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { cancel, next } from "@ember/runloop";
|
||||
import { isLTR, isRTL, siteDir } from "discourse/lib/text-direction";
|
||||
import I18n from "I18n";
|
||||
import { TextField } from "@ember/legacy-built-in-components";
|
||||
import TextField from "@ember/component/text-field";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
import TextField from "@ember/component/text-field";
|
||||
import TextArea from "@ember/component/text-area";
|
||||
let initializedOnce = false;
|
||||
|
||||
export default {
|
||||
name: "ember-input-component-extensions",
|
||||
|
||||
initialize() {
|
||||
if (initializedOnce) {
|
||||
return;
|
||||
}
|
||||
|
||||
TextField.reopen({
|
||||
attributeBindings: ["aria-describedby", "aria-invalid"],
|
||||
});
|
||||
TextArea.reopen({
|
||||
attributeBindings: ["aria-describedby", "aria-invalid"],
|
||||
});
|
||||
|
||||
initializedOnce = true;
|
||||
},
|
||||
};
|
||||
@ -2,7 +2,6 @@
|
||||
import Component from "@ember/component";
|
||||
import EmberObject from "@ember/object";
|
||||
import { actionModifier } from "./ember-action-modifier";
|
||||
import Ember from "ember";
|
||||
|
||||
/**
|
||||
* Classic Ember components (i.e. "@ember/component") rely upon "event
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
import { assert, deprecate } from "@ember/debug";
|
||||
import EmberObject from "@ember/object";
|
||||
import Component from "@ember/component";
|
||||
import jQuery from "jquery";
|
||||
|
||||
let done = false;
|
||||
|
||||
// Adapted from https://github.com/emberjs/ember-jquery/blob/master/vendor/jquery/component.dollar.js
|
||||
// but implemented in a module to avoid transpiled version triggering the Ember Global deprecation.
|
||||
// To be dropped when we remove the jquery integration as part of the 4.x update.
|
||||
export default {
|
||||
name: "deprecate-jquery-integration",
|
||||
|
||||
initialize() {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
EmberObject.reopen.call(Component, {
|
||||
$(sel) {
|
||||
assert(
|
||||
"You cannot access this.$() on a component with `tagName: ''` specified.",
|
||||
this.tagName !== ""
|
||||
);
|
||||
|
||||
deprecate(
|
||||
"Using this.$() in a component has been deprecated, consider using this.element",
|
||||
false,
|
||||
{
|
||||
id: "ember-views.curly-components.jquery-element",
|
||||
since: "3.4.0",
|
||||
until: "4.0.0",
|
||||
url: "https://emberjs.com/deprecations/v3.x#toc_jquery-apis",
|
||||
for: "ember-source",
|
||||
}
|
||||
);
|
||||
|
||||
if (this.element) {
|
||||
return sel ? jQuery(sel, this.element) : jQuery(this.element);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
done = true;
|
||||
},
|
||||
};
|
||||
@ -94,8 +94,8 @@
|
||||
@value={{this.accountName}}
|
||||
@id="new-account-name"
|
||||
@class={{value-entered this.accountName}}
|
||||
aria-describedby="fullname-validation"
|
||||
aria-invalid={{this.nameValidation.failed}}
|
||||
@aria-describedby="fullname-validation"
|
||||
@aria-invalid={{this.nameValidation.failed}}
|
||||
/>
|
||||
<label class="alt-placeholder" for="new-account-name">
|
||||
{{i18n "user.name.title"}}
|
||||
@ -134,8 +134,8 @@
|
||||
id="new-account-password"
|
||||
@autocomplete="current-password"
|
||||
@capsLockOn={{this.capsLockOn}}
|
||||
aria-describedby="password-validation"
|
||||
aria-invalid={{this.passwordValidation.failed}}
|
||||
@aria-describedby="password-validation"
|
||||
@aria-invalid={{this.passwordValidation.failed}}
|
||||
/>
|
||||
<label class="alt-placeholder" for="new-account-password">
|
||||
{{i18n "user.password.title"}}
|
||||
|
||||
@ -3,6 +3,9 @@ globalThis.deprecationWorkflow.config = {
|
||||
// We're using RAISE_ON_DEPRECATION in environment.js instead of
|
||||
// `throwOnUnhandled` here since it is easier to toggle.
|
||||
workflow: [
|
||||
{ handler: "silence", matchId: "ember-global" },
|
||||
{ handler: "silence", matchId: "ember.built-in-components.reopen" },
|
||||
{ handler: "silence", matchId: "ember.built-in-components.import" },
|
||||
{ handler: "silence", matchId: "implicit-injections" },
|
||||
{ handler: "silence", matchId: "route-render-template" },
|
||||
{ handler: "silence", matchId: "routing.transition-methods" },
|
||||
|
||||
@ -163,16 +163,6 @@ module.exports = function (defaults) {
|
||||
}
|
||||
};
|
||||
|
||||
// @ember/jquery introduces a shim which triggers the ember-global deprecation.
|
||||
// We remove that shim, and re-implement ourselves in the deprecate-jquery-integration pre-initializer
|
||||
const vendorScripts = app._scriptOutputFiles["/assets/vendor.js"];
|
||||
const componentDollarShimIndex = vendorScripts.indexOf(
|
||||
"vendor/jquery/component.dollar.js"
|
||||
);
|
||||
if (componentDollarShimIndex) {
|
||||
vendorScripts.splice(componentDollarShimIndex, 1);
|
||||
}
|
||||
|
||||
// WARNING: We should only import scripts here if they are not in NPM.
|
||||
// For example: our very specific version of bootstrap-modal.
|
||||
app.import(vendorJs + "bootbox.js");
|
||||
|
||||
@ -23,7 +23,6 @@
|
||||
"@discourse/virtual-dom": "^2.1.2-0",
|
||||
"@ember-compat/tracked-built-ins": "^0.9.1",
|
||||
"@ember/jquery": "^2.0.0",
|
||||
"@ember/legacy-built-in-components": "^0.4.2",
|
||||
"@ember/optional-features": "^2.0.0",
|
||||
"@ember/render-modifiers": "^2.0.5",
|
||||
"@ember/test-helpers": "^2.9.3",
|
||||
@ -47,6 +46,7 @@
|
||||
"deepmerge": "^4.3.0",
|
||||
"dialog-holder": "1.0.0",
|
||||
"discourse-common": "1.0.0",
|
||||
"discourse-ensure-deprecation-order": "1.0.0",
|
||||
"discourse-hbr": "1.0.0",
|
||||
"discourse-plugins": "1.0.0",
|
||||
"discourse-widget-hbs": "1.0.0",
|
||||
|
||||
@ -3,13 +3,6 @@
|
||||
throw "Unsupported browser detected";
|
||||
}
|
||||
|
||||
// In Ember 3.28, the `ember` package is responsible for configuring `Helper.helper`,
|
||||
// so we need to require('ember') before setting up any helpers.
|
||||
// https://github.com/emberjs/ember.js/blob/744e536d37/packages/ember/index.js#L493-L493
|
||||
// In modern Ember, the Helper.helper definition has moved to the helper module itself
|
||||
// https://github.com/emberjs/ember.js/blob/0c5518ea7b/packages/%40ember/-internals/glimmer/lib/helper.ts#L134-L138
|
||||
require("ember");
|
||||
|
||||
window.__widget_helpers = require("discourse-widget-hbs/helpers").default;
|
||||
|
||||
// TODO: Eliminate this global
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"dialog-holder",
|
||||
"discourse",
|
||||
"discourse-common",
|
||||
"discourse-ensure-deprecation-order",
|
||||
"discourse-hbr",
|
||||
"discourse-plugins",
|
||||
"discourse-widget-hbs",
|
||||
|
||||
@ -1088,16 +1088,6 @@
|
||||
jquery "^3.5.0"
|
||||
resolve "^1.15.1"
|
||||
|
||||
"@ember/legacy-built-in-components@^0.4.2":
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@ember/legacy-built-in-components/-/legacy-built-in-components-0.4.2.tgz#79a97d66153ff17909759b368b2a117bc9e168e5"
|
||||
integrity sha512-rJulbyVQIVe1zEDQDqAQHechHy44DsS2qxO24+NmU/AYxwPFSzWC/OZNCDFSfLU+Y5BVd/00qjxF0pu7Nk+TNA==
|
||||
dependencies:
|
||||
"@embroider/macros" "^1.0.0"
|
||||
ember-cli-babel "^7.26.6"
|
||||
ember-cli-htmlbars "^5.7.1"
|
||||
ember-cli-typescript "^4.1.0"
|
||||
|
||||
"@ember/optional-features@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@ember/optional-features/-/optional-features-2.0.0.tgz#c809abd5a27d5b0ef3c6de3941334ab6153313f0"
|
||||
@ -4009,22 +3999,6 @@ ember-cli-typescript@^2.0.2:
|
||||
stagehand "^1.0.0"
|
||||
walk-sync "^1.0.0"
|
||||
|
||||
ember-cli-typescript@^4.1.0:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-typescript/-/ember-cli-typescript-4.2.1.tgz#54d08fc90318cc986f3ea562f93ce58a6cc4c24d"
|
||||
integrity sha512-0iKTZ+/wH6UB/VTWKvGuXlmwiE8HSIGcxHamwNhEC5x1mN3z8RfvsFZdQWYUzIWFN2Tek0gmepGRPTwWdBYl/A==
|
||||
dependencies:
|
||||
ansi-to-html "^0.6.15"
|
||||
broccoli-stew "^3.0.0"
|
||||
debug "^4.0.0"
|
||||
execa "^4.0.0"
|
||||
fs-extra "^9.0.1"
|
||||
resolve "^1.5.0"
|
||||
rsvp "^4.8.1"
|
||||
semver "^7.3.2"
|
||||
stagehand "^1.0.0"
|
||||
walk-sync "^2.2.0"
|
||||
|
||||
ember-cli-typescript@^5.0.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-typescript/-/ember-cli-typescript-5.1.0.tgz#460eb848564e29d64f2b36b2a75bbe98172b72a4"
|
||||
|
||||
@ -32,14 +32,20 @@ class ApplicationRequest < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def self.write_cache!(req_type, count, date)
|
||||
id = req_id(date, req_type)
|
||||
where(id: id).update_all(["count = count + ?", count])
|
||||
end
|
||||
|
||||
def self.req_id(date, req_type, retries = 0)
|
||||
req_type_id = req_types[req_type]
|
||||
|
||||
DB.exec(<<~SQL, date: date, req_type_id: req_type_id, count: count)
|
||||
INSERT INTO application_requests (date, req_type, count)
|
||||
VALUES (:date, :req_type_id, :count)
|
||||
ON CONFLICT (date, req_type)
|
||||
DO UPDATE SET count = application_requests.count + excluded.count
|
||||
SQL
|
||||
create_or_find_by!(date: date, req_type: req_type_id).id
|
||||
rescue StandardError # primary key violation
|
||||
if retries == 0
|
||||
req_id(date, req_type, 1)
|
||||
else
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
def self.stats
|
||||
|
||||
@ -46,8 +46,7 @@ class Bookmark < ActiveRecord::Base
|
||||
validates :name, length: { maximum: 100 }
|
||||
|
||||
def registered_bookmarkable
|
||||
type = Bookmark.polymorphic_class_for(self.bookmarkable_type).name
|
||||
Bookmark.registered_bookmarkable_from_type(type)
|
||||
Bookmark.registered_bookmarkable_from_type(self.bookmarkable_type)
|
||||
end
|
||||
|
||||
def polymorphic_columns_present
|
||||
|
||||
@ -129,7 +129,7 @@ class Reviewable < ActiveRecord::Base
|
||||
update_args = {
|
||||
status: statuses[:pending],
|
||||
id: target.id,
|
||||
type: target.class.sti_name,
|
||||
type: target.class.name,
|
||||
potential_spam: potential_spam == true ? true : nil,
|
||||
}
|
||||
|
||||
|
||||
@ -213,10 +213,7 @@ task "docker:test" do
|
||||
@good &&= run_or_fail("bundle exec rspec #{params.join(" ")}".strip)
|
||||
end
|
||||
|
||||
if ENV["RUN_SYSTEM_TESTS"]
|
||||
@good &&= run_or_fail("bin/ember-cli --build")
|
||||
@good &&= run_or_fail("bundle exec rspec spec/system")
|
||||
end
|
||||
@good &&= run_or_fail("bundle exec rspec spec/system".strip) if ENV["RUN_SYSTEM_TESTS"]
|
||||
end
|
||||
|
||||
unless ENV["SKIP_PLUGINS"]
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::AdminIncomingChatWebhooksController < Admin::AdminController
|
||||
requires_plugin Chat::PLUGIN_NAME
|
||||
|
||||
def index
|
||||
render_serialized(
|
||||
{
|
||||
chat_channels: ChatChannel.public_channels,
|
||||
incoming_chat_webhooks: IncomingChatWebhook.includes(:chat_channel).all,
|
||||
},
|
||||
AdminChatIndexSerializer,
|
||||
root: false,
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
params.require(%i[name chat_channel_id])
|
||||
|
||||
chat_channel = ChatChannel.find_by(id: params[:chat_channel_id])
|
||||
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
|
||||
|
||||
webhook = IncomingChatWebhook.new(name: params[:name], chat_channel: chat_channel)
|
||||
if webhook.save
|
||||
render_serialized(webhook, IncomingChatWebhookSerializer, root: false)
|
||||
else
|
||||
render_json_error(webhook)
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
params.require(%i[incoming_chat_webhook_id name chat_channel_id])
|
||||
|
||||
webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id])
|
||||
raise Discourse::NotFound unless webhook
|
||||
|
||||
chat_channel = ChatChannel.find_by(id: params[:chat_channel_id])
|
||||
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
|
||||
|
||||
if webhook.update(
|
||||
name: params[:name],
|
||||
description: params[:description],
|
||||
emoji: params[:emoji],
|
||||
username: params[:username],
|
||||
chat_channel: chat_channel,
|
||||
)
|
||||
render json: success_json
|
||||
else
|
||||
render_json_error(webhook)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
params.require(:incoming_chat_webhook_id)
|
||||
|
||||
webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id])
|
||||
webhook.destroy if webhook
|
||||
render json: success_json
|
||||
end
|
||||
end
|
||||
@ -1,9 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::ChannelThreadsController < Chat::ApiController
|
||||
class Chat::Api::ChatChannelThreadsController < Chat::Api
|
||||
def show
|
||||
with_service(::Chat::LookupThread) do
|
||||
on_success { render_serialized(result.thread, ::Chat::ThreadSerializer, root: "thread") }
|
||||
with_service(Chat::Service::LookupThread) do
|
||||
on_success { render_serialized(result.thread, ChatThreadSerializer, root: "thread") }
|
||||
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }
|
||||
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
|
||||
on_model_not_found(:thread) { raise Discourse::NotFound }
|
||||
@ -1,13 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController
|
||||
class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsController
|
||||
def create
|
||||
existing_archive = channel_from_params.chat_channel_archive
|
||||
|
||||
if existing_archive.present?
|
||||
guardian.ensure_can_change_channel_status!(channel_from_params, :archived)
|
||||
raise Discourse::InvalidAccess if !existing_archive.failed?
|
||||
Chat::ChannelArchiveService.retry_archive_process(chat_channel: channel_from_params)
|
||||
Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: channel_from_params)
|
||||
return render json: success_json
|
||||
end
|
||||
|
||||
@ -20,12 +20,12 @@ class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController
|
||||
end
|
||||
|
||||
begin
|
||||
Chat::ChannelArchiveService.create_archive_process(
|
||||
Chat::ChatChannelArchiveService.create_archive_process(
|
||||
chat_channel: channel_from_params,
|
||||
acting_user: current_user,
|
||||
topic_params: topic_params,
|
||||
)
|
||||
rescue Chat::ChannelArchiveService::ArchiveValidationError => err
|
||||
rescue Chat::ChatChannelArchiveService::ArchiveValidationError => err
|
||||
return render json: failed_json.merge(errors: err.errors), status: 400
|
||||
end
|
||||
|
||||
@ -3,19 +3,19 @@
|
||||
CHANNEL_EDITABLE_PARAMS = %i[name description slug]
|
||||
CATEGORY_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions]
|
||||
|
||||
class Chat::Api::ChannelsController < Chat::ApiController
|
||||
class Chat::Api::ChatChannelsController < Chat::Api
|
||||
def index
|
||||
permitted = params.permit(:filter, :limit, :offset, :status)
|
||||
|
||||
options = { filter: permitted[:filter], limit: (permitted[:limit] || 25).to_i }
|
||||
options[:offset] = permitted[:offset].to_i
|
||||
options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil
|
||||
options[:status] = ChatChannel.statuses[permitted[:status]] ? permitted[:status] : nil
|
||||
|
||||
memberships = Chat::ChannelMembershipManager.all_for_user(current_user)
|
||||
channels = Chat::ChannelFetcher.secured_public_channels(guardian, memberships, options)
|
||||
memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user)
|
||||
channels = Chat::ChatChannelFetcher.secured_public_channels(guardian, memberships, options)
|
||||
serialized_channels =
|
||||
channels.map do |channel|
|
||||
Chat::ChannelSerializer.new(
|
||||
ChatChannelSerializer.new(
|
||||
channel,
|
||||
scope: Guardian.new(current_user),
|
||||
membership: memberships.find { |membership| membership.chat_channel_id == channel.id },
|
||||
@ -29,7 +29,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
|
||||
end
|
||||
|
||||
def destroy
|
||||
with_service Chat::TrashChannel do
|
||||
with_service Chat::Service::TrashChannel do
|
||||
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
|
||||
end
|
||||
end
|
||||
@ -43,7 +43,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
|
||||
raise Discourse::InvalidParameters.new(:name)
|
||||
end
|
||||
|
||||
if Chat::Channel.exists?(
|
||||
if ChatChannel.exists?(
|
||||
chatable_type: "Category",
|
||||
chatable_id: channel_params[:chatable_id],
|
||||
name: channel_params[:name],
|
||||
@ -69,12 +69,12 @@ class Chat::Api::ChannelsController < Chat::ApiController
|
||||
channel.user_chat_channel_memberships.create!(user: current_user, following: true)
|
||||
|
||||
if channel.auto_join_users
|
||||
Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
|
||||
Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
|
||||
end
|
||||
|
||||
render_serialized(
|
||||
channel,
|
||||
Chat::ChannelSerializer,
|
||||
ChatChannelSerializer,
|
||||
membership: channel.membership_for(current_user),
|
||||
root: "channel",
|
||||
)
|
||||
@ -83,7 +83,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
|
||||
def show
|
||||
render_serialized(
|
||||
channel_from_params,
|
||||
Chat::ChannelSerializer,
|
||||
ChatChannelSerializer,
|
||||
membership: channel_from_params.membership_for(current_user),
|
||||
root: "channel",
|
||||
)
|
||||
@ -96,11 +96,11 @@ class Chat::Api::ChannelsController < Chat::ApiController
|
||||
auto_join_limiter(channel_from_params).performed!
|
||||
end
|
||||
|
||||
with_service(Chat::UpdateChannel, **params_to_edit) do
|
||||
with_service(Chat::Service::UpdateChannel, **params_to_edit) do
|
||||
on_success do
|
||||
render_serialized(
|
||||
result.channel,
|
||||
Chat::ChannelSerializer,
|
||||
ChatChannelSerializer,
|
||||
root: "channel",
|
||||
membership: result.channel.membership_for(current_user),
|
||||
)
|
||||
@ -116,7 +116,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
|
||||
def channel_from_params
|
||||
@channel ||=
|
||||
begin
|
||||
channel = Chat::Channel.find(params.require(:channel_id))
|
||||
channel = ChatChannel.find(params.require(:channel_id))
|
||||
guardian.ensure_can_preview_chat_channel!(channel)
|
||||
channel
|
||||
end
|
||||
@ -126,7 +126,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
|
||||
@membership ||=
|
||||
begin
|
||||
membership =
|
||||
Chat::ChannelMembershipManager.new(channel_from_params).find_for_user(current_user)
|
||||
Chat::ChatChannelMembershipManager.new(channel_from_params).find_for_user(current_user)
|
||||
raise Discourse::NotFound if membership.blank?
|
||||
membership
|
||||
end
|
||||
@ -1,12 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsController
|
||||
class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatChannelsController
|
||||
def create
|
||||
guardian.ensure_can_join_chat_channel!(channel_from_params)
|
||||
|
||||
render_serialized(
|
||||
channel_from_params.add(current_user),
|
||||
Chat::UserChannelMembershipSerializer,
|
||||
UserChatChannelMembershipSerializer,
|
||||
root: "membership",
|
||||
)
|
||||
end
|
||||
@ -14,7 +14,7 @@ class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsCo
|
||||
def destroy
|
||||
render_serialized(
|
||||
channel_from_params.remove(current_user),
|
||||
Chat::UserChannelMembershipSerializer,
|
||||
UserChatChannelMembershipSerializer,
|
||||
root: "membership",
|
||||
)
|
||||
end
|
||||
@ -2,13 +2,13 @@
|
||||
|
||||
MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level]
|
||||
|
||||
class Chat::Api::ChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChannelsController
|
||||
class Chat::Api::ChatChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChatChannelsController
|
||||
def update
|
||||
settings_params = params.require(:notifications_settings).permit(MEMBERSHIP_EDITABLE_PARAMS)
|
||||
membership_from_params.update!(settings_params.to_h)
|
||||
render_serialized(
|
||||
membership_from_params,
|
||||
Chat::UserChannelMembershipSerializer,
|
||||
UserChatChannelMembershipSerializer,
|
||||
root: "membership",
|
||||
)
|
||||
end
|
||||
@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController
|
||||
class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsController
|
||||
def index
|
||||
params.permit(:username, :offset, :limit)
|
||||
|
||||
@ -8,7 +8,7 @@ class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController
|
||||
limit = (params[:limit] || 50).to_i.clamp(1, 50)
|
||||
|
||||
memberships =
|
||||
Chat::ChannelMembershipsQuery.call(
|
||||
ChatChannelMembershipsQuery.call(
|
||||
channel: channel_from_params,
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
@ -17,7 +17,7 @@ class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController
|
||||
|
||||
render_serialized(
|
||||
memberships,
|
||||
Chat::UserChannelMembershipSerializer,
|
||||
UserChatChannelMembershipSerializer,
|
||||
root: "memberships",
|
||||
meta: {
|
||||
total_rows: channel_from_params.user_count,
|
||||
@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController
|
||||
class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsController
|
||||
def create
|
||||
move_params = params.require(:move)
|
||||
move_params.require(:message_ids)
|
||||
@ -8,7 +8,10 @@ class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController
|
||||
|
||||
raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(channel_from_params)
|
||||
destination_channel =
|
||||
Chat::ChannelFetcher.find_with_access_check(move_params[:destination_channel_id], guardian)
|
||||
Chat::ChatChannelFetcher.find_with_access_check(
|
||||
move_params[:destination_channel_id],
|
||||
guardian,
|
||||
)
|
||||
|
||||
begin
|
||||
message_ids = move_params[:message_ids].map(&:to_i)
|
||||
@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::ChatChannelsStatusController < Chat::Api::ChatChannelsController
|
||||
def update
|
||||
with_service(Chat::Service::UpdateChannelStatus) do
|
||||
on_success { render_serialized(result.channel, ChatChannelSerializer, root: "channel") }
|
||||
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
|
||||
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,14 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::ChatablesController < Chat::ApiController
|
||||
class Chat::Api::ChatChatablesController < Chat::Api
|
||||
def index
|
||||
params.require(:filter)
|
||||
filter = params[:filter].downcase
|
||||
|
||||
memberships = Chat::ChannelMembershipManager.all_for_user(current_user)
|
||||
|
||||
memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user)
|
||||
public_channels =
|
||||
Chat::ChannelFetcher.secured_public_channels(
|
||||
Chat::ChatChannelFetcher.secured_public_channels(
|
||||
guardian,
|
||||
memberships,
|
||||
filter: filter,
|
||||
@ -42,7 +41,7 @@ class Chat::Api::ChatablesController < Chat::ApiController
|
||||
direct_message_channels =
|
||||
if users.count > 0
|
||||
# FIXME: investigate the cost of this query
|
||||
Chat::Channel
|
||||
ChatChannel
|
||||
.includes(chatable: :users)
|
||||
.joins(direct_message: :direct_message_users)
|
||||
.group(1)
|
||||
@ -76,7 +75,7 @@ class Chat::Api::ChatablesController < Chat::ApiController
|
||||
users: users_without_channel,
|
||||
memberships: memberships,
|
||||
},
|
||||
Chat::ChannelSearchSerializer,
|
||||
ChatChannelSearchSerializer,
|
||||
root: false,
|
||||
)
|
||||
end
|
||||
@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::ChatCurrentUserChannelsController < Chat::Api
|
||||
def index
|
||||
structured = Chat::ChatChannelFetcher.structured(guardian)
|
||||
render_serialized(structured, ChatChannelIndexSerializer, root: false)
|
||||
end
|
||||
end
|
||||
29
plugins/chat/app/controllers/api_controller.rb
Normal file
29
plugins/chat/app/controllers/api_controller.rb
Normal file
@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api < Chat::ChatBaseController
|
||||
before_action :ensure_logged_in
|
||||
before_action :ensure_can_chat
|
||||
|
||||
include Chat::WithServiceHelper
|
||||
|
||||
private
|
||||
|
||||
def ensure_can_chat
|
||||
raise Discourse::NotFound unless SiteSetting.chat_enabled
|
||||
guardian.ensure_can_chat!
|
||||
end
|
||||
|
||||
def default_actions_for_service
|
||||
proc do
|
||||
on_success { render(json: success_json) }
|
||||
on_failure { render(json: failed_json, status: 422) }
|
||||
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
|
||||
on_failed_contract do
|
||||
render(
|
||||
json: failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages),
|
||||
status: 400,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,64 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
module Admin
|
||||
class IncomingWebhooksController < ::Admin::AdminController
|
||||
requires_plugin Chat::PLUGIN_NAME
|
||||
|
||||
def index
|
||||
render_serialized(
|
||||
{
|
||||
chat_channels: Chat::Channel.public_channels,
|
||||
incoming_chat_webhooks: Chat::IncomingWebhook.includes(:chat_channel).all,
|
||||
},
|
||||
Chat::AdminChatIndexSerializer,
|
||||
root: false,
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
params.require(%i[name chat_channel_id])
|
||||
|
||||
chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id])
|
||||
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
|
||||
|
||||
webhook = Chat::IncomingWebhook.new(name: params[:name], chat_channel: chat_channel)
|
||||
if webhook.save
|
||||
render_serialized(webhook, Chat::IncomingWebhookSerializer, root: false)
|
||||
else
|
||||
render_json_error(webhook)
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
params.require(%i[incoming_chat_webhook_id name chat_channel_id])
|
||||
|
||||
webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id])
|
||||
raise Discourse::NotFound unless webhook
|
||||
|
||||
chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id])
|
||||
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
|
||||
|
||||
if webhook.update(
|
||||
name: params[:name],
|
||||
description: params[:description],
|
||||
emoji: params[:emoji],
|
||||
username: params[:username],
|
||||
chat_channel: chat_channel,
|
||||
)
|
||||
render json: success_json
|
||||
else
|
||||
render_json_error(webhook)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
params.require(:incoming_chat_webhook_id)
|
||||
|
||||
webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id])
|
||||
webhook.destroy if webhook
|
||||
render json: success_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,11 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::ChannelsStatusController < Chat::Api::ChannelsController
|
||||
def update
|
||||
with_service(Chat::UpdateChannelStatus) do
|
||||
on_success { render_serialized(result.channel, Chat::ChannelSerializer, root: "channel") }
|
||||
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
|
||||
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,8 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::CurrentUserChannelsController < Chat::ApiController
|
||||
def index
|
||||
structured = Chat::ChannelFetcher.structured(guardian)
|
||||
render_serialized(structured, Chat::ChannelIndexSerializer, root: false)
|
||||
end
|
||||
end
|
||||
@ -1,32 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class ApiController < ::Chat::BaseController
|
||||
before_action :ensure_logged_in
|
||||
before_action :ensure_can_chat
|
||||
|
||||
include Chat::WithServiceHelper
|
||||
|
||||
private
|
||||
|
||||
def ensure_can_chat
|
||||
raise Discourse::NotFound unless SiteSetting.chat_enabled
|
||||
guardian.ensure_can_chat!
|
||||
end
|
||||
|
||||
def default_actions_for_service
|
||||
proc do
|
||||
on_success { render(json: success_json) }
|
||||
on_failure { render(json: failed_json, status: 422) }
|
||||
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
|
||||
on_failed_contract do
|
||||
render(
|
||||
json:
|
||||
failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages),
|
||||
status: 400,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,22 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class BaseController < ::ApplicationController
|
||||
before_action :ensure_logged_in
|
||||
before_action :ensure_can_chat
|
||||
|
||||
private
|
||||
|
||||
def ensure_can_chat
|
||||
raise Discourse::NotFound unless SiteSetting.chat_enabled
|
||||
guardian.ensure_can_chat!
|
||||
end
|
||||
|
||||
def set_channel_and_chatable_with_access_check(chat_channel_id: nil)
|
||||
params.require(:chat_channel_id) if chat_channel_id.blank?
|
||||
id_or_name = chat_channel_id || params[:chat_channel_id]
|
||||
@chat_channel = Chat::ChannelFetcher.find_with_access_check(id_or_name, guardian)
|
||||
@chatable = @chat_channel.chatable
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,481 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class ChatController < ::Chat::BaseController
|
||||
PAST_MESSAGE_LIMIT = 40
|
||||
FUTURE_MESSAGE_LIMIT = 40
|
||||
PAST = "past"
|
||||
FUTURE = "future"
|
||||
CHAT_DIRECTIONS = [PAST, FUTURE]
|
||||
|
||||
# Other endpoints use set_channel_and_chatable_with_access_check, but
|
||||
# these endpoints require a standalone find because they need to be
|
||||
# able to get deleted channels and recover them.
|
||||
before_action :find_chatable, only: %i[enable_chat disable_chat]
|
||||
before_action :find_chat_message,
|
||||
only: %i[delete restore lookup_message edit_message rebake message_link]
|
||||
before_action :set_channel_and_chatable_with_access_check,
|
||||
except: %i[
|
||||
respond
|
||||
enable_chat
|
||||
disable_chat
|
||||
message_link
|
||||
lookup_message
|
||||
set_user_chat_status
|
||||
dismiss_retention_reminder
|
||||
flag
|
||||
]
|
||||
|
||||
def respond
|
||||
render
|
||||
end
|
||||
|
||||
def enable_chat
|
||||
chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable)
|
||||
|
||||
guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel
|
||||
|
||||
if chat_channel && chat_channel.trashed?
|
||||
chat_channel.recover!
|
||||
elsif chat_channel
|
||||
return render_json_error I18n.t("chat.already_enabled")
|
||||
else
|
||||
chat_channel = @chatable.chat_channel
|
||||
guardian.ensure_can_join_chat_channel!(chat_channel)
|
||||
end
|
||||
|
||||
success = chat_channel.save
|
||||
if success && chat_channel.chatable_has_custom_fields?
|
||||
@chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true
|
||||
@chatable.save!
|
||||
end
|
||||
|
||||
if success
|
||||
membership = Chat::ChannelMembershipManager.new(channel).follow(user)
|
||||
render_serialized(chat_channel, Chat::ChannelSerializer, membership: membership)
|
||||
else
|
||||
render_json_error(chat_channel)
|
||||
end
|
||||
|
||||
Chat::ChannelMembershipManager.new(channel).follow(user)
|
||||
end
|
||||
|
||||
def disable_chat
|
||||
chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable)
|
||||
guardian.ensure_can_join_chat_channel!(chat_channel)
|
||||
return render json: success_json if chat_channel.trashed?
|
||||
chat_channel.trash!(current_user)
|
||||
|
||||
success = chat_channel.save
|
||||
if success
|
||||
if chat_channel.chatable_has_custom_fields?
|
||||
@chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED)
|
||||
@chatable.save!
|
||||
end
|
||||
|
||||
render json: success_json
|
||||
else
|
||||
render_json_error(chat_channel)
|
||||
end
|
||||
end
|
||||
|
||||
def create_message
|
||||
raise Discourse::InvalidAccess if current_user.silenced?
|
||||
|
||||
Chat::MessageRateLimiter.run!(current_user)
|
||||
|
||||
@user_chat_channel_membership =
|
||||
Chat::ChannelMembershipManager.new(@chat_channel).find_for_user(
|
||||
current_user,
|
||||
following: true,
|
||||
)
|
||||
raise Discourse::InvalidAccess unless @user_chat_channel_membership
|
||||
|
||||
reply_to_msg_id = params[:in_reply_to_id]
|
||||
if reply_to_msg_id
|
||||
rm = Chat::Message.find(reply_to_msg_id)
|
||||
raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id
|
||||
end
|
||||
|
||||
content = params[:message]
|
||||
|
||||
chat_message_creator =
|
||||
Chat::MessageCreator.create(
|
||||
chat_channel: @chat_channel,
|
||||
user: current_user,
|
||||
in_reply_to_id: reply_to_msg_id,
|
||||
content: content,
|
||||
staged_id: params[:staged_id],
|
||||
upload_ids: params[:upload_ids],
|
||||
)
|
||||
|
||||
return render_json_error(chat_message_creator.error) if chat_message_creator.failed?
|
||||
|
||||
@user_chat_channel_membership.update!(
|
||||
last_read_message_id: chat_message_creator.chat_message.id,
|
||||
)
|
||||
|
||||
if @chat_channel.direct_message_channel?
|
||||
# If any of the channel users is ignoring, muting, or preventing DMs from
|
||||
# the current user then we shold not auto-follow the channel once again or
|
||||
# publish the new channel.
|
||||
user_ids_allowing_communication =
|
||||
UserCommScreener.new(
|
||||
acting_user: current_user,
|
||||
target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id),
|
||||
).allowing_actor_communication
|
||||
|
||||
if user_ids_allowing_communication.any?
|
||||
Chat::Publisher.publish_new_channel(
|
||||
@chat_channel,
|
||||
@chat_channel.chatable.users.where(id: user_ids_allowing_communication),
|
||||
)
|
||||
|
||||
@chat_channel
|
||||
.user_chat_channel_memberships
|
||||
.where(user_id: user_ids_allowing_communication)
|
||||
.update_all(following: true)
|
||||
end
|
||||
end
|
||||
|
||||
Chat::Publisher.publish_user_tracking_state(
|
||||
current_user,
|
||||
@chat_channel.id,
|
||||
chat_message_creator.chat_message.id,
|
||||
)
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def edit_message
|
||||
chat_message_updater =
|
||||
Chat::MessageUpdater.update(
|
||||
guardian: guardian,
|
||||
chat_message: @message,
|
||||
new_content: params[:new_message],
|
||||
upload_ids: params[:upload_ids] || [],
|
||||
)
|
||||
|
||||
return render_json_error(chat_message_updater.error) if chat_message_updater.failed?
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def update_user_last_read
|
||||
membership =
|
||||
Chat::ChannelMembershipManager.new(@chat_channel).find_for_user(
|
||||
current_user,
|
||||
following: true,
|
||||
)
|
||||
raise Discourse::NotFound if membership.nil?
|
||||
|
||||
if membership.last_read_message_id &&
|
||||
params[:message_id].to_i < membership.last_read_message_id
|
||||
raise Discourse::InvalidParameters.new(:message_id)
|
||||
end
|
||||
|
||||
unless Chat::Message.with_deleted.exists?(
|
||||
chat_channel_id: @chat_channel.id,
|
||||
id: params[:message_id],
|
||||
)
|
||||
raise Discourse::NotFound
|
||||
end
|
||||
|
||||
membership.update!(last_read_message_id: params[:message_id])
|
||||
|
||||
Notification
|
||||
.where(notification_type: Notification.types[:chat_mention])
|
||||
.where(user: current_user)
|
||||
.where(read: false)
|
||||
.joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id")
|
||||
.joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id")
|
||||
.where("chat_messages.id <= ?", params[:message_id].to_i)
|
||||
.where("chat_messages.chat_channel_id = ?", @chat_channel.id)
|
||||
.update_all(read: true)
|
||||
|
||||
Chat::Publisher.publish_user_tracking_state(
|
||||
current_user,
|
||||
@chat_channel.id,
|
||||
params[:message_id],
|
||||
)
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def messages
|
||||
page_size = params[:page_size]&.to_i || 1000
|
||||
direction = params[:direction].to_s
|
||||
message_id = params[:message_id]
|
||||
if page_size > 50 ||
|
||||
(
|
||||
message_id.blank? ^ direction.blank? &&
|
||||
(direction.present? && !CHAT_DIRECTIONS.include?(direction))
|
||||
)
|
||||
raise Discourse::InvalidParameters
|
||||
end
|
||||
|
||||
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
|
||||
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
|
||||
|
||||
if message_id.present?
|
||||
condition = direction == PAST ? "<" : ">"
|
||||
messages = messages.where("id #{condition} ?", message_id.to_i)
|
||||
end
|
||||
|
||||
# NOTE: This order is reversed when we return the Chat::View below if the direction
|
||||
# is not FUTURE.
|
||||
order = direction == FUTURE ? "ASC" : "DESC"
|
||||
messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a
|
||||
|
||||
can_load_more_past = nil
|
||||
can_load_more_future = nil
|
||||
|
||||
if direction == FUTURE
|
||||
can_load_more_future = messages.size == page_size
|
||||
elsif direction == PAST
|
||||
can_load_more_past = messages.size == page_size
|
||||
else
|
||||
# When direction is blank, we'll return the latest messages.
|
||||
can_load_more_future = false
|
||||
can_load_more_past = messages.size == page_size
|
||||
end
|
||||
|
||||
chat_view =
|
||||
Chat::View.new(
|
||||
chat_channel: @chat_channel,
|
||||
chat_messages: direction == FUTURE ? messages : messages.reverse,
|
||||
user: current_user,
|
||||
can_load_more_past: can_load_more_past,
|
||||
can_load_more_future: can_load_more_future,
|
||||
)
|
||||
render_serialized(chat_view, Chat::ViewSerializer, root: false)
|
||||
end
|
||||
|
||||
def react
|
||||
params.require(%i[message_id emoji react_action])
|
||||
guardian.ensure_can_react!
|
||||
|
||||
Chat::MessageReactor.new(current_user, @chat_channel).react!(
|
||||
message_id: params[:message_id],
|
||||
react_action: params[:react_action].to_sym,
|
||||
emoji: params[:emoji],
|
||||
)
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def delete
|
||||
guardian.ensure_can_delete_chat!(@message, @chatable)
|
||||
|
||||
Chat::MessageDestroyer.new.trash_message(@message, current_user)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
def restore
|
||||
chat_channel = @message.chat_channel
|
||||
guardian.ensure_can_restore_chat!(@message, chat_channel.chatable)
|
||||
updated = @message.recover!
|
||||
if updated
|
||||
Chat::Publisher.publish_restore!(chat_channel, @message)
|
||||
render json: success_json
|
||||
else
|
||||
render_json_error(@message)
|
||||
end
|
||||
end
|
||||
|
||||
def rebake
|
||||
guardian.ensure_can_rebake_chat_message!(@message)
|
||||
@message.rebake!(invalidate_oneboxes: true)
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def message_link
|
||||
raise Discourse::NotFound if @message.blank? || @message.deleted_at.present?
|
||||
raise Discourse::NotFound if @message.chat_channel.blank?
|
||||
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
|
||||
render json:
|
||||
success_json.merge(
|
||||
chat_channel_id: @chat_channel.id,
|
||||
chat_channel_title: @chat_channel.title(current_user),
|
||||
)
|
||||
end
|
||||
|
||||
def lookup_message
|
||||
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
|
||||
|
||||
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
|
||||
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
|
||||
|
||||
past_messages =
|
||||
messages
|
||||
.where("created_at < ?", @message.created_at)
|
||||
.order(created_at: :desc)
|
||||
.limit(PAST_MESSAGE_LIMIT)
|
||||
|
||||
future_messages =
|
||||
messages
|
||||
.where("created_at > ?", @message.created_at)
|
||||
.order(created_at: :asc)
|
||||
.limit(FUTURE_MESSAGE_LIMIT)
|
||||
|
||||
can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT
|
||||
can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT
|
||||
messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat)
|
||||
chat_view =
|
||||
Chat::View.new(
|
||||
chat_channel: @chat_channel,
|
||||
chat_messages: messages,
|
||||
user: current_user,
|
||||
can_load_more_past: can_load_more_past,
|
||||
can_load_more_future: can_load_more_future,
|
||||
)
|
||||
render_serialized(chat_view, Chat::ViewSerializer, root: false)
|
||||
end
|
||||
|
||||
def set_user_chat_status
|
||||
params.require(:chat_enabled)
|
||||
|
||||
current_user.user_option.update(chat_enabled: params[:chat_enabled])
|
||||
render json: { chat_enabled: current_user.user_option.chat_enabled }
|
||||
end
|
||||
|
||||
def invite_users
|
||||
params.require(:user_ids)
|
||||
|
||||
users =
|
||||
User
|
||||
.includes(:groups)
|
||||
.joins(:user_option)
|
||||
.where(user_options: { chat_enabled: true })
|
||||
.not_suspended
|
||||
.where(id: params[:user_ids])
|
||||
users.each do |user|
|
||||
guardian = Guardian.new(user)
|
||||
if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
|
||||
data = {
|
||||
message: "chat.invitation_notification",
|
||||
chat_channel_id: @chat_channel.id,
|
||||
chat_channel_title: @chat_channel.title(user),
|
||||
chat_channel_slug: @chat_channel.slug,
|
||||
invited_by_username: current_user.username,
|
||||
}
|
||||
data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id]
|
||||
user.notifications.create(
|
||||
notification_type: Notification.types[:chat_invitation],
|
||||
high_priority: true,
|
||||
data: data.to_json,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def dismiss_retention_reminder
|
||||
params.require(:chatable_type)
|
||||
guardian.ensure_can_chat!
|
||||
unless Chat::Channel.chatable_types.include?(params[:chatable_type])
|
||||
raise Discourse::InvalidParameters
|
||||
end
|
||||
|
||||
field =
|
||||
(
|
||||
if Chat::Channel.public_channel_chatable_types.include?(params[:chatable_type])
|
||||
:dismissed_channel_retention_reminder
|
||||
else
|
||||
:dismissed_dm_retention_reminder
|
||||
end
|
||||
)
|
||||
current_user.user_option.update(field => true)
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def quote_messages
|
||||
params.require(:message_ids)
|
||||
|
||||
message_ids = params[:message_ids].map(&:to_i)
|
||||
markdown =
|
||||
Chat::TranscriptService.new(
|
||||
@chat_channel,
|
||||
current_user,
|
||||
messages_or_ids: message_ids,
|
||||
).generate_markdown
|
||||
render json: success_json.merge(markdown: markdown)
|
||||
end
|
||||
|
||||
def flag
|
||||
RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed!
|
||||
|
||||
permitted_params =
|
||||
params.permit(
|
||||
%i[chat_message_id flag_type_id message is_warning take_action queue_for_review],
|
||||
)
|
||||
|
||||
chat_message =
|
||||
Chat::Message.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id])
|
||||
|
||||
flag_type_id = permitted_params[:flag_type_id].to_i
|
||||
|
||||
if !ReviewableScore.types.values.include?(flag_type_id)
|
||||
raise Discourse::InvalidParameters.new(:flag_type_id)
|
||||
end
|
||||
|
||||
set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id)
|
||||
|
||||
result =
|
||||
Chat::ReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params)
|
||||
|
||||
if result[:success]
|
||||
render json: success_json
|
||||
else
|
||||
render_json_error(result[:errors])
|
||||
end
|
||||
end
|
||||
|
||||
def set_draft
|
||||
if params[:data].present?
|
||||
Chat::Draft.find_or_initialize_by(
|
||||
user: current_user,
|
||||
chat_channel_id: @chat_channel.id,
|
||||
).update!(data: params[:data])
|
||||
else
|
||||
Chat::Draft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all
|
||||
end
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preloaded_chat_message_query
|
||||
query =
|
||||
Chat::Message
|
||||
.includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]])
|
||||
.includes(:revisions)
|
||||
.includes(user: :primary_group)
|
||||
.includes(chat_webhook_event: :incoming_chat_webhook)
|
||||
.includes(reactions: :user)
|
||||
.includes(:bookmarks)
|
||||
.includes(:uploads)
|
||||
.includes(chat_channel: :chatable)
|
||||
|
||||
query = query.includes(user: :user_status) if SiteSetting.enable_user_status
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
def find_chatable
|
||||
@chatable = Category.find_by(id: params[:chatable_id])
|
||||
guardian.ensure_can_moderate_chat!(@chatable)
|
||||
end
|
||||
|
||||
def find_chat_message
|
||||
@message = preloaded_chat_message_query.with_deleted
|
||||
@message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[
|
||||
:chat_channel_id
|
||||
]
|
||||
@message = @message.find_by(id: params[:message_id])
|
||||
raise Discourse::NotFound unless @message
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,57 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class DirectMessagesController < ::Chat::BaseController
|
||||
# NOTE: For V1 of chat channel archiving and deleting we are not doing
|
||||
# anything for DM channels, their behaviour will stay as is.
|
||||
def create
|
||||
guardian.ensure_can_chat!
|
||||
users = users_from_usernames(current_user, params)
|
||||
|
||||
begin
|
||||
chat_channel =
|
||||
Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users)
|
||||
render_serialized(
|
||||
chat_channel,
|
||||
Chat::ChannelSerializer,
|
||||
root: "channel",
|
||||
membership: chat_channel.membership_for(current_user),
|
||||
)
|
||||
rescue Chat::DirectMessageChannelCreator::NotAllowed => err
|
||||
render_json_error(err.message)
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
guardian.ensure_can_chat!
|
||||
users = users_from_usernames(current_user, params)
|
||||
|
||||
direct_message = Chat::DirectMessage.for_user_ids(users.map(&:id).uniq)
|
||||
if direct_message
|
||||
chat_channel = Chat::Channel.find_by(chatable_id: direct_message)
|
||||
render_serialized(
|
||||
chat_channel,
|
||||
Chat::ChannelSerializer,
|
||||
root: "channel",
|
||||
membership: chat_channel.membership_for(current_user),
|
||||
)
|
||||
else
|
||||
render body: nil, status: 404
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def users_from_usernames(current_user, params)
|
||||
params.require(:usernames)
|
||||
|
||||
usernames =
|
||||
(params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames])
|
||||
|
||||
users = [current_user]
|
||||
other_usernames = usernames - [current_user.username]
|
||||
users.concat(User.where(username: other_usernames).to_a) if other_usernames.any?
|
||||
users
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,10 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class EmojisController < ::Chat::BaseController
|
||||
def index
|
||||
emojis = Emoji.all.group_by(&:group)
|
||||
render json: MultiJson.dump(emojis)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,113 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class IncomingWebhooksController < ::ApplicationController
|
||||
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10
|
||||
|
||||
skip_before_action :verify_authenticity_token, :redirect_to_login_if_required
|
||||
|
||||
before_action :validate_payload
|
||||
|
||||
def create_message
|
||||
debug_payload
|
||||
|
||||
process_webhook_payload(text: params[:text], key: params[:key])
|
||||
end
|
||||
|
||||
# See https://api.slack.com/reference/messaging/payload for the
|
||||
# slack message payload format. For now we only support the
|
||||
# text param, which we preprocess lightly to remove the slack-isms
|
||||
# in the formatting.
|
||||
def create_message_slack_compatible
|
||||
debug_payload
|
||||
|
||||
# See note in validate_payload on why this is needed
|
||||
attachments =
|
||||
if params[:payload].present?
|
||||
payload = params[:payload]
|
||||
if String === payload
|
||||
payload = JSON.parse(payload)
|
||||
payload.deep_symbolize_keys!
|
||||
end
|
||||
payload[:attachments]
|
||||
else
|
||||
params[:attachments]
|
||||
end
|
||||
|
||||
if params[:text].present?
|
||||
text = Chat::SlackCompatibility.process_text(params[:text])
|
||||
else
|
||||
text = Chat::SlackCompatibility.process_legacy_attachments(attachments)
|
||||
end
|
||||
|
||||
process_webhook_payload(text: text, key: params[:key])
|
||||
rescue JSON::ParserError
|
||||
raise Discourse::InvalidParameters
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_webhook_payload(text:, key:)
|
||||
validate_message_length(text)
|
||||
webhook = find_and_rate_limit_webhook(key)
|
||||
|
||||
chat_message_creator =
|
||||
Chat::MessageCreator.create(
|
||||
chat_channel: webhook.chat_channel,
|
||||
user: Discourse.system_user,
|
||||
content: text,
|
||||
incoming_chat_webhook: webhook,
|
||||
)
|
||||
if chat_message_creator.failed?
|
||||
render_json_error(chat_message_creator.error)
|
||||
else
|
||||
render json: success_json
|
||||
end
|
||||
end
|
||||
|
||||
def find_and_rate_limit_webhook(key)
|
||||
webhook = Chat::IncomingWebhook.includes(:chat_channel).find_by(key: key)
|
||||
raise Discourse::NotFound unless webhook
|
||||
|
||||
# Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed.
|
||||
RateLimiter.new(
|
||||
nil,
|
||||
"incoming_chat_webhook_#{webhook.id}",
|
||||
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT,
|
||||
1.minute,
|
||||
).performed!
|
||||
webhook
|
||||
end
|
||||
|
||||
def validate_message_length(message)
|
||||
return if message.length <= SiteSetting.chat_maximum_message_length
|
||||
raise Discourse::InvalidParameters.new(
|
||||
"Body cannot be over #{SiteSetting.chat_maximum_message_length} characters",
|
||||
)
|
||||
end
|
||||
|
||||
# The webhook POST body can be in 3 different formats:
|
||||
#
|
||||
# * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads
|
||||
# * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments
|
||||
# * { payload: "<JSON STRING>", attachments: null, text: null }, where JSON STRING can look
|
||||
# like the `attachments` example above (along with other attributes), which is fired by OpsGenie
|
||||
def validate_payload
|
||||
params.require(:key)
|
||||
|
||||
if !params[:text] && !params[:payload] && !params[:attachments]
|
||||
raise Discourse::InvalidParameters
|
||||
end
|
||||
end
|
||||
|
||||
def debug_payload
|
||||
return if !SiteSetting.chat_debug_webhook_payloads
|
||||
Rails.logger.warn(
|
||||
"Debugging chat webhook payload for endpoint #{params[:key]}: " +
|
||||
JSON.dump(
|
||||
{ payload: params[:payload], attachments: params[:attachments], text: params[:text] },
|
||||
),
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
20
plugins/chat/app/controllers/chat_base_controller.rb
Normal file
20
plugins/chat/app/controllers/chat_base_controller.rb
Normal file
@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::ChatBaseController < ::ApplicationController
|
||||
before_action :ensure_logged_in
|
||||
before_action :ensure_can_chat
|
||||
|
||||
private
|
||||
|
||||
def ensure_can_chat
|
||||
raise Discourse::NotFound unless SiteSetting.chat_enabled
|
||||
guardian.ensure_can_chat!
|
||||
end
|
||||
|
||||
def set_channel_and_chatable_with_access_check(chat_channel_id: nil)
|
||||
params.require(:chat_channel_id) if chat_channel_id.blank?
|
||||
id_or_name = chat_channel_id || params[:chat_channel_id]
|
||||
@chat_channel = Chat::ChatChannelFetcher.find_with_access_check(id_or_name, guardian)
|
||||
@chatable = @chat_channel.chatable
|
||||
end
|
||||
end
|
||||
472
plugins/chat/app/controllers/chat_controller.rb
Normal file
472
plugins/chat/app/controllers/chat_controller.rb
Normal file
@ -0,0 +1,472 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::ChatController < Chat::ChatBaseController
|
||||
PAST_MESSAGE_LIMIT = 40
|
||||
FUTURE_MESSAGE_LIMIT = 40
|
||||
PAST = "past"
|
||||
FUTURE = "future"
|
||||
CHAT_DIRECTIONS = [PAST, FUTURE]
|
||||
|
||||
# Other endpoints use set_channel_and_chatable_with_access_check, but
|
||||
# these endpoints require a standalone find because they need to be
|
||||
# able to get deleted channels and recover them.
|
||||
before_action :find_chatable, only: %i[enable_chat disable_chat]
|
||||
before_action :find_chat_message,
|
||||
only: %i[delete restore lookup_message edit_message rebake message_link]
|
||||
before_action :set_channel_and_chatable_with_access_check,
|
||||
except: %i[
|
||||
respond
|
||||
enable_chat
|
||||
disable_chat
|
||||
message_link
|
||||
lookup_message
|
||||
set_user_chat_status
|
||||
dismiss_retention_reminder
|
||||
flag
|
||||
]
|
||||
|
||||
def respond
|
||||
render
|
||||
end
|
||||
|
||||
def enable_chat
|
||||
chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable)
|
||||
|
||||
guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel
|
||||
|
||||
if chat_channel && chat_channel.trashed?
|
||||
chat_channel.recover!
|
||||
elsif chat_channel
|
||||
return render_json_error I18n.t("chat.already_enabled")
|
||||
else
|
||||
chat_channel = @chatable.chat_channel
|
||||
guardian.ensure_can_join_chat_channel!(chat_channel)
|
||||
end
|
||||
|
||||
success = chat_channel.save
|
||||
if success && chat_channel.chatable_has_custom_fields?
|
||||
@chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true
|
||||
@chatable.save!
|
||||
end
|
||||
|
||||
if success
|
||||
membership = Chat::ChatChannelMembershipManager.new(channel).follow(user)
|
||||
render_serialized(chat_channel, ChatChannelSerializer, membership: membership)
|
||||
else
|
||||
render_json_error(chat_channel)
|
||||
end
|
||||
|
||||
Chat::ChatChannelMembershipManager.new(channel).follow(user)
|
||||
end
|
||||
|
||||
def disable_chat
|
||||
chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable)
|
||||
guardian.ensure_can_join_chat_channel!(chat_channel)
|
||||
return render json: success_json if chat_channel.trashed?
|
||||
chat_channel.trash!(current_user)
|
||||
|
||||
success = chat_channel.save
|
||||
if success
|
||||
if chat_channel.chatable_has_custom_fields?
|
||||
@chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED)
|
||||
@chatable.save!
|
||||
end
|
||||
|
||||
render json: success_json
|
||||
else
|
||||
render_json_error(chat_channel)
|
||||
end
|
||||
end
|
||||
|
||||
def create_message
|
||||
raise Discourse::InvalidAccess if current_user.silenced?
|
||||
|
||||
Chat::ChatMessageRateLimiter.run!(current_user)
|
||||
|
||||
@user_chat_channel_membership =
|
||||
Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user(
|
||||
current_user,
|
||||
following: true,
|
||||
)
|
||||
raise Discourse::InvalidAccess unless @user_chat_channel_membership
|
||||
|
||||
reply_to_msg_id = params[:in_reply_to_id]
|
||||
if reply_to_msg_id
|
||||
rm = ChatMessage.find(reply_to_msg_id)
|
||||
raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id
|
||||
end
|
||||
|
||||
content = params[:message]
|
||||
|
||||
chat_message_creator =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: @chat_channel,
|
||||
user: current_user,
|
||||
in_reply_to_id: reply_to_msg_id,
|
||||
content: content,
|
||||
staged_id: params[:staged_id],
|
||||
upload_ids: params[:upload_ids],
|
||||
)
|
||||
|
||||
return render_json_error(chat_message_creator.error) if chat_message_creator.failed?
|
||||
|
||||
@user_chat_channel_membership.update!(
|
||||
last_read_message_id: chat_message_creator.chat_message.id,
|
||||
)
|
||||
|
||||
if @chat_channel.direct_message_channel?
|
||||
# If any of the channel users is ignoring, muting, or preventing DMs from
|
||||
# the current user then we shold not auto-follow the channel once again or
|
||||
# publish the new channel.
|
||||
user_ids_allowing_communication =
|
||||
UserCommScreener.new(
|
||||
acting_user: current_user,
|
||||
target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id),
|
||||
).allowing_actor_communication
|
||||
|
||||
if user_ids_allowing_communication.any?
|
||||
ChatPublisher.publish_new_channel(
|
||||
@chat_channel,
|
||||
@chat_channel.chatable.users.where(id: user_ids_allowing_communication),
|
||||
)
|
||||
|
||||
@chat_channel
|
||||
.user_chat_channel_memberships
|
||||
.where(user_id: user_ids_allowing_communication)
|
||||
.update_all(following: true)
|
||||
end
|
||||
end
|
||||
|
||||
ChatPublisher.publish_user_tracking_state(
|
||||
current_user,
|
||||
@chat_channel.id,
|
||||
chat_message_creator.chat_message.id,
|
||||
)
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def edit_message
|
||||
chat_message_updater =
|
||||
Chat::ChatMessageUpdater.update(
|
||||
guardian: guardian,
|
||||
chat_message: @message,
|
||||
new_content: params[:new_message],
|
||||
upload_ids: params[:upload_ids] || [],
|
||||
)
|
||||
|
||||
return render_json_error(chat_message_updater.error) if chat_message_updater.failed?
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def update_user_last_read
|
||||
membership =
|
||||
Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user(
|
||||
current_user,
|
||||
following: true,
|
||||
)
|
||||
raise Discourse::NotFound if membership.nil?
|
||||
|
||||
if membership.last_read_message_id && params[:message_id].to_i < membership.last_read_message_id
|
||||
raise Discourse::InvalidParameters.new(:message_id)
|
||||
end
|
||||
|
||||
unless ChatMessage.with_deleted.exists?(
|
||||
chat_channel_id: @chat_channel.id,
|
||||
id: params[:message_id],
|
||||
)
|
||||
raise Discourse::NotFound
|
||||
end
|
||||
|
||||
membership.update!(last_read_message_id: params[:message_id])
|
||||
|
||||
Notification
|
||||
.where(notification_type: Notification.types[:chat_mention])
|
||||
.where(user: current_user)
|
||||
.where(read: false)
|
||||
.joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id")
|
||||
.joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id")
|
||||
.where("chat_messages.id <= ?", params[:message_id].to_i)
|
||||
.where("chat_messages.chat_channel_id = ?", @chat_channel.id)
|
||||
.update_all(read: true)
|
||||
|
||||
ChatPublisher.publish_user_tracking_state(current_user, @chat_channel.id, params[:message_id])
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def messages
|
||||
page_size = params[:page_size]&.to_i || 1000
|
||||
direction = params[:direction].to_s
|
||||
message_id = params[:message_id]
|
||||
if page_size > 50 ||
|
||||
(
|
||||
message_id.blank? ^ direction.blank? &&
|
||||
(direction.present? && !CHAT_DIRECTIONS.include?(direction))
|
||||
)
|
||||
raise Discourse::InvalidParameters
|
||||
end
|
||||
|
||||
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
|
||||
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
|
||||
|
||||
if message_id.present?
|
||||
condition = direction == PAST ? "<" : ">"
|
||||
messages = messages.where("id #{condition} ?", message_id.to_i)
|
||||
end
|
||||
|
||||
# NOTE: This order is reversed when we return the ChatView below if the direction
|
||||
# is not FUTURE.
|
||||
order = direction == FUTURE ? "ASC" : "DESC"
|
||||
messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a
|
||||
|
||||
can_load_more_past = nil
|
||||
can_load_more_future = nil
|
||||
|
||||
if direction == FUTURE
|
||||
can_load_more_future = messages.size == page_size
|
||||
elsif direction == PAST
|
||||
can_load_more_past = messages.size == page_size
|
||||
else
|
||||
# When direction is blank, we'll return the latest messages.
|
||||
can_load_more_future = false
|
||||
can_load_more_past = messages.size == page_size
|
||||
end
|
||||
|
||||
chat_view =
|
||||
ChatView.new(
|
||||
chat_channel: @chat_channel,
|
||||
chat_messages: direction == FUTURE ? messages : messages.reverse,
|
||||
user: current_user,
|
||||
can_load_more_past: can_load_more_past,
|
||||
can_load_more_future: can_load_more_future,
|
||||
)
|
||||
render_serialized(chat_view, ChatViewSerializer, root: false)
|
||||
end
|
||||
|
||||
def react
|
||||
params.require(%i[message_id emoji react_action])
|
||||
guardian.ensure_can_react!
|
||||
|
||||
Chat::ChatMessageReactor.new(current_user, @chat_channel).react!(
|
||||
message_id: params[:message_id],
|
||||
react_action: params[:react_action].to_sym,
|
||||
emoji: params[:emoji],
|
||||
)
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def delete
|
||||
guardian.ensure_can_delete_chat!(@message, @chatable)
|
||||
|
||||
ChatMessageDestroyer.new.trash_message(@message, current_user)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
def restore
|
||||
chat_channel = @message.chat_channel
|
||||
guardian.ensure_can_restore_chat!(@message, chat_channel.chatable)
|
||||
updated = @message.recover!
|
||||
if updated
|
||||
ChatPublisher.publish_restore!(chat_channel, @message)
|
||||
render json: success_json
|
||||
else
|
||||
render_json_error(@message)
|
||||
end
|
||||
end
|
||||
|
||||
def rebake
|
||||
guardian.ensure_can_rebake_chat_message!(@message)
|
||||
@message.rebake!(invalidate_oneboxes: true)
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def message_link
|
||||
raise Discourse::NotFound if @message.blank? || @message.deleted_at.present?
|
||||
raise Discourse::NotFound if @message.chat_channel.blank?
|
||||
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
|
||||
render json:
|
||||
success_json.merge(
|
||||
chat_channel_id: @chat_channel.id,
|
||||
chat_channel_title: @chat_channel.title(current_user),
|
||||
)
|
||||
end
|
||||
|
||||
def lookup_message
|
||||
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
|
||||
|
||||
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
|
||||
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
|
||||
|
||||
past_messages =
|
||||
messages
|
||||
.where("created_at < ?", @message.created_at)
|
||||
.order(created_at: :desc)
|
||||
.limit(PAST_MESSAGE_LIMIT)
|
||||
|
||||
future_messages =
|
||||
messages
|
||||
.where("created_at > ?", @message.created_at)
|
||||
.order(created_at: :asc)
|
||||
.limit(FUTURE_MESSAGE_LIMIT)
|
||||
|
||||
can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT
|
||||
can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT
|
||||
messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat)
|
||||
chat_view =
|
||||
ChatView.new(
|
||||
chat_channel: @chat_channel,
|
||||
chat_messages: messages,
|
||||
user: current_user,
|
||||
can_load_more_past: can_load_more_past,
|
||||
can_load_more_future: can_load_more_future,
|
||||
)
|
||||
render_serialized(chat_view, ChatViewSerializer, root: false)
|
||||
end
|
||||
|
||||
def set_user_chat_status
|
||||
params.require(:chat_enabled)
|
||||
|
||||
current_user.user_option.update(chat_enabled: params[:chat_enabled])
|
||||
render json: { chat_enabled: current_user.user_option.chat_enabled }
|
||||
end
|
||||
|
||||
def invite_users
|
||||
params.require(:user_ids)
|
||||
|
||||
users =
|
||||
User
|
||||
.includes(:groups)
|
||||
.joins(:user_option)
|
||||
.where(user_options: { chat_enabled: true })
|
||||
.not_suspended
|
||||
.where(id: params[:user_ids])
|
||||
users.each do |user|
|
||||
guardian = Guardian.new(user)
|
||||
if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
|
||||
data = {
|
||||
message: "chat.invitation_notification",
|
||||
chat_channel_id: @chat_channel.id,
|
||||
chat_channel_title: @chat_channel.title(user),
|
||||
chat_channel_slug: @chat_channel.slug,
|
||||
invited_by_username: current_user.username,
|
||||
}
|
||||
data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id]
|
||||
user.notifications.create(
|
||||
notification_type: Notification.types[:chat_invitation],
|
||||
high_priority: true,
|
||||
data: data.to_json,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def dismiss_retention_reminder
|
||||
params.require(:chatable_type)
|
||||
guardian.ensure_can_chat!
|
||||
unless ChatChannel.chatable_types.include?(params[:chatable_type])
|
||||
raise Discourse::InvalidParameters
|
||||
end
|
||||
|
||||
field =
|
||||
(
|
||||
if ChatChannel.public_channel_chatable_types.include?(params[:chatable_type])
|
||||
:dismissed_channel_retention_reminder
|
||||
else
|
||||
:dismissed_dm_retention_reminder
|
||||
end
|
||||
)
|
||||
current_user.user_option.update(field => true)
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def quote_messages
|
||||
params.require(:message_ids)
|
||||
|
||||
message_ids = params[:message_ids].map(&:to_i)
|
||||
markdown =
|
||||
ChatTranscriptService.new(
|
||||
@chat_channel,
|
||||
current_user,
|
||||
messages_or_ids: message_ids,
|
||||
).generate_markdown
|
||||
render json: success_json.merge(markdown: markdown)
|
||||
end
|
||||
|
||||
def flag
|
||||
RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed!
|
||||
|
||||
permitted_params =
|
||||
params.permit(
|
||||
%i[chat_message_id flag_type_id message is_warning take_action queue_for_review],
|
||||
)
|
||||
|
||||
chat_message =
|
||||
ChatMessage.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id])
|
||||
|
||||
flag_type_id = permitted_params[:flag_type_id].to_i
|
||||
|
||||
if !ReviewableScore.types.values.include?(flag_type_id)
|
||||
raise Discourse::InvalidParameters.new(:flag_type_id)
|
||||
end
|
||||
|
||||
set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id)
|
||||
|
||||
result =
|
||||
Chat::ChatReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params)
|
||||
|
||||
if result[:success]
|
||||
render json: success_json
|
||||
else
|
||||
render_json_error(result[:errors])
|
||||
end
|
||||
end
|
||||
|
||||
def set_draft
|
||||
if params[:data].present?
|
||||
ChatDraft.find_or_initialize_by(
|
||||
user: current_user,
|
||||
chat_channel_id: @chat_channel.id,
|
||||
).update!(data: params[:data])
|
||||
else
|
||||
ChatDraft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all
|
||||
end
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preloaded_chat_message_query
|
||||
query =
|
||||
ChatMessage
|
||||
.includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]])
|
||||
.includes(:revisions)
|
||||
.includes(user: :primary_group)
|
||||
.includes(chat_webhook_event: :incoming_chat_webhook)
|
||||
.includes(reactions: :user)
|
||||
.includes(:bookmarks)
|
||||
.includes(:uploads)
|
||||
.includes(chat_channel: :chatable)
|
||||
|
||||
query = query.includes(user: :user_status) if SiteSetting.enable_user_status
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
def find_chatable
|
||||
@chatable = Category.find_by(id: params[:chatable_id])
|
||||
guardian.ensure_can_moderate_chat!(@chatable)
|
||||
end
|
||||
|
||||
def find_chat_message
|
||||
@message = preloaded_chat_message_query.with_deleted
|
||||
@message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[:chat_channel_id]
|
||||
@message = @message.find_by(id: params[:message_id])
|
||||
raise Discourse::NotFound unless @message
|
||||
end
|
||||
end
|
||||
55
plugins/chat/app/controllers/direct_messages_controller.rb
Normal file
55
plugins/chat/app/controllers/direct_messages_controller.rb
Normal file
@ -0,0 +1,55 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::DirectMessagesController < Chat::ChatBaseController
|
||||
# NOTE: For V1 of chat channel archiving and deleting we are not doing
|
||||
# anything for DM channels, their behaviour will stay as is.
|
||||
def create
|
||||
guardian.ensure_can_chat!
|
||||
users = users_from_usernames(current_user, params)
|
||||
|
||||
begin
|
||||
chat_channel =
|
||||
Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users)
|
||||
render_serialized(
|
||||
chat_channel,
|
||||
ChatChannelSerializer,
|
||||
root: "channel",
|
||||
membership: chat_channel.membership_for(current_user),
|
||||
)
|
||||
rescue Chat::DirectMessageChannelCreator::NotAllowed => err
|
||||
render_json_error(err.message)
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
guardian.ensure_can_chat!
|
||||
users = users_from_usernames(current_user, params)
|
||||
|
||||
direct_message = DirectMessage.for_user_ids(users.map(&:id).uniq)
|
||||
if direct_message
|
||||
chat_channel = ChatChannel.find_by(chatable: direct_message)
|
||||
render_serialized(
|
||||
chat_channel,
|
||||
ChatChannelSerializer,
|
||||
root: "channel",
|
||||
membership: chat_channel.membership_for(current_user),
|
||||
)
|
||||
else
|
||||
render body: nil, status: 404
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def users_from_usernames(current_user, params)
|
||||
params.require(:usernames)
|
||||
|
||||
usernames =
|
||||
(params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames])
|
||||
|
||||
users = [current_user]
|
||||
other_usernames = usernames - [current_user.username]
|
||||
users.concat(User.where(username: other_usernames).to_a) if other_usernames.any?
|
||||
users
|
||||
end
|
||||
end
|
||||
8
plugins/chat/app/controllers/emojis_controller.rb
Normal file
8
plugins/chat/app/controllers/emojis_controller.rb
Normal file
@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::EmojisController < Chat::ChatBaseController
|
||||
def index
|
||||
emojis = Emoji.all.group_by(&:group)
|
||||
render json: MultiJson.dump(emojis)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,111 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::IncomingChatWebhooksController < ApplicationController
|
||||
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10
|
||||
|
||||
skip_before_action :verify_authenticity_token, :redirect_to_login_if_required
|
||||
|
||||
before_action :validate_payload
|
||||
|
||||
def create_message
|
||||
debug_payload
|
||||
|
||||
process_webhook_payload(text: params[:text], key: params[:key])
|
||||
end
|
||||
|
||||
# See https://api.slack.com/reference/messaging/payload for the
|
||||
# slack message payload format. For now we only support the
|
||||
# text param, which we preprocess lightly to remove the slack-isms
|
||||
# in the formatting.
|
||||
def create_message_slack_compatible
|
||||
debug_payload
|
||||
|
||||
# See note in validate_payload on why this is needed
|
||||
attachments =
|
||||
if params[:payload].present?
|
||||
payload = params[:payload]
|
||||
if String === payload
|
||||
payload = JSON.parse(payload)
|
||||
payload.deep_symbolize_keys!
|
||||
end
|
||||
payload[:attachments]
|
||||
else
|
||||
params[:attachments]
|
||||
end
|
||||
|
||||
if params[:text].present?
|
||||
text = Chat::SlackCompatibility.process_text(params[:text])
|
||||
else
|
||||
text = Chat::SlackCompatibility.process_legacy_attachments(attachments)
|
||||
end
|
||||
|
||||
process_webhook_payload(text: text, key: params[:key])
|
||||
rescue JSON::ParserError
|
||||
raise Discourse::InvalidParameters
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_webhook_payload(text:, key:)
|
||||
validate_message_length(text)
|
||||
webhook = find_and_rate_limit_webhook(key)
|
||||
|
||||
chat_message_creator =
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: webhook.chat_channel,
|
||||
user: Discourse.system_user,
|
||||
content: text,
|
||||
incoming_chat_webhook: webhook,
|
||||
)
|
||||
if chat_message_creator.failed?
|
||||
render_json_error(chat_message_creator.error)
|
||||
else
|
||||
render json: success_json
|
||||
end
|
||||
end
|
||||
|
||||
def find_and_rate_limit_webhook(key)
|
||||
webhook = IncomingChatWebhook.includes(:chat_channel).find_by(key: key)
|
||||
raise Discourse::NotFound unless webhook
|
||||
|
||||
# Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed.
|
||||
RateLimiter.new(
|
||||
nil,
|
||||
"incoming_chat_webhook_#{webhook.id}",
|
||||
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT,
|
||||
1.minute,
|
||||
).performed!
|
||||
webhook
|
||||
end
|
||||
|
||||
def validate_message_length(message)
|
||||
return if message.length <= SiteSetting.chat_maximum_message_length
|
||||
raise Discourse::InvalidParameters.new(
|
||||
"Body cannot be over #{SiteSetting.chat_maximum_message_length} characters",
|
||||
)
|
||||
end
|
||||
|
||||
# The webhook POST body can be in 3 different formats:
|
||||
#
|
||||
# * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads
|
||||
# * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments
|
||||
# * { payload: "<JSON STRING>", attachments: null, text: null }, where JSON STRING can look
|
||||
# like the `attachments` example above (along with other attributes), which is fired by OpsGenie
|
||||
def validate_payload
|
||||
params.require(:key)
|
||||
|
||||
if !params[:text] && !params[:payload] && !params[:attachments]
|
||||
raise Discourse::InvalidParameters
|
||||
end
|
||||
end
|
||||
|
||||
def debug_payload
|
||||
return if !SiteSetting.chat_debug_webhook_payloads
|
||||
Rails.logger.warn(
|
||||
"Debugging chat webhook payload for endpoint #{params[:key]}: " +
|
||||
JSON.dump(
|
||||
{ payload: params[:payload], attachments: params[:attachments], text: params[:text] },
|
||||
),
|
||||
)
|
||||
end
|
||||
end
|
||||
15
plugins/chat/app/core_ext/plugin_instance.rb
Normal file
15
plugins/chat/app/core_ext/plugin_instance.rb
Normal file
@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscoursePluginRegistry.define_register(:chat_markdown_features, Set)
|
||||
|
||||
class Plugin::Instance
|
||||
def chat
|
||||
ChatPluginApiExtensions
|
||||
end
|
||||
|
||||
module ChatPluginApiExtensions
|
||||
def self.enable_markdown_feature(name)
|
||||
DiscoursePluginRegistry.chat_markdown_features << name
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -12,7 +12,7 @@ module Chat
|
||||
instance_exec(&object.method(:default_actions_for_service).call) if default_actions
|
||||
instance_exec(&(block || proc {}))
|
||||
end
|
||||
ServiceRunner.call(service, object, **dependencies, &merged_block)
|
||||
Chat::ServiceRunner.call(service, object, **dependencies, &merged_block)
|
||||
end
|
||||
|
||||
def run_service(service, dependencies)
|
||||
81
plugins/chat/app/jobs/regular/auto_join_channel_batch.rb
Normal file
81
plugins/chat/app/jobs/regular/auto_join_channel_batch.rb
Normal file
@ -0,0 +1,81 @@
|
||||
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
class AutoJoinChannelBatch < ::Jobs::Base
|
||||
def execute(args)
|
||||
return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank?
|
||||
start_user_id = args[:starts_at].to_i
|
||||
end_user_id = args[:ends_at].to_i
|
||||
|
||||
return "End is higher than start" if end_user_id < start_user_id
|
||||
|
||||
channel =
|
||||
ChatChannel.find_by(
|
||||
id: args[:chat_channel_id],
|
||||
auto_join_users: true,
|
||||
chatable_type: "Category",
|
||||
)
|
||||
|
||||
return if !channel
|
||||
|
||||
category = channel.chatable
|
||||
return if !category
|
||||
|
||||
query_args = {
|
||||
chat_channel_id: channel.id,
|
||||
start: start_user_id,
|
||||
end: end_user_id,
|
||||
suspended_until: Time.zone.now,
|
||||
last_seen_at: 3.months.ago,
|
||||
channel_category: channel.chatable_id,
|
||||
mode: UserChatChannelMembership.join_modes[:automatic],
|
||||
}
|
||||
|
||||
new_member_ids = DB.query_single(create_memberships_query(category), query_args)
|
||||
|
||||
# Only do this if we are running auto-join for a single user, if we
|
||||
# are doing it for many then we should do it after all batches are
|
||||
# complete for the channel in Jobs::AutoManageChannelMemberships
|
||||
if start_user_id == end_user_id
|
||||
Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count
|
||||
end
|
||||
|
||||
ChatPublisher.publish_new_channel(channel.reload, User.where(id: new_member_ids))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_memberships_query(category)
|
||||
query = <<~SQL
|
||||
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode)
|
||||
SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode
|
||||
FROM users
|
||||
INNER JOIN user_options uo ON uo.user_id = users.id
|
||||
LEFT OUTER JOIN user_chat_channel_memberships uccm ON
|
||||
uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id
|
||||
SQL
|
||||
|
||||
query += <<~SQL if category.read_restricted?
|
||||
INNER JOIN group_users gu ON gu.user_id = users.id
|
||||
LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id
|
||||
SQL
|
||||
|
||||
query += <<~SQL
|
||||
WHERE (users.id >= :start AND users.id <= :end) AND
|
||||
users.staged IS FALSE AND users.active AND
|
||||
NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND
|
||||
(suspended_till IS NULL OR suspended_till <= :suspended_until) AND
|
||||
(last_seen_at > :last_seen_at) AND
|
||||
uo.chat_enabled AND
|
||||
uccm.id IS NULL
|
||||
SQL
|
||||
|
||||
query += <<~SQL if category.read_restricted?
|
||||
AND cg.category_id = :channel_category
|
||||
SQL
|
||||
|
||||
query += "RETURNING user_chat_channel_memberships.user_id"
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,79 @@
|
||||
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
class AutoManageChannelMemberships < ::Jobs::Base
|
||||
def execute(args)
|
||||
channel =
|
||||
ChatChannel.includes(:chatable).find_by(
|
||||
id: args[:chat_channel_id],
|
||||
auto_join_users: true,
|
||||
chatable_type: "Category",
|
||||
)
|
||||
|
||||
return if !channel&.chatable
|
||||
|
||||
processed =
|
||||
UserChatChannelMembership.where(
|
||||
chat_channel: channel,
|
||||
following: true,
|
||||
join_mode: UserChatChannelMembership.join_modes[:automatic],
|
||||
).count
|
||||
|
||||
auto_join_query(channel).find_in_batches do |batch|
|
||||
break if processed >= SiteSetting.max_chat_auto_joined_users
|
||||
|
||||
starts_at = batch.first.query_user_id
|
||||
ends_at = batch.last.query_user_id
|
||||
|
||||
Jobs.enqueue(
|
||||
:auto_join_channel_batch,
|
||||
chat_channel_id: channel.id,
|
||||
starts_at: starts_at,
|
||||
ends_at: ends_at,
|
||||
)
|
||||
|
||||
processed += batch.size
|
||||
end
|
||||
|
||||
# The Jobs::AutoJoinChannelBatch job will only do this recalculation
|
||||
# if it's operating on one user, so we need to make sure we do it for
|
||||
# the channel here once this job is complete.
|
||||
Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def auto_join_query(channel)
|
||||
category = channel.chatable
|
||||
|
||||
users =
|
||||
User
|
||||
.real
|
||||
.activated
|
||||
.not_suspended
|
||||
.not_staged
|
||||
.distinct
|
||||
.select(:id, "users.id AS query_user_id")
|
||||
.where("last_seen_at > ?", 3.months.ago)
|
||||
.joins(:user_option)
|
||||
.where(user_options: { chat_enabled: true })
|
||||
.joins(<<~SQL)
|
||||
LEFT OUTER JOIN user_chat_channel_memberships uccm
|
||||
ON uccm.chat_channel_id = #{channel.id} AND
|
||||
uccm.user_id = users.id
|
||||
SQL
|
||||
.where("uccm.id IS NULL")
|
||||
|
||||
if category.read_restricted?
|
||||
users =
|
||||
users
|
||||
.joins(:group_users)
|
||||
.joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id")
|
||||
.where("cg.category_id = ?", channel.chatable_id)
|
||||
end
|
||||
|
||||
users
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,83 +0,0 @@
|
||||
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
module Chat
|
||||
class AutoJoinChannelBatch < ::Jobs::Base
|
||||
def execute(args)
|
||||
return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank?
|
||||
start_user_id = args[:starts_at].to_i
|
||||
end_user_id = args[:ends_at].to_i
|
||||
|
||||
return "End is higher than start" if end_user_id < start_user_id
|
||||
|
||||
channel =
|
||||
::Chat::Channel.find_by(
|
||||
id: args[:chat_channel_id],
|
||||
auto_join_users: true,
|
||||
chatable_type: "Category",
|
||||
)
|
||||
|
||||
return if !channel
|
||||
|
||||
category = channel.chatable
|
||||
return if !category
|
||||
|
||||
query_args = {
|
||||
chat_channel_id: channel.id,
|
||||
start: start_user_id,
|
||||
end: end_user_id,
|
||||
suspended_until: Time.zone.now,
|
||||
last_seen_at: 3.months.ago,
|
||||
channel_category: channel.chatable_id,
|
||||
mode: ::Chat::UserChatChannelMembership.join_modes[:automatic],
|
||||
}
|
||||
|
||||
new_member_ids = DB.query_single(create_memberships_query(category), query_args)
|
||||
|
||||
# Only do this if we are running auto-join for a single user, if we
|
||||
# are doing it for many then we should do it after all batches are
|
||||
# complete for the channel in Jobs::Chat::AutoManageChannelMemberships
|
||||
if start_user_id == end_user_id
|
||||
::Chat::ChannelMembershipManager.new(channel).recalculate_user_count
|
||||
end
|
||||
|
||||
::Chat::Publisher.publish_new_channel(channel.reload, User.where(id: new_member_ids))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_memberships_query(category)
|
||||
query = <<~SQL
|
||||
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode)
|
||||
SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode
|
||||
FROM users
|
||||
INNER JOIN user_options uo ON uo.user_id = users.id
|
||||
LEFT OUTER JOIN user_chat_channel_memberships uccm ON
|
||||
uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id
|
||||
SQL
|
||||
|
||||
query += <<~SQL if category.read_restricted?
|
||||
INNER JOIN group_users gu ON gu.user_id = users.id
|
||||
LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id
|
||||
SQL
|
||||
|
||||
query += <<~SQL
|
||||
WHERE (users.id >= :start AND users.id <= :end) AND
|
||||
users.staged IS FALSE AND users.active AND
|
||||
NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND
|
||||
(suspended_till IS NULL OR suspended_till <= :suspended_until) AND
|
||||
(last_seen_at > :last_seen_at) AND
|
||||
uo.chat_enabled AND
|
||||
uccm.id IS NULL
|
||||
SQL
|
||||
|
||||
query += <<~SQL if category.read_restricted?
|
||||
AND cg.category_id = :channel_category
|
||||
SQL
|
||||
|
||||
query += "RETURNING user_chat_channel_memberships.user_id"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,81 +0,0 @@
|
||||
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
module Chat
|
||||
class AutoManageChannelMemberships < ::Jobs::Base
|
||||
def execute(args)
|
||||
channel =
|
||||
::Chat::Channel.includes(:chatable).find_by(
|
||||
id: args[:chat_channel_id],
|
||||
auto_join_users: true,
|
||||
chatable_type: "Category",
|
||||
)
|
||||
|
||||
return if !channel&.chatable
|
||||
|
||||
processed =
|
||||
::Chat::UserChatChannelMembership.where(
|
||||
chat_channel: channel,
|
||||
following: true,
|
||||
join_mode: ::Chat::UserChatChannelMembership.join_modes[:automatic],
|
||||
).count
|
||||
|
||||
auto_join_query(channel).find_in_batches do |batch|
|
||||
break if processed >= ::SiteSetting.max_chat_auto_joined_users
|
||||
|
||||
starts_at = batch.first.query_user_id
|
||||
ends_at = batch.last.query_user_id
|
||||
|
||||
::Jobs.enqueue(
|
||||
::Jobs::Chat::AutoJoinChannelBatch,
|
||||
chat_channel_id: channel.id,
|
||||
starts_at: starts_at,
|
||||
ends_at: ends_at,
|
||||
)
|
||||
|
||||
processed += batch.size
|
||||
end
|
||||
|
||||
# The Jobs::Chat::AutoJoinChannelBatch job will only do this recalculation
|
||||
# if it's operating on one user, so we need to make sure we do it for
|
||||
# the channel here once this job is complete.
|
||||
::Chat::ChannelMembershipManager.new(channel).recalculate_user_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def auto_join_query(channel)
|
||||
category = channel.chatable
|
||||
|
||||
users =
|
||||
::User
|
||||
.real
|
||||
.activated
|
||||
.not_suspended
|
||||
.not_staged
|
||||
.distinct
|
||||
.select(:id, "users.id AS query_user_id")
|
||||
.where("last_seen_at > ?", 3.months.ago)
|
||||
.joins(:user_option)
|
||||
.where(user_options: { chat_enabled: true })
|
||||
.joins(<<~SQL)
|
||||
LEFT OUTER JOIN user_chat_channel_memberships uccm
|
||||
ON uccm.chat_channel_id = #{channel.id} AND
|
||||
uccm.user_id = users.id
|
||||
SQL
|
||||
.where("uccm.id IS NULL")
|
||||
|
||||
if category.read_restricted?
|
||||
users =
|
||||
users
|
||||
.joins(:group_users)
|
||||
.joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id")
|
||||
.where("cg.category_id = ?", channel.chatable_id)
|
||||
end
|
||||
|
||||
users
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,40 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
module Chat
|
||||
class ChannelArchive < ::Jobs::Base
|
||||
sidekiq_options retry: false
|
||||
|
||||
def execute(args = {})
|
||||
channel_archive = ::Chat::ChannelArchive.find_by(id: args[:chat_channel_archive_id])
|
||||
|
||||
# this should not really happen, but better to do this than throw an error
|
||||
if channel_archive.blank?
|
||||
::Rails.logger.warn(
|
||||
"Chat channel archive #{args[:chat_channel_archive_id]} could not be found, aborting archive job.",
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
if channel_archive.complete?
|
||||
channel_archive.chat_channel.update!(status: :archived)
|
||||
|
||||
::Chat::Publisher.publish_archive_status(
|
||||
channel_archive.chat_channel,
|
||||
archive_status: :success,
|
||||
archived_messages: channel_archive.archived_messages,
|
||||
archive_topic_id: channel_archive.destination_topic_id,
|
||||
total_messages: channel_archive.total_messages,
|
||||
)
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
::DistributedMutex.synchronize(
|
||||
"archive_chat_channel_#{channel_archive.chat_channel_id}",
|
||||
validity: 20.minutes,
|
||||
) { ::Chat::ChannelArchiveService.new(channel_archive).execute }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,63 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
module Chat
|
||||
class ChannelDelete < ::Jobs::Base
|
||||
def execute(args = {})
|
||||
chat_channel = ::Chat::Channel.with_deleted.find_by(id: args[:chat_channel_id])
|
||||
|
||||
# this should not really happen, but better to do this than throw an error
|
||||
if chat_channel.blank?
|
||||
::Rails.logger.warn(
|
||||
"Chat channel #{args[:chat_channel_id]} could not be found, aborting delete job.",
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
::DistributedMutex.synchronize("delete_chat_channel_#{chat_channel.id}") do
|
||||
::Rails.logger.debug("Deleting webhooks and events for channel #{chat_channel.id}")
|
||||
::Chat::Message.transaction do
|
||||
webhooks = ::Chat::IncomingWebhook.where(chat_channel: chat_channel)
|
||||
::Chat::WebhookEvent.where(incoming_chat_webhook_id: webhooks.select(:id)).delete_all
|
||||
webhooks.delete_all
|
||||
end
|
||||
|
||||
::Rails.logger.debug("Deleting drafts and memberships for channel #{chat_channel.id}")
|
||||
::Chat::Draft.where(chat_channel: chat_channel).delete_all
|
||||
::Chat::UserChatChannelMembership.where(chat_channel: chat_channel).delete_all
|
||||
|
||||
::Rails.logger.debug(
|
||||
"Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}",
|
||||
)
|
||||
chat_messages = ::Chat::Message.where(chat_channel: chat_channel)
|
||||
delete_messages_and_related_records(chat_channel, chat_messages) if chat_messages.any?
|
||||
end
|
||||
end
|
||||
|
||||
def delete_messages_and_related_records(chat_channel, chat_messages)
|
||||
message_ids = chat_messages.pluck(:id)
|
||||
|
||||
::Chat::Message.transaction do
|
||||
::Chat::Mention.where(chat_message_id: message_ids).delete_all
|
||||
::Chat::MessageRevision.where(chat_message_id: message_ids).delete_all
|
||||
::Chat::MessageReaction.where(chat_message_id: message_ids).delete_all
|
||||
|
||||
# if the uploads are not used anywhere else they will be deleted
|
||||
# by the CleanUpUploads job in core
|
||||
::DB.exec("DELETE FROM chat_uploads WHERE chat_message_id IN (#{message_ids.join(",")})")
|
||||
::UploadReference.where(
|
||||
target_id: message_ids,
|
||||
target_type: ::Chat::Message.sti_name,
|
||||
).delete_all
|
||||
|
||||
# only the messages and the channel are Trashable, everything else gets
|
||||
# permanently destroyed
|
||||
chat_messages.update_all(
|
||||
deleted_by_id: chat_channel.deleted_by_id,
|
||||
deleted_at: Time.zone.now,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,15 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
module Chat
|
||||
class DeleteUserMessages < ::Jobs::Base
|
||||
def execute(args)
|
||||
return if args[:user_id].nil?
|
||||
|
||||
::Chat::MessageDestroyer.new.destroy_in_batches(
|
||||
::Chat::Message.with_deleted.where(user_id: args[:user_id]),
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,148 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
module Chat
|
||||
class NotifyMentioned < ::Jobs::Base
|
||||
def execute(args = {})
|
||||
@chat_message =
|
||||
::Chat::Message.includes(:user, :revisions, chat_channel: :chatable).find_by(
|
||||
id: args[:chat_message_id],
|
||||
)
|
||||
if @chat_message.nil? ||
|
||||
@chat_message.revisions.where("created_at > ?", args[:timestamp]).any?
|
||||
return
|
||||
end
|
||||
|
||||
@creator = @chat_message.user
|
||||
@chat_channel = @chat_message.chat_channel
|
||||
@already_notified_user_ids = args[:already_notified_user_ids] || []
|
||||
user_ids_to_notify = args[:to_notify_ids_map] || {}
|
||||
user_ids_to_notify.each { |mention_type, ids| process_mentions(ids, mention_type.to_sym) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_memberships(user_ids)
|
||||
query =
|
||||
::Chat::UserChatChannelMembership.includes(:user).where(
|
||||
user_id: (user_ids - @already_notified_user_ids),
|
||||
chat_channel_id: @chat_message.chat_channel_id,
|
||||
)
|
||||
query = query.where(following: true) if @chat_channel.public_channel?
|
||||
query
|
||||
end
|
||||
|
||||
def build_data_for(membership, identifier_type:)
|
||||
data = {
|
||||
chat_message_id: @chat_message.id,
|
||||
chat_channel_id: @chat_channel.id,
|
||||
mentioned_by_username: @creator.username,
|
||||
is_direct_message_channel: @chat_channel.direct_message_channel?,
|
||||
}
|
||||
|
||||
if !@is_direct_message_channel
|
||||
data[:chat_channel_title] = @chat_channel.title(membership.user)
|
||||
data[:chat_channel_slug] = @chat_channel.slug
|
||||
end
|
||||
|
||||
return data if identifier_type == :direct_mentions
|
||||
|
||||
case identifier_type
|
||||
when :here_mentions
|
||||
data[:identifier] = "here"
|
||||
when :global_mentions
|
||||
data[:identifier] = "all"
|
||||
else
|
||||
data[:identifier] = identifier_type if identifier_type
|
||||
data[:is_group_mention] = true
|
||||
end
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
def build_payload_for(membership, identifier_type:)
|
||||
payload = {
|
||||
notification_type: ::Notification.types[:chat_mention],
|
||||
username: @creator.username,
|
||||
tag: ::Chat::Notifier.push_notification_tag(:mention, @chat_channel.id),
|
||||
excerpt: @chat_message.push_notification_excerpt,
|
||||
post_url: "#{@chat_channel.relative_url}/#{@chat_message.id}",
|
||||
}
|
||||
|
||||
translation_prefix =
|
||||
(
|
||||
if @chat_channel.direct_message_channel?
|
||||
"discourse_push_notifications.popup.direct_message_chat_mention"
|
||||
else
|
||||
"discourse_push_notifications.popup.chat_mention"
|
||||
end
|
||||
)
|
||||
|
||||
translation_suffix = identifier_type == :direct_mentions ? "direct" : "other_type"
|
||||
identifier_text =
|
||||
case identifier_type
|
||||
when :here_mentions
|
||||
"@here"
|
||||
when :global_mentions
|
||||
"@all"
|
||||
when :direct_mentions
|
||||
""
|
||||
else
|
||||
"@#{identifier_type}"
|
||||
end
|
||||
|
||||
payload[:translated_title] = ::I18n.t(
|
||||
"#{translation_prefix}.#{translation_suffix}",
|
||||
username: @creator.username,
|
||||
identifier: identifier_text,
|
||||
channel: @chat_channel.title(membership.user),
|
||||
)
|
||||
|
||||
payload
|
||||
end
|
||||
|
||||
def create_notification!(membership, mention, mention_type)
|
||||
notification_data = build_data_for(membership, identifier_type: mention_type)
|
||||
is_read = ::Chat::Notifier.user_has_seen_message?(membership, @chat_message.id)
|
||||
notification =
|
||||
::Notification.create!(
|
||||
notification_type: ::Notification.types[:chat_mention],
|
||||
user_id: membership.user_id,
|
||||
high_priority: true,
|
||||
data: notification_data.to_json,
|
||||
read: is_read,
|
||||
)
|
||||
|
||||
mention.update!(notification: notification)
|
||||
end
|
||||
|
||||
def send_notifications(membership, mention_type)
|
||||
payload = build_payload_for(membership, identifier_type: mention_type)
|
||||
|
||||
if !membership.desktop_notifications_never? && !membership.muted?
|
||||
::MessageBus.publish(
|
||||
"/chat/notification-alert/#{membership.user_id}",
|
||||
payload,
|
||||
user_ids: [membership.user_id],
|
||||
)
|
||||
end
|
||||
|
||||
if !membership.mobile_notifications_never? && !membership.muted?
|
||||
::PostAlerter.push_notification(membership.user, payload)
|
||||
end
|
||||
end
|
||||
|
||||
def process_mentions(user_ids, mention_type)
|
||||
memberships = get_memberships(user_ids)
|
||||
|
||||
memberships.each do |membership|
|
||||
mention = ::Chat::Mention.find_by(user: membership.user, chat_message: @chat_message)
|
||||
if mention.present?
|
||||
create_notification!(membership, mention, mention_type)
|
||||
send_notifications(membership, mention_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user