mirror of
https://github.com/flarum/core.git
synced 2025-08-13 20:04:24 +02:00
Compare commits
139 Commits
as/comment
...
post_strea
Author | SHA1 | Date | |
---|---|---|---|
|
fc5eddb99d | ||
|
bad8115a4a | ||
|
1fc76acf06 | ||
|
527d93120a | ||
|
53582ab999 | ||
|
6f6a09d7c4 | ||
|
e8394e4a1d | ||
|
e455e6c431 | ||
|
a044c642f6 | ||
|
01384139ef | ||
|
57f5ad4893 | ||
|
8b69b24272 | ||
|
09c722e522 | ||
|
3ce94757fc | ||
|
aae6f24356 | ||
|
1a2f9527fd | ||
|
8c362bf7c7 | ||
|
f99f79e3c0 | ||
|
bbd8136695 | ||
|
1d8662088f | ||
|
a850f4a6fb | ||
|
af55a13c61 | ||
|
92b62e7ab6 | ||
|
5ef4de75d1 | ||
|
88e6be9d0e | ||
|
228c7b883d | ||
|
cdcf64852e | ||
|
d20650fb42 | ||
|
875a1f70c1 | ||
|
ef206495cd | ||
|
2360745237 | ||
|
cc10eaadd2 | ||
|
c98c0b027f | ||
|
73507f403a | ||
|
d3fb5ee77c | ||
|
479e5a8cf6 | ||
|
4bce030115 | ||
|
9f2540dbe3 | ||
|
aa15db6f44 | ||
|
0c63be527b | ||
|
9db2f78939 | ||
|
9572863648 | ||
|
ac1eef7578 | ||
|
514165c3af | ||
|
e84960dcd1 | ||
|
f8d1c7a317 | ||
|
ba82969a58 | ||
|
b2917c8716 | ||
|
c150c097c1 | ||
|
beab8ce39c | ||
|
1360723c3f | ||
|
5cdfeaf9a5 | ||
|
6e1d385268 | ||
|
193f3b040d | ||
|
74cb4f9007 | ||
|
eb24e628fa | ||
|
c03feceb9f | ||
|
51008bc65d | ||
|
9a357f5d19 | ||
|
9c63c54868 | ||
|
5427b35c6d | ||
|
2ec49db6df | ||
|
062dc8f57f | ||
|
8a9e50d192 | ||
|
6c087da65f | ||
|
6bcecd623b | ||
|
614bb0d71e | ||
|
cff9b327a9 | ||
|
7af8e35a6e | ||
|
f9c9b5d5e4 | ||
|
8a73cc522e | ||
|
db83003eb5 | ||
|
4dc4dc624e | ||
|
ad42058a8a | ||
|
5e465f6051 | ||
|
62a2e8463d | ||
|
0098c64ebf | ||
|
2b5939d538 | ||
|
2431df5602 | ||
|
264ff67304 | ||
|
c08a56e9d8 | ||
|
4ee6d6fd88 | ||
|
9c09fe8465 | ||
|
b46d5e67a3 | ||
|
7fd23ff950 | ||
|
e4077ab4ad | ||
|
3b39c212e0 | ||
|
bca833d3f1 | ||
|
451a557532 | ||
|
eaac78650f | ||
|
2b3dec2be1 | ||
|
37ebeb5705 | ||
|
71abac0323 | ||
|
7e3d71a0a0 | ||
|
b5e891df30 | ||
|
3117d2ad7a | ||
|
1ce0b926b6 | ||
|
24b16f9d7c | ||
|
bd40353bcc | ||
|
455327cca1 | ||
|
20baa93ca7 | ||
|
4f34e326ef | ||
|
521cefbc2d | ||
|
dc738d68dc | ||
|
286af7084b | ||
|
4869baea74 | ||
|
24a48310ff | ||
|
bdb759c558 | ||
|
36eb5cc5fb | ||
|
d189272473 | ||
|
7d48c24dda | ||
|
5786f1a10b | ||
|
b4421e1cce | ||
|
359b4ab5a3 | ||
|
8a686911ff | ||
|
0b5a9a2fe6 | ||
|
50a9f7ce86 | ||
|
8dd5420405 | ||
|
640cc0989b | ||
|
44376cef61 | ||
|
4f181c84fc | ||
|
ea9d601338 | ||
|
aaebd3581f | ||
|
e2c416903e | ||
|
e81159249f | ||
|
d93cf4a574 | ||
|
a33fbbf814 | ||
|
0c645a6c15 | ||
|
b44b79eba9 | ||
|
93398b738b | ||
|
7816b61bfb | ||
|
7dc3a194c3 | ||
|
cea7824b57 | ||
|
088eb0c4f2 | ||
|
2ba67b021f | ||
|
92791a253d | ||
|
138c784a50 | ||
|
bb567e5278 | ||
|
cf4f2f283e |
@@ -41,21 +41,21 @@
|
||||
"dflydev/fig-cookies": "^2.0.1",
|
||||
"doctrine/dbal": "^2.7",
|
||||
"franzl/whoops-middleware": "^0.4.0",
|
||||
"illuminate/bus": "5.8.*",
|
||||
"illuminate/cache": "5.8.*",
|
||||
"illuminate/config": "5.8.*",
|
||||
"illuminate/container": "5.8.*",
|
||||
"illuminate/contracts": "5.8.*",
|
||||
"illuminate/database": "5.8.*",
|
||||
"illuminate/events": "5.8.*",
|
||||
"illuminate/filesystem": "5.8.*",
|
||||
"illuminate/hashing": "5.8.*",
|
||||
"illuminate/mail": "5.8.*",
|
||||
"illuminate/queue": "5.8.*",
|
||||
"illuminate/session": "5.8.*",
|
||||
"illuminate/support": "5.8.*",
|
||||
"illuminate/validation": "5.8.*",
|
||||
"illuminate/view": "5.8.*",
|
||||
"illuminate/bus": "^6.0",
|
||||
"illuminate/cache": "^6.0",
|
||||
"illuminate/config": "^6.0",
|
||||
"illuminate/container": "^6.0",
|
||||
"illuminate/contracts": "^6.0",
|
||||
"illuminate/database": "^6.0",
|
||||
"illuminate/events": "^6.0",
|
||||
"illuminate/filesystem": "^6.0",
|
||||
"illuminate/hashing": "^6.0",
|
||||
"illuminate/mail": "^6.0",
|
||||
"illuminate/queue": "^6.0",
|
||||
"illuminate/session": "^6.0",
|
||||
"illuminate/support": "^6.0",
|
||||
"illuminate/validation": "^6.0",
|
||||
"illuminate/view": "^6.0",
|
||||
"intervention/image": "^2.5.0",
|
||||
"laminas/laminas-diactoros": "^1.8.4",
|
||||
"laminas/laminas-httphandlerrunner": "^1.0",
|
||||
@@ -66,6 +66,7 @@
|
||||
"middlewares/base-path-router": "^0.2.1",
|
||||
"middlewares/request-handler": "^1.2",
|
||||
"monolog/monolog": "^1.16.0",
|
||||
"nesbot/carbon": "^2.0",
|
||||
"nikic/fast-route": "^0.6",
|
||||
"psr/http-message": "^1.0",
|
||||
"psr/http-server-handler": "^1.0",
|
||||
|
6
js/dist/admin.js
vendored
6
js/dist/admin.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
vendored
2
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
8
js/dist/forum.js
vendored
8
js/dist/forum.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
vendored
2
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
211
js/package-lock.json
generated
211
js/package-lock.json
generated
@@ -289,6 +289,11 @@
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.3.tgz",
|
||||
"integrity": "sha512-bU8JvtlYpJSBPuj1VUmKpFGaDZuLxASky3LhaKj3bmpSTY6VWooSM8msk+Z0CZoErFye2tlABF6yDkT3FOPAXw=="
|
||||
},
|
||||
"@babel/helper-wrap-function": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz",
|
||||
@@ -445,6 +450,21 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-syntax-typescript": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.10.1.tgz",
|
||||
"integrity": "sha512-X/d8glkrAtra7CaQGMiGs/OGa6XgUzqPcBXCIGFCpCqnfGlT0Wfbzo/B89xHhnInTaItPK8LALblVXcUOEh95Q==",
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
|
||||
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-arrow-functions": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz",
|
||||
@@ -740,6 +760,159 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-typescript": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.10.3.tgz",
|
||||
"integrity": "sha512-qU9Lu7oQyh3PGMQncNjQm8RWkzw6LqsWZQlZPQMgrGt6s3YiBIaQ+3CQV/FA/icGS5XlSWZGwo/l8ErTyelS0Q==",
|
||||
"requires": {
|
||||
"@babel/helper-create-class-features-plugin": "^7.10.3",
|
||||
"@babel/helper-plugin-utils": "^7.10.3",
|
||||
"@babel/plugin-syntax-typescript": "^7.10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.3.tgz",
|
||||
"integrity": "sha512-fDx9eNW0qz0WkUeqL6tXEXzVlPh6Y5aCDEZesl0xBGA8ndRukX91Uk44ZqnkECp01NAZUdCAl+aiQNGi0k88Eg==",
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/generator": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.10.3.tgz",
|
||||
"integrity": "sha512-drt8MUHbEqRzNR0xnF8nMehbY11b1SDkRw03PSNH/3Rb2Z35oxkddVSi3rcaak0YJQ86PCuE7Qx1jSFhbLNBMA==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3",
|
||||
"jsesc": "^2.5.1",
|
||||
"lodash": "^4.17.13",
|
||||
"source-map": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.3.tgz",
|
||||
"integrity": "sha512-iRT9VwqtdFmv7UheJWthGc/h2s7MqoweBF9RUj77NFZsg9VfISvBTum3k6coAhJ8RWv2tj3yUjA03HxPd0vfpQ==",
|
||||
"requires": {
|
||||
"@babel/helper-function-name": "^7.10.3",
|
||||
"@babel/helper-member-expression-to-functions": "^7.10.3",
|
||||
"@babel/helper-optimise-call-expression": "^7.10.3",
|
||||
"@babel/helper-plugin-utils": "^7.10.3",
|
||||
"@babel/helper-replace-supers": "^7.10.1",
|
||||
"@babel/helper-split-export-declaration": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-function-name": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.3.tgz",
|
||||
"integrity": "sha512-FvSj2aiOd8zbeqijjgqdMDSyxsGHaMt5Tr0XjQsGKHD3/1FP3wksjnLAWzxw7lvXiej8W1Jt47SKTZ6upQNiRw==",
|
||||
"requires": {
|
||||
"@babel/helper-get-function-arity": "^7.10.3",
|
||||
"@babel/template": "^7.10.3",
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-get-function-arity": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.3.tgz",
|
||||
"integrity": "sha512-iUD/gFsR+M6uiy69JA6fzM5seno8oE85IYZdbVVEuQaZlEzMO2MXblh+KSPJgsZAUx0EEbWXU0yJaW7C9CdAVg==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-member-expression-to-functions": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.10.3.tgz",
|
||||
"integrity": "sha512-q7+37c4EPLSjNb2NmWOjNwj0+BOyYlssuQ58kHEWk1Z78K5i8vTUsteq78HMieRPQSl/NtpQyJfdjt3qZ5V2vw==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-optimise-call-expression": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.3.tgz",
|
||||
"integrity": "sha512-kT2R3VBH/cnSz+yChKpaKRJQJWxdGoc6SjioRId2wkeV3bK0wLLioFpJROrX0U4xr/NmxSSAWT/9Ih5snwIIzg==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
|
||||
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
|
||||
},
|
||||
"@babel/helper-replace-supers": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.1.tgz",
|
||||
"integrity": "sha512-SOwJzEfpuQwInzzQJGjGaiG578UYmyi2Xw668klPWV5n07B73S0a9btjLk/52Mlcxa+5AdIYqws1KyXRfMoB7A==",
|
||||
"requires": {
|
||||
"@babel/helper-member-expression-to-functions": "^7.10.1",
|
||||
"@babel/helper-optimise-call-expression": "^7.10.1",
|
||||
"@babel/traverse": "^7.10.1",
|
||||
"@babel/types": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-split-export-declaration": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.1.tgz",
|
||||
"integrity": "sha512-UQ1LVBPrYdbchNhLwj6fetj46BcFwfS4NllJo/1aJsT+1dLTEnXJL0qHqtY7gPzF8S2fXBJamf1biAXV3X077g==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.3.tgz",
|
||||
"integrity": "sha512-Ih9B/u7AtgEnySE2L2F0Xm0GaM729XqqLfHkalTsbjXGyqmf/6M0Cu0WpvqueUlW+xk88BHw9Nkpj49naU+vWw==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.10.3",
|
||||
"chalk": "^2.0.0",
|
||||
"js-tokens": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.3.tgz",
|
||||
"integrity": "sha512-oJtNJCMFdIMwXGmx+KxuaD7i3b8uS7TTFYW/FNG2BT8m+fmGHoiPYoH0Pe3gya07WuFmM5FCDIr1x0irkD/hyA=="
|
||||
},
|
||||
"@babel/template": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.3.tgz",
|
||||
"integrity": "sha512-5BjI4gdtD+9fHZUsaxPHPNpwa+xRkDO7c7JbhYn2afvrkDu5SfAAbi9AIMXw2xEhO/BR35TqiW97IqNvCo/GqA==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.10.3",
|
||||
"@babel/parser": "^7.10.3",
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/traverse": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.3.tgz",
|
||||
"integrity": "sha512-qO6623eBFhuPm0TmmrUFMT1FulCmsSeJuVGhiLodk2raUDFhhTECLd9E9jC4LBIWziqt4wgF6KuXE4d+Jz9yug==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.10.3",
|
||||
"@babel/generator": "^7.10.3",
|
||||
"@babel/helper-function-name": "^7.10.3",
|
||||
"@babel/helper-split-export-declaration": "^7.10.1",
|
||||
"@babel/parser": "^7.10.3",
|
||||
"@babel/types": "^7.10.3",
|
||||
"debug": "^4.1.0",
|
||||
"globals": "^11.1.0",
|
||||
"lodash": "^4.17.13"
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.3.tgz",
|
||||
"integrity": "sha512-nZxaJhBXBQ8HVoIcGsf9qWep3Oh3jCENK54V4mRF7qaJabVsAYdbTtmSD8WmAp1R6ytPiu5apMwSXyxB1WlaBA==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.10.3",
|
||||
"lodash": "^4.17.13",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-unicode-regex": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.2.0.tgz",
|
||||
@@ -812,6 +985,22 @@
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/preset-typescript": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.10.1.tgz",
|
||||
"integrity": "sha512-m6GV3y1ShiqxnyQj10600ZVOFrSSAa8HQ3qIUk2r+gcGtHTIRw0dJnFLt1WNXpKjtVw7yw1DAPU/6ma2ZvgJuA==",
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.10.1",
|
||||
"@babel/plugin-transform-typescript": "^7.10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
|
||||
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/runtime": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.1.5.tgz",
|
||||
@@ -1834,6 +2023,11 @@
|
||||
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
|
||||
"integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk="
|
||||
},
|
||||
"dayjs": {
|
||||
"version": "1.8.28",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.28.tgz",
|
||||
"integrity": "sha512-ccnYgKC0/hPSGXxj7Ju6AV/BP4HUkXC2u15mikXT5mX9YorEaoi1bEKOmAqdkJHN4EEkmAf97SpH66Try5Mbeg=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
|
||||
@@ -1942,9 +2136,9 @@
|
||||
"integrity": "sha512-De+lPAxEcpxvqPTyZAXELNpRZXABRxf+uL/rSykstQhzj/B0l1150G/ExIIxKc16lI89Hgz81J0BHAcbTqK49g=="
|
||||
},
|
||||
"elliptic": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
|
||||
"integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
|
||||
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
|
||||
"requires": {
|
||||
"bn.js": "^4.4.0",
|
||||
"brorand": "^1.0.1",
|
||||
@@ -3443,9 +3637,9 @@
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
|
||||
"version": "4.17.19",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
|
||||
"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
|
||||
},
|
||||
"lodash-es": {
|
||||
"version": "4.17.14",
|
||||
@@ -3644,11 +3838,6 @@
|
||||
"minimist": "^1.2.5"
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.22.2",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz",
|
||||
"integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y="
|
||||
},
|
||||
"move-concurrently": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
|
||||
|
@@ -2,9 +2,11 @@
|
||||
"private": true,
|
||||
"name": "@flarum/core",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"bootstrap": "^3.4.1",
|
||||
"classnames": "^2.2.5",
|
||||
"color-thief-browser": "^2.0.2",
|
||||
"dayjs": "^1.8.28",
|
||||
"expose-loader": "^0.7.5",
|
||||
"flarum-webpack-config": "0.1.0-beta.10",
|
||||
"jquery": "^3.4.1",
|
||||
@@ -12,7 +14,6 @@
|
||||
"lodash-es": "^4.17.14",
|
||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||
"mithril": "^0.2.8",
|
||||
"moment": "^2.22.2",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.0",
|
||||
"webpack": "^4.43.0",
|
||||
|
@@ -13,8 +13,8 @@ export default class AppearancePage extends Page {
|
||||
|
||||
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
|
||||
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
|
||||
this.darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
|
||||
this.coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
|
||||
this.darkMode = m.prop(app.data.settings.theme_dark_mode);
|
||||
this.coloredHeader = m.prop(app.data.settings.theme_colored_header);
|
||||
}
|
||||
|
||||
view() {
|
||||
@@ -82,7 +82,7 @@ export default class AppearancePage extends Page {
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_header_button'),
|
||||
onclick: () => app.modal.show(new EditCustomHeaderModal()),
|
||||
onclick: () => app.modal.show(EditCustomHeaderModal),
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
@@ -92,7 +92,7 @@ export default class AppearancePage extends Page {
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
|
||||
onclick: () => app.modal.show(new EditCustomFooterModal()),
|
||||
onclick: () => app.modal.show(EditCustomFooterModal),
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
@@ -102,7 +102,7 @@ export default class AppearancePage extends Page {
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_css_button'),
|
||||
onclick: () => app.modal.show(new EditCustomCssModal()),
|
||||
onclick: () => app.modal.show(EditCustomCssModal),
|
||||
})}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
@@ -2,7 +2,6 @@ import Page from '../../common/components/Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Select from '../../common/components/Select';
|
||||
import Button from '../../common/components/Button';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Switch from '../../common/components/Switch';
|
||||
@@ -186,7 +185,10 @@ export default class BasicsPage extends Page {
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
|
||||
this.successAlert = app.alerts.show({
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.admin.basics.saved_message'),
|
||||
});
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
|
@@ -16,7 +16,7 @@ export default class ExtensionsPage extends Page {
|
||||
children: app.translator.trans('core.admin.extensions.add_button'),
|
||||
icon: 'fas fa-plus',
|
||||
className: 'Button Button--primary',
|
||||
onclick: () => app.modal.show(new AddExtensionModal()),
|
||||
onclick: () => app.modal.show(AddExtensionModal),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@ export default class ExtensionsPage extends Page {
|
||||
})
|
||||
.then(() => window.location.reload());
|
||||
|
||||
app.modal.show(new LoadingModal());
|
||||
app.modal.show(LoadingModal);
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -123,6 +123,6 @@ export default class ExtensionsPage extends Page {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
app.modal.show(new LoadingModal());
|
||||
app.modal.show(LoadingModal);
|
||||
}
|
||||
}
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class LoadingModal extends Modal {
|
||||
isDismissible() {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
static isDismissible = false;
|
||||
|
||||
className() {
|
||||
return 'LoadingModal Modal--small';
|
||||
|
@@ -179,9 +179,10 @@ export default class MailPage extends Page {
|
||||
})
|
||||
.then((response) => {
|
||||
this.sendingTest = false;
|
||||
app.alerts.show(
|
||||
(this.testEmailSuccessAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.email.send_test_mail_success') }))
|
||||
);
|
||||
this.testEmailSuccessAlert = app.alerts.show({
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.admin.email.send_test_mail_success'),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.sendingTest = false;
|
||||
@@ -204,7 +205,10 @@ export default class MailPage extends Page {
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
|
||||
this.successAlert = app.alerts.show({
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.admin.basics.saved_message'),
|
||||
});
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
|
@@ -15,7 +15,7 @@ export default class PermissionsPage extends Page {
|
||||
.all('groups')
|
||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) => (
|
||||
<button className="Button Group" onclick={() => app.modal.show(new EditGroupModal({ group }))}>
|
||||
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
@@ -24,7 +24,7 @@ export default class PermissionsPage extends Page {
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(new EditGroupModal())}>
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
|
||||
{icon('fas fa-plus', { className: 'Group-icon' })}
|
||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||
</button>
|
||||
|
@@ -46,7 +46,7 @@ export default class StatusWidget extends DashboardWidget {
|
||||
}
|
||||
|
||||
handleClearCache(e) {
|
||||
app.modal.show(new LoadingModal());
|
||||
app.modal.show(LoadingModal);
|
||||
|
||||
app
|
||||
.request({
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import ItemList from './utils/ItemList';
|
||||
import Alert from './components/Alert';
|
||||
import Button from './components/Button';
|
||||
import ModalManager from './components/ModalManager';
|
||||
import AlertManager from './components/AlertManager';
|
||||
@@ -12,6 +11,7 @@ import Drawer from './utils/Drawer';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import RequestError from './utils/RequestError';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import liveHumanTimes from './utils/liveHumanTimes';
|
||||
import { extend } from './extend';
|
||||
|
||||
import Forum from './models/Forum';
|
||||
@@ -22,6 +22,8 @@ import Group from './models/Group';
|
||||
import Notification from './models/Notification';
|
||||
import { flattenDeep } from 'lodash-es';
|
||||
import PageState from './states/PageState';
|
||||
import ModalManagerState from './states/ModalManagerState';
|
||||
import AlertManagerState from './states/AlertManagerState';
|
||||
|
||||
/**
|
||||
* The `App` class provides a container for an application, as well as various
|
||||
@@ -108,13 +110,13 @@ export default class Application {
|
||||
booted = false;
|
||||
|
||||
/**
|
||||
* An Alert that was shown as a result of an AJAX request error. If present,
|
||||
* it will be dismissed on the next successful request.
|
||||
* The key for an Alert that was shown as a result of an AJAX request error.
|
||||
* If present, it will be dismissed on the next successful request.
|
||||
*
|
||||
* @type {null|Alert}
|
||||
* @type {int}
|
||||
* @private
|
||||
*/
|
||||
requestError = null;
|
||||
requestErrorAlert = null;
|
||||
|
||||
/**
|
||||
* The page the app is currently on.
|
||||
@@ -138,6 +140,20 @@ export default class Application {
|
||||
*/
|
||||
previous = new PageState(null);
|
||||
|
||||
/*
|
||||
* An object that manages modal state.
|
||||
*
|
||||
* @type {ModalManagerState}
|
||||
*/
|
||||
modal = new ModalManagerState();
|
||||
|
||||
/**
|
||||
* An object that manages the state of active alerts.
|
||||
*
|
||||
* @type {AlertManagerState}
|
||||
*/
|
||||
alerts = new AlertManagerState();
|
||||
|
||||
data;
|
||||
|
||||
title = '';
|
||||
@@ -173,8 +189,8 @@ export default class Application {
|
||||
}
|
||||
|
||||
mount(basePath = '') {
|
||||
this.modal = m.mount(document.getElementById('modal'), <ModalManager />);
|
||||
this.alerts = m.mount(document.getElementById('alerts'), <AlertManager />);
|
||||
m.mount(document.getElementById('modal'), <ModalManager state={this.modal} />);
|
||||
m.mount(document.getElementById('alerts'), <AlertManager state={this.alerts} />);
|
||||
|
||||
this.drawer = new Drawer();
|
||||
|
||||
@@ -192,6 +208,8 @@ export default class Application {
|
||||
$(() => {
|
||||
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||
});
|
||||
|
||||
liveHumanTimes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,6 +230,16 @@ export default class Application {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the current screen mode, based on our media queries.
|
||||
*
|
||||
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
|
||||
*/
|
||||
screen() {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
return styles.getPropertyValue('--flarum-screen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the <title> of the page.
|
||||
*
|
||||
@@ -234,7 +262,10 @@ export default class Application {
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title');
|
||||
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
||||
const pageTitleWithSeparator = this.title && m.route() !== '/' ? this.title + ' - ' : '';
|
||||
const title = this.forum.attribute('title');
|
||||
document.title = count + pageTitleWithSeparator + title;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,7 +338,7 @@ export default class Application {
|
||||
}
|
||||
};
|
||||
|
||||
if (this.requestError) this.alerts.dismiss(this.requestError.alert);
|
||||
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
// Now make the request. If it's a failure, inspect the error that was
|
||||
// returned and show an alert containing its contents.
|
||||
@@ -316,8 +347,6 @@ export default class Application {
|
||||
m.request(options).then(
|
||||
(response) => deferred.resolve(response),
|
||||
(error) => {
|
||||
this.requestError = error;
|
||||
|
||||
let children;
|
||||
|
||||
switch (error.status) {
|
||||
@@ -351,7 +380,7 @@ export default class Application {
|
||||
// the details property is decoded to transform escaped characters such as '\n'
|
||||
const formattedError = error.response && Array.isArray(error.response.errors) && error.response.errors.map((e) => decodeURI(e.detail));
|
||||
|
||||
error.alert = new Alert({
|
||||
error.alert = {
|
||||
type: 'error',
|
||||
children,
|
||||
controls: isDebug && [
|
||||
@@ -359,7 +388,7 @@ export default class Application {
|
||||
Debug
|
||||
</Button>,
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
options.errorHandler(error);
|
||||
@@ -375,7 +404,7 @@ export default class Application {
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
this.alerts.show(error.alert);
|
||||
this.requestErrorAlert = this.alerts.show(error.alert);
|
||||
}
|
||||
|
||||
deferred.reject(error);
|
||||
@@ -391,9 +420,9 @@ export default class Application {
|
||||
* @private
|
||||
*/
|
||||
showDebug(error, formattedError) {
|
||||
this.alerts.dismiss(this.requestError.alert);
|
||||
this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
this.modal.show(new RequestErrorModal({ error, formattedError }));
|
||||
this.modal.show(RequestErrorModal, { error, formattedError });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -7,20 +7,16 @@ import Alert from './Alert';
|
||||
*/
|
||||
export default class AlertManager extends Component {
|
||||
init() {
|
||||
/**
|
||||
* An array of Alert components which are currently showing.
|
||||
*
|
||||
* @type {Alert[]}
|
||||
* @protected
|
||||
*/
|
||||
this.components = [];
|
||||
this.state = this.props.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AlertManager">
|
||||
{this.components.map((component) => (
|
||||
<div className="AlertManager-alert">{component}</div>
|
||||
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
|
||||
<div className="AlertManager-alert">
|
||||
{(alert.componentClass || Alert).component({ ...alert.attrs, ondismiss: this.state.dismiss.bind(this.state, key) })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -32,46 +28,4 @@ export default class AlertManager extends Component {
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an Alert in the alerts area.
|
||||
*
|
||||
* @param {Alert} component
|
||||
* @public
|
||||
*/
|
||||
show(component) {
|
||||
if (!(component instanceof Alert)) {
|
||||
throw new Error('The AlertManager component can only show Alert components');
|
||||
}
|
||||
|
||||
component.props.ondismiss = this.dismiss.bind(this, component);
|
||||
|
||||
this.components.push(component);
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an alert.
|
||||
*
|
||||
* @param {Alert} component
|
||||
* @public
|
||||
*/
|
||||
dismiss(component) {
|
||||
const index = this.components.indexOf(component);
|
||||
|
||||
if (index !== -1) {
|
||||
this.components.splice(index, 1);
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all alerts.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
clear() {
|
||||
this.components = [];
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
@@ -16,6 +16,9 @@ import icon from '../helpers/icon';
|
||||
*/
|
||||
export default class Checkbox extends Component {
|
||||
view() {
|
||||
// Sometimes, false is stored in the DB as '0'. This is a temporary
|
||||
// conversion layer until a more robust settings encoding is introduced
|
||||
if (this.props.state === '0') this.props.state = false;
|
||||
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
|
||||
if (this.props.loading) className += ' loading';
|
||||
if (this.props.disabled) className += ' disabled';
|
||||
|
37
js/src/common/components/ConfirmDocumentUnload.js
Normal file
37
js/src/common/components/ConfirmDocumentUnload.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import Component from '../Component';
|
||||
|
||||
/**
|
||||
* The `ConfirmDocumentUnload` component can be used to register a global
|
||||
* event handler that prevents closing the browser window/tab based on the
|
||||
* return value of a given callback prop.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `when` - a callback returning true when the browser should prompt for
|
||||
* confirmation before closing the window/tab
|
||||
*
|
||||
* ### Children
|
||||
*
|
||||
* NOTE: Only the first child will be rendered. (Use this component to wrap
|
||||
* another component / DOM element.)
|
||||
*
|
||||
*/
|
||||
export default class ConfirmDocumentUnload extends Component {
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
const handler = () => this.props.when() || undefined;
|
||||
|
||||
$(window).on('beforeunload', handler);
|
||||
|
||||
context.onunload = () => {
|
||||
$(window).off('beforeunload', handler);
|
||||
};
|
||||
}
|
||||
|
||||
view() {
|
||||
// To avoid having to render another wrapping <div> here, we assume that
|
||||
// this component is only wrapped around a single element / component.
|
||||
return this.props.children[0];
|
||||
}
|
||||
}
|
@@ -9,24 +9,39 @@ import Button from './Button';
|
||||
* @abstract
|
||||
*/
|
||||
export default class Modal extends Component {
|
||||
/**
|
||||
* Determine whether or not the modal should be dismissible via an 'x' button.
|
||||
*/
|
||||
static isDismissible = true;
|
||||
|
||||
init() {
|
||||
/**
|
||||
* An alert component to show below the header.
|
||||
* Attributes for an alert component to show below the header.
|
||||
*
|
||||
* @type {Alert}
|
||||
* @type {object}
|
||||
*/
|
||||
this.alert = null;
|
||||
this.alertAttrs = null;
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
this.props.onshow(() => this.onready());
|
||||
|
||||
context.onunload = () => {
|
||||
this.props.onhide();
|
||||
};
|
||||
}
|
||||
|
||||
view() {
|
||||
if (this.alert) {
|
||||
this.alert.props.dismissible = false;
|
||||
if (this.alertAttrs) {
|
||||
this.alertAttrs.dismissible = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'Modal modal-dialog ' + this.className()}>
|
||||
<div className="Modal-content">
|
||||
{this.isDismissible() ? (
|
||||
{this.constructor.isDismissible ? (
|
||||
<div className="Modal-close App-backControl">
|
||||
{Button.component({
|
||||
icon: 'fas fa-times',
|
||||
@@ -43,7 +58,7 @@ export default class Modal extends Component {
|
||||
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
|
||||
</div>
|
||||
|
||||
{alert ? <div className="Modal-alert">{this.alert}</div> : ''}
|
||||
{this.alertAttrs ? <div className="Modal-alert">{Alert.component(this.alertAttrs)}</div> : ''}
|
||||
|
||||
{this.content()}
|
||||
</form>
|
||||
@@ -52,15 +67,6 @@ export default class Modal extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the modal should be dismissible via an 'x' button.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isDismissible() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the class name to apply to the modal.
|
||||
*
|
||||
@@ -105,7 +111,7 @@ export default class Modal extends Component {
|
||||
* Hide the modal.
|
||||
*/
|
||||
hide() {
|
||||
app.modal.close();
|
||||
this.props.onhide();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +129,7 @@ export default class Modal extends Component {
|
||||
* @param {RequestError} error
|
||||
*/
|
||||
onerror(error) {
|
||||
this.alert = error.alert;
|
||||
this.alertAttrs = error.alert;
|
||||
|
||||
m.redraw();
|
||||
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import Component from '../Component';
|
||||
import Modal from './Modal';
|
||||
|
||||
/**
|
||||
* The `ModalManager` component manages a modal dialog. Only one modal dialog
|
||||
@@ -8,12 +7,17 @@ import Modal from './Modal';
|
||||
*/
|
||||
export default class ModalManager extends Component {
|
||||
init() {
|
||||
this.showing = false;
|
||||
this.component = null;
|
||||
this.state = this.props.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
return <div className="ModalManager modal fade">{this.component && this.component.render()}</div>;
|
||||
const modal = this.state.modal;
|
||||
|
||||
return (
|
||||
<div className="ModalManager modal fade">
|
||||
{modal ? modal.componentClass.component({ ...modal.attrs, onshow: this.animateShow.bind(this), onhide: this.animateHide.bind(this) }) : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
@@ -24,29 +28,17 @@ export default class ModalManager extends Component {
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
|
||||
this.$().on('hidden.bs.modal', this.clear.bind(this)).on('shown.bs.modal', this.onready.bind(this));
|
||||
// Ensure the modal state is notified about a closed modal, even when the
|
||||
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
|
||||
// e.g. via ESC key or a click on the modal backdrop.
|
||||
this.$().on('hidden.bs.modal', this.state.close.bind(this.state));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a modal dialog.
|
||||
*
|
||||
* @param {Modal} component
|
||||
* @public
|
||||
*/
|
||||
show(component) {
|
||||
if (!(component instanceof Modal)) {
|
||||
throw new Error('The ModalManager component can only show Modal components');
|
||||
}
|
||||
animateShow(readyCallback) {
|
||||
const dismissible = !!this.state.modal.componentClass.isDismissible;
|
||||
|
||||
clearTimeout(this.hideTimeout);
|
||||
|
||||
this.showing = true;
|
||||
this.component = component;
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
const dismissible = !!this.component.isDismissible();
|
||||
this.$()
|
||||
.one('shown.bs.modal', readyCallback)
|
||||
.modal({
|
||||
backdrop: dismissible || 'static',
|
||||
keyboard: dismissible,
|
||||
@@ -54,50 +46,7 @@ export default class ModalManager extends Component {
|
||||
.modal('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal dialog.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.showing) return;
|
||||
|
||||
// Don't hide the modal immediately, because if the consumer happens to call
|
||||
// the `show` method straight after to show another modal dialog, it will
|
||||
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
|
||||
// bit to give the `show` method the opportunity to prevent this from going
|
||||
// ahead.
|
||||
this.hideTimeout = setTimeout(() => {
|
||||
this.$().modal('hide');
|
||||
this.showing = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear content from the modal area.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
clear() {
|
||||
if (this.component) {
|
||||
this.component.onhide();
|
||||
}
|
||||
|
||||
this.component = null;
|
||||
|
||||
app.current.retain = false;
|
||||
|
||||
m.lazyRedraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the modal dialog is ready to be used, tell it!
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
onready() {
|
||||
if (this.component && this.component.onready) {
|
||||
this.component.onready(this.$());
|
||||
}
|
||||
animateHide() {
|
||||
this.$().modal('hide');
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
export default class Routes {
|
||||
export default class Model {
|
||||
type;
|
||||
attributes = [];
|
||||
hasOnes = [];
|
||||
|
@@ -6,10 +6,10 @@
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function fullTime(time) {
|
||||
const mo = moment(time);
|
||||
const d = dayjs(time);
|
||||
|
||||
const datetime = mo.format();
|
||||
const full = mo.format('LLLL');
|
||||
const datetime = d.format();
|
||||
const full = d.format('LLLL');
|
||||
|
||||
return (
|
||||
<time pubdate datetime={datetime}>
|
||||
|
@@ -9,10 +9,10 @@ import humanTimeUtil from '../utils/humanTime';
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function humanTime(time) {
|
||||
const mo = moment(time);
|
||||
const d = dayjs(time);
|
||||
|
||||
const datetime = mo.format();
|
||||
const full = mo.format('LLLL');
|
||||
const datetime = d.format();
|
||||
const full = d.format('LLLL');
|
||||
const ago = humanTimeUtil(time);
|
||||
|
||||
return (
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
||||
import 'expose-loader?m!mithril';
|
||||
import 'expose-loader?moment!moment';
|
||||
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
|
||||
import 'expose-loader?m.bidi!m.attrs.bidi';
|
||||
import 'bootstrap/js/affix';
|
||||
import 'bootstrap/js/dropdown';
|
||||
@@ -9,6 +9,12 @@ import 'bootstrap/js/tooltip';
|
||||
import 'bootstrap/js/transition';
|
||||
import 'jquery.hotkeys/jquery.hotkeys';
|
||||
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
import patchMithril from './utils/patchMithril';
|
||||
|
||||
patchMithril(window);
|
||||
|
@@ -68,7 +68,10 @@ Object.assign(Discussion.prototype, {
|
||||
const user = app.session.user;
|
||||
|
||||
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
|
||||
return Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
|
||||
const unreadCount = Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
|
||||
// If posts have been deleted, it's possible that the unread count could exceed the
|
||||
// actual post count. As such, we take the min of the two to ensure this isn't an issue.
|
||||
return Math.min(unreadCount, this.commentCount());
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
@@ -54,7 +54,7 @@ Object.assign(User.prototype, {
|
||||
* @public
|
||||
*/
|
||||
isOnline() {
|
||||
return this.lastSeenAt() > moment().subtract(5, 'minutes').toDate();
|
||||
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
|
||||
},
|
||||
|
||||
/**
|
||||
|
50
js/src/common/states/AlertManagerState.js
Normal file
50
js/src/common/states/AlertManagerState.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import Alert from '../components/Alert';
|
||||
|
||||
export default class AlertManagerState {
|
||||
constructor() {
|
||||
this.activeAlerts = {};
|
||||
this.alertId = 0;
|
||||
}
|
||||
|
||||
getActiveAlerts() {
|
||||
return this.activeAlerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an Alert in the alerts area.
|
||||
*/
|
||||
show(attrs, componentClass = Alert) {
|
||||
// Breaking Change Compliance Warning, Remove in Beta 15.
|
||||
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
|
||||
if (attrs === Alert || attrs instanceof Alert) {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
console.error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
||||
throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
||||
}
|
||||
// End Change Compliance Warning, Remove in Beta 15
|
||||
this.activeAlerts[++this.alertId] = { attrs, componentClass };
|
||||
m.redraw();
|
||||
|
||||
return this.alertId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an alert.
|
||||
*/
|
||||
dismiss(key) {
|
||||
if (!key || !(key in this.activeAlerts)) return;
|
||||
|
||||
delete this.activeAlerts[key];
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all alerts.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
clear() {
|
||||
this.activeAlerts = {};
|
||||
m.redraw();
|
||||
}
|
||||
}
|
56
js/src/common/states/ModalManagerState.js
Normal file
56
js/src/common/states/ModalManagerState.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import Modal from '../components/Modal';
|
||||
|
||||
export default class ModalManagerState {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a modal dialog.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
show(componentClass, attrs) {
|
||||
// Breaking Change Compliance Warning, Remove in Beta 15.
|
||||
if (!(componentClass.prototype instanceof Modal)) {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
console.error('The ModalManager can only show Modals');
|
||||
throw new Error('The ModalManager can only show Modals');
|
||||
}
|
||||
if (componentClass.init) {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
console.error(
|
||||
'The componentClass parameter must be a modal class, not a modal instance. Whichever extension triggered this modal should be updated to comply with beta 14.'
|
||||
);
|
||||
throw new Error(
|
||||
'The componentClass parameter must be a modal class, not a modal instance. Whichever extension triggered this modal should be updated to comply with beta 14.'
|
||||
);
|
||||
}
|
||||
// End Change Compliance Warning, Remove in Beta 15
|
||||
|
||||
clearTimeout(this.closeTimeout);
|
||||
|
||||
this.modal = { componentClass, attrs };
|
||||
|
||||
m.redraw(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal dialog.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.modal) return;
|
||||
|
||||
// Don't hide the modal immediately, because if the consumer happens to call
|
||||
// the `show` method straight after to show another modal dialog, it will
|
||||
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
|
||||
// bit to give the `show` method the opportunity to prevent this from going
|
||||
// ahead.
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.modal = null;
|
||||
m.lazyRedraw();
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,5 +1,9 @@
|
||||
class Item {
|
||||
constructor(content, priority) {
|
||||
content: any;
|
||||
priority: number;
|
||||
key?: number;
|
||||
|
||||
constructor(content: any, priority?: number) {
|
||||
this.content = content;
|
||||
this.priority = priority;
|
||||
}
|
||||
@@ -10,23 +14,15 @@ class Item {
|
||||
* by priority.
|
||||
*/
|
||||
export default class ItemList {
|
||||
constructor() {
|
||||
/**
|
||||
* The items in the list.
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
this.items = {};
|
||||
}
|
||||
/**
|
||||
* The items in the list
|
||||
*/
|
||||
items: { [key: string]: Item } = {};
|
||||
|
||||
/**
|
||||
* Check whether the list is empty.
|
||||
*
|
||||
* @returns {boolean}
|
||||
* @public
|
||||
*/
|
||||
isEmpty() {
|
||||
isEmpty(): boolean {
|
||||
for (const i in this.items) {
|
||||
if (this.items.hasOwnProperty(i)) {
|
||||
return false;
|
||||
@@ -38,36 +34,27 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Check whether an item is present in the list.
|
||||
*
|
||||
* @param key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(key) {
|
||||
has(key: string): boolean {
|
||||
return !!this.items[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of an item.
|
||||
*
|
||||
* @param {String} key
|
||||
* @return {*}
|
||||
* @public
|
||||
*/
|
||||
get(key) {
|
||||
get(key: string): any {
|
||||
return this.items[key].content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the list.
|
||||
*
|
||||
* @param {String} key A unique key for the item.
|
||||
* @param {*} content The item's content.
|
||||
* @param {Integer} [priority] The priority of the item. Items with a higher
|
||||
* @param key A unique key for the item.
|
||||
* @param content The item's content.
|
||||
* @param [priority] The priority of the item. Items with a higher
|
||||
* priority will be positioned before items with a lower priority.
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
add(key, content, priority = 0) {
|
||||
add(key: string, content: any, priority: number = 0): this {
|
||||
this.items[key] = new Item(content, priority);
|
||||
|
||||
return this;
|
||||
@@ -75,14 +62,8 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Replace an item in the list, only if it is already present.
|
||||
*
|
||||
* @param {String} key
|
||||
* @param {*} [content]
|
||||
* @param {Integer} [priority]
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
replace(key, content = null, priority = null) {
|
||||
replace(key: string, content: any = null, priority: number = null): this {
|
||||
if (this.items[key]) {
|
||||
if (content !== null) {
|
||||
this.items[key].content = content;
|
||||
@@ -98,12 +79,8 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Remove an item from the list.
|
||||
*
|
||||
* @param {String} key
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
remove(key) {
|
||||
remove(key: string): this {
|
||||
delete this.items[key];
|
||||
|
||||
return this;
|
||||
@@ -111,12 +88,8 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Merge another list's items into this one.
|
||||
*
|
||||
* @param {ItemList} items
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
merge(items) {
|
||||
merge(items: this): this {
|
||||
for (const i in items.items) {
|
||||
if (items.items.hasOwnProperty(i) && items.items[i] instanceof Item) {
|
||||
this.items[i] = items.items[i];
|
||||
@@ -130,12 +103,9 @@ export default class ItemList {
|
||||
* Convert the list into an array of item content arranged by priority. Each
|
||||
* item's content will be assigned an `itemName` property equal to the item's
|
||||
* unique key.
|
||||
*
|
||||
* @return {Array}
|
||||
* @public
|
||||
*/
|
||||
toArray() {
|
||||
const items = [];
|
||||
toArray(): any[] {
|
||||
const items: Item[] = [];
|
||||
|
||||
for (const i in this.items) {
|
||||
if (this.items.hasOwnProperty(i) && this.items[i] instanceof Item) {
|
@@ -1,5 +1,14 @@
|
||||
export default class RequestError {
|
||||
constructor(status, responseText, options, xhr) {
|
||||
status: string;
|
||||
options: object;
|
||||
xhr: XMLHttpRequest;
|
||||
|
||||
responseText: string | null;
|
||||
response: object | null;
|
||||
|
||||
alert: any;
|
||||
|
||||
constructor(status: string, responseText: string | null, options: object, xhr: XMLHttpRequest) {
|
||||
this.status = status;
|
||||
this.responseText = responseText;
|
||||
this.options = options;
|
109
js/src/common/utils/SuperTextarea.js
Normal file
109
js/src/common/utils/SuperTextarea.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* A textarea wrapper with powerful helpers for text manipulation.
|
||||
*
|
||||
* This wraps a <textarea> DOM element and allows directly manipulating its text
|
||||
* contents and cursor positions.
|
||||
*
|
||||
* I apologize for the pretentious name. :)
|
||||
*/
|
||||
export default class SuperTextarea {
|
||||
/**
|
||||
* @param {HTMLTextAreaElement} textarea
|
||||
*/
|
||||
constructor(textarea) {
|
||||
this.el = textarea;
|
||||
this.$ = $(textarea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the text editor.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
setValue(value) {
|
||||
this.$.val(value).trigger('input');
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the textarea and place the cursor at the given index.
|
||||
*
|
||||
* @param {number} position
|
||||
*/
|
||||
moveCursorTo(position) {
|
||||
this.setSelectionRange(position, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected range of the textarea.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
getSelectionRange() {
|
||||
return [this.el.selectionStart, this.el.selectionEnd];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the position of the cursor.
|
||||
*
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAtCursor(text) {
|
||||
this.insertAt(this.el.selectionStart, text);
|
||||
|
||||
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the given position.
|
||||
*
|
||||
* @param {number} pos
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAt(pos, text) {
|
||||
this.insertBetween(pos, pos, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea between the given positions.
|
||||
*
|
||||
* If the start and end positions are different, any text between them will be
|
||||
* overwritten.
|
||||
*
|
||||
* @param start
|
||||
* @param end
|
||||
* @param text
|
||||
*/
|
||||
insertBetween(start, end, text) {
|
||||
const value = this.el.value;
|
||||
|
||||
const before = value.slice(0, start);
|
||||
const after = value.slice(end);
|
||||
|
||||
this.setValue(`${before}${text}${after}`);
|
||||
|
||||
// Move the textarea cursor to the end of the content we just inserted.
|
||||
this.moveCursorTo(start + text.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace existing content from the start to the current cursor position.
|
||||
*
|
||||
* @param start
|
||||
* @param text
|
||||
*/
|
||||
replaceBeforeCursor(start, text) {
|
||||
this.insertBetween(start, this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected range of the textarea.
|
||||
*
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @private
|
||||
*/
|
||||
setSelectionRange(start, end) {
|
||||
this.el.setSelectionRange(start, end);
|
||||
this.$.focus();
|
||||
}
|
||||
}
|
@@ -4,11 +4,8 @@
|
||||
* @example
|
||||
* abbreviateNumber(1234);
|
||||
* // "1.2K"
|
||||
*
|
||||
* @param {Integer} number
|
||||
* @return {String}
|
||||
*/
|
||||
export default function abbreviateNumber(number) {
|
||||
export default function abbreviateNumber(number: number): string {
|
||||
// TODO: translation
|
||||
if (number >= 1000000) {
|
||||
return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');
|
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* The `extract` utility deletes a property from an object and returns its
|
||||
* value.
|
||||
*
|
||||
* @param {Object} object The object that owns the property
|
||||
* @param {String} property The name of the property to extract
|
||||
* @return {*} The value of the property
|
||||
*/
|
||||
export default function extract(object, property) {
|
||||
const value = object[property];
|
||||
|
||||
delete object[property];
|
||||
|
||||
return value;
|
||||
}
|
15
js/src/common/utils/extract.ts
Normal file
15
js/src/common/utils/extract.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* The `extract` utility deletes a property from an object and returns its
|
||||
* value.
|
||||
*
|
||||
* @param object The object that owns the property
|
||||
* @param property The name of the property to extract
|
||||
* @return The value of the property
|
||||
*/
|
||||
export default function extract<T, K extends keyof T>(object: T, property: K): T[K] {
|
||||
const value = object[property];
|
||||
|
||||
delete object[property];
|
||||
|
||||
return value;
|
||||
}
|
@@ -5,10 +5,7 @@
|
||||
* @example
|
||||
* formatNumber(1234);
|
||||
* // 1,234
|
||||
*
|
||||
* @param {Number} number
|
||||
* @return {String}
|
||||
*/
|
||||
export default function formatNumber(number) {
|
||||
export default function formatNumber(number: number): string {
|
||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
@@ -1,35 +1,32 @@
|
||||
/**
|
||||
* The `humanTime` utility converts a date to a localized, human-readable time-
|
||||
* ago string.
|
||||
*
|
||||
* @param {Date} time
|
||||
* @return {String}
|
||||
*/
|
||||
export default function humanTime(time) {
|
||||
let m = moment(time);
|
||||
const now = moment();
|
||||
export default function humanTime(time: Date): string {
|
||||
let d = dayjs(time);
|
||||
const now = dayjs();
|
||||
|
||||
// To prevent showing things like "in a few seconds" due to small offsets
|
||||
// between client and server time, we always reset future dates to the
|
||||
// current time. This will result in "just now" being shown instead.
|
||||
if (m.isAfter(now)) {
|
||||
m = now;
|
||||
if (d.isAfter(now)) {
|
||||
d = now;
|
||||
}
|
||||
|
||||
const day = 864e5;
|
||||
const diff = m.diff(moment());
|
||||
let ago = null;
|
||||
const diff = d.diff(dayjs());
|
||||
let ago: string;
|
||||
|
||||
// If this date was more than a month ago, we'll show the name of the month
|
||||
// in the string. If it wasn't this year, we'll show the year as well.
|
||||
if (diff < -30 * day) {
|
||||
if (m.year() === moment().year()) {
|
||||
ago = m.format('D MMM');
|
||||
if (d.year() === dayjs().year()) {
|
||||
ago = d.format('D MMM');
|
||||
} else {
|
||||
ago = m.format('ll');
|
||||
ago = d.format('ll');
|
||||
}
|
||||
} else {
|
||||
ago = m.fromNow();
|
||||
ago = d.fromNow();
|
||||
}
|
||||
|
||||
return ago;
|
@@ -1,18 +1,18 @@
|
||||
import humanTimeUtil from './humanTime';
|
||||
import humanTime from './humanTime';
|
||||
|
||||
function updateHumanTimes() {
|
||||
$('[data-humantime]').each(function () {
|
||||
const $this = $(this);
|
||||
const ago = humanTimeUtil($this.attr('datetime'));
|
||||
const ago = humanTime($this.attr('datetime'));
|
||||
|
||||
$this.html(ago);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `humanTime` initializer sets up a loop every 1 second to update
|
||||
* The `liveHumanTimes` initializer sets up a loop every 1 second to update
|
||||
* timestamps rendered with the `humanTime` helper.
|
||||
*/
|
||||
export default function humanTime() {
|
||||
export default function liveHumanTimes() {
|
||||
setInterval(updateHumanTimes, 10000);
|
||||
}
|
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Truncate a string to the given length, appending ellipses if necessary.
|
||||
*
|
||||
* @param {String} string
|
||||
* @param {Number} length
|
||||
* @param {Number} [start=0]
|
||||
* @return {String}
|
||||
*/
|
||||
export function truncate(string, length, start = 0) {
|
||||
export function truncate(string: string, length: number, start: number = 0): string {
|
||||
return (start > 0 ? '...' : '') + string.substring(start, start + length) + (string.length > start + length ? '...' : '');
|
||||
}
|
||||
|
||||
@@ -17,11 +12,8 @@ export function truncate(string, length, start = 0) {
|
||||
* NOTE: This method does not use the comparably sophisticated transliteration
|
||||
* mechanism that is employed in the backend. Therefore, it should only be used
|
||||
* to *suggest* slugs that can be overridden by the user.
|
||||
*
|
||||
* @param {String} string
|
||||
* @return {String}
|
||||
*/
|
||||
export function slug(string) {
|
||||
export function slug(string: string): string {
|
||||
return string
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
@@ -32,11 +24,8 @@ export function slug(string) {
|
||||
/**
|
||||
* Strip HTML tags and quotes out of the given string, replacing them with
|
||||
* meaningful punctuation.
|
||||
*
|
||||
* @param {String} string
|
||||
* @return {String}
|
||||
*/
|
||||
export function getPlainContent(string) {
|
||||
export function getPlainContent(string: string): string {
|
||||
const html = string.replace(/(<\/p>|<br>)/g, '$1 ').replace(/<img\b[^>]*>/gi, ' ');
|
||||
|
||||
const dom = $('<div/>').html(html);
|
||||
@@ -55,10 +44,7 @@ getPlainContent.removeSelectors = ['blockquote', 'script'];
|
||||
|
||||
/**
|
||||
* Make a string's first character uppercase.
|
||||
*
|
||||
* @param {String} string
|
||||
* @return {String}
|
||||
*/
|
||||
export function ucfirst(string) {
|
||||
export function ucfirst(string: string): string {
|
||||
return string.substr(0, 1).toUpperCase() + string.substr(1);
|
||||
}
|
@@ -1,4 +1,6 @@
|
||||
function hsvToRgb(h, s, v) {
|
||||
type RGB = { r: number; g: number; b: number };
|
||||
|
||||
function hsvToRgb(h: number, s: number, v: number): RGB {
|
||||
let r;
|
||||
let g;
|
||||
let b;
|
||||
@@ -51,11 +53,8 @@ function hsvToRgb(h, s, v) {
|
||||
|
||||
/**
|
||||
* Convert the given string to a unique color.
|
||||
*
|
||||
* @param {String} string
|
||||
* @return {String}
|
||||
*/
|
||||
export default function stringToColor(string) {
|
||||
export default function stringToColor(string: string): string {
|
||||
let num = 0;
|
||||
|
||||
// Convert the username into a number based on the ASCII value of each
|
@@ -1,6 +1,5 @@
|
||||
import History from './utils/History';
|
||||
import Pane from './utils/Pane';
|
||||
import ReplyComposer from './components/ReplyComposer';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
import SignUpModal from './components/SignUpModal';
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
@@ -15,7 +14,8 @@ import Application from '../common/Application';
|
||||
import Navigation from '../common/components/Navigation';
|
||||
import NotificationListState from './states/NotificationListState';
|
||||
import GlobalSearchState from './states/GlobalSearchState';
|
||||
import DiscussionListState from './state/DiscussionListState';
|
||||
import DiscussionListState from './states/DiscussionListState';
|
||||
import ComposerState from './states/ComposerState';
|
||||
|
||||
export default class ForumApplication extends Application {
|
||||
/**
|
||||
@@ -73,6 +73,11 @@ export default class ForumApplication extends Application {
|
||||
*/
|
||||
search = new GlobalSearchState();
|
||||
|
||||
/*
|
||||
* An object which controls the state of the composer.
|
||||
*/
|
||||
composer = new ComposerState();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -84,7 +89,7 @@ export default class ForumApplication extends Application {
|
||||
*
|
||||
* @type {DiscussionListState}
|
||||
*/
|
||||
this.discussions = new DiscussionListState({ forumApp: this });
|
||||
this.discussions = new DiscussionListState({}, this);
|
||||
|
||||
/**
|
||||
* @deprecated beta 14, remove in beta 15.
|
||||
@@ -114,9 +119,9 @@ export default class ForumApplication extends Application {
|
||||
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
|
||||
m.mount(document.getElementById('composer'), Composer.component({ state: this.composer }));
|
||||
|
||||
this.pane = new Pane(document.getElementById('app'));
|
||||
this.composer = m.mount(document.getElementById('composer'), Composer.component());
|
||||
|
||||
m.route.mode = 'pathname';
|
||||
super.mount(this.forum.attribute('basePath'));
|
||||
@@ -138,21 +143,6 @@ export default class ForumApplication extends Application {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the user is currently composing a reply to a
|
||||
* discussion.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @return {Boolean}
|
||||
*/
|
||||
composingReplyTo(discussion) {
|
||||
return (
|
||||
this.composer.component instanceof ReplyComposer &&
|
||||
this.composer.component.props.discussion === discussion &&
|
||||
this.composer.position !== Composer.PositionEnum.HIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the user is currently viewing a discussion.
|
||||
*
|
||||
@@ -180,8 +170,7 @@ export default class ForumApplication extends Application {
|
||||
if (payload.loggedIn) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const modal = new SignUpModal(payload);
|
||||
this.modal.show(modal);
|
||||
this.modal.show(SignUpModal, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -9,6 +9,10 @@ import DiscussionControls from './utils/DiscussionControls';
|
||||
import alertEmailConfirmation from './utils/alertEmailConfirmation';
|
||||
import UserControls from './utils/UserControls';
|
||||
import Pane from './utils/Pane';
|
||||
import DiscussionListState from './states/DiscussionListState';
|
||||
import GlobalSearchState from './states/GlobalSearchState';
|
||||
import NotificationListState from './states/NotificationListState';
|
||||
import SearchState from './states/SearchState';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
import LogInModal from './components/LogInModal';
|
||||
import ComposerBody from './components/ComposerBody';
|
||||
@@ -77,6 +81,10 @@ export default Object.assign(compat, {
|
||||
'utils/alertEmailConfirmation': alertEmailConfirmation,
|
||||
'utils/UserControls': UserControls,
|
||||
'utils/Pane': Pane,
|
||||
'states/DiscussionListState': DiscussionListState,
|
||||
'states/GlobalSearchState': GlobalSearchState,
|
||||
'states/NotificationListState': NotificationListState,
|
||||
'states/SearchState': SearchState,
|
||||
'components/DiscussionPage': DiscussionPage,
|
||||
'components/LogInModal': LogInModal,
|
||||
'components/ComposerBody': ComposerBody,
|
||||
|
@@ -122,7 +122,7 @@ export default class ChangeEmailModal extends Modal {
|
||||
|
||||
onerror(error) {
|
||||
if (error.status === 401) {
|
||||
error.alert.props.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
|
||||
error.alert.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
|
||||
}
|
||||
|
||||
super.onerror(error);
|
||||
|
@@ -31,7 +31,18 @@ export default class CommentPost extends Post {
|
||||
*/
|
||||
this.revealContent = false;
|
||||
|
||||
this.subtree.check(() => this.isEditing());
|
||||
/**
|
||||
* Whether or not the user hover card inside of PostUser is visible.
|
||||
* The property must be managed in CommentPost to be able to use it in the subtree check
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.cardVisible = false;
|
||||
|
||||
this.subtree.check(
|
||||
() => this.cardVisible,
|
||||
() => this.isEditing()
|
||||
);
|
||||
}
|
||||
|
||||
content() {
|
||||
@@ -66,7 +77,7 @@ export default class CommentPost extends Post {
|
||||
}
|
||||
|
||||
isEditing() {
|
||||
return app.composer.component instanceof EditPostComposer && app.composer.component.props.post === this.props.post;
|
||||
return app.composer.bodyMatches(EditPostComposer, { post: this.props.post });
|
||||
}
|
||||
|
||||
attrs() {
|
||||
@@ -94,7 +105,7 @@ export default class CommentPost extends Post {
|
||||
// body with a preview.
|
||||
let preview;
|
||||
const updatePreview = () => {
|
||||
const content = app.composer.component.content();
|
||||
const content = app.composer.fields.content();
|
||||
|
||||
if (preview === content) return;
|
||||
|
||||
@@ -124,7 +135,22 @@ export default class CommentPost extends Post {
|
||||
const items = new ItemList();
|
||||
const post = this.props.post;
|
||||
|
||||
items.add('user', PostUser.component({ post }), 100);
|
||||
items.add(
|
||||
'user',
|
||||
PostUser.component({
|
||||
post,
|
||||
cardVisible: this.cardVisible,
|
||||
oncardshow: () => {
|
||||
this.cardVisible = true;
|
||||
m.redraw();
|
||||
},
|
||||
oncardhide: () => {
|
||||
this.cardVisible = false;
|
||||
m.redraw();
|
||||
},
|
||||
}),
|
||||
100
|
||||
);
|
||||
items.add('meta', PostMeta.component({ post }));
|
||||
|
||||
if (post.isEdited() && !post.isHidden()) {
|
||||
|
@@ -3,28 +3,21 @@ import ItemList from '../../common/utils/ItemList';
|
||||
import ComposerButton from './ComposerButton';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import classList from '../../common/utils/classList';
|
||||
import ComposerState from '../states/ComposerState';
|
||||
|
||||
/**
|
||||
* The `Composer` component displays the composer. It can be loaded with a
|
||||
* content component with `load` and then its position/state can be altered with
|
||||
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
||||
*/
|
||||
class Composer extends Component {
|
||||
export default class Composer extends Component {
|
||||
init() {
|
||||
/**
|
||||
* The composer's current position.
|
||||
* The composer's "state".
|
||||
*
|
||||
* @type {Composer.PositionEnum}
|
||||
* @type {ComposerState}
|
||||
*/
|
||||
this.position = Composer.PositionEnum.HIDDEN;
|
||||
|
||||
/**
|
||||
* The composer's intended height, which can be modified by the user
|
||||
* (by dragging the composer handle).
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
this.height = null;
|
||||
this.state = this.props.state;
|
||||
|
||||
/**
|
||||
* Whether or not the composer currently has focus.
|
||||
@@ -32,39 +25,45 @@ class Composer extends Component {
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.active = false;
|
||||
|
||||
// Store the initial position so that we can trigger animations correctly.
|
||||
this.prevPosition = this.state.position;
|
||||
}
|
||||
|
||||
view() {
|
||||
const body = this.state.body;
|
||||
const classes = {
|
||||
normal: this.position === Composer.PositionEnum.NORMAL,
|
||||
minimized: this.position === Composer.PositionEnum.MINIMIZED,
|
||||
fullScreen: this.position === Composer.PositionEnum.FULLSCREEN,
|
||||
normal: this.state.position === ComposerState.Position.NORMAL,
|
||||
minimized: this.state.position === ComposerState.Position.MINIMIZED,
|
||||
fullScreen: this.state.position === ComposerState.Position.FULLSCREEN,
|
||||
active: this.active,
|
||||
visible: this.state.isVisible(),
|
||||
};
|
||||
classes.visible = classes.normal || classes.minimized || classes.fullScreen;
|
||||
|
||||
// If the composer is minimized, tell the composer's content component that
|
||||
// it shouldn't let the user interact with it. Set up a handler so that if
|
||||
// the content IS clicked, the composer will be shown.
|
||||
if (this.component) this.component.props.disabled = classes.minimized;
|
||||
|
||||
const showIfMinimized = this.position === Composer.PositionEnum.MINIMIZED ? this.show.bind(this) : undefined;
|
||||
// Set up a handler so that clicks on the content will show the composer.
|
||||
const showIfMinimized = this.state.position === ComposerState.Position.MINIMIZED ? this.state.show.bind(this.state) : undefined;
|
||||
|
||||
return (
|
||||
<div className={'Composer ' + classList(classes)}>
|
||||
<div className="Composer-handle" config={this.configHandle.bind(this)} />
|
||||
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
|
||||
<div className="Composer-content" onclick={showIfMinimized}>
|
||||
{this.component ? this.component.render() : ''}
|
||||
{body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
// Set the height of the Composer element and its contents on each redraw,
|
||||
// so that they do not lose it if their DOM elements are recreated.
|
||||
this.updateHeight();
|
||||
if (this.state.position === this.prevPosition) {
|
||||
// Set the height of the Composer element and its contents on each redraw,
|
||||
// so that they do not lose it if their DOM elements are recreated.
|
||||
this.updateHeight();
|
||||
} else {
|
||||
this.animatePositionChange();
|
||||
|
||||
this.prevPosition = this.state.position;
|
||||
}
|
||||
|
||||
if (isInitialized) return;
|
||||
|
||||
@@ -73,7 +72,7 @@ class Composer extends Component {
|
||||
context.retain = true;
|
||||
|
||||
this.initializeHeight();
|
||||
this.$().hide().css('bottom', -this.computedHeight());
|
||||
this.$().hide().css('bottom', -this.state.computedHeight());
|
||||
|
||||
// Whenever any of the inputs inside the composer are have focus, we want to
|
||||
// add a class to the composer to draw attention to it.
|
||||
@@ -85,13 +84,6 @@ class Composer extends Component {
|
||||
// When the escape key is pressed on any inputs, close the composer.
|
||||
this.$().on('keydown', ':input', 'esc', () => this.close());
|
||||
|
||||
// Don't let the user leave the page without first giving the composer's
|
||||
// component a chance to scream at the user to make sure they don't
|
||||
// unintentionally lose any contnet.
|
||||
window.onbeforeunload = () => {
|
||||
return (this.component && this.component.preventExit()) || undefined;
|
||||
};
|
||||
|
||||
const handlers = {};
|
||||
|
||||
$(window)
|
||||
@@ -166,13 +158,20 @@ class Composer extends Component {
|
||||
$('body').css('cursor', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw focus to the first focusable content element (the text editor).
|
||||
*/
|
||||
focus() {
|
||||
this.$('.Composer-content :input:enabled:visible:first').focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the DOM to reflect the composer's current height. This involves
|
||||
* setting the height of the composer's root element, and adjusting the height
|
||||
* of any flexible elements inside the composer's body.
|
||||
*/
|
||||
updateHeight() {
|
||||
const height = this.computedHeight();
|
||||
const height = this.state.computedHeight();
|
||||
const $flexible = this.$('.Composer-flexible');
|
||||
|
||||
this.$().height(height);
|
||||
@@ -193,109 +192,59 @@ class Composer extends Component {
|
||||
*/
|
||||
updateBodyPadding() {
|
||||
const visible =
|
||||
this.position !== Composer.PositionEnum.HIDDEN && this.position !== Composer.PositionEnum.MINIMIZED && this.$().css('position') !== 'absolute';
|
||||
this.state.position !== ComposerState.Position.HIDDEN && this.state.position !== ComposerState.Position.MINIMIZED && app.screen() !== 'phone';
|
||||
|
||||
const paddingBottom = visible ? this.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;
|
||||
const paddingBottom = visible ? this.state.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;
|
||||
|
||||
$('#content').css({ paddingBottom });
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the Composer is covering the screen.
|
||||
*
|
||||
* This will be true if the Composer is in full-screen mode on desktop, or
|
||||
* if the Composer is positioned absolutely as on mobile devices.
|
||||
*
|
||||
* @return {Boolean}
|
||||
* @public
|
||||
* Trigger the right animation depending on the desired new position.
|
||||
*/
|
||||
isFullScreen() {
|
||||
return this.position === Composer.PositionEnum.FULLSCREEN || this.$().css('position') === 'absolute';
|
||||
}
|
||||
animatePositionChange() {
|
||||
// When exiting full-screen mode: focus content
|
||||
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
|
||||
this.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm with the user that they want to close the composer and lose their
|
||||
* content.
|
||||
*
|
||||
* @return {Boolean} Whether or not the exit was cancelled.
|
||||
*/
|
||||
preventExit() {
|
||||
if (this.component) {
|
||||
const preventExit = this.component.preventExit();
|
||||
|
||||
if (preventExit) {
|
||||
return !confirm(preventExit);
|
||||
}
|
||||
switch (this.state.position) {
|
||||
case ComposerState.Position.HIDDEN:
|
||||
return this.hide();
|
||||
case ComposerState.Position.MINIMIZED:
|
||||
return this.minimize();
|
||||
case ComposerState.Position.FULLSCREEN:
|
||||
return this.focus();
|
||||
case ComposerState.Position.NORMAL:
|
||||
return this.show();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a content component into the composer.
|
||||
*
|
||||
* @param {Component} component
|
||||
* @public
|
||||
* Animate the Composer into the new position by changing the height.
|
||||
*/
|
||||
load(component) {
|
||||
if (this.preventExit()) return;
|
||||
|
||||
// If we load a similar component into the composer, then Mithril will be
|
||||
// able to diff the old/new contents and some DOM-related state from the
|
||||
// old composer will remain. To prevent this from happening, we clear the
|
||||
// component and force a redraw, so that the new component will be working
|
||||
// on a blank slate.
|
||||
if (this.component) {
|
||||
this.clear();
|
||||
m.redraw(true);
|
||||
}
|
||||
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the composer's content component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
clear() {
|
||||
this.component = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the Composer into the given position.
|
||||
*
|
||||
* @param {Composer.PositionEnum} position
|
||||
*/
|
||||
animateToPosition(position) {
|
||||
// Before we redraw the composer to its new state, we need to save the
|
||||
// current height of the composer, as well as the page's scroll position, so
|
||||
// that we can smoothly transition from the old to the new state.
|
||||
const oldPosition = this.position;
|
||||
animateHeightChange() {
|
||||
const $composer = this.$().stop(true);
|
||||
const oldHeight = $composer.outerHeight();
|
||||
const scrollTop = $(window).scrollTop();
|
||||
|
||||
this.position = position;
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
// Now that we've redrawn and the composer's DOM has been updated, we want
|
||||
// to update the composer's height. Once we've done that, we'll capture the
|
||||
// real value to use as the end point for our animation later on.
|
||||
$composer.show();
|
||||
this.updateHeight();
|
||||
|
||||
const newHeight = $composer.outerHeight();
|
||||
|
||||
if (oldPosition === Composer.PositionEnum.HIDDEN) {
|
||||
if (this.prevPosition === ComposerState.Position.HIDDEN) {
|
||||
$composer.css({ bottom: -newHeight, height: newHeight });
|
||||
} else {
|
||||
$composer.css({ height: oldHeight });
|
||||
}
|
||||
|
||||
$composer.animate({ bottom: 0, height: newHeight }, 'fast', () => this.component.focus());
|
||||
const animation = $composer.animate({ bottom: 0, height: newHeight }, 'fast').promise();
|
||||
|
||||
this.updateBodyPadding();
|
||||
$(window).scrollTop(scrollTop);
|
||||
return animation;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,40 +262,30 @@ class Composer extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the composer.
|
||||
* Animate the composer sliding up from the bottom to take its normal height.
|
||||
*
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
show() {
|
||||
if (this.position === Composer.PositionEnum.NORMAL || this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
return;
|
||||
}
|
||||
this.animateHeightChange().then(() => this.focus());
|
||||
|
||||
this.animateToPosition(Composer.PositionEnum.NORMAL);
|
||||
|
||||
if (this.isFullScreen()) {
|
||||
if (app.screen() === 'phone') {
|
||||
this.$().css('top', $(window).scrollTop());
|
||||
this.showBackdrop();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the composer.
|
||||
* Animate closing the composer.
|
||||
*
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
hide() {
|
||||
const $composer = this.$();
|
||||
|
||||
// Animate the composer sliding down off the bottom edge of the viewport.
|
||||
// Only when the animation is completed, update the Composer state flag and
|
||||
// other elements on the page.
|
||||
// Only when the animation is completed, update other elements on the page.
|
||||
$composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => {
|
||||
this.position = Composer.PositionEnum.HIDDEN;
|
||||
this.clear();
|
||||
m.redraw();
|
||||
|
||||
$composer.hide();
|
||||
this.hideBackdrop();
|
||||
this.updateBodyPadding();
|
||||
@@ -354,60 +293,17 @@ class Composer extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm with the user so they don't lose their content, then close the
|
||||
* composer.
|
||||
* Shrink the composer until only its title is visible.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.preventExit()) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimize the composer. Has no effect if the composer is hidden.
|
||||
*
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
minimize() {
|
||||
if (this.position === Composer.PositionEnum.HIDDEN) return;
|
||||
|
||||
this.animateToPosition(Composer.PositionEnum.MINIMIZED);
|
||||
this.animateHeightChange();
|
||||
|
||||
this.$().css('top', 'auto');
|
||||
this.hideBackdrop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the composer into fullscreen mode. Has no effect if the composer is
|
||||
* hidden.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
fullScreen() {
|
||||
if (this.position !== Composer.PositionEnum.HIDDEN) {
|
||||
this.position = Composer.PositionEnum.FULLSCREEN;
|
||||
m.redraw();
|
||||
this.updateHeight();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit fullscreen mode.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
exitFullScreen() {
|
||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
this.position = Composer.PositionEnum.NORMAL;
|
||||
m.redraw();
|
||||
this.updateHeight();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the composer's controls.
|
||||
*
|
||||
@@ -416,23 +312,23 @@ class Composer extends Component {
|
||||
controlItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
if (this.state.position === ComposerState.Position.FULLSCREEN) {
|
||||
items.add(
|
||||
'exitFullScreen',
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-compress',
|
||||
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
|
||||
onclick: this.exitFullScreen.bind(this),
|
||||
onclick: this.state.exitFullScreen.bind(this.state),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
if (this.position !== Composer.PositionEnum.MINIMIZED) {
|
||||
if (this.state.position !== ComposerState.Position.MINIMIZED) {
|
||||
items.add(
|
||||
'minimize',
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-minus minimize',
|
||||
title: app.translator.trans('core.forum.composer.minimize_tooltip'),
|
||||
onclick: this.minimize.bind(this),
|
||||
onclick: this.state.minimize.bind(this.state),
|
||||
itemClassName: 'App-backControl',
|
||||
})
|
||||
);
|
||||
@@ -442,7 +338,7 @@ class Composer extends Component {
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-expand',
|
||||
title: app.translator.trans('core.forum.composer.full_screen_tooltip'),
|
||||
onclick: this.fullScreen.bind(this),
|
||||
onclick: this.state.fullScreen.bind(this.state),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -452,7 +348,7 @@ class Composer extends Component {
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-times',
|
||||
title: app.translator.trans('core.forum.composer.close_tooltip'),
|
||||
onclick: this.close.bind(this),
|
||||
onclick: this.state.close.bind(this.state),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -464,10 +360,10 @@ class Composer extends Component {
|
||||
* Initialize default Composer height.
|
||||
*/
|
||||
initializeHeight() {
|
||||
this.height = localStorage.getItem('composerHeight');
|
||||
this.state.height = localStorage.getItem('composerHeight');
|
||||
|
||||
if (!this.height) {
|
||||
this.height = this.defaultHeight();
|
||||
if (!this.state.height) {
|
||||
this.state.height = this.defaultHeight();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,60 +375,14 @@ class Composer extends Component {
|
||||
return this.$().height();
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum height of the Composer.
|
||||
* @returns {Integer}
|
||||
*/
|
||||
minimumHeight() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maxmimum height of the Composer.
|
||||
* @returns {Integer}
|
||||
*/
|
||||
maximumHeight() {
|
||||
return $(window).height() - $('#header').outerHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed the composer's current height, based on the intended height, and
|
||||
* the composer's current state. This will be applied to the composer's
|
||||
* content's DOM element.
|
||||
* @returns {Integer|String}
|
||||
*/
|
||||
computedHeight() {
|
||||
// If the composer is minimized, then we don't want to set a height; we'll
|
||||
// let the CSS decide how high it is. If it's fullscreen, then we need to
|
||||
// make it as high as the window.
|
||||
if (this.position === Composer.PositionEnum.MINIMIZED) {
|
||||
return '';
|
||||
} else if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
return $(window).height();
|
||||
}
|
||||
|
||||
// Otherwise, if it's normal or hidden, then we use the intended height.
|
||||
// We don't let the composer get too small or too big, though.
|
||||
return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new Composer height and update the DOM.
|
||||
* @param {Integer} height
|
||||
*/
|
||||
changeHeight(height) {
|
||||
this.height = height;
|
||||
this.state.height = height;
|
||||
this.updateHeight();
|
||||
|
||||
localStorage.setItem('composerHeight', this.height);
|
||||
localStorage.setItem('composerHeight', this.state.height);
|
||||
}
|
||||
}
|
||||
|
||||
Composer.PositionEnum = {
|
||||
HIDDEN: 'hidden',
|
||||
NORMAL: 'normal',
|
||||
MINIMIZED: 'minimized',
|
||||
FULLSCREEN: 'fullScreen',
|
||||
};
|
||||
|
||||
export default Composer;
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import Component from '../../common/Component';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload';
|
||||
import TextEditor from './TextEditor';
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
@@ -12,6 +13,7 @@ import ItemList from '../../common/utils/ItemList';
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `composer`
|
||||
* - `originalContent`
|
||||
* - `submitLabel`
|
||||
* - `placeholder`
|
||||
@@ -23,6 +25,8 @@ import ItemList from '../../common/utils/ItemList';
|
||||
*/
|
||||
export default class ComposerBody extends Component {
|
||||
init() {
|
||||
this.composer = this.props.composer;
|
||||
|
||||
/**
|
||||
* Whether or not the component is loading.
|
||||
*
|
||||
@@ -30,60 +34,57 @@ export default class ComposerBody extends Component {
|
||||
*/
|
||||
this.loading = false;
|
||||
|
||||
/**
|
||||
* The content of the text editor.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.content = m.prop(this.props.originalContent);
|
||||
// Let the composer state know to ask for confirmation under certain
|
||||
// circumstances, if the body supports / requires it and has a corresponding
|
||||
// confirmation question to ask.
|
||||
if (this.props.confirmExit) {
|
||||
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit);
|
||||
}
|
||||
|
||||
this.composer.fields.content(this.props.originalContent || '');
|
||||
|
||||
/**
|
||||
* The text editor component instance.
|
||||
*
|
||||
* @type {TextEditor}
|
||||
* @deprecated BC layer, remove in Beta 15.
|
||||
*/
|
||||
this.editor = new TextEditor({
|
||||
submitLabel: this.props.submitLabel,
|
||||
placeholder: this.props.placeholder,
|
||||
onchange: this.content,
|
||||
onsubmit: this.onsubmit.bind(this),
|
||||
value: this.content(),
|
||||
});
|
||||
this.content = this.composer.fields.content;
|
||||
this.editor = this.composer;
|
||||
}
|
||||
|
||||
view() {
|
||||
// If the component is loading, we should disable the text editor.
|
||||
this.editor.props.disabled = this.loading;
|
||||
|
||||
return (
|
||||
<div className={'ComposerBody ' + (this.props.className || '')}>
|
||||
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
|
||||
<div className="ComposerBody-content">
|
||||
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
|
||||
<div className="ComposerBody-editor">{this.editor.render()}</div>
|
||||
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
|
||||
<div className={'ComposerBody ' + (this.props.className || '')}>
|
||||
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
|
||||
<div className="ComposerBody-content">
|
||||
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
|
||||
<div className="ComposerBody-editor">
|
||||
{TextEditor.component({
|
||||
submitLabel: this.props.submitLabel,
|
||||
placeholder: this.props.placeholder,
|
||||
disabled: this.loading || this.props.disabled,
|
||||
composer: this.composer,
|
||||
preview: this.jumpToPreview && this.jumpToPreview.bind(this),
|
||||
onchange: this.composer.fields.content,
|
||||
onsubmit: this.onsubmit.bind(this),
|
||||
value: this.composer.fields.content(),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
|
||||
</div>
|
||||
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
|
||||
</div>
|
||||
</ConfirmDocumentUnload>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw focus to the text editor.
|
||||
*/
|
||||
focus() {
|
||||
this.$(':input:enabled:visible:first').focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is any unsaved data – if there is, return a confirmation
|
||||
* message to prompt the user with.
|
||||
* Check if there is any unsaved data.
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
preventExit() {
|
||||
const content = this.content();
|
||||
hasChanges() {
|
||||
const content = this.composer.fields.content();
|
||||
|
||||
return content && content !== this.props.originalContent && this.props.confirmExit;
|
||||
return content && content !== this.props.originalContent;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -16,12 +16,14 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.composer.fields.title = this.composer.fields.title || m.prop('');
|
||||
|
||||
/**
|
||||
* The value of the title input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.title = m.prop('');
|
||||
this.title = this.composer.fields.title;
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
@@ -66,14 +68,14 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
if (e.which === 13) {
|
||||
// Return
|
||||
e.preventDefault();
|
||||
this.editor.setSelectionRange(0, 0);
|
||||
this.composer.editor.moveCursorTo(0);
|
||||
}
|
||||
|
||||
m.redraw.strategy('none');
|
||||
}
|
||||
|
||||
preventExit() {
|
||||
return (this.title() || this.content()) && this.props.confirmExit;
|
||||
hasChanges() {
|
||||
return this.title() || this.composer.fields.content();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +86,7 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
data() {
|
||||
return {
|
||||
title: this.title(),
|
||||
content: this.content(),
|
||||
content: this.composer.fields.content(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,7 +99,7 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
.createRecord('discussions')
|
||||
.save(data)
|
||||
.then((discussion) => {
|
||||
app.composer.hide();
|
||||
this.composer.hide();
|
||||
app.discussions.refresh();
|
||||
m.route(app.route.discussion(discussion));
|
||||
}, this.loaded.bind(this));
|
||||
|
@@ -9,8 +9,6 @@ import Placeholder from '../../common/components/Placeholder';
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `params` A map of parameters used to construct a refined parameter object
|
||||
* to send along in the API request to get discussion results.
|
||||
* - `state` A DiscussionListState object that represents the discussion lists's state.
|
||||
*/
|
||||
export default class DiscussionList extends Component {
|
||||
|
@@ -8,6 +8,8 @@ import SplitDropdown from '../../common/components/SplitDropdown';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import DiscussionControls from '../utils/DiscussionControls';
|
||||
import DiscussionList from './DiscussionList';
|
||||
import PostStreamState from '../states/PostStreamState';
|
||||
import ScrollListener from '../../common/utils/ScrollListener';
|
||||
|
||||
/**
|
||||
* The `DiscussionPage` component displays a whole discussion page, including
|
||||
@@ -27,11 +29,13 @@ export default class DiscussionPage extends Page {
|
||||
/**
|
||||
* The number of the first post that is currently visible in the viewport.
|
||||
*
|
||||
* @type {Integer}
|
||||
* @type {number}
|
||||
*/
|
||||
this.near = null;
|
||||
this.near = m.route.param('near') || 0;
|
||||
|
||||
this.refresh();
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
|
||||
this.load();
|
||||
|
||||
// If the discussion list has been loaded, then we'll enable the pane (and
|
||||
// hide it by default). Also, if we've just come from another discussion
|
||||
@@ -79,7 +83,7 @@ export default class DiscussionPage extends Page {
|
||||
// we'll just close it.
|
||||
app.pane.disable();
|
||||
|
||||
if (app.composingReplyTo(this.discussion) && !app.composer.component.content()) {
|
||||
if (app.composer.composingReplyTo(this.discussion) && !app.composer.fields.content()) {
|
||||
app.composer.hide();
|
||||
} else {
|
||||
app.composer.minimize();
|
||||
@@ -107,7 +111,13 @@ export default class DiscussionPage extends Page {
|
||||
<nav className="DiscussionPage-nav">
|
||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||
</nav>
|
||||
<div className="DiscussionPage-stream">{this.stream.render()}</div>
|
||||
<div className="DiscussionPage-stream">
|
||||
{PostStream.component({
|
||||
discussion,
|
||||
stream: this.stream,
|
||||
targetPost: this.stream.targetPost,
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
]
|
||||
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
|
||||
@@ -116,21 +126,24 @@ export default class DiscussionPage extends Page {
|
||||
);
|
||||
}
|
||||
|
||||
config(...args) {
|
||||
super.config(...args);
|
||||
config(isInitialized, context) {
|
||||
super.config(isInitialized, context);
|
||||
|
||||
if (this.discussion) {
|
||||
app.setTitle(this.discussion.title());
|
||||
}
|
||||
|
||||
context.onunload = () => {
|
||||
this.scrollListener.stop();
|
||||
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear and reload the discussion.
|
||||
* Load the discussion from the API or use the preloaded one.
|
||||
*/
|
||||
refresh() {
|
||||
this.near = m.route.param('near') || 0;
|
||||
this.discussion = null;
|
||||
|
||||
load() {
|
||||
const preloadedDiscussion = app.preloadedApiDocument();
|
||||
if (preloadedDiscussion) {
|
||||
// We must wrap this in a setTimeout because if we are mounting this
|
||||
@@ -197,12 +210,13 @@ export default class DiscussionPage extends Page {
|
||||
// Set up the post stream for this discussion, along with the first page of
|
||||
// posts we want to display. Tell the stream to scroll down and highlight
|
||||
// the specific post that was routed to.
|
||||
this.stream = new PostStream({ discussion, includedPosts });
|
||||
this.stream.on('positionChanged', this.positionChanged.bind(this));
|
||||
this.stream = new PostStreamState(discussion, includedPosts);
|
||||
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
|
||||
|
||||
app.current.set('discussion', discussion);
|
||||
app.current.set('stream', this.stream);
|
||||
|
||||
this.scrollListener.start();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,8 +282,12 @@ export default class DiscussionPage extends Page {
|
||||
items.add(
|
||||
'scrubber',
|
||||
PostStreamScrubber.component({
|
||||
stream: this.stream,
|
||||
discussion: this.discussion,
|
||||
className: 'App-titleControl',
|
||||
onNavigate: this.stream.goToIndex.bind(this.stream),
|
||||
count: this.stream.count(),
|
||||
paused: this.stream.paused,
|
||||
...this.scrubberProps(),
|
||||
}),
|
||||
-100
|
||||
);
|
||||
@@ -277,6 +295,84 @@ export default class DiscussionPage extends Page {
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the window is scrolled, check if either extreme of the post stream is
|
||||
* in the viewport, and if so, trigger loading the next/previous page.
|
||||
*
|
||||
* @param {number} top
|
||||
*/
|
||||
onscroll(top = window.pageYOffset) {
|
||||
if (this.stream.paused) return;
|
||||
const marginTop = this.getMarginTop();
|
||||
const viewportHeight = $(window).height() - marginTop;
|
||||
const viewportTop = top + marginTop;
|
||||
const loadAheadDistance = 300;
|
||||
|
||||
if (this.stream.visibleStart > 0) {
|
||||
const $item = this.$('.PostStream-item[data-index=' + this.stream.visibleStart + ']');
|
||||
|
||||
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
|
||||
this.stream.loadPrevious();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.stream.visibleEnd < this.stream.count()) {
|
||||
const $item = this.$('.PostStream-item[data-index=' + (this.stream.visibleEnd - 1) + ']');
|
||||
|
||||
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
|
||||
this.stream.loadNext();
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle calculation of our position (start/end numbers of posts in the
|
||||
// viewport) to 100ms.
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
|
||||
|
||||
// Update numbers for the scrubber if necessary
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out which posts (by number) are currently visible in the viewport, and
|
||||
* fire an event with the information.
|
||||
*/
|
||||
calculatePosition(top = window.pageYOffset) {
|
||||
const marginTop = this.getMarginTop();
|
||||
const $window = $(window);
|
||||
const viewportHeight = $window.height() - marginTop;
|
||||
const scrollTop = $window.scrollTop() + marginTop;
|
||||
const viewportTop = top + marginTop;
|
||||
|
||||
let startNumber;
|
||||
let endNumber;
|
||||
|
||||
this.$('.PostStream-item').each(function () {
|
||||
const $item = $(this);
|
||||
const top = $item.offset().top;
|
||||
const height = $item.outerHeight(true);
|
||||
const visibleTop = Math.max(0, viewportTop - top);
|
||||
|
||||
const threeQuartersVisible = visibleTop / height < 0.75;
|
||||
const coversQuarterOfViewport = (height - visibleTop) / viewportHeight > 0.25;
|
||||
if (startNumber === undefined && (threeQuartersVisible || coversQuarterOfViewport)) {
|
||||
startNumber = $item.data('number');
|
||||
}
|
||||
|
||||
if (top + height > scrollTop) {
|
||||
if (top + height < scrollTop + viewportHeight) {
|
||||
if ($item.data('number')) {
|
||||
endNumber = $item.data('number');
|
||||
}
|
||||
} else return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (startNumber) {
|
||||
this.positionChanged(startNumber || 1, endNumber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the posts that are visible in the post stream change (i.e. the user
|
||||
* scrolls up or down), then we update the URL and mark the posts as read.
|
||||
@@ -303,4 +399,73 @@ export default class DiscussionPage extends Page {
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
scrubberProps(top = window.pageYOffset) {
|
||||
const marginTop = this.getMarginTop();
|
||||
const viewportHeight = $(window).height() - marginTop;
|
||||
const viewportTop = top + marginTop;
|
||||
|
||||
// Before looping through all of the posts, we reset the scrollbar
|
||||
// properties to a 'default' state. These values reflect what would be
|
||||
// seen if the browser were scrolled right up to the top of the page,
|
||||
// and the viewport had a height of 0.
|
||||
const $items = this.$('.PostStream-item[data-index]');
|
||||
let index = $items.first().data('index') || 0;
|
||||
let visible = 0;
|
||||
let period = '';
|
||||
|
||||
// Now loop through each of the items in the discussion. An 'item' is
|
||||
// either a single post or a 'gap' of one or more posts that haven't
|
||||
// been loaded yet.
|
||||
$items.each(function () {
|
||||
const $this = $(this);
|
||||
const top = $this.offset().top;
|
||||
const height = $this.outerHeight(true);
|
||||
|
||||
// If this item is above the top of the viewport, skip to the next
|
||||
// one. If it's below the bottom of the viewport, break out of the
|
||||
// loop.
|
||||
if (top + height < viewportTop) {
|
||||
return true;
|
||||
}
|
||||
if (top > viewportTop + viewportHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Work out how many pixels of this item are visible inside the viewport.
|
||||
// Then add the proportion of this item's total height to the index.
|
||||
const visibleTop = Math.max(0, viewportTop - top);
|
||||
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
|
||||
const visiblePost = visibleBottom - visibleTop;
|
||||
|
||||
if (top <= viewportTop) {
|
||||
index = parseFloat($this.data('index')) + visibleTop / height;
|
||||
}
|
||||
|
||||
if (visiblePost > 0) {
|
||||
visible += visiblePost / height;
|
||||
}
|
||||
|
||||
// If this item has a time associated with it, then set the
|
||||
// scrollbar's current period to a formatted version of this time.
|
||||
const time = $this.data('time');
|
||||
if (time) period = time;
|
||||
});
|
||||
|
||||
return {
|
||||
index: index + 1,
|
||||
visible: visible || 1,
|
||||
description: period && dayjs(period).format('MMMM YYYY'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the distance from the top of the viewport to the point at which we
|
||||
* would consider a post to be the first one visible.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
getMarginTop() {
|
||||
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import UserPage from './UserPage';
|
||||
import DiscussionList from './DiscussionList';
|
||||
import DiscussionListState from '../states/DiscussionListState';
|
||||
|
||||
/**
|
||||
* The `DiscussionsUserPage` component shows a discussion list inside of a user
|
||||
@@ -12,16 +13,18 @@ export default class DiscussionsUserPage extends UserPage {
|
||||
this.loadUser(m.route.param('username'));
|
||||
}
|
||||
|
||||
show(user) {
|
||||
super.show(user);
|
||||
|
||||
this.state = new DiscussionListState({
|
||||
q: 'author:' + user.username(),
|
||||
sort: 'newest',
|
||||
});
|
||||
|
||||
this.state.refresh();
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="DiscussionsUserPage">
|
||||
{DiscussionList.component({
|
||||
params: {
|
||||
q: 'author:' + this.user.username(),
|
||||
sort: 'newest',
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return <div className="DiscussionsUserPage">{DiscussionList.component({ state: this.state })}</div>;
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import ComposerBody from './ComposerBody';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import Button from '../../common/components/Button';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
@@ -21,16 +20,6 @@ function minimizeComposerIfFullScreen(e) {
|
||||
* - `post`
|
||||
*/
|
||||
export default class EditPostComposer extends ComposerBody {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.editor.props.preview = (e) => {
|
||||
minimizeComposerIfFullScreen(e);
|
||||
|
||||
m.route(app.route.post(this.props.post));
|
||||
};
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
@@ -65,6 +54,15 @@ export default class EditPostComposer extends ComposerBody {
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to the preview when triggered by the text editor.
|
||||
*/
|
||||
jumpToPreview(e) {
|
||||
minimizeComposerIfFullScreen(e);
|
||||
|
||||
m.route(app.route.post(this.props.post));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to submit to the server when the post is saved.
|
||||
*
|
||||
@@ -72,7 +70,7 @@ export default class EditPostComposer extends ComposerBody {
|
||||
*/
|
||||
data() {
|
||||
return {
|
||||
content: this.content(),
|
||||
content: this.composer.fields.content(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,7 +85,7 @@ export default class EditPostComposer extends ComposerBody {
|
||||
// If we're currently viewing the discussion which this edit was made
|
||||
// in, then we can scroll to the post.
|
||||
if (app.viewingDiscussion(discussion)) {
|
||||
app.current.stream.goToNumber(post.number());
|
||||
app.current.get('stream').goToNumber(post.number());
|
||||
} else {
|
||||
// Otherwise, we'll create an alert message to inform the user that
|
||||
// their edit has been made, containing a button which will
|
||||
@@ -101,16 +99,14 @@ export default class EditPostComposer extends ComposerBody {
|
||||
app.alerts.dismiss(alert);
|
||||
},
|
||||
});
|
||||
app.alerts.show(
|
||||
(alert = new Alert({
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.forum.composer_edit.edited_message'),
|
||||
controls: [viewButton],
|
||||
}))
|
||||
);
|
||||
alert = app.alerts.show({
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.forum.composer_edit.edited_message'),
|
||||
controls: [viewButton],
|
||||
});
|
||||
}
|
||||
|
||||
app.composer.hide();
|
||||
this.composer.hide();
|
||||
}, this.loaded.bind(this));
|
||||
}
|
||||
}
|
||||
|
@@ -104,7 +104,7 @@ export default class ForgotPasswordModal extends Modal {
|
||||
|
||||
onerror(error) {
|
||||
if (error.status === 404) {
|
||||
error.alert.props.children = app.translator.trans('core.forum.forgot_password.not_found_message');
|
||||
error.alert.children = app.translator.trans('core.forum.forgot_password.not_found_message');
|
||||
}
|
||||
|
||||
super.onerror(error);
|
||||
|
@@ -77,7 +77,7 @@ export default class HeaderSecondary extends Component {
|
||||
Button.component({
|
||||
children: app.translator.trans('core.forum.header.sign_up_link'),
|
||||
className: 'Button Button--link',
|
||||
onclick: () => app.modal.show(new SignUpModal()),
|
||||
onclick: () => app.modal.show(SignUpModal),
|
||||
}),
|
||||
10
|
||||
);
|
||||
@@ -88,7 +88,7 @@ export default class HeaderSecondary extends Component {
|
||||
Button.component({
|
||||
children: app.translator.trans('core.forum.header.log_in_link'),
|
||||
className: 'Button Button--link',
|
||||
onclick: () => app.modal.show(new LogInModal()),
|
||||
onclick: () => app.modal.show(LogInModal),
|
||||
}),
|
||||
0
|
||||
);
|
||||
|
@@ -79,7 +79,7 @@ export default class IndexPage extends Page {
|
||||
|
||||
extend(context, 'onunload', () => $('#app').css('min-height', ''));
|
||||
|
||||
app.setTitle('');
|
||||
app.setTitle(app.translator.trans('core.forum.index.meta_title_text'));
|
||||
app.setTitleCount(0);
|
||||
|
||||
// Work out the difference between the height of this hero and that of the
|
||||
@@ -273,16 +273,14 @@ export default class IndexPage extends Page {
|
||||
const deferred = m.deferred();
|
||||
|
||||
if (app.session.user) {
|
||||
const component = new DiscussionComposer({ user: app.session.user });
|
||||
|
||||
app.composer.load(component);
|
||||
app.composer.load(DiscussionComposer, { user: app.session.user });
|
||||
app.composer.show();
|
||||
|
||||
deferred.resolve(component);
|
||||
deferred.resolve(app.composer);
|
||||
} else {
|
||||
deferred.reject();
|
||||
|
||||
app.modal.show(new LogInModal());
|
||||
app.modal.show(LogInModal);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
|
@@ -142,7 +142,7 @@ export default class LogInModal extends Modal {
|
||||
const email = this.identification();
|
||||
const props = email.indexOf('@') !== -1 ? { email } : undefined;
|
||||
|
||||
app.modal.show(new ForgotPasswordModal(props));
|
||||
app.modal.show(ForgotPasswordModal, props);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,7 +156,7 @@ export default class LogInModal extends Modal {
|
||||
const identification = this.identification();
|
||||
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
|
||||
|
||||
app.modal.show(new SignUpModal(props));
|
||||
app.modal.show(SignUpModal, props);
|
||||
}
|
||||
|
||||
onready() {
|
||||
@@ -179,7 +179,7 @@ export default class LogInModal extends Modal {
|
||||
|
||||
onerror(error) {
|
||||
if (error.status === 401) {
|
||||
error.alert.props.children = app.translator.trans('core.forum.log_in.invalid_login_message');
|
||||
error.alert.children = app.translator.trans('core.forum.log_in.invalid_login_message');
|
||||
}
|
||||
|
||||
super.onerror(error);
|
||||
|
@@ -13,10 +13,6 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
super.initProps(props);
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init();
|
||||
}
|
||||
|
||||
getButton() {
|
||||
const newNotifications = this.getNewCount();
|
||||
const vdom = super.getButton();
|
||||
|
@@ -1,8 +1,5 @@
|
||||
import Component from '../../common/Component';
|
||||
import ScrollListener from '../../common/utils/ScrollListener';
|
||||
import PostLoading from './LoadingPost';
|
||||
import anchorScroll from '../../common/utils/anchorScroll';
|
||||
import evented from '../../common/utils/evented';
|
||||
import ReplyPlaceholder from './ReplyPlaceholder';
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
@@ -13,9 +10,10 @@ import Button from '../../common/components/Button';
|
||||
* ### Props
|
||||
*
|
||||
* - `discussion`
|
||||
* - `includedPosts`
|
||||
* - `stream`
|
||||
* - `targetPost`
|
||||
*/
|
||||
class PostStream extends Component {
|
||||
export default class PostStream extends Component {
|
||||
init() {
|
||||
/**
|
||||
* The discussion to display the post stream for.
|
||||
@@ -25,171 +23,11 @@ class PostStream extends Component {
|
||||
this.discussion = this.props.discussion;
|
||||
|
||||
/**
|
||||
* Whether or not the infinite-scrolling auto-load functionality is
|
||||
* disabled.
|
||||
* The shared state of the post stream.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @type {PostStreamState}
|
||||
*/
|
||||
this.paused = false;
|
||||
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
this.loadPageTimeouts = {};
|
||||
this.pagesLoading = 0;
|
||||
|
||||
this.show(this.props.includedPosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll to a post with a certain number.
|
||||
*
|
||||
* @param {Integer|String} number The post number to go to. If 'reply', go to
|
||||
* the last post and scroll the reply preview into view.
|
||||
* @param {Boolean} noAnimation
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToNumber(number, noAnimation) {
|
||||
// If we want to go to the reply preview, then we will go to the end of the
|
||||
// discussion and then scroll to the very bottom of the page.
|
||||
if (number === 'reply') {
|
||||
return this.goToLast().then(() => {
|
||||
$('html,body')
|
||||
.stop(true)
|
||||
.animate(
|
||||
{
|
||||
scrollTop: $(document).height() - $(window).height(),
|
||||
},
|
||||
'fast',
|
||||
() => {
|
||||
this.flashItem(this.$('.PostStream-item:last-child'));
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.paused = true;
|
||||
|
||||
const promise = this.loadNearNumber(number);
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
return promise.then(() => {
|
||||
m.redraw(true);
|
||||
|
||||
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll to a certain index within the discussion.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @param {Boolean} backwards Whether or not to load backwards from the given
|
||||
* index.
|
||||
* @param {Boolean} noAnimation
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToIndex(index, backwards, noAnimation) {
|
||||
this.paused = true;
|
||||
|
||||
const promise = this.loadNearIndex(index);
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
return promise.then(() => {
|
||||
anchorScroll(this.$('.PostStream-item:' + (backwards ? 'last' : 'first')), () => m.redraw(true));
|
||||
|
||||
this.scrollToIndex(index, noAnimation, backwards).done(this.unpause.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll up to the first post in the discussion.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToFirst() {
|
||||
return this.goToIndex(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll down to the last post in the discussion.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToLast() {
|
||||
return this.goToIndex(this.count() - 1, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stream so that it loads and includes the latest posts in the
|
||||
* discussion, if the end is being viewed.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
update() {
|
||||
if (!this.viewingEnd) return m.deferred().resolve().promise;
|
||||
|
||||
this.visibleEnd = this.count();
|
||||
|
||||
return this.loadRange(this.visibleStart, this.visibleEnd).then(() => m.redraw());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of posts in the discussion.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
count() {
|
||||
return this.discussion.postIds().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that the given index is not outside of the possible range of
|
||||
* indexes in the discussion.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @protected
|
||||
*/
|
||||
sanitizeIndex(index) {
|
||||
return Math.max(0, Math.min(this.count(), index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the stream with the given array of posts.
|
||||
*
|
||||
* @param {Post[]} posts
|
||||
*/
|
||||
show(posts) {
|
||||
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
|
||||
this.visibleEnd = this.visibleStart + posts.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the stream so that a specific range of posts is displayed. If a range
|
||||
* is not specified, the first page of posts will be displayed.
|
||||
*
|
||||
* @param {Integer} [start]
|
||||
* @param {Integer} [end]
|
||||
*/
|
||||
reset(start, end) {
|
||||
this.visibleStart = start || 0;
|
||||
this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible page of posts.
|
||||
*
|
||||
* @return {Post[]}
|
||||
*/
|
||||
posts() {
|
||||
return this.discussion
|
||||
.postIds()
|
||||
.slice(this.visibleStart, this.visibleEnd)
|
||||
.map((id) => {
|
||||
const post = app.store.getById('posts', id);
|
||||
|
||||
return post && post.discussion() && typeof post.canEdit() !== 'undefined' ? post : null;
|
||||
});
|
||||
this.stream = this.props.stream;
|
||||
}
|
||||
|
||||
view() {
|
||||
@@ -200,15 +38,13 @@ class PostStream extends Component {
|
||||
|
||||
let lastTime;
|
||||
|
||||
this.visibleEnd = this.sanitizeIndex(this.visibleEnd);
|
||||
this.viewingEnd = this.visibleEnd === this.count();
|
||||
|
||||
const posts = this.posts();
|
||||
const viewingEnd = this.stream.viewingEnd();
|
||||
const posts = this.stream.posts();
|
||||
const postIds = this.discussion.postIds();
|
||||
|
||||
const items = posts.map((post, i) => {
|
||||
let content;
|
||||
const attrs = { 'data-index': this.visibleStart + i };
|
||||
const attrs = { 'data-index': this.stream.visibleStart + i };
|
||||
|
||||
if (post) {
|
||||
const time = post.createdAt();
|
||||
@@ -230,7 +66,7 @@ class PostStream extends Component {
|
||||
if (dt > 1000 * 60 * 60 * 24 * 4) {
|
||||
content = [
|
||||
<div className="PostStream-timeGap">
|
||||
<span>{app.translator.trans('core.forum.post_stream.time_lapsed_text', { period: moment.duration(dt).humanize() })}</span>
|
||||
<span>{app.translator.trans('core.forum.post_stream.time_lapsed_text', { period: dayjs().add(dt, 'ms').fromNow(true) })}</span>
|
||||
</div>,
|
||||
content,
|
||||
];
|
||||
@@ -238,7 +74,7 @@ class PostStream extends Component {
|
||||
|
||||
lastTime = time;
|
||||
} else {
|
||||
attrs.key = 'post' + postIds[this.visibleStart + i];
|
||||
attrs.key = 'post' + postIds[this.stream.visibleStart + i];
|
||||
|
||||
content = PostLoading.component();
|
||||
}
|
||||
@@ -250,10 +86,10 @@ class PostStream extends Component {
|
||||
);
|
||||
});
|
||||
|
||||
if (!this.viewingEnd && posts[this.visibleEnd - this.visibleStart - 1]) {
|
||||
if (!viewingEnd && posts[this.stream.visibleEnd - this.stream.visibleStart - 1]) {
|
||||
items.push(
|
||||
<div className="PostStream-loadMore" key="loadMore">
|
||||
<Button className="Button" onclick={this.loadNext.bind(this)}>
|
||||
<Button className="Button" onclick={this.stream.loadNext.bind(this.stream)}>
|
||||
{app.translator.trans('core.forum.post_stream.load_more_button')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -262,7 +98,7 @@ class PostStream extends Component {
|
||||
|
||||
// If we're viewing the end of the discussion, the user can reply, and
|
||||
// is not already doing so, then show a 'write a reply' placeholder.
|
||||
if (this.viewingEnd && (!app.session.user || this.discussion.canReply())) {
|
||||
if (viewingEnd && (!app.session.user || this.discussion.canReply())) {
|
||||
items.push(
|
||||
<div className="PostStream-item" key="reply">
|
||||
{ReplyPlaceholder.component({ discussion: this.discussion })}
|
||||
@@ -274,237 +110,25 @@ class PostStream extends Component {
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
// Start scrolling, if appropriate, to a newly-targeted post.
|
||||
if (!this.props.targetPost) return;
|
||||
|
||||
// This is wrapped in setTimeout due to the following Mithril issue:
|
||||
// https://github.com/lhorie/mithril.js/issues/637
|
||||
setTimeout(() => this.scrollListener.start());
|
||||
const oldTarget = this.prevTarget;
|
||||
const newTarget = this.props.targetPost;
|
||||
|
||||
context.onunload = () => {
|
||||
this.scrollListener.stop();
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* When the window is scrolled, check if either extreme of the post stream is
|
||||
* in the viewport, and if so, trigger loading the next/previous page.
|
||||
*
|
||||
* @param {Integer} top
|
||||
*/
|
||||
onscroll(top) {
|
||||
if (this.paused) return;
|
||||
|
||||
const marginTop = this.getMarginTop();
|
||||
const viewportHeight = $(window).height() - marginTop;
|
||||
const viewportTop = top + marginTop;
|
||||
const loadAheadDistance = 300;
|
||||
|
||||
if (this.visibleStart > 0) {
|
||||
const $item = this.$('.PostStream-item[data-index=' + this.visibleStart + ']');
|
||||
|
||||
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
|
||||
this.loadPrevious();
|
||||
}
|
||||
if (oldTarget) {
|
||||
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
|
||||
if ('index' in oldTarget && oldTarget.index === newTarget.index) return;
|
||||
}
|
||||
|
||||
if (this.visibleEnd < this.count()) {
|
||||
const $item = this.$('.PostStream-item[data-index=' + (this.visibleEnd - 1) + ']');
|
||||
|
||||
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
|
||||
this.loadNext();
|
||||
}
|
||||
if ('number' in newTarget) {
|
||||
this.scrollToNumber(newTarget.number, this.stream.noAnimationScroll);
|
||||
} else if ('index' in newTarget) {
|
||||
const backwards = newTarget.index === this.stream.count() - 1;
|
||||
this.scrollToIndex(newTarget.index, this.stream.noAnimationScroll, backwards);
|
||||
}
|
||||
|
||||
// Throttle calculation of our position (start/end numbers of posts in the
|
||||
// viewport) to 100ms.
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the next page of posts.
|
||||
*/
|
||||
loadNext() {
|
||||
const start = this.visibleEnd;
|
||||
const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount));
|
||||
|
||||
// Unload the posts which are two pages back from the page we're currently
|
||||
// loading.
|
||||
const twoPagesAway = start - this.constructor.loadCount * 2;
|
||||
if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
|
||||
this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
|
||||
|
||||
if (this.loadPageTimeouts[twoPagesAway]) {
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
this.loadPageTimeouts[twoPagesAway] = null;
|
||||
this.pagesLoading--;
|
||||
}
|
||||
}
|
||||
|
||||
this.loadPage(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the previous page of posts.
|
||||
*/
|
||||
loadPrevious() {
|
||||
const end = this.visibleStart;
|
||||
const start = (this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount));
|
||||
|
||||
// Unload the posts which are two pages back from the page we're currently
|
||||
// loading.
|
||||
const twoPagesAway = start + this.constructor.loadCount * 2;
|
||||
if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
|
||||
this.visibleEnd = twoPagesAway;
|
||||
|
||||
if (this.loadPageTimeouts[twoPagesAway]) {
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
this.loadPageTimeouts[twoPagesAway] = null;
|
||||
this.pagesLoading--;
|
||||
}
|
||||
}
|
||||
|
||||
this.loadPage(start, end, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page of posts into the stream and redraw.
|
||||
*
|
||||
* @param {Integer} start
|
||||
* @param {Integer} end
|
||||
* @param {Boolean} backwards
|
||||
*/
|
||||
loadPage(start, end, backwards) {
|
||||
const redraw = () => {
|
||||
if (start < this.visibleStart || end > this.visibleEnd) return;
|
||||
|
||||
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true));
|
||||
|
||||
this.unpause();
|
||||
};
|
||||
redraw();
|
||||
|
||||
this.loadPageTimeouts[start] = setTimeout(
|
||||
() => {
|
||||
this.loadRange(start, end).then(() => {
|
||||
redraw();
|
||||
this.pagesLoading--;
|
||||
});
|
||||
this.loadPageTimeouts[start] = null;
|
||||
},
|
||||
this.pagesLoading ? 1000 : 0
|
||||
);
|
||||
|
||||
this.pagesLoading++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and inject the specified range of posts into the stream, without
|
||||
* clearing it.
|
||||
*
|
||||
* @param {Integer} start
|
||||
* @param {Integer} end
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadRange(start, end) {
|
||||
const loadIds = [];
|
||||
const loaded = [];
|
||||
|
||||
this.discussion
|
||||
.postIds()
|
||||
.slice(start, end)
|
||||
.forEach((id) => {
|
||||
const post = app.store.getById('posts', id);
|
||||
|
||||
if (post && post.discussion() && typeof post.canEdit() !== 'undefined') {
|
||||
loaded.push(post);
|
||||
} else {
|
||||
loadIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
return loadIds.length ? app.store.find('posts', loadIds) : m.deferred().resolve(loaded).promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stream and load posts near a certain number. Returns a promise.
|
||||
* If the post with the given number is already loaded, the promise will be
|
||||
* resolved immediately.
|
||||
*
|
||||
* @param {Integer} number
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadNearNumber(number) {
|
||||
if (this.posts().some((post) => post && Number(post.number()) === Number(number))) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
this.reset();
|
||||
|
||||
return app.store
|
||||
.find('posts', {
|
||||
filter: { discussion: this.discussion.id() },
|
||||
page: { near: number },
|
||||
})
|
||||
.then(this.show.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stream and load posts near a certain index. A page of posts
|
||||
* surrounding the given index will be loaded. Returns a promise. If the given
|
||||
* index is already loaded, the promise will be resolved immediately.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadNearIndex(index) {
|
||||
if (index >= this.visibleStart && index <= this.visibleEnd) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
const start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
|
||||
const end = start + this.constructor.loadCount;
|
||||
|
||||
this.reset(start, end);
|
||||
|
||||
return this.loadRange(start, end).then(this.show.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out which posts (by number) are currently visible in the viewport, and
|
||||
* fire an event with the information.
|
||||
*/
|
||||
calculatePosition() {
|
||||
const marginTop = this.getMarginTop();
|
||||
const $window = $(window);
|
||||
const viewportHeight = $window.height() - marginTop;
|
||||
const scrollTop = $window.scrollTop() + marginTop;
|
||||
let startNumber;
|
||||
let endNumber;
|
||||
|
||||
this.$('.PostStream-item').each(function () {
|
||||
const $item = $(this);
|
||||
const top = $item.offset().top;
|
||||
const height = $item.outerHeight(true);
|
||||
|
||||
if (top + height > scrollTop) {
|
||||
if (!startNumber) {
|
||||
startNumber = endNumber = $item.data('number');
|
||||
}
|
||||
|
||||
if (top + height < scrollTop + viewportHeight) {
|
||||
if ($item.data('number')) {
|
||||
endNumber = $item.data('number');
|
||||
}
|
||||
} else return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (startNumber) {
|
||||
this.trigger('positionChanged', startNumber || 1, endNumber);
|
||||
}
|
||||
this.prevTarget = newTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -521,42 +145,46 @@ class PostStream extends Component {
|
||||
* Scroll down to a certain post by number and 'flash' it.
|
||||
*
|
||||
* @param {Integer} number
|
||||
* @param {Boolean} noAnimation
|
||||
* @param {Boolean} animate
|
||||
* @return {jQuery.Deferred}
|
||||
*/
|
||||
scrollToNumber(number, noAnimation) {
|
||||
scrollToNumber(number, animate) {
|
||||
const $item = this.$(`.PostStream-item[data-number=${number}]`);
|
||||
|
||||
return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item));
|
||||
return this.scrollToItem($item, animate).then(this.flashItem.bind(this, $item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll down to a certain post by index.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @param {Boolean} noAnimation
|
||||
* @param {Boolean} animate
|
||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||
* at the given index, instead of the top of it.
|
||||
* @return {jQuery.Deferred}
|
||||
*/
|
||||
scrollToIndex(index, noAnimation, bottom) {
|
||||
scrollToIndex(index, animate, bottom) {
|
||||
const $item = this.$(`.PostStream-item[data-index=${index}]`);
|
||||
|
||||
return this.scrollToItem($item, noAnimation, true, bottom);
|
||||
return this.scrollToItem($item, animate, true, bottom).then(() => {
|
||||
if (index == this.stream.count() - 1) {
|
||||
this.flashItem(this.$('.PostStream-item:last-child'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll down to the given post.
|
||||
*
|
||||
* @param {jQuery} $item
|
||||
* @param {Boolean} noAnimation
|
||||
* @param {Boolean} animate
|
||||
* @param {Boolean} force Whether or not to force scrolling to the item, even
|
||||
* if it is already in the viewport.
|
||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||
* at the given index, instead of the top of it.
|
||||
* @return {jQuery.Deferred}
|
||||
*/
|
||||
scrollToItem($item, noAnimation, force, bottom) {
|
||||
scrollToItem($item, animate, force, bottom) {
|
||||
const $container = $('html, body').stop(true);
|
||||
|
||||
if ($item.length) {
|
||||
@@ -571,7 +199,7 @@ class PostStream extends Component {
|
||||
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||
const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
|
||||
|
||||
if (noAnimation) {
|
||||
if (!animate) {
|
||||
$container.scrollTop(top);
|
||||
} else if (top !== scrollTop) {
|
||||
$container.animate({ scrollTop: top }, 'fast');
|
||||
@@ -590,24 +218,4 @@ class PostStream extends Component {
|
||||
flashItem($item) {
|
||||
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the stream's ability to auto-load posts on scroll.
|
||||
*/
|
||||
unpause() {
|
||||
this.paused = false;
|
||||
this.scrollListener.update();
|
||||
this.trigger('unpaused');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of posts to load per page.
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
PostStream.loadCount = 20;
|
||||
|
||||
Object.assign(PostStream.prototype, evented);
|
||||
|
||||
export default PostStream;
|
||||
|
@@ -10,41 +10,22 @@ import formatNumber from '../../common/utils/formatNumber';
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `stream`
|
||||
* - `discussion`
|
||||
* - `className`
|
||||
* - `onNavigate`
|
||||
* - `count`
|
||||
* - `paused`
|
||||
* - `index`
|
||||
* - `visible`
|
||||
* - `description`
|
||||
*/
|
||||
export default class PostStreamScrubber extends Component {
|
||||
init() {
|
||||
this.handlers = {};
|
||||
|
||||
/**
|
||||
* The index of the post that is currently at the top of the viewport.
|
||||
*
|
||||
* @type {Number}
|
||||
*/
|
||||
this.index = 0;
|
||||
|
||||
/**
|
||||
* The number of posts that are currently visible in the viewport.
|
||||
*
|
||||
* @type {Number}
|
||||
*/
|
||||
this.visible = 1;
|
||||
|
||||
/**
|
||||
* The description to render on the scrubber.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.description = '';
|
||||
|
||||
// When the post stream begins loading posts at a certain index, we want our
|
||||
// scrubber scrollbar to jump to that position.
|
||||
this.props.stream.on('unpaused', (this.handlers.streamWasUnpaused = this.streamWasUnpaused.bind(this)));
|
||||
|
||||
// Define a handler to update the state of the scrollbar to reflect the
|
||||
// current scroll position of the page.
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
this.scrollListener = new ScrollListener(this.renderScrollbar.bind(this, { fromScroll: true, forceHeightChange: true }));
|
||||
|
||||
// Create a subtree retainer that will always cache the subtree after the
|
||||
// initial draw. We render parts of the scrubber using this because we
|
||||
@@ -55,12 +36,12 @@ export default class PostStreamScrubber extends Component {
|
||||
|
||||
view() {
|
||||
const retain = this.subtree.retain();
|
||||
const count = this.count();
|
||||
const unreadCount = this.props.stream.discussion.unreadCount();
|
||||
const unreadPercent = count ? Math.min(count - this.index, unreadCount) / count : 0;
|
||||
const { count, index, visible } = this.props;
|
||||
const unreadCount = this.props.discussion.unreadCount();
|
||||
const unreadPercent = count ? Math.min(count - this.props.index, unreadCount) / count : 0;
|
||||
|
||||
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
|
||||
index: <span className="Scrubber-index">{retain || formatNumber(Math.min(Math.ceil(this.index + this.visible), count))}</span>,
|
||||
index: <span className="Scrubber-index">{retain || formatNumber(Math.min(Math.ceil(index + visible), count))}</span>,
|
||||
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
|
||||
});
|
||||
|
||||
@@ -98,7 +79,7 @@ export default class PostStreamScrubber extends Component {
|
||||
<div className="Scrubber-bar" />
|
||||
<div className="Scrubber-info">
|
||||
<strong>{viewing}</strong>
|
||||
<span className="Scrubber-description">{retain || this.description}</span>
|
||||
<span className="Scrubber-description">{retain || this.props.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Scrubber-after" />
|
||||
@@ -121,35 +102,14 @@ export default class PostStreamScrubber extends Component {
|
||||
* Go to the first post in the discussion.
|
||||
*/
|
||||
goToFirst() {
|
||||
this.props.stream.goToFirst();
|
||||
this.index = 0;
|
||||
this.renderScrollbar(true);
|
||||
this.navigateTo(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the last post in the discussion.
|
||||
*/
|
||||
goToLast() {
|
||||
this.props.stream.goToLast();
|
||||
this.index = this.count();
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of posts in the discussion.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
count() {
|
||||
return this.props.stream.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the stream is unpaused, update the scrubber to reflect its position.
|
||||
*/
|
||||
streamWasUnpaused() {
|
||||
this.update(window.pageYOffset);
|
||||
this.renderScrollbar(true);
|
||||
this.navigateTo(this.props.count - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,87 +119,7 @@ export default class PostStreamScrubber extends Component {
|
||||
* @return {Boolean}
|
||||
*/
|
||||
disabled() {
|
||||
return this.visible >= this.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the page is scrolled, update the scrollbar to reflect the visible
|
||||
* posts.
|
||||
*
|
||||
* @param {Integer} top
|
||||
*/
|
||||
onscroll(top) {
|
||||
const stream = this.props.stream;
|
||||
|
||||
if (stream.paused || !stream.$()) return;
|
||||
|
||||
this.update(top);
|
||||
this.renderScrollbar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the index/visible/description properties according to the window's
|
||||
* current scroll position.
|
||||
*
|
||||
* @param {Integer} scrollTop
|
||||
*/
|
||||
update(scrollTop) {
|
||||
const stream = this.props.stream;
|
||||
|
||||
const marginTop = stream.getMarginTop();
|
||||
const viewportTop = scrollTop + marginTop;
|
||||
const viewportHeight = $(window).height() - marginTop;
|
||||
|
||||
// Before looping through all of the posts, we reset the scrollbar
|
||||
// properties to a 'default' state. These values reflect what would be
|
||||
// seen if the browser were scrolled right up to the top of the page,
|
||||
// and the viewport had a height of 0.
|
||||
const $items = stream.$('> .PostStream-item[data-index]');
|
||||
let index = $items.first().data('index') || 0;
|
||||
let visible = 0;
|
||||
let period = '';
|
||||
|
||||
// Now loop through each of the items in the discussion. An 'item' is
|
||||
// either a single post or a 'gap' of one or more posts that haven't
|
||||
// been loaded yet.
|
||||
$items.each(function () {
|
||||
const $this = $(this);
|
||||
const top = $this.offset().top;
|
||||
const height = $this.outerHeight(true);
|
||||
|
||||
// If this item is above the top of the viewport, skip to the next
|
||||
// one. If it's below the bottom of the viewport, break out of the
|
||||
// loop.
|
||||
if (top + height < viewportTop) {
|
||||
return true;
|
||||
}
|
||||
if (top > viewportTop + viewportHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Work out how many pixels of this item are visible inside the viewport.
|
||||
// Then add the proportion of this item's total height to the index.
|
||||
const visibleTop = Math.max(0, viewportTop - top);
|
||||
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
|
||||
const visiblePost = visibleBottom - visibleTop;
|
||||
|
||||
if (top <= viewportTop) {
|
||||
index = parseFloat($this.data('index')) + visibleTop / height;
|
||||
}
|
||||
|
||||
if (visiblePost > 0) {
|
||||
visible += visiblePost / height;
|
||||
}
|
||||
|
||||
// If this item has a time associated with it, then set the
|
||||
// scrollbar's current period to a formatted version of this time.
|
||||
const time = $this.data('time');
|
||||
if (time) period = time;
|
||||
});
|
||||
|
||||
this.index = index;
|
||||
this.visible = visible;
|
||||
this.description = period ? moment(period).format('MMMM YYYY') : '';
|
||||
return this.props.visible >= this.props.count;
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
@@ -272,6 +152,7 @@ export default class PostStreamScrubber extends Component {
|
||||
this.dragging = false;
|
||||
this.mouseStart = 0;
|
||||
this.indexStart = 0;
|
||||
this.dragIndex = null;
|
||||
|
||||
this.$('.Scrubber-handle')
|
||||
.css('cursor', 'move')
|
||||
@@ -292,8 +173,6 @@ export default class PostStreamScrubber extends Component {
|
||||
ondestroy() {
|
||||
this.scrollListener.stop();
|
||||
|
||||
this.props.stream.off('unpaused', this.handlers.streamWasUnpaused);
|
||||
|
||||
$(window).off('resize', this.handlers.onresize);
|
||||
|
||||
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
|
||||
@@ -305,31 +184,43 @@ export default class PostStreamScrubber extends Component {
|
||||
*
|
||||
* @param {Boolean} animate
|
||||
*/
|
||||
renderScrollbar(animate) {
|
||||
renderScrollbar(options = {}) {
|
||||
const { count, visible, description, paused } = this.props;
|
||||
const percentPerPost = this.percentPerPost();
|
||||
const index = this.index;
|
||||
const count = this.count();
|
||||
const visible = this.visible || 1;
|
||||
|
||||
const index = this.dragIndex || this.props.index;
|
||||
|
||||
const $scrubber = this.$();
|
||||
$scrubber.find('.Scrubber-index').text(formatNumber(Math.min(Math.ceil(index + visible), count)));
|
||||
$scrubber.find('.Scrubber-description').text(this.description);
|
||||
$scrubber.find('.Scrubber-description').text(description);
|
||||
$scrubber.toggleClass('disabled', this.disabled());
|
||||
|
||||
const heights = {};
|
||||
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
|
||||
heights.before = Math.max(0, percentPerPost.index * Math.min(index - 1, count - visible));
|
||||
heights.handle = Math.min(100 - heights.before, percentPerPost.visible * visible);
|
||||
heights.after = 100 - heights.before - heights.handle;
|
||||
|
||||
const func = animate ? 'animate' : 'css';
|
||||
// If the stream is paused, don't change height on scroll, as the viewport is being scrolled by the JS
|
||||
// If a height change animation is already in progress, don't adjust height unless overridden
|
||||
if ((options.fromScroll && paused) || (this.adjustingHeight && !options.forceHeightChange)) return;
|
||||
|
||||
const func = options.animate ? 'animate' : 'css';
|
||||
this.adjustingHeight = true;
|
||||
const animationPromises = [];
|
||||
for (const part in heights) {
|
||||
const $part = $scrubber.find(`.Scrubber-${part}`);
|
||||
$part.stop(true, true)[func]({ height: heights[part] + '%' }, 'fast');
|
||||
animationPromises.push(
|
||||
$part
|
||||
.stop(true, true)
|
||||
[func]({ height: heights[part] + '%' }, 'fast')
|
||||
.promise()
|
||||
);
|
||||
|
||||
// jQuery likes to put overflow:hidden, but because the scrollbar handle
|
||||
// has a negative margin-left, we need to override.
|
||||
if (func === 'animate') $part.css('overflow', 'visible');
|
||||
}
|
||||
Promise.all(animationPromises).then(() => (this.adjustingHeight = false));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,8 +234,8 @@ export default class PostStreamScrubber extends Component {
|
||||
* scrubber.
|
||||
*/
|
||||
percentPerPost() {
|
||||
const count = this.count() || 1;
|
||||
const visible = this.visible || 1;
|
||||
const count = this.props.count || 1;
|
||||
const visible = this.props.visible || 1;
|
||||
|
||||
// To stop the handle of the scrollbar from getting too small when there
|
||||
// are many posts, we define a minimum percentage height for the handle
|
||||
@@ -381,11 +272,13 @@ export default class PostStreamScrubber extends Component {
|
||||
}
|
||||
|
||||
onmousedown(e) {
|
||||
e.redraw = false;
|
||||
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
|
||||
this.indexStart = this.index;
|
||||
this.indexStart = this.props.index;
|
||||
this.dragging = true;
|
||||
this.props.stream.paused = true;
|
||||
this.dragIndex = null;
|
||||
$('body').css('cursor', 'move');
|
||||
this.$().toggleClass('dragging', this.dragging);
|
||||
}
|
||||
|
||||
onmousemove(e) {
|
||||
@@ -398,13 +291,14 @@ export default class PostStreamScrubber extends Component {
|
||||
const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
|
||||
const deltaPercent = (deltaPixels / this.$('.Scrubber-scrollbar').outerHeight()) * 100;
|
||||
const deltaIndex = deltaPercent / this.percentPerPost().index || 0;
|
||||
const newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
|
||||
const newIndex = Math.min(this.indexStart + deltaIndex, this.props.count - 1);
|
||||
|
||||
this.index = Math.max(0, newIndex);
|
||||
this.dragIndex = Math.max(0, newIndex);
|
||||
this.renderScrollbar();
|
||||
}
|
||||
|
||||
onmouseup() {
|
||||
this.$().toggleClass('dragging', this.dragging);
|
||||
if (!this.dragging) return;
|
||||
|
||||
this.mouseStart = 0;
|
||||
@@ -416,9 +310,9 @@ export default class PostStreamScrubber extends Component {
|
||||
|
||||
// If the index we've landed on is in a gap, then tell the stream-
|
||||
// content that we want to load those posts.
|
||||
const intIndex = Math.floor(this.index);
|
||||
this.props.stream.goToIndex(intIndex);
|
||||
this.renderScrollbar(true);
|
||||
this.navigateTo(this.dragIndex);
|
||||
|
||||
this.dragIndex = null;
|
||||
}
|
||||
|
||||
onclick(e) {
|
||||
@@ -438,11 +332,21 @@ export default class PostStreamScrubber extends Component {
|
||||
// 3. Now we can convert the percentage into an index, and tell the stream-
|
||||
// content component to jump to that index.
|
||||
let offsetIndex = offsetPercent / this.percentPerPost().index;
|
||||
offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex));
|
||||
this.props.stream.goToIndex(Math.floor(offsetIndex));
|
||||
this.index = offsetIndex;
|
||||
this.renderScrollbar(true);
|
||||
offsetIndex = Math.max(0, Math.min(this.props.count - 1, offsetIndex));
|
||||
|
||||
this.navigateTo(offsetIndex);
|
||||
|
||||
this.$().removeClass('open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger post stream navigation, but also animate the scrollbar according
|
||||
* to the expected result.
|
||||
*
|
||||
* @param {number} index
|
||||
*/
|
||||
navigateTo(index) {
|
||||
this.props.onNavigate(Math.floor(index));
|
||||
this.renderScrollbar({ animate: true });
|
||||
}
|
||||
}
|
||||
|
@@ -29,7 +29,7 @@ export default class PostUser extends Component {
|
||||
|
||||
let card = '';
|
||||
|
||||
if (!post.isHidden()) {
|
||||
if (!post.isHidden() && this.props.cardVisible) {
|
||||
card = UserCard.component({
|
||||
user,
|
||||
className: 'UserCard--popover',
|
||||
@@ -72,6 +72,8 @@ export default class PostUser extends Component {
|
||||
* Show the user card.
|
||||
*/
|
||||
showCard() {
|
||||
this.props.oncardshow();
|
||||
|
||||
setTimeout(() => this.$('.UserCard').addClass('in'));
|
||||
}
|
||||
|
||||
@@ -79,6 +81,10 @@ export default class PostUser extends Component {
|
||||
* Hide the user card.
|
||||
*/
|
||||
hideCard() {
|
||||
this.$('.UserCard').removeClass('in');
|
||||
this.$('.UserCard')
|
||||
.removeClass('in')
|
||||
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
|
||||
this.props.oncardhide();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import ComposerBody from './ComposerBody';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import Button from '../../common/components/Button';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
@@ -21,16 +20,6 @@ function minimizeComposerIfFullScreen(e) {
|
||||
* - `discussion`
|
||||
*/
|
||||
export default class ReplyComposer extends ComposerBody {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.editor.props.preview = (e) => {
|
||||
minimizeComposerIfFullScreen(e);
|
||||
|
||||
m.route(app.route.discussion(this.props.discussion, 'reply'));
|
||||
};
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
@@ -62,6 +51,15 @@ export default class ReplyComposer extends ComposerBody {
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to the preview when triggered by the text editor.
|
||||
*/
|
||||
jumpToPreview(e) {
|
||||
minimizeComposerIfFullScreen(e);
|
||||
|
||||
m.route(app.route.discussion(this.props.discussion, 'reply'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to submit to the server when the reply is saved.
|
||||
*
|
||||
@@ -69,7 +67,7 @@ export default class ReplyComposer extends ComposerBody {
|
||||
*/
|
||||
data() {
|
||||
return {
|
||||
content: this.content(),
|
||||
content: this.composer.fields.content(),
|
||||
relationships: { discussion: this.props.discussion },
|
||||
};
|
||||
}
|
||||
@@ -104,16 +102,14 @@ export default class ReplyComposer extends ComposerBody {
|
||||
app.alerts.dismiss(alert);
|
||||
},
|
||||
});
|
||||
app.alerts.show(
|
||||
(alert = new Alert({
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.forum.composer_reply.posted_message'),
|
||||
controls: [viewButton],
|
||||
}))
|
||||
);
|
||||
alert = app.alerts.show({
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.forum.composer_reply.posted_message'),
|
||||
controls: [viewButton],
|
||||
});
|
||||
}
|
||||
|
||||
app.composer.hide();
|
||||
this.composer.hide();
|
||||
}, this.loaded.bind(this));
|
||||
}
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ import DiscussionControls from '../utils/DiscussionControls';
|
||||
*/
|
||||
export default class ReplyPlaceholder extends Component {
|
||||
view() {
|
||||
if (app.composingReplyTo(this.props.discussion)) {
|
||||
if (app.composer.composingReplyTo(this.props.discussion)) {
|
||||
return (
|
||||
<article className="Post CommentPost editing">
|
||||
<header className="Post-header">
|
||||
@@ -53,9 +53,9 @@ export default class ReplyPlaceholder extends Component {
|
||||
const updateInterval = setInterval(() => {
|
||||
// Since we're polling, the composer may have been closed in the meantime,
|
||||
// so we bail in that case.
|
||||
if (!app.composer.component) return;
|
||||
if (!app.composer.isVisible()) return;
|
||||
|
||||
const content = app.composer.component.content();
|
||||
const content = app.composer.fields.content();
|
||||
|
||||
if (preview === content) return;
|
||||
|
||||
|
@@ -12,13 +12,13 @@ import UsersSearchSource from './UsersSearchSource';
|
||||
* The `Search` component displays a menu of as-you-type results from a variety
|
||||
* of sources.
|
||||
*
|
||||
* The search box will be 'activated' if the app's seach state's
|
||||
* The search box will be 'activated' if the app's search state's
|
||||
* getInitialSearch() value is a truthy value. If this is the case, an 'x'
|
||||
* button will be shown next to the search field, and clicking it will clear the search.
|
||||
*
|
||||
* PROPS:
|
||||
*
|
||||
* - state: AlertState instance.
|
||||
* - state: SearchState instance.
|
||||
*/
|
||||
export default class Search extends Component {
|
||||
init() {
|
||||
|
@@ -79,7 +79,7 @@ export default class SettingsPage extends UserPage {
|
||||
Button.component({
|
||||
children: app.translator.trans('core.forum.settings.change_password_button'),
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(new ChangePasswordModal()),
|
||||
onclick: () => app.modal.show(ChangePasswordModal),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -88,7 +88,7 @@ export default class SettingsPage extends UserPage {
|
||||
Button.component({
|
||||
children: app.translator.trans('core.forum.settings.change_email_button'),
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(new ChangeEmailModal()),
|
||||
onclick: () => app.modal.show(ChangeEmailModal),
|
||||
})
|
||||
);
|
||||
|
||||
|
@@ -145,7 +145,7 @@ export default class SignUpModal extends Modal {
|
||||
password: this.password(),
|
||||
};
|
||||
|
||||
app.modal.show(new LogInModal(props));
|
||||
app.modal.show(LogInModal, props);
|
||||
}
|
||||
|
||||
onready() {
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import SuperTextarea from '../../common/utils/SuperTextarea';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
@@ -9,10 +10,12 @@ import Button from '../../common/components/Button';
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `composer`
|
||||
* - `submitLabel`
|
||||
* - `value`
|
||||
* - `placeholder`
|
||||
* - `disabled`
|
||||
* - `preview`
|
||||
*/
|
||||
export default class TextEditor extends Component {
|
||||
init() {
|
||||
@@ -21,7 +24,7 @@ export default class TextEditor extends Component {
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.value = m.prop(this.props.value || '');
|
||||
this.value = this.props.value || '';
|
||||
}
|
||||
|
||||
view() {
|
||||
@@ -33,7 +36,7 @@ export default class TextEditor extends Component {
|
||||
oninput={m.withAttr('value', this.oninput.bind(this))}
|
||||
placeholder={this.props.placeholder || ''}
|
||||
disabled={!!this.props.disabled}
|
||||
value={this.value()}
|
||||
value={this.value}
|
||||
/>
|
||||
|
||||
<ul className="TextEditor-controls Composer-footer">
|
||||
@@ -47,7 +50,7 @@ export default class TextEditor extends Component {
|
||||
/**
|
||||
* Configure the textarea element.
|
||||
*
|
||||
* @param {DOMElement} element
|
||||
* @param {HTMLTextAreaElement} element
|
||||
* @param {Boolean} isInitialized
|
||||
*/
|
||||
configTextarea(element, isInitialized) {
|
||||
@@ -60,6 +63,8 @@ export default class TextEditor extends Component {
|
||||
|
||||
$(element).bind('keydown', 'meta+return', handler);
|
||||
$(element).bind('keydown', 'ctrl+return', handler);
|
||||
|
||||
this.props.composer.editor = new SuperTextarea(element);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,73 +111,15 @@ export default class TextEditor extends Component {
|
||||
return new ItemList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the text editor.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
setValue(value) {
|
||||
this.$('textarea').val(value).trigger('input');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected range of the textarea.
|
||||
*
|
||||
* @param {Integer} start
|
||||
* @param {Integer} end
|
||||
*/
|
||||
setSelectionRange(start, end) {
|
||||
const $textarea = this.$('textarea');
|
||||
|
||||
if (!$textarea.length) return;
|
||||
|
||||
$textarea[0].setSelectionRange(start, end);
|
||||
$textarea.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected range of the textarea.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
getSelectionRange() {
|
||||
const $textarea = this.$('textarea');
|
||||
|
||||
if (!$textarea.length) return [0, 0];
|
||||
|
||||
return [$textarea[0].selectionStart, $textarea[0].selectionEnd];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the position of the cursor.
|
||||
*
|
||||
* @param {String} insert
|
||||
*/
|
||||
insertAtCursor(insert) {
|
||||
const textarea = this.$('textarea')[0];
|
||||
const value = this.value();
|
||||
const index = textarea ? textarea.selectionStart : value.length;
|
||||
|
||||
this.setValue(value.slice(0, index) + insert + value.slice(index));
|
||||
|
||||
// Move the textarea cursor to the end of the content we just inserted.
|
||||
if (textarea) {
|
||||
const pos = index + insert.length;
|
||||
this.setSelectionRange(pos, pos);
|
||||
}
|
||||
|
||||
textarea.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input into the textarea.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
oninput(value) {
|
||||
this.value(value);
|
||||
this.value = value;
|
||||
|
||||
this.props.onchange(this.value());
|
||||
this.props.onchange(this.value);
|
||||
|
||||
m.redraw.strategy('none');
|
||||
}
|
||||
@@ -181,6 +128,6 @@ export default class TextEditor extends Component {
|
||||
* Handle the submit button being clicked.
|
||||
*/
|
||||
onsubmit() {
|
||||
this.props.onsubmit(this.value());
|
||||
this.props.onsubmit(this.value);
|
||||
}
|
||||
}
|
||||
|
285
js/src/forum/states/ComposerState.js
Normal file
285
js/src/forum/states/ComposerState.js
Normal file
@@ -0,0 +1,285 @@
|
||||
import subclassOf from '../../common/utils/subclassOf';
|
||||
import ReplyComposer from '../components/ReplyComposer';
|
||||
|
||||
class ComposerState {
|
||||
constructor() {
|
||||
/**
|
||||
* The composer's current position.
|
||||
*
|
||||
* @type {ComposerState.Position}
|
||||
*/
|
||||
this.position = ComposerState.Position.HIDDEN;
|
||||
|
||||
/**
|
||||
* The composer's intended height, which can be modified by the user
|
||||
* (by dragging the composer handle).
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
this.height = null;
|
||||
|
||||
/**
|
||||
* The dynamic component being shown inside the composer.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.body = { attrs: {} };
|
||||
|
||||
/**
|
||||
* A reference to the text editor that allows text manipulation.
|
||||
*
|
||||
* @type {SuperTextArea|null}
|
||||
*/
|
||||
this.editor = null;
|
||||
|
||||
this.clear();
|
||||
|
||||
/**
|
||||
* @deprecated BC layer, remove in Beta 15.
|
||||
*/
|
||||
this.component = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a content component into the composer.
|
||||
*
|
||||
* @param {ComposerBody} componentClass
|
||||
* @public
|
||||
*/
|
||||
load(componentClass, attrs) {
|
||||
const body = { componentClass, attrs };
|
||||
|
||||
if (this.preventExit()) return;
|
||||
|
||||
// If we load a similar component into the composer, then Mithril will be
|
||||
// able to diff the old/new contents and some DOM-related state from the
|
||||
// old composer will remain. To prevent this from happening, we clear the
|
||||
// component and force a redraw, so that the new component will be working
|
||||
// on a blank slate.
|
||||
if (this.isVisible()) {
|
||||
this.clear();
|
||||
m.redraw(true);
|
||||
}
|
||||
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the composer's content component.
|
||||
*/
|
||||
clear() {
|
||||
this.position = ComposerState.Position.HIDDEN;
|
||||
this.body = { attrs: {} };
|
||||
this.editor = null;
|
||||
this.onExit = null;
|
||||
|
||||
this.fields = {
|
||||
content: m.prop(''),
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated BC layer, remove in Beta 15.
|
||||
*/
|
||||
this.content = this.fields.content;
|
||||
this.value = this.fields.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the composer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
show() {
|
||||
if (this.position === ComposerState.Position.NORMAL || this.position === ComposerState.Position.FULLSCREEN) return;
|
||||
|
||||
this.position = ComposerState.Position.NORMAL;
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the composer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
hide() {
|
||||
this.clear();
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm with the user so they don't lose their content, then close the
|
||||
* composer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (this.preventExit()) return;
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimize the composer. Has no effect if the composer is hidden.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
minimize() {
|
||||
if (!this.isVisible()) return;
|
||||
|
||||
this.position = ComposerState.Position.MINIMIZED;
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the composer into fullscreen mode. Has no effect if the composer is
|
||||
* hidden.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
fullScreen() {
|
||||
if (!this.isVisible()) return;
|
||||
|
||||
this.position = ComposerState.Position.FULLSCREEN;
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit fullscreen mode.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
exitFullScreen() {
|
||||
if (this.position !== ComposerState.Position.FULLSCREEN) return;
|
||||
|
||||
this.position = ComposerState.Position.NORMAL;
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the body matches the given component class and data.
|
||||
*
|
||||
* @param {object} type The component class to check against. Subclasses are
|
||||
* accepted as well.
|
||||
* @param {object} data
|
||||
* @return {boolean}
|
||||
*/
|
||||
bodyMatches(type, data = {}) {
|
||||
// Fail early when the body is of a different type
|
||||
if (!subclassOf(this.body.componentClass, type)) return false;
|
||||
|
||||
// Now that the type is known to be correct, we loop through the provided
|
||||
// data to see whether it matches the data in the attributes for the body.
|
||||
return Object.keys(data).every((key) => this.body.attrs[key] === data[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the Composer is visible.
|
||||
*
|
||||
* True when the composer is displayed on the screen and has a body component.
|
||||
* It could be open in "normal" or full-screen mode, or even minimized.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isVisible() {
|
||||
return this.position !== ComposerState.Position.HIDDEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the Composer is covering the screen.
|
||||
*
|
||||
* This will be true if the Composer is in full-screen mode on desktop,
|
||||
* or if we are on a mobile device, where we always consider the composer as full-screen..
|
||||
*
|
||||
* @return {Boolean}
|
||||
* @public
|
||||
*/
|
||||
isFullScreen() {
|
||||
return this.position === ComposerState.Position.FULLSCREEN || app.screen() === 'phone';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the user is currently composing a reply to a
|
||||
* discussion.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @return {Boolean}
|
||||
*/
|
||||
composingReplyTo(discussion) {
|
||||
return this.isVisible() && this.bodyMatches(ReplyComposer, { discussion });
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm with the user that they want to close the composer and lose their
|
||||
* content.
|
||||
*
|
||||
* @return {Boolean} Whether or not the exit was cancelled.
|
||||
*/
|
||||
preventExit() {
|
||||
if (!this.isVisible()) return;
|
||||
if (!this.onExit) return;
|
||||
|
||||
if (this.onExit.callback()) {
|
||||
return !confirm(this.onExit.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure when / what to ask the user before closing the composer.
|
||||
*
|
||||
* The provided callback will be used to determine whether asking for
|
||||
* confirmation is necessary. If the callback returns true at the time of
|
||||
* closing, the provided text will be shown in a standard confirmation dialog.
|
||||
*
|
||||
* @param {Function} callback
|
||||
* @param {String} message
|
||||
*/
|
||||
preventClosingWhen(callback, message) {
|
||||
this.onExit = { callback, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum height of the Composer.
|
||||
* @returns {Integer}
|
||||
*/
|
||||
minimumHeight() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maxmimum height of the Composer.
|
||||
* @returns {Integer}
|
||||
*/
|
||||
maximumHeight() {
|
||||
return $(window).height() - $('#header').outerHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed the composer's current height, based on the intended height, and
|
||||
* the composer's current state. This will be applied to the composer's
|
||||
* content's DOM element.
|
||||
* @returns {Integer|String}
|
||||
*/
|
||||
computedHeight() {
|
||||
// If the composer is minimized, then we don't want to set a height; we'll
|
||||
// let the CSS decide how high it is. If it's fullscreen, then we need to
|
||||
// make it as high as the window.
|
||||
if (this.position === ComposerState.Position.MINIMIZED) {
|
||||
return '';
|
||||
} else if (this.position === ComposerState.Position.FULLSCREEN) {
|
||||
return $(window).height();
|
||||
}
|
||||
|
||||
// Otherwise, if it's normal or hidden, then we use the intended height.
|
||||
// We don't let the composer get too small or too big, though.
|
||||
return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight()));
|
||||
}
|
||||
}
|
||||
|
||||
ComposerState.Position = {
|
||||
HIDDEN: 'hidden',
|
||||
NORMAL: 'normal',
|
||||
MINIMIZED: 'minimized',
|
||||
FULLSCREEN: 'fullScreen',
|
||||
};
|
||||
|
||||
export default ComposerState;
|
@@ -1,8 +1,8 @@
|
||||
export default class DiscussionListState {
|
||||
constructor({ params = {}, forumApp = app } = {}) {
|
||||
constructor(params = {}, app = window.app) {
|
||||
this.params = params;
|
||||
|
||||
this.app = forumApp;
|
||||
this.app = app;
|
||||
|
||||
this.discussions = [];
|
||||
|
@@ -9,6 +9,10 @@ export default class NotificationListState {
|
||||
this.moreResults = false;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.notificationPages = [];
|
||||
}
|
||||
|
||||
getNotificationPages() {
|
||||
return this.notificationPages;
|
||||
}
|
||||
|
329
js/src/forum/states/PostStreamState.js
Normal file
329
js/src/forum/states/PostStreamState.js
Normal file
@@ -0,0 +1,329 @@
|
||||
import anchorScroll from '../../common/utils/anchorScroll';
|
||||
|
||||
class PostStreamState {
|
||||
constructor(discussion, includedPosts = []) {
|
||||
/**
|
||||
* The discussion to display the post stream for.
|
||||
*
|
||||
* @type {Discussion}
|
||||
*/
|
||||
this.discussion = discussion;
|
||||
|
||||
/**
|
||||
* Whether or not the infinite-scrolling auto-load functionality is
|
||||
* disabled.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.paused = false;
|
||||
|
||||
this.loadPageTimeouts = {};
|
||||
this.pagesLoading = 0;
|
||||
|
||||
this.show(includedPosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stream so that it loads and includes the latest posts in the
|
||||
* discussion, if the end is being viewed.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
update() {
|
||||
if (!this.viewingEnd()) return m.deferred().resolve().promise;
|
||||
|
||||
this.visibleEnd = this.count();
|
||||
|
||||
return this.loadRange(this.visibleStart, this.visibleEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll up to the first post in the discussion.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToFirst() {
|
||||
return this.goToIndex(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll down to the last post in the discussion.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToLast() {
|
||||
return this.goToIndex(this.count() - 1, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll to a post with a certain number.
|
||||
*
|
||||
* @param {number|String} number The post number to go to. If 'reply', go to
|
||||
* the last post and scroll the reply preview into view.
|
||||
* @param {Boolean} noAnimation
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToNumber(number, noAnimation = false) {
|
||||
// If we want to go to the reply preview, then we will go to the end of the
|
||||
// discussion and then scroll to the very bottom of the page.
|
||||
if (number === 'reply') {
|
||||
return this.goToLast();
|
||||
}
|
||||
|
||||
this.paused = true;
|
||||
|
||||
this.targetPost = { number };
|
||||
this.noAnimationScroll = noAnimation;
|
||||
|
||||
// In this case, the redraw is only called after the response has been loaded
|
||||
// because we need to know the indices of the post range before we can
|
||||
// start scrolling to items. Calling redraw early causes issues.
|
||||
// Since this is only used for external navigation to the post stream, the delay
|
||||
// before the stream is moved is not an issue.
|
||||
return this.loadNearNumber(number).then(() => {
|
||||
this.paused = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll to a certain index within the discussion.
|
||||
*
|
||||
* @param {number} index
|
||||
* @param {Boolean} noAnimation
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToIndex(index, noAnimation = false) {
|
||||
this.paused = true;
|
||||
|
||||
const promise = this.loadNearIndex(index);
|
||||
|
||||
this.targetPost = { index };
|
||||
this.noAnimationScroll = noAnimation;
|
||||
this.index = index;
|
||||
|
||||
m.redraw();
|
||||
|
||||
return promise.then(() => (this.paused = false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stream and load posts near a certain number. Returns a promise.
|
||||
* If the post with the given number is already loaded, the promise will be
|
||||
* resolved immediately.
|
||||
*
|
||||
* @param {number} number
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadNearNumber(number) {
|
||||
if (this.posts().some((post) => post && Number(post.number()) === Number(number))) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
this.reset();
|
||||
|
||||
return app.store
|
||||
.find('posts', {
|
||||
filter: { discussion: this.discussion.id() },
|
||||
page: { near: number },
|
||||
})
|
||||
.then(this.show.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stream and load posts near a certain index. A page of posts
|
||||
* surrounding the given index will be loaded. Returns a promise. If the given
|
||||
* index is already loaded, the promise will be resolved immediately.
|
||||
*
|
||||
* @param {number} index
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadNearIndex(index) {
|
||||
if (index >= this.visibleStart && index <= this.visibleEnd) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
const start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
|
||||
const end = start + this.constructor.loadCount;
|
||||
|
||||
this.reset(start, end);
|
||||
|
||||
return this.loadRange(start, end).then(this.show.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the next page of posts.
|
||||
*/
|
||||
loadNext() {
|
||||
const start = this.visibleEnd;
|
||||
const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount));
|
||||
|
||||
// Unload the posts which are two pages back from the page we're currently
|
||||
// loading.
|
||||
const twoPagesAway = start - this.constructor.loadCount * 2;
|
||||
if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
|
||||
this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
|
||||
|
||||
if (this.loadPageTimeouts[twoPagesAway]) {
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
this.loadPageTimeouts[twoPagesAway] = null;
|
||||
this.pagesLoading--;
|
||||
}
|
||||
}
|
||||
|
||||
this.loadPage(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the previous page of posts.
|
||||
*/
|
||||
loadPrevious() {
|
||||
const end = this.visibleStart;
|
||||
const start = (this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount));
|
||||
|
||||
// Unload the posts which are two pages back from the page we're currently
|
||||
// loading.
|
||||
const twoPagesAway = start + this.constructor.loadCount * 2;
|
||||
if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
|
||||
this.visibleEnd = twoPagesAway;
|
||||
|
||||
if (this.loadPageTimeouts[twoPagesAway]) {
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
this.loadPageTimeouts[twoPagesAway] = null;
|
||||
this.pagesLoading--;
|
||||
}
|
||||
}
|
||||
|
||||
this.loadPage(start, end, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page of posts into the stream and redraw.
|
||||
*
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @param {Boolean} backwards
|
||||
*/
|
||||
loadPage(start, end, backwards = false) {
|
||||
m.redraw();
|
||||
|
||||
this.loadPageTimeouts[start] = setTimeout(
|
||||
() => {
|
||||
this.loadRange(start, end).then(() => {
|
||||
if (start >= this.visibleStart && end <= this.visibleEnd) {
|
||||
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true));
|
||||
}
|
||||
this.pagesLoading--;
|
||||
});
|
||||
this.loadPageTimeouts[start] = null;
|
||||
},
|
||||
this.pagesLoading ? 1000 : 0
|
||||
);
|
||||
|
||||
this.pagesLoading++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and inject the specified range of posts into the stream, without
|
||||
* clearing it.
|
||||
*
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadRange(start, end) {
|
||||
const loadIds = [];
|
||||
const loaded = [];
|
||||
|
||||
this.discussion
|
||||
.postIds()
|
||||
.slice(start, end)
|
||||
.forEach((id) => {
|
||||
const post = app.store.getById('posts', id);
|
||||
|
||||
if (post && post.discussion() && typeof post.canEdit() !== 'undefined') {
|
||||
loaded.push(post);
|
||||
} else {
|
||||
loadIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
return loadIds.length ? app.store.find('posts', loadIds) : m.deferred().resolve(loaded).promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the stream with the given array of posts.
|
||||
*
|
||||
* @param {Post[]} posts
|
||||
*/
|
||||
show(posts) {
|
||||
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
|
||||
this.visibleEnd = this.sanitizeIndex(this.visibleStart + posts.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the stream so that a specific range of posts is displayed. If a range
|
||||
* is not specified, the first page of posts will be displayed.
|
||||
*
|
||||
* @param {number} [start]
|
||||
* @param {number} [end]
|
||||
*/
|
||||
reset(start, end) {
|
||||
this.visibleStart = start || 0;
|
||||
this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible page of posts.
|
||||
*
|
||||
* @return {Post[]}
|
||||
*/
|
||||
posts() {
|
||||
return this.discussion
|
||||
.postIds()
|
||||
.slice(this.visibleStart, this.visibleEnd)
|
||||
.map((id) => {
|
||||
const post = app.store.getById('posts', id);
|
||||
|
||||
return post && post.discussion() && typeof post.canEdit() !== 'undefined' ? post : null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of posts in the discussion.
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
count() {
|
||||
return this.discussion.postIds().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Are we currently viewing the end of the discussion?
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
viewingEnd() {
|
||||
return this.visibleEnd === this.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that the given index is not outside of the possible range of
|
||||
* indexes in the discussion.
|
||||
*
|
||||
* @param {number} index
|
||||
*/
|
||||
sanitizeIndex(index) {
|
||||
return Math.max(0, Math.min(this.count(), Math.floor(index)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of posts to load per page.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
PostStreamState.loadCount = 20;
|
||||
|
||||
export default PostStreamState;
|
@@ -167,13 +167,11 @@ export default {
|
||||
|
||||
if (app.session.user) {
|
||||
if (this.canReply()) {
|
||||
let component = app.composer.component;
|
||||
if (!app.composingReplyTo(this) || forceRefresh) {
|
||||
component = new ReplyComposer({
|
||||
if (!app.composer.composingReplyTo(this) || forceRefresh) {
|
||||
app.composer.load(ReplyComposer, {
|
||||
user: app.session.user,
|
||||
discussion: this,
|
||||
});
|
||||
app.composer.load(component);
|
||||
}
|
||||
app.composer.show();
|
||||
|
||||
@@ -181,14 +179,14 @@ export default {
|
||||
app.current.get('stream').goToNumber('reply');
|
||||
}
|
||||
|
||||
deferred.resolve(component);
|
||||
deferred.resolve(app.composer);
|
||||
} else {
|
||||
deferred.reject();
|
||||
}
|
||||
} else {
|
||||
deferred.reject();
|
||||
|
||||
app.modal.show(new LogInModal());
|
||||
app.modal.show(LogInModal);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
@@ -239,11 +237,9 @@ export default {
|
||||
* @return {Promise}
|
||||
*/
|
||||
renameAction() {
|
||||
return app.modal.show(
|
||||
new RenameDiscussionModal({
|
||||
currentTitle: this.title(),
|
||||
discussion: this,
|
||||
})
|
||||
);
|
||||
return app.modal.show(RenameDiscussionModal, {
|
||||
currentTitle: this.title(),
|
||||
discussion: this,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
@@ -130,12 +130,10 @@ export default {
|
||||
editAction() {
|
||||
const deferred = m.deferred();
|
||||
|
||||
const component = new EditPostComposer({ post: this });
|
||||
|
||||
app.composer.load(component);
|
||||
app.composer.load(EditPostComposer, { post: this });
|
||||
app.composer.show();
|
||||
|
||||
deferred.resolve(component);
|
||||
deferred.resolve(app.composer);
|
||||
|
||||
return deferred.promise;
|
||||
},
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import Alert from '../../common/components/Alert';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import EditUserModal from '../components/EditUserModal';
|
||||
@@ -134,12 +133,10 @@ export default {
|
||||
error: 'core.forum.user_controls.delete_error_message',
|
||||
}[type];
|
||||
|
||||
app.alerts.show(
|
||||
new Alert({
|
||||
type,
|
||||
children: app.translator.trans(message, { username, email }),
|
||||
})
|
||||
);
|
||||
app.alerts.show({
|
||||
type,
|
||||
children: app.translator.trans(message, { username, email }),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -148,6 +145,6 @@ export default {
|
||||
* @param {User} user
|
||||
*/
|
||||
editAction(user) {
|
||||
app.modal.show(new EditUserModal({ user }));
|
||||
app.modal.show(EditUserModal, { user });
|
||||
},
|
||||
};
|
||||
|
@@ -1,12 +1,16 @@
|
||||
const config = require('flarum-webpack-config');
|
||||
const webpack = require('webpack');
|
||||
const merge = require('webpack-merge');
|
||||
|
||||
module.exports = merge(config(), {
|
||||
output: {
|
||||
library: 'flarum.core'
|
||||
},
|
||||
plugins: [
|
||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
|
||||
]
|
||||
|
||||
// temporary TS configuration
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js', '.json'],
|
||||
},
|
||||
});
|
||||
|
||||
module.exports['module'].rules[0].test = /\.(tsx?|js)$/;
|
||||
module.exports['module'].rules[0].use.options.presets.push('@babel/preset-typescript');
|
||||
|
@@ -1,3 +1,14 @@
|
||||
// Store the current responsive screen mode in a CSS variable, to make it
|
||||
// available to the JS code.
|
||||
:root {
|
||||
--flarum-screen: none;
|
||||
|
||||
@media @phone { --flarum-screen: phone }
|
||||
@media @tablet { --flarum-screen: tablet }
|
||||
@media @desktop { --flarum-screen: desktop }
|
||||
@media @desktop-hd { --flarum-screen: desktop-hd }
|
||||
}
|
||||
|
||||
* {
|
||||
&,
|
||||
&:before,
|
||||
|
@@ -2,7 +2,7 @@
|
||||
& a {
|
||||
margin-left: -1px;
|
||||
color: @muted-color;
|
||||
|
||||
|
||||
& .fa {
|
||||
font-size: 14px;
|
||||
margin-right: 2px;
|
||||
@@ -39,7 +39,7 @@
|
||||
.Scrubber-handle {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: @body-bg;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
@@ -18,7 +18,6 @@ return Migration::createTable(
|
||||
$table->string('email', 150)->unique();
|
||||
$table->boolean('is_activated')->default(0);
|
||||
$table->string('password', 100);
|
||||
$table->text('bio')->nullable();
|
||||
$table->string('avatar_path', 100)->nullable();
|
||||
$table->binary('preferences')->nullable();
|
||||
$table->dateTime('join_time')->nullable();
|
||||
|
@@ -9,8 +9,6 @@
|
||||
|
||||
namespace Flarum\Admin;
|
||||
|
||||
use Flarum\Extension\Event\Disabled;
|
||||
use Flarum\Extension\Event\Enabled;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\ErrorHandling\Registry;
|
||||
use Flarum\Foundation\ErrorHandling\Reporter;
|
||||
@@ -118,7 +116,7 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
$events = $this->app->make('events');
|
||||
|
||||
$events->listen(
|
||||
[Enabled::class, Disabled::class, ClearingCache::class],
|
||||
ClearingCache::class,
|
||||
function () {
|
||||
$recompile = new RecompileFrontendAssets(
|
||||
$this->app->make('flarum.assets.admin'),
|
||||
|
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace Flarum\Admin\Middleware;
|
||||
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface as Middleware;
|
||||
@@ -17,11 +16,9 @@ use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||
|
||||
class RequireAdministrateAbility implements Middleware
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
public function process(Request $request, Handler $handler): Response
|
||||
{
|
||||
$this->assertAdmin($request->getAttribute('actor'));
|
||||
$request->getAttribute('actor')->assertAdmin();
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
@@ -10,7 +10,6 @@
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Foundation\Console\CacheClearCommand;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
@@ -18,8 +17,6 @@ use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
class ClearCacheController extends AbstractDeleteController
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var CacheClearCommand
|
||||
*/
|
||||
@@ -38,7 +35,7 @@ class ClearCacheController extends AbstractDeleteController
|
||||
*/
|
||||
protected function delete(ServerRequestInterface $request)
|
||||
{
|
||||
$this->assertAdmin($request->getAttribute('actor'));
|
||||
$request->getAttribute('actor')->assertAdmin();
|
||||
|
||||
$this->command->run(
|
||||
new ArrayInput([]),
|
||||
|
@@ -10,15 +10,12 @@
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use League\Flysystem\FilesystemInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class DeleteFaviconController extends AbstractDeleteController
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var SettingsRepositoryInterface
|
||||
*/
|
||||
@@ -44,7 +41,7 @@ class DeleteFaviconController extends AbstractDeleteController
|
||||
*/
|
||||
protected function delete(ServerRequestInterface $request)
|
||||
{
|
||||
$this->assertAdmin($request->getAttribute('actor'));
|
||||
$request->getAttribute('actor')->assertAdmin();
|
||||
|
||||
$path = $this->settings->get('favicon_path');
|
||||
|
||||
|
@@ -10,15 +10,12 @@
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use League\Flysystem\FilesystemInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class DeleteLogoController extends AbstractDeleteController
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var SettingsRepositoryInterface
|
||||
*/
|
||||
@@ -44,7 +41,7 @@ class DeleteLogoController extends AbstractDeleteController
|
||||
*/
|
||||
protected function delete(ServerRequestInterface $request)
|
||||
{
|
||||
$this->assertAdmin($request->getAttribute('actor'));
|
||||
$request->getAttribute('actor')->assertAdmin();
|
||||
|
||||
$path = $this->settings->get('logo_path');
|
||||
|
||||
|
@@ -13,14 +13,11 @@ use Flarum\Api\Serializer\NotificationSerializer;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Notification\NotificationRepository;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
class ListNotificationsController extends AbstractListController
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -67,7 +64,7 @@ class ListNotificationsController extends AbstractListController
|
||||
{
|
||||
$actor = $request->getAttribute('actor');
|
||||
|
||||
$this->assertRegistered($actor);
|
||||
$actor->assertRegistered();
|
||||
|
||||
$actor->markNotificationsAsRead()->save();
|
||||
|
||||
|
@@ -12,7 +12,6 @@ namespace Flarum\Api\Controller;
|
||||
use Flarum\Api\Serializer\UserSerializer;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Flarum\User\Search\UserSearcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
@@ -20,8 +19,6 @@ use Tobscure\JsonApi\Document;
|
||||
|
||||
class ListUsersController extends AbstractListController
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -70,7 +67,7 @@ class ListUsersController extends AbstractListController
|
||||
{
|
||||
$actor = $request->getAttribute('actor');
|
||||
|
||||
$this->assertCan($actor, 'viewUserList');
|
||||
$actor->assertCan('viewUserList');
|
||||
|
||||
$query = Arr::get($this->extractFilter($request), 'q');
|
||||
$sort = $this->extractSort($request);
|
||||
|
@@ -12,7 +12,6 @@ namespace Flarum\Api\Controller;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Mail\Job\SendRawEmailJob;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Flarum\User\EmailToken;
|
||||
use Flarum\User\Exception\PermissionDeniedException;
|
||||
use Illuminate\Contracts\Queue\Queue;
|
||||
@@ -25,8 +24,6 @@ use Symfony\Component\Translation\TranslatorInterface;
|
||||
|
||||
class SendConfirmationEmailController implements RequestHandlerInterface
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var SettingsRepositoryInterface
|
||||
*/
|
||||
@@ -69,7 +66,7 @@ class SendConfirmationEmailController implements RequestHandlerInterface
|
||||
$id = Arr::get($request->getQueryParams(), 'id');
|
||||
$actor = $request->getAttribute('actor');
|
||||
|
||||
$this->assertRegistered($actor);
|
||||
$actor->assertRegistered();
|
||||
|
||||
if ($actor->id != $id || $actor->is_email_confirmed) {
|
||||
throw new PermissionDeniedException;
|
||||
|
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Mail\Message;
|
||||
@@ -21,8 +20,6 @@ use Symfony\Component\Translation\TranslatorInterface;
|
||||
|
||||
class SendTestMailController implements RequestHandlerInterface
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
protected $container;
|
||||
|
||||
protected $mailer;
|
||||
@@ -39,7 +36,7 @@ class SendTestMailController implements RequestHandlerInterface
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$actor = $request->getAttribute('actor');
|
||||
$this->assertAdmin($actor);
|
||||
$actor->assertAdmin();
|
||||
|
||||
$body = $this->translator->trans('core.email.send_test.body', ['{username}' => $actor->username]);
|
||||
|
||||
|
@@ -10,7 +10,6 @@
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Group\Permission;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
@@ -19,14 +18,12 @@ use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class SetPermissionController implements RequestHandlerInterface
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$this->assertAdmin($request->getAttribute('actor'));
|
||||
$request->getAttribute('actor')->assertAdmin();
|
||||
|
||||
$body = $request->getParsedBody();
|
||||
$permission = Arr::get($body, 'permission');
|
||||
|
@@ -11,7 +11,6 @@ namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Settings\Event;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
@@ -20,8 +19,6 @@ use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class SetSettingsController implements RequestHandlerInterface
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var \Flarum\Settings\SettingsRepositoryInterface
|
||||
*/
|
||||
@@ -46,7 +43,7 @@ class SetSettingsController implements RequestHandlerInterface
|
||||
*/
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$this->assertAdmin($request->getAttribute('actor'));
|
||||
$request->getAttribute('actor')->assertAdmin();
|
||||
|
||||
$settings = $request->getParsedBody();
|
||||
|
||||
|
@@ -11,15 +11,12 @@ namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Api\Serializer\MailSettingsSerializer;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Contracts\Validation\Factory;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
class ShowMailSettingsController extends AbstractShowController
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -30,7 +27,7 @@ class ShowMailSettingsController extends AbstractShowController
|
||||
*/
|
||||
protected function data(ServerRequestInterface $request, Document $document)
|
||||
{
|
||||
$this->assertAdmin($request->getAttribute('actor'));
|
||||
$request->getAttribute('actor')->assertAdmin();
|
||||
|
||||
$drivers = array_map(function ($driver) {
|
||||
return self::$container->make($driver);
|
||||
|
@@ -10,14 +10,11 @@
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Extension\ExtensionManager;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class UninstallExtensionController extends AbstractDeleteController
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var ExtensionManager
|
||||
*/
|
||||
@@ -33,7 +30,7 @@ class UninstallExtensionController extends AbstractDeleteController
|
||||
|
||||
protected function delete(ServerRequestInterface $request)
|
||||
{
|
||||
$this->assertAdmin($request->getAttribute('actor'));
|
||||
$request->getAttribute('actor')->assertAdmin();
|
||||
|
||||
$name = Arr::get($request->getQueryParams(), 'name');
|
||||
|
||||
|
@@ -10,7 +10,6 @@
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Extension\ExtensionManager;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
@@ -19,8 +18,6 @@ use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class UpdateExtensionController implements RequestHandlerInterface
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var ExtensionManager
|
||||
*/
|
||||
@@ -39,7 +36,7 @@ class UpdateExtensionController implements RequestHandlerInterface
|
||||
*/
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$this->assertAdmin($request->getAttribute('actor'));
|
||||
$request->getAttribute('actor')->assertAdmin();
|
||||
|
||||
$enabled = Arr::get($request->getParsedBody(), 'enabled');
|
||||
$name = Arr::get($request->getQueryParams(), 'name');
|
||||
|
@@ -10,7 +10,6 @@
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\ImageManager;
|
||||
@@ -20,8 +19,6 @@ use Tobscure\JsonApi\Document;
|
||||
|
||||
class UploadFaviconController extends ShowForumController
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var SettingsRepositoryInterface
|
||||
*/
|
||||
@@ -47,7 +44,7 @@ class UploadFaviconController extends ShowForumController
|
||||
*/
|
||||
public function data(ServerRequestInterface $request, Document $document)
|
||||
{
|
||||
$this->assertAdmin($request->getAttribute('actor'));
|
||||
$request->getAttribute('actor')->assertAdmin();
|
||||
|
||||
$file = Arr::get($request->getUploadedFiles(), 'favicon');
|
||||
$extension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
|
||||
|
@@ -10,7 +10,6 @@
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\ImageManager;
|
||||
@@ -20,8 +19,6 @@ use Tobscure\JsonApi\Document;
|
||||
|
||||
class UploadLogoController extends ShowForumController
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var SettingsRepositoryInterface
|
||||
*/
|
||||
@@ -47,7 +44,7 @@ class UploadLogoController extends ShowForumController
|
||||
*/
|
||||
public function data(ServerRequestInterface $request, Document $document)
|
||||
{
|
||||
$this->assertAdmin($request->getAttribute('actor'));
|
||||
$request->getAttribute('actor')->assertAdmin();
|
||||
|
||||
$file = Arr::get($request->getUploadedFiles(), 'logo');
|
||||
|
||||
|
@@ -27,7 +27,7 @@ abstract class Migration
|
||||
{
|
||||
return [
|
||||
'up' => function (Builder $schema) use ($name, $definition) {
|
||||
$schema->create($name, function (Blueprint $table) use ($schema, $definition) {
|
||||
$schema->create($name, function (Blueprint $table) use ($definition) {
|
||||
$definition($table);
|
||||
});
|
||||
},
|
||||
@@ -59,7 +59,7 @@ abstract class Migration
|
||||
{
|
||||
return [
|
||||
'up' => function (Builder $schema) use ($tableName, $columnDefinitions) {
|
||||
$schema->table($tableName, function (Blueprint $table) use ($schema, $columnDefinitions) {
|
||||
$schema->table($tableName, function (Blueprint $table) use ($columnDefinitions) {
|
||||
foreach ($columnDefinitions as $columnName => $options) {
|
||||
$type = array_shift($options);
|
||||
$table->addColumn($type, $columnName, $options);
|
||||
|
@@ -12,14 +12,12 @@ namespace Flarum\Discussion\Command;
|
||||
use Flarum\Discussion\DiscussionRepository;
|
||||
use Flarum\Discussion\Event\Deleting;
|
||||
use Flarum\Foundation\DispatchEventsTrait;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Flarum\User\Exception\PermissionDeniedException;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
|
||||
class DeleteDiscussionHandler
|
||||
{
|
||||
use DispatchEventsTrait;
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var \Flarum\Discussion\DiscussionRepository
|
||||
@@ -47,7 +45,7 @@ class DeleteDiscussionHandler
|
||||
|
||||
$discussion = $this->discussions->findOrFail($command->discussionId, $actor);
|
||||
|
||||
$this->assertCan($actor, 'delete', $discussion);
|
||||
$actor->assertCan('delete', $discussion);
|
||||
|
||||
$this->events->dispatch(
|
||||
new Deleting($discussion, $actor, $command->data)
|
||||
|
@@ -13,14 +13,12 @@ use Flarum\Discussion\DiscussionRepository;
|
||||
use Flarum\Discussion\DiscussionValidator;
|
||||
use Flarum\Discussion\Event\Saving;
|
||||
use Flarum\Foundation\DispatchEventsTrait;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EditDiscussionHandler
|
||||
{
|
||||
use DispatchEventsTrait;
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var DiscussionRepository
|
||||
@@ -58,13 +56,13 @@ class EditDiscussionHandler
|
||||
$discussion = $this->discussions->findOrFail($command->discussionId, $actor);
|
||||
|
||||
if (isset($attributes['title'])) {
|
||||
$this->assertCan($actor, 'rename', $discussion);
|
||||
$actor->assertCan('rename', $discussion);
|
||||
|
||||
$discussion->rename($attributes['title']);
|
||||
}
|
||||
|
||||
if (isset($attributes['isHidden'])) {
|
||||
$this->assertCan($actor, 'hide', $discussion);
|
||||
$actor->assertCan('hide', $discussion);
|
||||
|
||||
if ($attributes['isHidden']) {
|
||||
$discussion->hide($actor);
|
||||
|
@@ -12,13 +12,11 @@ namespace Flarum\Discussion\Command;
|
||||
use Flarum\Discussion\DiscussionRepository;
|
||||
use Flarum\Discussion\Event\UserDataSaving;
|
||||
use Flarum\Foundation\DispatchEventsTrait;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
|
||||
class ReadDiscussionHandler
|
||||
{
|
||||
use DispatchEventsTrait;
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var DiscussionRepository
|
||||
@@ -44,7 +42,7 @@ class ReadDiscussionHandler
|
||||
{
|
||||
$actor = $command->actor;
|
||||
|
||||
$this->assertRegistered($actor);
|
||||
$actor->assertRegistered();
|
||||
|
||||
$discussion = $this->discussions->findOrFail($command->discussionId, $actor);
|
||||
|
||||
|
@@ -15,7 +15,6 @@ use Flarum\Discussion\DiscussionValidator;
|
||||
use Flarum\Discussion\Event\Saving;
|
||||
use Flarum\Foundation\DispatchEventsTrait;
|
||||
use Flarum\Post\Command\PostReply;
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
|
||||
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -23,7 +22,6 @@ use Illuminate\Support\Arr;
|
||||
class StartDiscussionHandler
|
||||
{
|
||||
use DispatchEventsTrait;
|
||||
use AssertPermissionTrait;
|
||||
|
||||
/**
|
||||
* @var BusDispatcher
|
||||
@@ -58,7 +56,7 @@ class StartDiscussionHandler
|
||||
$data = $command->data;
|
||||
$ipAddress = $command->ipAddress;
|
||||
|
||||
$this->assertCan($actor, 'startDiscussion');
|
||||
$actor->assertCan('startDiscussion');
|
||||
|
||||
// Create a new Discussion entity, persist it, and dispatch domain
|
||||
// events. Before persistence, though, fire an event to give plugins
|
||||
|
@@ -12,6 +12,7 @@ namespace Flarum\Event;
|
||||
use Flarum\User\User;
|
||||
|
||||
/**
|
||||
* @deprecated beta 14, remove in beta 15. Use the User extender instead.
|
||||
* The `PrepareUserGroups` event.
|
||||
*/
|
||||
class PrepareUserGroups
|
||||
|
@@ -9,8 +9,6 @@
|
||||
|
||||
namespace Flarum\Extend;
|
||||
|
||||
use Flarum\Extension\Event\Disabled;
|
||||
use Flarum\Extension\Event\Enabled;
|
||||
use Flarum\Extension\Extension;
|
||||
use Flarum\Foundation\Event\ClearingCache;
|
||||
use Flarum\Frontend\Assets;
|
||||
@@ -23,7 +21,7 @@ use Flarum\Locale\LocaleManager;
|
||||
use Flarum\Settings\Event\Saved;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
|
||||
class Frontend implements ExtenderInterface
|
||||
class Frontend implements ExtenderInterface, LifecycleInterface
|
||||
{
|
||||
private $frontend;
|
||||
|
||||
@@ -115,13 +113,9 @@ class Frontend implements ExtenderInterface
|
||||
$events = $container->make('events');
|
||||
|
||||
$events->listen(
|
||||
[Enabled::class, Disabled::class, ClearingCache::class],
|
||||
function () use ($container, $abstract) {
|
||||
$recompile = new RecompileFrontendAssets(
|
||||
$container->make($abstract),
|
||||
$container->make(LocaleManager::class)
|
||||
);
|
||||
$recompile->flush();
|
||||
ClearingCache::class,
|
||||
function () use ($container) {
|
||||
$this->recompile($container);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -185,4 +179,27 @@ class Frontend implements ExtenderInterface
|
||||
{
|
||||
return $extension ? $extension->getId() : 'site-custom';
|
||||
}
|
||||
|
||||
public function onEnable(Container $container, Extension $extension)
|
||||
{
|
||||
if (! empty($this->js) || ! empty($this->css)) {
|
||||
$this->recompile($container);
|
||||
}
|
||||
}
|
||||
|
||||
public function onDisable(Container $container, Extension $extension)
|
||||
{
|
||||
if (! empty($this->js) || ! empty($this->css)) {
|
||||
$this->recompile($container);
|
||||
}
|
||||
}
|
||||
|
||||
private function recompile($container)
|
||||
{
|
||||
$recompile = new RecompileFrontendAssets(
|
||||
$container->make('flarum.assets.'.$this->frontend),
|
||||
$container->make(LocaleManager::class)
|
||||
);
|
||||
$recompile->flush();
|
||||
}
|
||||
}
|
||||
|
@@ -15,16 +15,38 @@ use Illuminate\Contracts\Container\Container;
|
||||
class User implements ExtenderInterface
|
||||
{
|
||||
private $displayNameDrivers = [];
|
||||
private $groupProcessors = [];
|
||||
|
||||
/**
|
||||
* Add a mail driver.
|
||||
* Add a display name driver.
|
||||
*
|
||||
* @param string $identifier Identifier for display name driver. E.g. 'username' for UserNameDriver
|
||||
* @param string $driver ::class attribute of driver class, which must implement Flarum\User\DisplayName\DriverInterface
|
||||
*/
|
||||
public function displayNameDriver(string $identifier, $driver)
|
||||
{
|
||||
$this->drivers[$identifier] = $driver;
|
||||
$this->displayNameDrivers[$identifier] = $driver;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically process a user's list of groups when calculating permissions.
|
||||
* This can be used to give a user permissions for groups they aren't actually in, based on context.
|
||||
* It will not change the group badges displayed for the user.
|
||||
*
|
||||
* @param callable $callable
|
||||
*
|
||||
* The callable can be a closure or invokable class, and should accept:
|
||||
* - \Flarum\User\User $user: the user in question.
|
||||
* - array $groupIds: an array of ids for the groups the user belongs to.
|
||||
*
|
||||
* The callable should return:
|
||||
* - array $groupIds: an array of ids for the groups the user belongs to.
|
||||
*/
|
||||
public function permissionGroups(callable $callable)
|
||||
{
|
||||
$this->groupProcessors[] = $callable;
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -32,7 +54,11 @@ class User implements ExtenderInterface
|
||||
public function extend(Container $container, Extension $extension = null)
|
||||
{
|
||||
$container->extend('flarum.user.display_name.supported_drivers', function ($existingDrivers) {
|
||||
return array_merge($existingDrivers, $this->drivers);
|
||||
return array_merge($existingDrivers, $this->displayNameDrivers);
|
||||
});
|
||||
|
||||
$container->extend('flarum.user.group_processors', function ($existingRelations) {
|
||||
return array_merge($existingRelations, $this->groupProcessors);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
51
src/Extend/View.php
Normal file
51
src/Extend/View.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Extend;
|
||||
|
||||
use Flarum\Extension\Extension;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
|
||||
class View implements ExtenderInterface
|
||||
{
|
||||
private $namespaces = [];
|
||||
|
||||
/**
|
||||
* Register a new namespace of Laravel views.
|
||||
*
|
||||
* Views are php files that use the Laravel Blade syntax for creation of server-side generated html.
|
||||
* Flarum core uses them for error pages, the installer, HTML emails, and the skeletons for the forum and admin sites.
|
||||
* To create and use views in your extension, you will need to put them in a folder, and register that folder as a namespace.
|
||||
*
|
||||
* Views can then be used in your extension by injecting an instance of `Illuminate\Contracts\View\Factory`,
|
||||
* and calling its `make` method. The `make` method takes the view parameter in the format NAMESPACE::VIEW_NAME.
|
||||
* You can also pass variables into a view: for more information, see https://laravel.com/api/6.x/Illuminate/View/Factory.html#method_make
|
||||
*
|
||||
* @param string $namespace: The name of the namespace.
|
||||
* @param string|array $hints: This is a path (or an array of paths) to the folder(s)
|
||||
* where view files are stored, relative to the extend.php file.
|
||||
* @return $this
|
||||
*/
|
||||
public function namespace($namespace, $hints)
|
||||
{
|
||||
$this->namespaces[$namespace] = $hints;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function extend(Container $container, Extension $extension = null)
|
||||
{
|
||||
$container->resolving(Factory::class, function (Factory $view) {
|
||||
foreach ($this->namespaces as $namespace => $hints) {
|
||||
$view->addNamespace($namespace, $hints);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user