diff --git a/.eslintrc.js b/.eslintrc.js index 193d390bd..9f4b83d4c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -134,7 +134,7 @@ module.exports = { 'react/jsx-equals-spacing': 'error', 'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'], 'react/jsx-indent': ['error', 2], - 'react/jsx-no-bind': 'error', + // 'react/jsx-no-bind': ['error'], 'react/jsx-no-duplicate-props': 'error', 'react/jsx-no-undef': 'error', 'react/jsx-tag-spacing': 'error', diff --git a/.stylelintrc.json b/.stylelintrc.json index c164e2968..403345750 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -10,6 +10,7 @@ "font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free", "OpenDyslexic", "soapbox"] }], "no-descending-specificity": null, "no-duplicate-selectors": null, - "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["/tailwind/"]}] + "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["/tailwind/"]}], + "no-invalid-position-at-import-rule": [true, { "ignoreAtRules": ["/tailwind/"]}] } } diff --git a/app/application.js b/app/application.js index 96cedfeef..59cd3374b 100644 --- a/app/application.js +++ b/app/application.js @@ -3,6 +3,7 @@ import loadPolyfills from './soapbox/load_polyfills'; require.context('./images/', true); // Load stylesheet +require('react-datepicker/dist/react-datepicker.css'); require('./styles/application.scss'); loadPolyfills().then(() => { diff --git a/app/icons/alert.svg b/app/icons/alert.svg new file mode 100644 index 000000000..9ec4beec2 --- /dev/null +++ b/app/icons/alert.svg @@ -0,0 +1 @@ + diff --git a/app/icons/cog.svg b/app/icons/cog.svg new file mode 100644 index 000000000..5601b5489 --- /dev/null +++ b/app/icons/cog.svg @@ -0,0 +1 @@ + diff --git a/app/icons/compose.svg b/app/icons/compose.svg new file mode 100644 index 000000000..9f2190922 --- /dev/null +++ b/app/icons/compose.svg @@ -0,0 +1 @@ + diff --git a/app/icons/feed.svg b/app/icons/feed.svg new file mode 100644 index 000000000..1dd590a51 --- /dev/null +++ b/app/icons/feed.svg @@ -0,0 +1 @@ + diff --git a/app/icons/mail.svg b/app/icons/mail.svg new file mode 100644 index 000000000..808c58579 --- /dev/null +++ b/app/icons/mail.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/icons/user.svg b/app/icons/user.svg new file mode 100644 index 000000000..7c92e4f3b --- /dev/null +++ b/app/icons/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/images/bg-shape.svg b/app/images/bg-shape.svg new file mode 100644 index 000000000..aa0132f3d --- /dev/null +++ b/app/images/bg-shape.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/images/circles.json b/app/images/circles.json new file mode 100644 index 000000000..22cd26551 --- /dev/null +++ b/app/images/circles.json @@ -0,0 +1 @@ +{"v":"5.4.3","fr":60,"ip":0,"op":240,"w":840,"h":900,"nm":"合成 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"形状图层 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[420,450,0],"ix":2},"a":{"a":0,"k":[120,120,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0","0p667_1_0p333_0"],"t":0,"s":[100,100,100],"e":[110,110,100]},{"i":{"x":[0.667,0.667,0.667],"y":[-0.401,-0.401,2.575]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"n":["0p667_-0p401_0p333_0","0p667_-0p401_0p333_0","0p667_2p575_0p333_0"],"t":160,"s":[110,110,100],"e":[109.411,109.411,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0.088,0.088,-1.575]},"n":["0p667_1_0p333_0p088","0p667_1_0p333_0p088","0p667_1_0p333_-1p575"],"t":180,"s":[109.411,109.411,100],"e":[100,100,100]},{"t":200}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[69.588,0],[0,-69.588],[-69.588,0],[0,69.588]],"o":[[-69.588,0],[0,69.588],[69.588,0],[0,-69.588]],"v":[[0,-126],[-126,0],[0,126],[126,0]],"c":true},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[120,120],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"椭圆 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"形状图层 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[420,450,0],"ix":2},"a":{"a":0,"k":[-112,-181,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.313,0.313],"y":[1,1]},"o":{"x":[0.057,0.057],"y":[0,0]},"n":["0p313_1_0p057_0","0p313_1_0p057_0"],"t":95,"s":[240,240],"e":[408,408]},{"i":{"x":[0.496,0.496],"y":[1,1]},"o":{"x":[0.189,0.189],"y":[0,0]},"n":["0p496_1_0p189_0","0p496_1_0p189_0"],"t":180,"s":[408,408],"e":[900,900]},{"t":200}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.7803921568627451,0.8235294117647058,0.996078431372549,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":95,"s":[80],"e":[0]},{"t":200}],"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-112,-181],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"椭圆 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"形状图层 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[420,450,0],"ix":2},"a":{"a":0,"k":[-112,-181,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.418,0.418],"y":[1,1]},"o":{"x":[0.041,0.041],"y":[0,0]},"n":["0p418_1_0p041_0","0p418_1_0p041_0"],"t":60,"s":[240,240],"e":[552,552]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.095,0.095],"y":[0,0]},"n":["0p667_1_0p095_0","0p667_1_0p095_0"],"t":174,"s":[552,552],"e":[900,900]},{"t":194}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.8784313725490196,0.9058823529411765,1,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":60,"s":[60],"e":[0]},{"t":200}],"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-112,-181],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"椭圆 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"形状图层 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[420,450,0],"ix":2},"a":{"a":0,"k":[-112,-181,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.574,0.574],"y":[1,1]},"o":{"x":[0.043,0.043],"y":[0,0]},"n":["0p574_1_0p043_0","0p574_1_0p043_0"],"t":30,"s":[240,240],"e":[696,696]},{"i":{"x":[0.512,0.512],"y":[1,1]},"o":{"x":[0.096,0.096],"y":[0,0]},"n":["0p512_1_0p096_0","0p512_1_0p096_0"],"t":168,"s":[696,696],"e":[900,900]},{"t":188}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.9176470588235294,0.9372549019607843,1,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":30,"s":[40],"e":[0]},{"t":200}],"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-112,-181],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"椭圆 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"形状图层 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[420,450,0],"ix":2},"a":{"a":0,"k":[-112,-181,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.262,0.262],"y":[1,1]},"o":{"x":[0.066,0.066],"y":[0,0]},"n":["0p262_1_0p066_0","0p262_1_0p066_0"],"t":0,"s":[240,240],"e":[840,840]},{"i":{"x":[0.39,0.39],"y":[1,1]},"o":{"x":[0.138,0.138],"y":[0,0]},"n":["0p39_1_0p138_0","0p39_1_0p138_0"],"t":162,"s":[840,840],"e":[900,900]},{"t":182}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.9333333333333333,0.9490196078431372,1,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":0,"s":[30],"e":[0]},{"t":200}],"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-112,-181],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"椭圆 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0}],"markers":[]} diff --git a/app/index.ejs b/app/index.ejs index 735f6e7a0..17cf261c3 100644 --- a/app/index.ejs +++ b/app/index.ejs @@ -5,15 +5,45 @@ + + + + + + + + + + + + - -
-
-
-
+ +
+
+
+
+
+
diff --git a/app/soapbox/__fixtures__/intlMessages.json b/app/soapbox/__fixtures__/intlMessages.json index 093f4fc4f..4c32e4179 100644 --- a/app/soapbox/__fixtures__/intlMessages.json +++ b/app/soapbox/__fixtures__/intlMessages.json @@ -69,7 +69,7 @@ "column.home": "Home", "column.lists": "Lists", "column.mutes": "Muted users", - "column.notifications": "Notifications", + "column.notifications": "Alerts", "column.preferences": "Preferences", "column.public": "Federated timeline", "column.security": "Security", @@ -445,7 +445,7 @@ "tabs_bar.apps": "Apps", "tabs_bar.home": "Home", "tabs_bar.news": "News", - "tabs_bar.notifications": "Notifications", + "tabs_bar.notifications": "Alerts", "tabs_bar.post": "Post", "tabs_bar.search": "Search", "time_remaining.days": "{number, plural, one {# day} other {# days}} left", @@ -547,7 +547,7 @@ "column.home": "Home", "column.lists": "Lists", "column.mutes": "Muted users", - "column.notifications": "Notifications", + "column.notifications": "Alerts", "column.preferences": "Preferences", "column.public": "Federated timeline", "column.security": "Security", @@ -924,7 +924,7 @@ "tabs_bar.apps": "Apps", "tabs_bar.home": "Home", "tabs_bar.news": "News", - "tabs_bar.notifications": "Notifications", + "tabs_bar.notifications": "Alerts", "tabs_bar.post": "Post", "tabs_bar.search": "Search", "time_remaining.days": "{number, plural, one {# day} other {# days}} left", diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index 5274a98cf..1a700b9ec 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -160,8 +160,18 @@ export function verifyCredentials(token, accountUrl) { if (account.id === getState().get('me')) dispatch(fetchMeSuccess(account)); return account; }).catch(error => { - if (getState().get('me') === null) dispatch(fetchMeFail(error)); - dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error, skipAlert: true }); + if (error?.response?.status === 403 && error?.response?.data?.id) { + // The user is waitlisted + const account = error.response.data; + dispatch(importFetchedAccount(account)); + dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account }); + if (account.id === getState().get('me')) dispatch(fetchMeSuccess(account)); + return account; + } else { + if (getState().get('me') === null) dispatch(fetchMeFail(error)); + dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error, skipAlert: true }); + return error; + } }); }; } @@ -213,6 +223,12 @@ export function logIn(intl, username, password) { }; } +export function deleteSession() { + return (dispatch, getState) => { + return api(getState).delete('/api/sign_out'); + }; +} + export function logOut(intl) { return (dispatch, getState) => { const state = getState(); @@ -225,7 +241,10 @@ export function logOut(intl) { token: state.getIn(['auth', 'users', account.get('url'), 'access_token']), }; - return dispatch(revokeOAuthToken(params)).finally(() => { + return Promise.all([ + dispatch(revokeOAuthToken(params)), + dispatch(deleteSession()), + ]).finally(() => { dispatch({ type: AUTH_LOGGED_OUT, account, standalone }); dispatch(snackbar.success(intl.formatMessage(messages.loggedOut))); }); diff --git a/app/soapbox/actions/beta.js b/app/soapbox/actions/beta.js new file mode 100644 index 000000000..21f4013b4 --- /dev/null +++ b/app/soapbox/actions/beta.js @@ -0,0 +1,19 @@ +import { staticClient } from '../api'; + +export const FETCH_BETA_PAGE_REQUEST = 'FETCH_BETA_PAGE_REQUEST'; +export const FETCH_BETA_PAGE_SUCCESS = 'FETCH_BETA_PAGE_SUCCESS'; +export const FETCH_BETA_PAGE_FAIL = 'FETCH_BETA_PAGE_FAIL'; + +export function fetchBetaPage(slug = 'index', locale) { + return (dispatch, getState) => { + dispatch({ type: FETCH_BETA_PAGE_REQUEST, slug, locale }); + const filename = `${slug}${locale ? `.${locale}` : ''}.html`; + return staticClient.get(`/instance/beta/${filename}`).then(({ data: html }) => { + dispatch({ type: FETCH_BETA_PAGE_SUCCESS, slug, locale, html }); + return html; + }).catch(error => { + dispatch({ type: FETCH_BETA_PAGE_FAIL, slug, locale, error }); + throw error; + }); + }; +} diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js index 4b600c3d1..25e5fa4f1 100644 --- a/app/soapbox/actions/compose.js +++ b/app/soapbox/actions/compose.js @@ -1,4 +1,5 @@ import { CancelToken, isCancel } from 'axios'; +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; import { throttle } from 'lodash'; import { defineMessages } from 'react-intl'; @@ -219,7 +220,7 @@ export function submitCompose(routerHistory, force = false) { const status = state.getIn(['compose', 'text'], ''); const media = state.getIn(['compose', 'media_attachments']); - let to = state.getIn(['compose', 'to']); + let to = state.getIn(['compose', 'to'], ImmutableOrderedSet()); if (!validateSchedule(state)) { dispatch(snackbar.error(messages.scheduleError)); diff --git a/app/soapbox/actions/mobile.js b/app/soapbox/actions/mobile.js new file mode 100644 index 000000000..c7707c8f9 --- /dev/null +++ b/app/soapbox/actions/mobile.js @@ -0,0 +1,19 @@ +import { staticClient } from '../api'; + +export const FETCH_MOBILE_PAGE_REQUEST = 'FETCH_MOBILE_PAGE_REQUEST'; +export const FETCH_MOBILE_PAGE_SUCCESS = 'FETCH_MOBILE_PAGE_SUCCESS'; +export const FETCH_MOBILE_PAGE_FAIL = 'FETCH_MOBILE_PAGE_FAIL'; + +export function fetchMobilePage(slug = 'index', locale) { + return (dispatch, getState) => { + dispatch({ type: FETCH_MOBILE_PAGE_REQUEST, slug, locale }); + const filename = `${slug}${locale ? `.${locale}` : ''}.html`; + return staticClient.get(`/instance/mobile/${filename}`).then(({ data: html }) => { + dispatch({ type: FETCH_MOBILE_PAGE_SUCCESS, slug, locale, html }); + return html; + }).catch(error => { + dispatch({ type: FETCH_MOBILE_PAGE_FAIL, slug, locale, error }); + throw error; + }); + }; +} diff --git a/app/soapbox/actions/security.js b/app/soapbox/actions/security.js index 254acbdfb..53cf6293d 100644 --- a/app/soapbox/actions/security.js +++ b/app/soapbox/actions/security.js @@ -23,6 +23,10 @@ export const RESET_PASSWORD_REQUEST = 'RESET_PASSWORD_REQUEST'; export const RESET_PASSWORD_SUCCESS = 'RESET_PASSWORD_SUCCESS'; export const RESET_PASSWORD_FAIL = 'RESET_PASSWORD_FAIL'; +export const RESET_PASSWORD_CONFIRM_REQUEST = 'RESET_PASSWORD_CONFIRM_REQUEST'; +export const RESET_PASSWORD_CONFIRM_SUCCESS = 'RESET_PASSWORD_CONFIRM_SUCCESS'; +export const RESET_PASSWORD_CONFIRM_FAIL = 'RESET_PASSWORD_CONFIRM_FAIL'; + export const CHANGE_PASSWORD_REQUEST = 'CHANGE_PASSWORD_REQUEST'; export const CHANGE_PASSWORD_SUCCESS = 'CHANGE_PASSWORD_SUCCESS'; export const CHANGE_PASSWORD_FAIL = 'CHANGE_PASSWORD_FAIL'; @@ -78,14 +82,14 @@ export function changePassword(oldPassword, newPassword, confirmation) { }; } -export function resetPassword(nickNameOrEmail) { +export function resetPassword(usernameOrEmail) { return (dispatch, getState) => { dispatch({ type: RESET_PASSWORD_REQUEST }); const params = - nickNameOrEmail.includes('@') - ? { email: nickNameOrEmail } - : { nickname: nickNameOrEmail }; - return api(getState).post('/auth/password', params).then(() => { + usernameOrEmail.includes('@') + ? { email: usernameOrEmail } + : { username: usernameOrEmail }; + return api(getState).post('/api/v1/truth/password_reset/request', params).then(() => { dispatch({ type: RESET_PASSWORD_SUCCESS }); }).catch(error => { dispatch({ type: RESET_PASSWORD_FAIL, error }); @@ -94,6 +98,20 @@ export function resetPassword(nickNameOrEmail) { }; } +export function resetPasswordConfirm(password, token) { + return (dispatch, getState) => { + const params = { password, reset_password_token: token }; + dispatch({ type: RESET_PASSWORD_CONFIRM_REQUEST }); + + return api(getState).post('/api/v1/truth/password_reset/confirm', params).then(() => { + dispatch({ type: RESET_PASSWORD_CONFIRM_SUCCESS }); + }).catch(error => { + dispatch({ type: RESET_PASSWORD_CONFIRM_FAIL, error }); + throw error; + }); + }; +} + export function changeEmail(email, password) { return (dispatch, getState) => { dispatch({ type: CHANGE_EMAIL_REQUEST, email }); @@ -110,6 +128,12 @@ export function changeEmail(email, password) { }; } +export function confirmChangedEmail(token) { + return (_dispatch, getState) => { + return api(getState).get(`/api/v1/truth/email/confirm?confirmation_token=${token}`); + }; +} + export function deleteAccount(intl, password) { return (dispatch, getState) => { const account = getLoggedInAccount(getState()); diff --git a/app/soapbox/actions/settings.js b/app/soapbox/actions/settings.js index 418c41292..4c7768bd2 100644 --- a/app/soapbox/actions/settings.js +++ b/app/soapbox/actions/settings.js @@ -1,5 +1,5 @@ import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { debounce } from 'lodash'; +import { defineMessages } from 'react-intl'; import { createSelector } from 'reselect'; import { patchMe } from 'soapbox/actions/me'; @@ -8,6 +8,7 @@ import { isLoggedIn } from 'soapbox/utils/auth'; import uuid from '../uuid'; import { showAlertForError } from './alerts'; +import snackbar from './snackbar'; export const SETTING_CHANGE = 'SETTING_CHANGE'; export const SETTING_SAVE = 'SETTING_SAVE'; @@ -15,9 +16,12 @@ export const SETTINGS_UPDATE = 'SETTINGS_UPDATE'; export const FE_NAME = 'soapbox_fe'; +const messages = defineMessages({ + saveSuccess: { id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' }, +}); + export const defaultSettings = ImmutableMap({ onboarded: false, - skinTone: 1, reduceMotion: false, underlineLinks: false, @@ -184,7 +188,7 @@ export function changeSettingImmediate(path, value) { }; } -export function changeSetting(path, value) { +export function changeSetting(path, value, intl) { return dispatch => { dispatch({ type: SETTING_CHANGE, @@ -192,11 +196,11 @@ export function changeSetting(path, value) { value, }); - dispatch(saveSettings()); + return dispatch(saveSettings(intl)); }; } -export function saveSettingsImmediate() { +export function saveSettingsImmediate(intl) { return (dispatch, getState) => { if (!isLoggedIn(getState)) return; @@ -211,16 +215,16 @@ export function saveSettingsImmediate() { }, })).then(response => { dispatch({ type: SETTING_SAVE }); + + if (intl) { + dispatch(snackbar.success(intl.formatMessage(messages.saveSuccess))); + } }).catch(error => { dispatch(showAlertForError(error)); }); }; } -const debouncedSave = debounce((dispatch, getState) => { - dispatch(saveSettingsImmediate()); -}, 5000, { trailing: true }); - -export function saveSettings() { - return (dispatch, getState) => debouncedSave(dispatch, getState); +export function saveSettings(intl) { + return (dispatch, getState) => dispatch(saveSettingsImmediate(intl)); } diff --git a/app/soapbox/actions/soapbox.js b/app/soapbox/actions/soapbox.js index 35a32c840..5001ecd61 100644 --- a/app/soapbox/actions/soapbox.js +++ b/app/soapbox/actions/soapbox.js @@ -5,7 +5,7 @@ import { getHost } from 'soapbox/actions/instance'; import KVStore from 'soapbox/storage/kv_store'; import { getFeatures } from 'soapbox/utils/features'; -import api, { staticClient } from '../api'; +import { staticClient } from '../api'; export const SOAPBOX_CONFIG_REQUEST_SUCCESS = 'SOAPBOX_CONFIG_REQUEST_SUCCESS'; export const SOAPBOX_CONFIG_REQUEST_FAIL = 'SOAPBOX_CONFIG_REQUEST_FAIL'; @@ -47,7 +47,7 @@ export const makeDefaultConfig = features => { }), extensions: ImmutableMap(), defaultSettings: ImmutableMap(), - copyright: `♥${year}. Copying is an act of love. Please copy and share.`, + copyright: `©${year} TRUTH Social`, navlinks: ImmutableMap({ homeFooter: ImmutableList(), }), @@ -60,6 +60,8 @@ export const makeDefaultConfig = features => { limit: 1, }), aboutPages: ImmutableMap(), + betaPages: ImmutableMap(), + mobilePages: ImmutableMap(), authenticatedProfile: true, singleUserMode: false, singleUserModeProfile: '', @@ -86,17 +88,19 @@ export function rememberSoapboxConfig(host) { } export function fetchSoapboxConfig(host) { - return (dispatch, getState) => { - api(getState).get('/api/pleroma/frontend_configurations').then(response => { - if (response.data.soapbox_fe) { - dispatch(importSoapboxConfig(response.data.soapbox_fe, host)); - } else { - dispatch(fetchSoapboxJson(host)); - } - }).catch(error => { - dispatch(fetchSoapboxJson(host)); - }); - }; + return fetchSoapboxJson(host); + + // return (dispatch, getState) => { + // api(getState).get('/api/pleroma/frontend_configurations').then(response => { + // if (response.data.soapbox_fe) { + // dispatch(importSoapboxConfig(response.data.soapbox_fe, host)); + // } else { + // dispatch(fetchSoapboxJson(host)); + // } + // }).catch(error => { + // dispatch(fetchSoapboxJson(host)); + // }); + // }; } // Tries to remember the config from browser storage before fetching it diff --git a/app/soapbox/actions/trending_statuses.js b/app/soapbox/actions/trending_statuses.js new file mode 100644 index 000000000..b01a3da01 --- /dev/null +++ b/app/soapbox/actions/trending_statuses.js @@ -0,0 +1,20 @@ +import api from '../api'; + +import { importFetchedStatuses } from './importer'; + +export const TRENDING_STATUSES_FETCH_REQUEST = 'TRENDING_STATUSES_FETCH_REQUEST'; +export const TRENDING_STATUSES_FETCH_SUCCESS = 'TRENDING_STATUSES_FETCH_SUCCESS'; +export const TRENDING_STATUSES_FETCH_FAIL = 'TRENDING_STATUSES_FETCH_FAIL'; + +export function fetchTrendingStatuses() { + return (dispatch, getState) => { + dispatch({ type: TRENDING_STATUSES_FETCH_REQUEST }); + return api(getState).get('/api/v1/truth/trending/truths').then(({ data: statuses }) => { + dispatch(importFetchedStatuses(statuses)); + dispatch({ type: TRENDING_STATUSES_FETCH_SUCCESS, statuses }); + return statuses; + }).catch(error => { + dispatch({ type: TRENDING_STATUSES_FETCH_FAIL, error }); + }); + }; +} diff --git a/app/soapbox/actions/verification.js b/app/soapbox/actions/verification.js new file mode 100644 index 000000000..23b895afa --- /dev/null +++ b/app/soapbox/actions/verification.js @@ -0,0 +1,409 @@ +import api from '../api'; + +/** + * LocalStorage 'soapbox:verification' + * + * { + * token: String, + * challenges: { + * email: Number (0 = incomplete, 1 = complete), + * sms: Number, + * age: Number + * } + * } + */ +const LOCAL_STORAGE_VERIFICATION_KEY = 'soapbox:verification'; + +const PEPE_FETCH_INSTANCE_SUCCESS = 'PEPE_FETCH_INSTANCE_SUCCESS'; +const FETCH_CHALLENGES_SUCCESS = 'FETCH_CHALLENGES_SUCCESS'; +const FETCH_TOKEN_SUCCESS = 'FETCH_TOKEN_SUCCESS'; + +const SET_NEXT_CHALLENGE = 'SET_NEXT_CHALLENGE'; +const SET_CHALLENGES_COMPLETE = 'SET_CHALLENGES_COMPLETE'; +const SET_LOADING = 'SET_LOADING'; + +const ChallengeTypes = { + EMAIL: 'email', + SMS: 'sms', + AGE: 'age', +}; + +/** + * Fetch the state of the user's verification in local storage. + * + * @returns {object} + * { + * token: String, + * challenges: { + * email: Number (0 = incomplete, 1 = complete), + * sms: Number, + * age: Number + * } + * } + */ +function fetchStoredVerification() { + try { + return JSON.parse(localStorage.getItem(LOCAL_STORAGE_VERIFICATION_KEY)); + } catch { + return null; + } +} + +/** + * Remove the state of the user's verification from local storage. + */ +function removeStoredVerification() { + localStorage.removeItem(LOCAL_STORAGE_VERIFICATION_KEY); +} + + +/** + * Fetch and return the Registration token for Pepe. + * @returns {string} + */ +function fetchStoredToken() { + try { + const verification = fetchStoredVerification(); + return verification.token; + } catch { + return null; + } +} + +/** + * Fetch and return the state of the verification challenges. + * @returns {object} + * { + * challenges: { + * email: Number (0 = incomplete, 1 = complete), + * sms: Number, + * age: Number + * } + * } + */ +function fetchStoredChallenges() { + try { + const verification = fetchStoredVerification(); + return verification.challenges; + } catch { + return null; + } +} + +/** + * Update the verification object in local storage. + * + * @param {*} verification object + */ +function updateStorage({ ...updatedVerification }) { + const verification = fetchStoredVerification(); + + localStorage.setItem( + LOCAL_STORAGE_VERIFICATION_KEY, + JSON.stringify({ ...verification, ...updatedVerification }), + ); +} + +/** + * Fetch Pepe challenges and registration token + * @returns {promise} + */ +function fetchVerificationConfig() { + return async(dispatch) => { + await dispatch(fetchPepeInstance()); + + dispatch(fetchRegistrationToken()); + }; +} + +/** + * Save the challenges in localStorage. + * + * - If the API removes a challenge after the client has stored it, remove that + * challenge from localStorage. + * - If the API adds a challenge after the client has stored it, add that + * challenge to localStorage. + * - Don't overwrite a challenge that has already been completed. + * - Update localStorage to the new set of challenges. + * + * @param {array} challenges - ['age', 'sms', 'email'] + */ +function saveChallenges(challenges) { + const currentChallenges = fetchStoredChallenges() || {}; + + const challengesToRemove = Object.keys(currentChallenges).filter((currentChallenge) => !challenges.includes(currentChallenge)); + challengesToRemove.forEach((challengeToRemove) => delete currentChallenges[challengeToRemove]); + + for (let i = 0; i < challenges.length; i++) { + const challengeName = challenges[i]; + + if (typeof currentChallenges[challengeName] !== 'number') { + currentChallenges[challengeName] = 0; + } + } + + updateStorage({ challenges: currentChallenges }); +} + +/** + * Finish a challenge. + * @param {string} challenge - "sms" or "email" or "age" + */ +function finishChallenge(challenge) { + const currentChallenges = fetchStoredChallenges() || {}; + // Set challenge to "complete" + currentChallenges[challenge] = 1; + + updateStorage({ challenges: currentChallenges }); +} + +/** + * Fetch the next challenge + * @returns {string} challenge - "sms" or "email" or "age" + */ +function fetchNextChallenge() { + const currentChallenges = fetchStoredChallenges() || {}; + return Object.keys(currentChallenges).find((challenge) => currentChallenges[challenge] === 0); +} + +/** + * Dispatch the next challenge or set to complete if all challenges are completed. + */ +function dispatchNextChallenge(dispatch) { + const nextChallenge = fetchNextChallenge(); + + if (nextChallenge) { + dispatch({ type: SET_NEXT_CHALLENGE, challenge: nextChallenge }); + } else { + dispatch({ type: SET_CHALLENGES_COMPLETE }); + } +} + +/** + * Fetch the challenges and age mininum from Pepe + * @returns {promise} + */ +function fetchPepeInstance() { + return (dispatch, getState) => { + dispatch({ type: SET_LOADING }); + + return api(getState).get('/api/v1/pepe/instance').then(response => { + const { challenges, age_minimum: ageMinimum } = response.data; + saveChallenges(challenges); + const currentChallenge = fetchNextChallenge(); + + dispatch({ type: PEPE_FETCH_INSTANCE_SUCCESS, instance: { isReady: true, ...response.data } }); + + dispatch({ + type: FETCH_CHALLENGES_SUCCESS, + ageMinimum, + currentChallenge, + isComplete: !currentChallenge, + }); + }) + .finally(() => dispatch({ type: SET_LOADING, value: false })); + }; +} + +/** + * Fetch the regristration token from Pepe unless it's already been stored locally + * @returns {promise} + */ +function fetchRegistrationToken() { + return (dispatch, getState) => { + dispatch({ type: SET_LOADING }); + + const token = fetchStoredToken(); + if (token) { + dispatch({ + type: FETCH_TOKEN_SUCCESS, + value: token, + }); + return null; + } + + + return api(getState).post('/api/v1/pepe/registrations') + .then(response => { + updateStorage({ token: response.data.access_token }); + + return dispatch({ + type: FETCH_TOKEN_SUCCESS, + value: response.data.access_token, + }); + }) + .finally(() => dispatch({ type: SET_LOADING, value: false })); + }; +} + +function checkEmailAvailability(email) { + return (dispatch, getState) => { + dispatch({ type: SET_LOADING }); + + const token = fetchStoredToken(); + + return api(getState).get(`/api/v1/pepe/account/exists?email=${email}`, { + headers: { Authorization: `Bearer ${token}` }, + }).finally(() => dispatch({ type: SET_LOADING, value: false })); + }; +} + +/** + * Send the user's email to Pepe to request confirmation + * @param {string} email + * @returns {promise} + */ +function requestEmailVerification(email) { + return (dispatch, getState) => { + dispatch({ type: SET_LOADING }); + + const token = fetchStoredToken(); + + return api(getState).post('/api/v1/pepe/verify_email/request', { email }, { + headers: { Authorization: `Bearer ${token}` }, + }) + .finally(() => dispatch({ type: SET_LOADING, value: false })); + }; +} + +function checkEmailVerification() { + return (dispatch, getState) => { + const token = fetchStoredToken(); + + return api(getState).get('/api/v1/pepe/verify_email', { + headers: { Authorization: `Bearer ${token}` }, + }); + }; +} + +/** + * Confirm the user's email with Pepe + * @param {string} emailToken + * @returns {promise} + */ +function confirmEmailVerification(emailToken) { + return (dispatch, getState) => { + dispatch({ type: SET_LOADING }); + + const token = fetchStoredToken(); + + return api(getState).post('/api/v1/pepe/verify_email/confirm', { token: emailToken }, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then(() => { + finishChallenge(ChallengeTypes.EMAIL); + dispatchNextChallenge(dispatch); + }) + .finally(() => dispatch({ type: SET_LOADING, value: false })); + }; +} + +function postEmailVerification() { + return (dispatch, getState) => { + finishChallenge(ChallengeTypes.EMAIL); + dispatchNextChallenge(dispatch); + }; +} + +/** + * Send the user's phone number to Pepe to request confirmation + * @param {string} phone + * @returns {promise} + */ +function requestPhoneVerification(phone) { + return (dispatch, getState) => { + dispatch({ type: SET_LOADING }); + + const token = fetchStoredToken(); + + return api(getState).post('/api/v1/pepe/verify_sms/request', { phone }, { + headers: { Authorization: `Bearer ${token}` }, + }) + .finally(() => dispatch({ type: SET_LOADING, value: false })); + }; +} + +/** + * Confirm the user's phone number with Pepe + * @param {string} code + * @returns {promise} + */ +function confirmPhoneVerification(code) { + return (dispatch, getState) => { + dispatch({ type: SET_LOADING }); + + const token = fetchStoredToken(); + + return api(getState).post('/api/v1/pepe/verify_sms/confirm', { code }, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then(() => { + finishChallenge(ChallengeTypes.SMS); + dispatchNextChallenge(dispatch); + }) + .finally(() => dispatch({ type: SET_LOADING, value: false })); + }; +} + +/** + * Confirm the user's age with Pepe + * @param {date} birthday + * @returns {promise} + */ +function verifyAge(birthday) { + return (dispatch, getState) => { + dispatch({ type: SET_LOADING }); + + const token = fetchStoredToken(); + + return api(getState).post('/api/v1/pepe/verify_age/confirm', { birthday }, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then(() => { + finishChallenge(ChallengeTypes.AGE); + dispatchNextChallenge(dispatch); + }) + .finally(() => dispatch({ type: SET_LOADING, value: false })); + }; +} + +/** + * Create the user's account with Pepe + * @param {string} username + * @param {string} password + * @returns {promise} + */ +function createAccount(username, password) { + return (dispatch, getState) => { + dispatch({ type: SET_LOADING }); + + const token = fetchStoredToken(); + + return api(getState).post('/api/v1/pepe/accounts', { username, password }, { + headers: { Authorization: `Bearer ${token}` }, + }).finally(() => dispatch({ type: SET_LOADING, value: false })); + }; +} + +export { + PEPE_FETCH_INSTANCE_SUCCESS, + FETCH_CHALLENGES_SUCCESS, + FETCH_TOKEN_SUCCESS, + LOCAL_STORAGE_VERIFICATION_KEY, + SET_CHALLENGES_COMPLETE, + SET_LOADING, + SET_NEXT_CHALLENGE, + checkEmailAvailability, + confirmEmailVerification, + confirmPhoneVerification, + createAccount, + fetchStoredChallenges, + fetchVerificationConfig, + fetchRegistrationToken, + removeStoredVerification, + requestEmailVerification, + checkEmailVerification, + postEmailVerification, + requestPhoneVerification, + verifyAge, +}; diff --git a/app/soapbox/build_config.js b/app/soapbox/build_config.js index bb9209d6c..6ddb309cb 100644 --- a/app/soapbox/build_config.js +++ b/app/soapbox/build_config.js @@ -11,6 +11,7 @@ const { BACKEND_URL, FE_SUBDIRECTORY, FE_BUILD_DIR, + FE_INSTANCE_SOURCE_DIR, SENTRY_DSN, } = process.env; @@ -39,5 +40,6 @@ module.exports = sanitize({ BACKEND_URL: sanitizeURL(BACKEND_URL), FE_SUBDIRECTORY: sanitizeBasename(FE_SUBDIRECTORY), FE_BUILD_DIR: sanitizePath(FE_BUILD_DIR) || 'static', + FE_INSTANCE_SOURCE_DIR: FE_INSTANCE_SOURCE_DIR || 'instance', SENTRY_DSN, }); diff --git a/app/soapbox/components/__tests__/__snapshots__/avatar-test.js.snap b/app/soapbox/components/__tests__/__snapshots__/avatar-test.js.snap index 2938fb3ed..79035031e 100644 --- a/app/soapbox/components/__tests__/__snapshots__/avatar-test.js.snap +++ b/app/soapbox/components/__tests__/__snapshots__/avatar-test.js.snap @@ -2,7 +2,7 @@ exports[` Autoplay renders an animated avatar 1`] = `
Autoplay renders an animated avatar 1`] = ` exports[` Still renders a still avatar 1`] = `
adds class "button-secondary" if props.secondary given 1`] = ` - -`; - -exports[` -`; - -exports[` -`; diff --git a/app/soapbox/components/__tests__/__snapshots__/column-test.js.snap b/app/soapbox/components/__tests__/__snapshots__/column-test.js.snap deleted file mode 100644 index d6fef9f59..000000000 --- a/app/soapbox/components/__tests__/__snapshots__/column-test.js.snap +++ /dev/null @@ -1,8 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders correctly with minimal props 1`] = ` -
-`; diff --git a/app/soapbox/components/__tests__/__snapshots__/display_name-test.js.snap b/app/soapbox/components/__tests__/__snapshots__/display_name-test.js.snap index 5be2141d3..76c9e9445 100644 --- a/app/soapbox/components/__tests__/__snapshots__/display_name-test.js.snap +++ b/app/soapbox/components/__tests__/__snapshots__/display_name-test.js.snap @@ -18,7 +18,7 @@ exports[` renders display name + account name 1`] = ` className="display-name__html" dangerouslySetInnerHTML={ Object { - "__html": "

Foo

", + "__html": "bar", } } /> diff --git a/app/soapbox/components/__tests__/__snapshots__/timeline_queue_button_header-test.js.snap b/app/soapbox/components/__tests__/__snapshots__/timeline_queue_button_header-test.js.snap index d63691a71..7669b4fd3 100644 --- a/app/soapbox/components/__tests__/__snapshots__/timeline_queue_button_header-test.js.snap +++ b/app/soapbox/components/__tests__/__snapshots__/timeline_queue_button_header-test.js.snap @@ -2,10 +2,10 @@ exports[` renders correctly 1`] = `
renders correctly 1`] = ` exports[` renders correctly 2`] = ` `; exports[` renders correctly 3`] = ` `; diff --git a/app/soapbox/components/__tests__/display_name-test.js b/app/soapbox/components/__tests__/display_name-test.js index 66cc7cdf7..2583d341b 100644 --- a/app/soapbox/components/__tests__/display_name-test.js +++ b/app/soapbox/components/__tests__/display_name-test.js @@ -1,17 +1,13 @@ -import { fromJS } from 'immutable'; import React from 'react'; +import { normalizeAccount } from 'soapbox/normalizers'; import { createComponent } from 'soapbox/test_helpers'; import DisplayName from '../display_name'; describe('', () => { it('renders display name + account name', () => { - const account = fromJS({ - username: 'bar', - acct: 'bar@baz', - display_name_html: '

Foo

', - }); + const account = normalizeAccount({ acct: 'bar@baz' }); const component = createComponent(); const tree = component.toJSON(); diff --git a/app/soapbox/components/account.js b/app/soapbox/components/account.js deleted file mode 100644 index 4136f3783..000000000 --- a/app/soapbox/components/account.js +++ /dev/null @@ -1,143 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Fragment } from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import emojify from 'soapbox/features/emoji/emoji'; -import ActionButton from 'soapbox/features/ui/components/action_button'; - -import Avatar from './avatar'; -import DisplayName from './display_name'; -import Icon from './icon'; -import IconButton from './icon_button'; -import Permalink from './permalink'; -import RelativeTimestamp from './relative_timestamp'; - -const mapStateToProps = state => { - return { - me: state.get('me'), - }; -}; - -export default @connect(mapStateToProps) -class Account extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.map.isRequired, - onFollow: PropTypes.func.isRequired, - onBlock: PropTypes.func.isRequired, - onMute: PropTypes.func.isRequired, - onMuteNotifications: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - hidden: PropTypes.bool, - actionIcon: PropTypes.string, - actionTitle: PropTypes.string, - onActionClick: PropTypes.func, - withDate: PropTypes.bool, - withRelationship: PropTypes.bool, - reaction: PropTypes.string, - }; - - static defaultProps = { - withDate: false, - withRelationship: true, - } - - handleFollow = () => { - this.props.onFollow(this.props.account); - } - - handleBlock = () => { - this.props.onBlock(this.props.account); - } - - handleMute = () => { - this.props.onMute(this.props.account); - } - - handleMuteNotifications = () => { - this.props.onMuteNotifications(this.props.account, true); - } - - handleUnmuteNotifications = () => { - this.props.onMuteNotifications(this.props.account, false); - } - - handleAction = () => { - this.props.onActionClick(this.props.account); - } - - render() { - const { account, hidden, onActionClick, actionIcon, actionTitle, me, withDate, withRelationship, reaction } = this.props; - - if (!account) { - return
; - } - - if (hidden) { - return ( - - {account.get('display_name')} - {account.get('username')} - - ); - } - - let buttons; - let followedBy; - let emoji; - - if (onActionClick && actionIcon) { - buttons = ; - } else if (account.get('id') !== me && account.get('relationship', null) !== null) { - buttons = ; - } - - if (reaction) { - emoji = ( - - ); - } - - const createdAt = account.get('created_at'); - - const joinedAt = createdAt ? ( -
- - -
- ) : null; - - return ( -
-
- -
- {emoji} - -
- -
- - {withRelationship ? (<> - {followedBy && - - - } - -
- {buttons} -
- ) : withDate && joinedAt} -
-
- ); - } - -} diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx new file mode 100644 index 000000000..4fd4d4218 --- /dev/null +++ b/app/soapbox/components/account.tsx @@ -0,0 +1,192 @@ +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper'; +import VerificationBadge from 'soapbox/components/verification_badge'; +import ActionButton from 'soapbox/features/ui/components/action_button'; +import { useAppSelector } from 'soapbox/hooks'; +import { getAcct } from 'soapbox/utils/accounts'; +import { displayFqn } from 'soapbox/utils/state'; + +import RelativeTimestamp from './relative_timestamp'; +import { Avatar, HStack, IconButton, Text } from './ui'; + +import type { Account as AccountEntity } from 'soapbox/types/entities'; + +interface IProfilePopper { + condition: boolean, + wrapper: (children: any) => React.ReactElement +} + +const ProfilePopper: React.FC = ({ condition, wrapper, children }): any => + condition ? wrapper(children) : children; + +interface IAccount { + account: AccountEntity, + action?: React.ReactElement, + actionAlignment?: 'center' | 'top', + actionIcon?: string, + actionTitle?: string, + avatarSize?: number, + hidden?: boolean, + hideActions?: boolean, + onActionClick?: (account: any) => void, + showProfileHoverCard?: boolean, + timestamp?: string, + timestampUrl?: string, + withRelationship?: boolean, +} + +const Account = ({ + account, + action, + actionIcon, + actionTitle, + actionAlignment = 'center', + avatarSize = 42, + hidden = false, + hideActions = false, + onActionClick, + showProfileHoverCard = true, + timestamp, + timestampUrl, + withRelationship = true, +}: IAccount) => { + const overflowRef = React.useRef(null); + const actionRef = React.useRef(null); + + const [style, setStyle] = React.useState({ visibility: 'hidden' }); + + const me = useAppSelector((state) => state.me); + const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null); + + const handleAction = () => { + onActionClick(account); + }; + + const renderAction = () => { + if (action) { + return action; + } + + if (hideActions) { + return null; + } + + if (onActionClick && actionIcon) { + return ( + + ); + } + + if (account.get('id') !== me && account.get('relationship', null) !== null) { + return ; + } + + return null; + }; + + React.useEffect(() => { + const style: React.CSSProperties = {}; + + const actionWidth = actionRef.current?.clientWidth; + + if (overflowRef.current) { + style.maxWidth = overflowRef.current.clientWidth - 30 - avatarSize - actionWidth; + } else { + style.visibility = 'hidden'; + } + + setStyle(style); + }, [overflowRef, actionRef]); + + if (!account) { + return null; + } + + if (hidden) { + return ( + <> + {account.get('display_name')} + {account.get('username')} + + ); + } + + const LinkEl = showProfileHoverCard ? Link : 'div'; + + return ( +
+ + + {children}} + > + event.stopPropagation()} + > + + + + +
+ {children}} + > + event.stopPropagation()} + > +
+ + + {account.get('verified') && } +
+
+
+ + + @{username} + + {(timestamp) ? ( + <> + · + + {timestampUrl ? ( + + + + ) : ( + + )} + + ) : null} + +
+
+ +
+ {withRelationship ? renderAction() : null} +
+
+
+ ); +}; + +export default Account; diff --git a/app/soapbox/components/autosuggest_input.js b/app/soapbox/components/autosuggest_input.js index 997c0ff92..bdb739e06 100644 --- a/app/soapbox/components/autosuggest_input.js +++ b/app/soapbox/components/autosuggest_input.js @@ -7,7 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import Icon from 'soapbox/components/icon'; -import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; +import AutosuggestAccount from '../features/compose/components/autosuggest_account'; import { isRtl } from '../rtl'; import AutosuggestEmoji from './autosuggest_emoji'; @@ -195,12 +195,22 @@ export default class AutosuggestInput extends ImmutablePureComponent { inner = suggestion; key = suggestion; } else { - inner = ; + inner = ; key = suggestion; } return ( -
+
{inner}
); @@ -228,7 +238,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { return menu.map((item, i) => ( -