From 570fd25a70cb78e8af4768a9f5072d149dd3e9a1 Mon Sep 17 00:00:00 2001 From: Sean Meininger Date: Sun, 10 Apr 2022 12:18:44 -0700 Subject: [PATCH 01/17] Grammatical fix for birthday reminder text --- app/soapbox/components/birthday_reminders.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/components/birthday_reminders.js b/app/soapbox/components/birthday_reminders.js index 12fa249fd..976e44720 100644 --- a/app/soapbox/components/birthday_reminders.js +++ b/app/soapbox/components/birthday_reminders.js @@ -81,7 +81,7 @@ class BirthdayReminders extends ImmutablePureComponent { ); if (birthdays.size === 1) { - return ; + return ; } return ( @@ -108,7 +108,7 @@ class BirthdayReminders extends ImmutablePureComponent { const { intl, birthdays, account } = this.props; if (birthdays.size === 1) { - return intl.formatMessage({ id: 'notification.birthday', defaultMessage: '{name} has birthday today' }, { name: account.get('display_name') }); + return intl.formatMessage({ id: 'notification.birthday', defaultMessage: '{name} has a birthday today' }, { name: account.get('display_name') }); } return intl.formatMessage( From af92c6fd875c10607d06262af1bc8a70d175d5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 10 Apr 2022 22:20:43 +0200 Subject: [PATCH 02/17] Fix ThumbNavigationLink icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/thumb_navigation-link.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/thumb_navigation-link.tsx b/app/soapbox/components/thumb_navigation-link.tsx index 756abd25c..4e96260a0 100644 --- a/app/soapbox/components/thumb_navigation-link.tsx +++ b/app/soapbox/components/thumb_navigation-link.tsx @@ -31,7 +31,7 @@ const ThumbNavigationLink: React.FC = ({ count, src, text, {count !== undefined ? ( Date: Sun, 10 Apr 2022 15:25:07 -0500 Subject: [PATCH 03/17] Move emoji utils into its own module --- app/soapbox/components/ui/emoji/emoji.tsx | 28 +--------------- app/soapbox/utils/__tests__/emoji.test.ts | 39 +++++++++++++++++++++++ app/soapbox/utils/emoji.ts | 35 ++++++++++++++++++++ 3 files changed, 75 insertions(+), 27 deletions(-) create mode 100644 app/soapbox/utils/__tests__/emoji.test.ts create mode 100644 app/soapbox/utils/emoji.ts diff --git a/app/soapbox/components/ui/emoji/emoji.tsx b/app/soapbox/components/ui/emoji/emoji.tsx index e9bcda2ed..59d00df2d 100644 --- a/app/soapbox/components/ui/emoji/emoji.tsx +++ b/app/soapbox/components/ui/emoji/emoji.tsx @@ -1,34 +1,8 @@ import React from 'react'; +import { removeVS16s, toCodePoints } from 'soapbox/utils/emoji'; import { joinPublicPath } from 'soapbox/utils/static'; -// Taken from twemoji-parser -// https://github.com/twitter/twemoji-parser/blob/a97ef3994e4b88316812926844d51c296e889f76/src/index.js -const removeVS16s = (rawEmoji: string): string => { - const vs16RegExp = /\uFE0F/g; - const zeroWidthJoiner = String.fromCharCode(0x200d); - return rawEmoji.indexOf(zeroWidthJoiner) < 0 ? rawEmoji.replace(vs16RegExp, '') : rawEmoji; -}; - -const toCodePoints = (unicodeSurrogates: string): string[] => { - const points = []; - let char = 0; - let previous = 0; - let i = 0; - while (i < unicodeSurrogates.length) { - char = unicodeSurrogates.charCodeAt(i++); - if (previous) { - points.push((0x10000 + ((previous - 0xd800) << 10) + (char - 0xdc00)).toString(16)); - previous = 0; - } else if (char > 0xd800 && char <= 0xdbff) { - previous = char; - } else { - points.push(char.toString(16)); - } - } - return points; -}; - interface IEmoji extends React.ImgHTMLAttributes { emoji: string, } diff --git a/app/soapbox/utils/__tests__/emoji.test.ts b/app/soapbox/utils/__tests__/emoji.test.ts new file mode 100644 index 000000000..ab1a4ddaa --- /dev/null +++ b/app/soapbox/utils/__tests__/emoji.test.ts @@ -0,0 +1,39 @@ +import { + removeVS16s, + toCodePoints, +} from '../emoji'; + +const ASCII_HEART = '❤'; // '\u2764\uFE0F' +const RED_HEART_RGI = '❤️'; // '\u2764' +const JOY = '😂'; + +describe('removeVS16s()', () => { + it('removes Variation Selector-16 characters from emoji', () => { + // Sanity check + expect(ASCII_HEART).not.toBe(RED_HEART_RGI); + + // It normalizes an emoji with VS16s + expect(removeVS16s(RED_HEART_RGI)).toBe(ASCII_HEART); + + // Leaves a regular emoji alone + expect(removeVS16s(JOY)).toBe(JOY); + }); +}); + +describe('toCodePoints()', () => { + it('converts a plain emoji', () => { + expect(toCodePoints('😂')).toEqual(['1f602']); + }); + + it('converts a VS16 emoji', () => { + expect(toCodePoints(RED_HEART_RGI)).toEqual(['2764', 'fe0f']); + }); + + it('converts an ASCII character', () => { + expect(toCodePoints(ASCII_HEART)).toEqual(['2764']); + }); + + it('converts a sequence emoji', () => { + expect(toCodePoints('🇺🇸')).toEqual(['1f1fa', '1f1f8']); + }); +}); diff --git a/app/soapbox/utils/emoji.ts b/app/soapbox/utils/emoji.ts new file mode 100644 index 000000000..1d6da69d1 --- /dev/null +++ b/app/soapbox/utils/emoji.ts @@ -0,0 +1,35 @@ +// Taken from twemoji-parser +// https://github.com/twitter/twemoji-parser/blob/a97ef3994e4b88316812926844d51c296e889f76/src/index.js + +/** Remove Variation Selector-16 characters from emoji */ +// https://emojipedia.org/variation-selector-16/ +const removeVS16s = (rawEmoji: string): string => { + const vs16RegExp = /\uFE0F/g; + const zeroWidthJoiner = String.fromCharCode(0x200d); + return rawEmoji.indexOf(zeroWidthJoiner) < 0 ? rawEmoji.replace(vs16RegExp, '') : rawEmoji; +}; + +/** Convert emoji into an array of Unicode codepoints */ +const toCodePoints = (unicodeSurrogates: string): string[] => { + const points = []; + let char = 0; + let previous = 0; + let i = 0; + while (i < unicodeSurrogates.length) { + char = unicodeSurrogates.charCodeAt(i++); + if (previous) { + points.push((0x10000 + ((previous - 0xd800) << 10) + (char - 0xdc00)).toString(16)); + previous = 0; + } else if (char > 0xd800 && char <= 0xdbff) { + previous = char; + } else { + points.push(char.toString(16)); + } + } + return points; +}; + +export { + removeVS16s, + toCodePoints, +}; From 1466a081934331c4a37718c3f706e29d1e98c61e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 10 Apr 2022 15:44:51 -0500 Subject: [PATCH 04/17] Perform better normalization of allowedEmoji --- app/soapbox/actions/__tests__/soapbox.test.ts | 19 ++++++++ app/soapbox/actions/soapbox.js | 48 +++++++------------ app/soapbox/utils/features.ts | 2 +- 3 files changed, 37 insertions(+), 32 deletions(-) create mode 100644 app/soapbox/actions/__tests__/soapbox.test.ts diff --git a/app/soapbox/actions/__tests__/soapbox.test.ts b/app/soapbox/actions/__tests__/soapbox.test.ts new file mode 100644 index 000000000..e3dcf9a85 --- /dev/null +++ b/app/soapbox/actions/__tests__/soapbox.test.ts @@ -0,0 +1,19 @@ +import { rootState } from '../../jest/test-helpers'; +import { getSoapboxConfig } from '../soapbox'; + +const ASCII_HEART = '❤'; // '\u2764\uFE0F' +const RED_HEART_RGI = '❤️'; // '\u2764' + +describe('getSoapboxConfig()', () => { + it('returns RGI heart on Pleroma > 2.3', () => { + const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.3.0)'); + expect(getSoapboxConfig(state).allowedEmoji.includes(RED_HEART_RGI)).toBe(true); + expect(getSoapboxConfig(state).allowedEmoji.includes(ASCII_HEART)).toBe(false); + }); + + it('returns an ASCII heart on Pleroma < 2.3', () => { + const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.0.0)'); + expect(getSoapboxConfig(state).allowedEmoji.includes(ASCII_HEART)).toBe(true); + expect(getSoapboxConfig(state).allowedEmoji.includes(RED_HEART_RGI)).toBe(false); + }); +}); diff --git a/app/soapbox/actions/soapbox.js b/app/soapbox/actions/soapbox.js index bc3bdc82f..fd08d0300 100644 --- a/app/soapbox/actions/soapbox.js +++ b/app/soapbox/actions/soapbox.js @@ -1,9 +1,9 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { createSelector } from 'reselect'; import { getHost } from 'soapbox/actions/instance'; import { normalizeSoapboxConfig } from 'soapbox/normalizers'; import KVStore from 'soapbox/storage/kv_store'; +import { removeVS16s } from 'soapbox/utils/emoji'; import { getFeatures } from 'soapbox/utils/features'; import api, { staticClient } from '../api'; @@ -15,38 +15,24 @@ export const SOAPBOX_CONFIG_REMEMBER_REQUEST = 'SOAPBOX_CONFIG_REMEMBER_REQUEST' export const SOAPBOX_CONFIG_REMEMBER_SUCCESS = 'SOAPBOX_CONFIG_REMEMBER_SUCCESS'; export const SOAPBOX_CONFIG_REMEMBER_FAIL = 'SOAPBOX_CONFIG_REMEMBER_FAIL'; -const allowedEmoji = ImmutableList([ - '👍', - '❤', - '😆', - '😮', - '😢', - '😩', -]); - -// https://git.pleroma.social/pleroma/pleroma/-/issues/2355 -const allowedEmojiRGI = ImmutableList([ - '👍', - '❤️', - '😆', - '😮', - '😢', - '😩', -]); - -export const makeDefaultConfig = features => { - return ImmutableMap({ - allowedEmoji: features.emojiReactsRGI ? allowedEmojiRGI : allowedEmoji, - displayFqn: Boolean(features.federating), - }); -}; - export const getSoapboxConfig = createSelector([ - state => state.get('soapbox'), - state => getFeatures(state.get('instance')), + state => state.soapbox, + state => getFeatures(state.instance), ], (soapbox, features) => { - const defaultConfig = makeDefaultConfig(features); - return normalizeSoapboxConfig(soapbox).merge(defaultConfig); + // Do some additional normalization with the state + return normalizeSoapboxConfig(soapbox).withMutations(soapboxConfig => { + + // If displayFqn isn't set, infer it from federation + if (soapbox.get('displayFqn') === undefined) { + soapboxConfig.set('displayFqn', features.federating); + } + + // If RGI reacts aren't supported, strip VS16s + // // https://git.pleroma.social/pleroma/pleroma/-/issues/2355 + if (!features.emojiReactsRGI) { + soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(emoji => removeVS16s(emoji))); + } + }); }); export function rememberSoapboxConfig(host) { diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 4f70f9341..93529f21e 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -83,7 +83,7 @@ const getInstanceFeatures = (instance: Instance) => { chats: v.software === PLEROMA && gte(v.version, '2.1.0'), chatsV2: v.software === PLEROMA && gte(v.version, '2.3.0'), scopes: v.software === PLEROMA ? 'read write follow push admin' : 'read write follow push', - federating: federation.get('enabled', true), // Assume true unless explicitly false + federating: federation.get('enabled', true) === true, // Assume true unless explicitly false richText: v.software === PLEROMA, securityAPI: any([ v.software === PLEROMA, From ae48c6e619cc352c14d6415dee031f87c9d547c7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 10 Apr 2022 16:07:23 -0500 Subject: [PATCH 05/17] Fix action bar emoji labels --- app/soapbox/actions/soapbox.js | 2 +- app/soapbox/components/status_action_bar.tsx | 3 ++- app/soapbox/features/status/components/action-bar.tsx | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/soapbox/actions/soapbox.js b/app/soapbox/actions/soapbox.js index fd08d0300..0b18c9bc1 100644 --- a/app/soapbox/actions/soapbox.js +++ b/app/soapbox/actions/soapbox.js @@ -30,7 +30,7 @@ export const getSoapboxConfig = createSelector([ // If RGI reacts aren't supported, strip VS16s // // https://git.pleroma.social/pleroma/pleroma/-/issues/2355 if (!features.emojiReactsRGI) { - soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(emoji => removeVS16s(emoji))); + soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s)); } }); }); diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index 549c2f4ba..9011bec71 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -578,9 +578,10 @@ class StatusActionBar extends ImmutablePureComponent { '😮': messages.reactionOpenMouth, '😢': messages.reactionCry, '😩': messages.reactionWeary, + '': messages.favourite, }; - const meEmojiTitle = intl.formatMessage(meEmojiReact ? reactMessages[meEmojiReact] : messages.favourite); + const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); const menu: Menu = []; From 41d49e0fe70dc86f4e71c37ec40be2559a21d0ce Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 10 Apr 2022 18:21:52 -0500 Subject: [PATCH 06/17] Add preliminary Pixelfed support --- .../__fixtures__/pixelfed-instance.json | 66 +++++++++++++++++++ .../normalizers/__tests__/instance-test.js | 6 ++ app/soapbox/utils/__tests__/features-test.js | 9 +++ app/soapbox/utils/features.ts | 3 + 4 files changed, 84 insertions(+) create mode 100644 app/soapbox/__fixtures__/pixelfed-instance.json diff --git a/app/soapbox/__fixtures__/pixelfed-instance.json b/app/soapbox/__fixtures__/pixelfed-instance.json new file mode 100644 index 000000000..41830e0e4 --- /dev/null +++ b/app/soapbox/__fixtures__/pixelfed-instance.json @@ -0,0 +1,66 @@ +{ + "uri": "pixelfed.social", + "title": "pixelfed", + "short_description": "Pixelfed is an image sharing platform, an ethical alternative to centralized platforms", + "description": "Pixelfed is an image sharing platform, an ethical alternative to centralized platforms", + "email": "hello@pixelfed.org", + "version": "2.7.2 (compatible; Pixelfed 0.11.2)", + "urls": { + "streaming_api": "wss://pixelfed.social" + }, + "stats": { + "user_count": 45061, + "status_count": 301357, + "domain_count": 5028 + }, + "thumbnail": "https://pixelfed.social/img/pixelfed-icon-color.png", + "languages": [ + "en" + ], + "registrations": true, + "approval_required": false, + "contact_account": { + "id": "1", + "username": "admin", + "acct": "admin", + "display_name": "Admin", + "discoverable": true, + "locked": false, + "followers_count": 419, + "following_count": 2, + "statuses_count": 6, + "note": "pixelfed.social Admin. Managed by @dansup", + "url": "https://pixelfed.social/admin", + "avatar": "https://pixelfed.social/storage/avatars/000/000/000/001/LSHNCgwbby7wu3iCYV6H_avatar.png?v=4", + "created_at": "2018-06-01T03:54:08.000000Z", + "avatar_static": "https://pixelfed.social/storage/avatars/000/000/000/001/LSHNCgwbby7wu3iCYV6H_avatar.png?v=4", + "bot": false, + "emojis": [], + "fields": [], + "header": "https://pixelfed.social/storage/headers/missing.png", + "header_static": "https://pixelfed.social/storage/headers/missing.png", + "last_status_at": null + }, + "rules": [ + { + "id": "1", + "text": "Sexually explicit or violent media must be marked as sensitive when posting" + }, + { + "id": "2", + "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism" + }, + { + "id": "3", + "text": "No incitement of violence or promotion of violent ideologies" + }, + { + "id": "4", + "text": "No harassment, dogpiling or doxxing of other users" + }, + { + "id": "5", + "text": "No content illegal in United States" + } + ] +} diff --git a/app/soapbox/normalizers/__tests__/instance-test.js b/app/soapbox/normalizers/__tests__/instance-test.js index 918c8072a..638cefbe7 100644 --- a/app/soapbox/normalizers/__tests__/instance-test.js +++ b/app/soapbox/normalizers/__tests__/instance-test.js @@ -185,4 +185,10 @@ describe('normalizeInstance()', () => { expect(result.version).toEqual('3.5.0-rc1'); }); + + it('normalizes Pixelfed instance', () => { + const instance = require('soapbox/__fixtures__/pixelfed-instance.json'); + const result = normalizeInstance(instance); + expect(result.title).toBe('pixelfed'); + }); }); diff --git a/app/soapbox/utils/__tests__/features-test.js b/app/soapbox/utils/__tests__/features-test.js index 6caa38a7b..4027dd69e 100644 --- a/app/soapbox/utils/__tests__/features-test.js +++ b/app/soapbox/utils/__tests__/features-test.js @@ -23,6 +23,15 @@ describe('parseVersion', () => { compatVersion: '3.0.0', }); }); + + it('with a Pixelfed version string', () => { + const version = '2.7.2 (compatible; Pixelfed 0.11.2)'; + expect(parseVersion(version)).toEqual({ + software: 'Pixelfed', + version: '0.11.2', + compatVersion: '2.7.2', + }); + }); }); describe('getFeatures', () => { diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 93529f21e..e1c5d12e0 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -19,6 +19,7 @@ export const MASTODON = 'Mastodon'; export const PLEROMA = 'Pleroma'; export const MITRA = 'Mitra'; export const TRUTHSOCIAL = 'TruthSocial'; +export const PIXELFED = 'Pixelfed'; const getInstanceFeatures = (instance: Instance) => { const v = parseVersion(instance.version); @@ -41,6 +42,7 @@ const getInstanceFeatures = (instance: Instance) => { bookmarks: any([ v.software === MASTODON && gte(v.compatVersion, '3.1.0'), v.software === PLEROMA && gte(v.version, '0.9.9'), + v.software === PIXELFED, ]), lists: any([ v.software === MASTODON && gte(v.compatVersion, '2.1.0'), @@ -73,6 +75,7 @@ const getInstanceFeatures = (instance: Instance) => { conversations: any([ v.software === MASTODON && gte(v.compatVersion, '2.6.0'), v.software === PLEROMA && gte(v.version, '0.9.9'), + v.software === PIXELFED, ]), emojiReacts: v.software === PLEROMA && gte(v.version, '2.0.0'), emojiReactsRGI: v.software === PLEROMA && gte(v.version, '2.2.49'), From f316dac83e91e934f85b78bdab7ca05617d855f9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 10 Apr 2022 19:59:53 -0500 Subject: [PATCH 07/17] eslint: scream if I try putting a JS comment in a JSX text node --- .eslintrc.js | 1 + app/soapbox/components/ui/icon/svg-icon.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index 9a92e50a8..d885cbeea 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -141,6 +141,7 @@ module.exports = { 'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'], 'react/jsx-indent': ['error', 2], // 'react/jsx-no-bind': ['error'], + 'react/jsx-no-comment-textnodes': 'error', 'react/jsx-no-duplicate-props': 'error', 'react/jsx-no-undef': 'error', 'react/jsx-tag-spacing': 'error', diff --git a/app/soapbox/components/ui/icon/svg-icon.tsx b/app/soapbox/components/ui/icon/svg-icon.tsx index 5cb2dd192..84604150d 100644 --- a/app/soapbox/components/ui/icon/svg-icon.tsx +++ b/app/soapbox/components/ui/icon/svg-icon.tsx @@ -30,7 +30,7 @@ const SvgIcon: React.FC = ({ src, alt, size = 24, className }): JSX.El loader={loader} data-testid='svg-icon' > - /* If the fetch fails, fall back to displaying the loader */ + {/* If the fetch fails, fall back to displaying the loader */} {loader} ); From 0912700d153ae7efebd4cf313d79053571872791 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 10 Apr 2022 20:31:24 -0500 Subject: [PATCH 08/17] Create preliminary EmojiButtonWrapper component --- app/soapbox/actions/{modals.js => modals.ts} | 6 +- .../components/emoji-button-wrapper.tsx | 98 +++++++++++++++++++ app/soapbox/components/hoverable.tsx | 63 ------------ app/soapbox/components/status_action_bar.tsx | 15 +-- .../ui/emoji-selector/emoji-selector.tsx | 4 +- .../features/status/components/action-bar.tsx | 45 ++++++--- 6 files changed, 139 insertions(+), 92 deletions(-) rename app/soapbox/actions/{modals.js => modals.ts} (59%) create mode 100644 app/soapbox/components/emoji-button-wrapper.tsx delete mode 100644 app/soapbox/components/hoverable.tsx diff --git a/app/soapbox/actions/modals.js b/app/soapbox/actions/modals.ts similarity index 59% rename from app/soapbox/actions/modals.js rename to app/soapbox/actions/modals.ts index 72604ecc6..9d6e85139 100644 --- a/app/soapbox/actions/modals.js +++ b/app/soapbox/actions/modals.ts @@ -1,7 +1,8 @@ export const MODAL_OPEN = 'MODAL_OPEN'; export const MODAL_CLOSE = 'MODAL_CLOSE'; -export function openModal(type, props) { +/** Open a modal of the given type */ +export function openModal(type: string, props?: any) { return { type: MODAL_OPEN, modalType: type, @@ -9,7 +10,8 @@ export function openModal(type, props) { }; } -export function closeModal(type) { +/** Close the modal */ +export function closeModal(type: string) { return { type: MODAL_CLOSE, modalType: type, diff --git a/app/soapbox/components/emoji-button-wrapper.tsx b/app/soapbox/components/emoji-button-wrapper.tsx new file mode 100644 index 000000000..eb2ec2f3b --- /dev/null +++ b/app/soapbox/components/emoji-button-wrapper.tsx @@ -0,0 +1,98 @@ +import classNames from 'classnames'; +import React, { useState, useRef } from 'react'; +import { usePopper } from 'react-popper'; +import { useDispatch } from 'react-redux'; + +import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; +import { openModal } from 'soapbox/actions/modals'; +import EmojiSelector from 'soapbox/components/ui/emoji-selector/emoji-selector'; +import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks'; + +interface IEmojiButtonWrapper { + statusId: string, + children: JSX.Element, +} + +/** Provides emoji reaction functionality to the underlying button component */ +const EmojiButtonWrapper: React.FC = ({ statusId, children }): JSX.Element | null => { + const dispatch = useDispatch(); + const ownAccount = useOwnAccount(); + const status = useAppSelector(state => state.statuses.get(statusId)); + const soapboxConfig = useSoapboxConfig(); + + const [visible, setVisible] = useState(false); + // const [focused, setFocused] = useState(false); + + const ref = useRef(null); + const popperRef = useRef(null); + + const { styles, attributes } = usePopper(ref.current, popperRef.current, { + placement: 'top-start', + strategy: 'fixed', + modifiers: [ + { + name: 'offset', + options: { + offset: [-10, 0], + }, + }, + ], + }); + + if (!status) return null; + + const handleMouseEnter = () => { + setVisible(true); + }; + + const handleMouseLeave = () => { + setVisible(false); + }; + + const handleReact = (emoji: string): void => { + if (ownAccount) { + dispatch(simpleEmojiReact(status, emoji)); + } else { + dispatch(openModal('UNAUTHORIZED', { + action: 'FAVOURITE', + ap_id: status.url, + })); + } + + setVisible(false); + }; + + // const handleUnfocus: React.EventHandler = () => { + // setFocused(false); + // }; + + const selector = ( +
+ +
+ ); + + return ( +
+ {React.cloneElement(children, { + ref, + })} + + {selector} +
+ ); +}; + +export default EmojiButtonWrapper; diff --git a/app/soapbox/components/hoverable.tsx b/app/soapbox/components/hoverable.tsx deleted file mode 100644 index 751c413c1..000000000 --- a/app/soapbox/components/hoverable.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import classNames from 'classnames'; -import React, { useState, useRef } from 'react'; -import { usePopper } from 'react-popper'; - -interface IHoverable { - component: JSX.Element, -} - -/** Wrapper to render a given component when hovered */ -const Hoverable: React.FC = ({ - component, - children, -}): JSX.Element => { - - const [portalActive, setPortalActive] = useState(false); - - const ref = useRef(null); - const popperRef = useRef(null); - - const handleMouseEnter = () => { - setPortalActive(true); - }; - - const handleMouseLeave = () => { - setPortalActive(false); - }; - - const { styles, attributes } = usePopper(ref.current, popperRef.current, { - placement: 'top-start', - strategy: 'fixed', - modifiers: [ - { - name: 'offset', - options: { - offset: [-10, 0], - }, - }, - ], - }); - - return ( -
- {children} - -
- {component} -
-
- ); -}; - -export default Hoverable; diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index 9011bec71..dbafd64fd 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -6,8 +6,7 @@ import { connect } from 'react-redux'; import { withRouter, RouteComponentProps } from 'react-router-dom'; import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; -import EmojiSelector from 'soapbox/components/emoji_selector'; -import Hoverable from 'soapbox/components/hoverable'; +import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; import StatusActionButton from 'soapbox/components/status-action-button'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; import { isUserTouching } from 'soapbox/is_mobile'; @@ -641,15 +640,7 @@ class StatusActionBar extends ImmutablePureComponent - )} - > + - + ): ( = ({ emoji, className, onClick, tabInd }; interface IEmojiSelector { - emojis: string[], + emojis: Iterable, onReact: (emoji: string) => void, visible?: boolean, focused?: boolean, @@ -40,7 +40,7 @@ const EmojiSelector: React.FC = ({ emojis, onReact, visible = fa space={2} className={classNames('bg-white dark:bg-slate-900 p-3 rounded-full shadow-md z-[999] w-max')} > - {emojis.map((emoji, i) => ( + {Array.from(emojis).map((emoji, i) => ( { {reblogButton} - + {features.emojiReacts ? ( + + + + ) : ( + + )} {canShare && ( Date: Sun, 10 Apr 2022 20:41:00 -0500 Subject: [PATCH 09/17] EmojiButtonWrapper: handle click --- .../components/emoji-button-wrapper.tsx | 19 +++++++++++++++++++ app/soapbox/components/status_action_bar.tsx | 1 - .../features/status/components/action-bar.tsx | 1 - 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/soapbox/components/emoji-button-wrapper.tsx b/app/soapbox/components/emoji-button-wrapper.tsx index eb2ec2f3b..32159b329 100644 --- a/app/soapbox/components/emoji-button-wrapper.tsx +++ b/app/soapbox/components/emoji-button-wrapper.tsx @@ -7,6 +7,8 @@ import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; import { openModal } from 'soapbox/actions/modals'; import EmojiSelector from 'soapbox/components/ui/emoji-selector/emoji-selector'; import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks'; +import { isUserTouching } from 'soapbox/is_mobile'; +import { getReactForStatus } from 'soapbox/utils/emoji_reacts'; interface IEmojiButtonWrapper { statusId: string, @@ -62,6 +64,22 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children setVisible(false); }; + const handleClick: React.EventHandler = e => { + const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍'; + + if (isUserTouching()) { + if (visible) { + handleReact(meEmojiReact); + } else { + setVisible(true); + } + } else { + handleReact(meEmojiReact); + } + + e.stopPropagation(); + }; + // const handleUnfocus: React.EventHandler = () => { // setFocused(false); // }; @@ -87,6 +105,7 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children return (
{React.cloneElement(children, { + onClick: handleClick, ref, })} diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index dbafd64fd..fa99ffa4e 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -645,7 +645,6 @@ class StatusActionBar extends ImmutablePureComponent diff --git a/app/soapbox/features/status/components/action-bar.tsx b/app/soapbox/features/status/components/action-bar.tsx index 7efb40aff..9ac9b007f 100644 --- a/app/soapbox/features/status/components/action-bar.tsx +++ b/app/soapbox/features/status/components/action-bar.tsx @@ -588,7 +588,6 @@ class ActionBar extends React.PureComponent { 'fill-accent-300': Boolean(meEmojiReact), })} text={meEmojiTitle} - onClick={this.handleLikeButtonClick} /> ) : ( From c5c1f83f36a51168414fdf9dd113c143b5dd19c5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 10 Apr 2022 20:49:36 -0500 Subject: [PATCH 10/17] Fix lint --- app/soapbox/components/status_action_bar.tsx | 2 +- app/soapbox/features/ui/index.js | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index fa99ffa4e..41c873c7c 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -553,7 +553,7 @@ class StatusActionBar extends ImmutablePureComponent - // NOTE: we cannot nest routes in a fragment - // https://stackoverflow.com/a/68637108 + {/* + NOTE: we cannot nest routes in a fragment + https://stackoverflow.com/a/68637108 + */} {features.federating && } {features.federating && } {features.federating && } From 8cef636093874e279bcb9e2e4aa4761641abc0f0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 11 Apr 2022 14:27:32 -0500 Subject: [PATCH 11/17] Upgrade to Node.js 16.x --- .gitlab-ci.yml | 2 +- .tool-versions | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 546e3810e..0d140029f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: node:14 +image: node:16 variables: NODE_ENV: test diff --git a/.tool-versions b/.tool-versions index 2d8169e51..009455657 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 14.17.6 +nodejs 16.14.2 From 2943b9103409136281b3062903d51a6ab70009e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Mon, 11 Apr 2022 21:58:48 +0200 Subject: [PATCH 12/17] Typescript, convert some components to functional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/importer/index.js | 2 +- app/soapbox/actions/notifications.js | 2 +- app/soapbox/api.ts | 2 +- app/soapbox/{compare_id.js => compare_id.ts} | 2 +- app/soapbox/components/domain.js | 43 --------- app/soapbox/components/domain.tsx | 51 +++++++++++ app/soapbox/components/dropdown_menu.tsx | 2 +- app/soapbox/components/status_content.js | 2 +- app/soapbox/containers/domain_container.js | 35 -------- .../account_timeline/components/moved_note.js | 38 -------- .../components/moved_note.tsx | 34 +++++++ app/soapbox/features/audio/index.js | 2 +- app/soapbox/features/birthdays/account.js | 88 ------------------- app/soapbox/features/birthdays/account.tsx | 64 ++++++++++++++ .../{date_picker.js => date_picker.ts} | 0 app/soapbox/features/blocks/index.js | 74 ---------------- app/soapbox/features/blocks/index.tsx | 58 ++++++++++++ app/soapbox/features/bookmarks/index.js | 83 ----------------- app/soapbox/features/bookmarks/index.tsx | 56 ++++++++++++ .../features/chats/components/chat_list.js | 10 +-- app/soapbox/features/domain_blocks/index.js | 75 ---------------- app/soapbox/features/domain_blocks/index.tsx | 60 +++++++++++++ .../features/emoji/emoji_compressed.js | 2 +- app/soapbox/features/mutes/index.js | 74 ---------------- app/soapbox/features/mutes/index.tsx | 58 ++++++++++++ .../status/components/quoted_status.tsx | 3 +- .../ui/components/profile_info_panel.js | 2 +- app/soapbox/features/video/index.js | 6 +- app/soapbox/reducers/auth.js | 2 +- app/soapbox/reducers/conversations.js | 2 +- app/soapbox/stream.js | 2 +- app/soapbox/utils/auth.js | 64 -------------- app/soapbox/utils/auth.ts | 66 ++++++++++++++ app/soapbox/utils/{base64.js => base64.ts} | 2 +- app/soapbox/utils/greentext.js | 2 +- app/soapbox/utils/instance.js | 11 --- ..._aspect_ratio.js => media_aspect_ratio.ts} | 6 +- app/soapbox/utils/{phone.js => phone.ts} | 0 app/soapbox/utils/{status.js => status.ts} | 8 +- app/soapbox/utils/timelines.js | 13 --- app/soapbox/utils/timelines.ts | 15 ++++ package.json | 3 +- yarn.lock | 14 ++- 43 files changed, 508 insertions(+), 630 deletions(-) rename app/soapbox/{compare_id.js => compare_id.ts} (74%) delete mode 100644 app/soapbox/components/domain.js create mode 100644 app/soapbox/components/domain.tsx delete mode 100644 app/soapbox/containers/domain_container.js delete mode 100644 app/soapbox/features/account_timeline/components/moved_note.js create mode 100644 app/soapbox/features/account_timeline/components/moved_note.tsx delete mode 100644 app/soapbox/features/birthdays/account.js create mode 100644 app/soapbox/features/birthdays/account.tsx rename app/soapbox/features/birthdays/{date_picker.js => date_picker.ts} (100%) delete mode 100644 app/soapbox/features/blocks/index.js create mode 100644 app/soapbox/features/blocks/index.tsx delete mode 100644 app/soapbox/features/bookmarks/index.js create mode 100644 app/soapbox/features/bookmarks/index.tsx delete mode 100644 app/soapbox/features/domain_blocks/index.js create mode 100644 app/soapbox/features/domain_blocks/index.tsx delete mode 100644 app/soapbox/features/mutes/index.js create mode 100644 app/soapbox/features/mutes/index.tsx delete mode 100644 app/soapbox/utils/auth.js create mode 100644 app/soapbox/utils/auth.ts rename app/soapbox/utils/{base64.js => base64.ts} (82%) delete mode 100644 app/soapbox/utils/instance.js rename app/soapbox/utils/{media_aspect_ratio.js => media_aspect_ratio.ts} (70%) rename app/soapbox/utils/{phone.js => phone.ts} (100%) rename app/soapbox/utils/{status.js => status.ts} (67%) delete mode 100644 app/soapbox/utils/timelines.js create mode 100644 app/soapbox/utils/timelines.ts diff --git a/app/soapbox/actions/importer/index.js b/app/soapbox/actions/importer/index.js index ee3af3ac3..9ad58e114 100644 --- a/app/soapbox/actions/importer/index.js +++ b/app/soapbox/actions/importer/index.js @@ -93,7 +93,7 @@ const isBroken = status => { // https://gitlab.com/soapbox-pub/soapbox/-/issues/28 if (status.reblog && !status.reblog.account.id) return true; return false; - } catch(e) { + } catch (e) { return true; } }; diff --git a/app/soapbox/actions/notifications.js b/app/soapbox/actions/notifications.js index a4331bf8c..ce7e5eca0 100644 --- a/app/soapbox/actions/notifications.js +++ b/app/soapbox/actions/notifications.js @@ -121,7 +121,7 @@ export function updateNotificationsQueue(notification, intlMessages, intlLocale, }).catch(console.error); }).catch(console.error); } - } catch(e) { + } catch (e) { console.warn(e); } diff --git a/app/soapbox/api.ts b/app/soapbox/api.ts index bfa07b71a..6c81042bb 100644 --- a/app/soapbox/api.ts +++ b/app/soapbox/api.ts @@ -31,7 +31,7 @@ const getToken = (state: RootState, authType: string) => { const maybeParseJSON = (data: string) => { try { return JSON.parse(data); - } catch(Exception) { + } catch (Exception) { return data; } }; diff --git a/app/soapbox/compare_id.js b/app/soapbox/compare_id.ts similarity index 74% rename from app/soapbox/compare_id.js rename to app/soapbox/compare_id.ts index f8c15e327..e92d13ef5 100644 --- a/app/soapbox/compare_id.js +++ b/app/soapbox/compare_id.ts @@ -1,6 +1,6 @@ 'use strict'; -export default function compareId(id1, id2) { +export default function compareId(id1: string, id2: string) { if (id1 === id2) { return 0; } diff --git a/app/soapbox/components/domain.js b/app/soapbox/components/domain.js deleted file mode 100644 index 026497a14..000000000 --- a/app/soapbox/components/domain.js +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; - -import IconButton from './icon_button'; - -const messages = defineMessages({ - unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, -}); - -export default @injectIntl -class Account extends ImmutablePureComponent { - - static propTypes = { - domain: PropTypes.string, - onUnblockDomain: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - handleDomainUnblock = () => { - this.props.onUnblockDomain(this.props.domain); - } - - render() { - const { domain, intl } = this.props; - - return ( -
-
- - {domain} - - -
- -
-
-
- ); - } - -} diff --git a/app/soapbox/components/domain.tsx b/app/soapbox/components/domain.tsx new file mode 100644 index 000000000..005848c89 --- /dev/null +++ b/app/soapbox/components/domain.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { unblockDomain } from 'soapbox/actions/domain_blocks'; + +import IconButton from './icon_button'; + +const messages = defineMessages({ + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, +}); + +interface IDomain { + domain: string, +} + +const Domain: React.FC = ({ domain }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + + // const onBlockDomain = () => { + // dispatch(openModal('CONFIRM', { + // icon: require('@tabler/icons/icons/ban.svg'), + // heading: , + // message: {domain} }} />, + // confirm: intl.formatMessage(messages.blockDomainConfirm), + // onConfirm: () => dispatch(blockDomain(domain)), + // })); + // } + + const handleDomainUnblock = () => { + dispatch(unblockDomain(domain)); + }; + + return ( +
+
+ + {domain} + + +
+ +
+
+
+ ); +}; + +export default Domain; diff --git a/app/soapbox/components/dropdown_menu.tsx b/app/soapbox/components/dropdown_menu.tsx index 8114ca7dc..01a60948d 100644 --- a/app/soapbox/components/dropdown_menu.tsx +++ b/app/soapbox/components/dropdown_menu.tsx @@ -278,7 +278,7 @@ class Dropdown extends React.PureComponent { onShiftClick(e); } else if (this.state.id === openDropdownId) { this.handleClose(); - } else if(onOpen) { + } else if (onOpen) { const { top } = e.currentTarget.getBoundingClientRect(); const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top'; diff --git a/app/soapbox/components/status_content.js b/app/soapbox/components/status_content.js index 2bd5c0712..663480a75 100644 --- a/app/soapbox/components/status_content.js +++ b/app/soapbox/components/status_content.js @@ -87,7 +87,7 @@ class StatusContent extends React.PureComponent { && this.state.collapsed === null && this.props.status.get('spoiler_text').length === 0 ) { - if (node.clientHeight > MAX_HEIGHT){ + if (node.clientHeight > MAX_HEIGHT) { this.setState({ collapsed: true }); } } diff --git a/app/soapbox/containers/domain_container.js b/app/soapbox/containers/domain_container.js deleted file mode 100644 index a1d705eaf..000000000 --- a/app/soapbox/containers/domain_container.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { blockDomain, unblockDomain } from '../actions/domain_blocks'; -import { openModal } from '../actions/modals'; -import Domain from '../components/domain'; - -const messages = defineMessages({ - blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, -}); - -const makeMapStateToProps = () => { - const mapStateToProps = () => ({}); - - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch, { intl }) => ({ - onBlockDomain(domain) { - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/icons/ban.svg'), - heading: , - message: {domain} }} />, - confirm: intl.formatMessage(messages.blockDomainConfirm), - onConfirm: () => dispatch(blockDomain(domain)), - })); - }, - - onUnblockDomain(domain) { - dispatch(unblockDomain(domain)); - }, -}); - -export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Domain)); diff --git a/app/soapbox/features/account_timeline/components/moved_note.js b/app/soapbox/features/account_timeline/components/moved_note.js deleted file mode 100644 index fb2f000e8..000000000 --- a/app/soapbox/features/account_timeline/components/moved_note.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; -import { NavLink } from 'react-router-dom'; - -import Icon from 'soapbox/components/icon'; - -import AvatarOverlay from '../../../components/avatar_overlay'; -import DisplayName from '../../../components/display_name'; - -export default class MovedNote extends ImmutablePureComponent { - - static propTypes = { - from: ImmutablePropTypes.map.isRequired, - to: ImmutablePropTypes.map.isRequired, - }; - - render() { - const { from, to } = this.props; - const displayNameHtml = { __html: from.get('display_name_html') }; - - return ( -
-
-
- }} /> -
- - -
- -
-
- ); - } - -} diff --git a/app/soapbox/features/account_timeline/components/moved_note.tsx b/app/soapbox/features/account_timeline/components/moved_note.tsx new file mode 100644 index 000000000..6e901252d --- /dev/null +++ b/app/soapbox/features/account_timeline/components/moved_note.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { NavLink } from 'react-router-dom'; + +import AvatarOverlay from 'soapbox/components/avatar_overlay'; +import DisplayName from 'soapbox/components/display_name'; +import Icon from 'soapbox/components/icon'; + +import type { Account as AccountEntity } from 'soapbox/types/entities'; + +interface IMovedNote { + from: AccountEntity, + to: AccountEntity, +} + +const MovedNote: React.FC = ({ from, to }) => { + const displayNameHtml = { __html: from.display_name_html }; + + return ( +
+
+
+ }} /> +
+ + +
+ +
+
+ ); +}; + +export default MovedNote; diff --git a/app/soapbox/features/audio/index.js b/app/soapbox/features/audio/index.js index 7553f2388..b8ef5892c 100644 --- a/app/soapbox/features/audio/index.js +++ b/app/soapbox/features/audio/index.js @@ -255,7 +255,7 @@ class Audio extends React.PureComponent { handleMouseVolSlide = throttle(e => { const { x } = getPointerPosition(this.volume, e); - if(!isNaN(x)) { + if (!isNaN(x)) { this.setState({ volume: x }, () => { this.audio.volume = x; }); diff --git a/app/soapbox/features/birthdays/account.js b/app/soapbox/features/birthdays/account.js deleted file mode 100644 index b29d79589..000000000 --- a/app/soapbox/features/birthdays/account.js +++ /dev/null @@ -1,88 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; - -import Avatar from 'soapbox/components/avatar'; -import DisplayName from 'soapbox/components/display_name'; -import Icon from 'soapbox/components/icon'; -import Permalink from 'soapbox/components/permalink'; -import { makeGetAccount } from 'soapbox/selectors'; - -const messages = defineMessages({ - birthday: { id: 'account.birthday', defaultMessage: 'Born {date}' }, -}); - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, { accountId }) => { - const account = getAccount(state, accountId); - - return { - account, - }; - }; - - return mapStateToProps; -}; - -export default @connect(makeMapStateToProps) -@injectIntl -class Account extends ImmutablePureComponent { - - static propTypes = { - accountId: PropTypes.string.isRequired, - intl: PropTypes.object.isRequired, - account: ImmutablePropTypes.record, - }; - - static defaultProps = { - added: false, - }; - - componentDidMount() { - const { account, accountId } = this.props; - - if (accountId && !account) { - this.props.fetchAccount(accountId); - } - } - - render() { - const { account, intl } = this.props; - - if (!account) return null; - - const birthday = account.get('birthday'); - if (!birthday) return null; - - const formattedBirthday = intl.formatDate(birthday, { day: 'numeric', month: 'short', year: 'numeric' }); - - return ( -
-
- -
-
- - -
-
-
- - {formattedBirthday} -
-
-
- ); - } - -} diff --git a/app/soapbox/features/birthdays/account.tsx b/app/soapbox/features/birthdays/account.tsx new file mode 100644 index 000000000..e72499b3d --- /dev/null +++ b/app/soapbox/features/birthdays/account.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { useEffect } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import Avatar from 'soapbox/components/avatar'; +import DisplayName from 'soapbox/components/display_name'; +import Icon from 'soapbox/components/icon'; +import Permalink from 'soapbox/components/permalink'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetAccount } from 'soapbox/selectors'; + +const messages = defineMessages({ + birthday: { id: 'account.birthday', defaultMessage: 'Born {date}' }, +}); + +const getAccount = makeGetAccount(); + +interface IAccount { + accountId: string, + fetchAccount: (id: string) => void, +} + +const Account: React.FC = ({ accountId, fetchAccount }) => { + const intl = useIntl(); + const account = useAppSelector((state) => getAccount(state, accountId)); + + useEffect(() => { + if (accountId && !account) { + fetchAccount(accountId); + } + }, [accountId]); + + if (!account) return null; + + const birthday = account.get('birthday'); + if (!birthday) return null; + + const formattedBirthday = intl.formatDate(birthday, { day: 'numeric', month: 'short', year: 'numeric' }); + + return ( +
+
+ +
+
+ + +
+
+
+ + {formattedBirthday} +
+
+
+ ); +}; + +export default Account; diff --git a/app/soapbox/features/birthdays/date_picker.js b/app/soapbox/features/birthdays/date_picker.ts similarity index 100% rename from app/soapbox/features/birthdays/date_picker.js rename to app/soapbox/features/birthdays/date_picker.ts diff --git a/app/soapbox/features/blocks/index.js b/app/soapbox/features/blocks/index.js deleted file mode 100644 index 7bf3ca43e..000000000 --- a/app/soapbox/features/blocks/index.js +++ /dev/null @@ -1,74 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { Column, Spinner } from 'soapbox/components/ui'; - -import { fetchBlocks, expandBlocks } from '../../actions/blocks'; -import ScrollableList from '../../components/scrollable_list'; -import AccountContainer from '../../containers/account_container'; - -const messages = defineMessages({ - heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }, -}); - -const mapStateToProps = state => ({ - accountIds: state.getIn(['user_lists', 'blocks', 'items']), - hasMore: !!state.getIn(['user_lists', 'blocks', 'next']), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Blocks extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.orderedSet, - hasMore: PropTypes.bool, - intl: PropTypes.object.isRequired, - }; - - componentDidMount() { - this.props.dispatch(fetchBlocks()); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandBlocks()); - }, 300, { leading: true }); - - render() { - const { intl, accountIds, hasMore } = this.props; - - if (!accountIds) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - - - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/blocks/index.tsx b/app/soapbox/features/blocks/index.tsx new file mode 100644 index 000000000..cdc3b0a98 --- /dev/null +++ b/app/soapbox/features/blocks/index.tsx @@ -0,0 +1,58 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { fetchBlocks, expandBlocks } from 'soapbox/actions/blocks'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Column, Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandBlocks()); +}, 300, { leading: true }); + +const Blocks: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const accountIds = useAppSelector((state) => state.user_lists.getIn(['blocks', 'items'])); + const hasMore = useAppSelector((state) => !!state.user_lists.getIn(['blocks', 'next'])); + + React.useEffect(() => { + dispatch(fetchBlocks()); + }, []); + + if (!accountIds) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + hasMore={hasMore} + emptyMessage={emptyMessage} + className='space-y-4' + > + {accountIds.map((id: string) => + , + )} + + + ); +}; + +export default Blocks; \ No newline at end of file diff --git a/app/soapbox/features/bookmarks/index.js b/app/soapbox/features/bookmarks/index.js deleted file mode 100644 index d2b7ea613..000000000 --- a/app/soapbox/features/bookmarks/index.js +++ /dev/null @@ -1,83 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import SubNavigation from 'soapbox/components/sub_navigation'; -import { Column } from 'soapbox/components/ui'; - -import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks'; -import StatusList from '../../components/status_list'; - -const messages = defineMessages({ - heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, -}); - -const mapStateToProps = state => ({ - statusIds: state.getIn(['status_lists', 'bookmarks', 'items']), - isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), - hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Bookmarks extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - shouldUpdateScroll: PropTypes.func, - statusIds: ImmutablePropTypes.orderedSet.isRequired, - intl: PropTypes.object.isRequired, - columnId: PropTypes.string, - multiColumn: PropTypes.bool, - hasMore: PropTypes.bool, - isLoading: PropTypes.bool, - }; - - fetchData = () => { - const { dispatch } = this.props; - return dispatch(fetchBookmarkedStatuses()); - } - - componentDidMount() { - this.fetchData(); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandBookmarkedStatuses()); - }, 300, { leading: true }) - - handleRefresh = () => { - return this.fetchData(); - } - - render() { - const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; - const pinned = !!columnId; - - const emptyMessage = ; - - return ( - - - - - ); - } - -} diff --git a/app/soapbox/features/bookmarks/index.tsx b/app/soapbox/features/bookmarks/index.tsx new file mode 100644 index 000000000..90aae0c1f --- /dev/null +++ b/app/soapbox/features/bookmarks/index.tsx @@ -0,0 +1,56 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import SubNavigation from 'soapbox/components/sub_navigation'; +import { Column } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks'; +import StatusList from '../../components/status_list'; + +const messages = defineMessages({ + heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandBookmarkedStatuses()); +}, 300, { leading: true }); + +const Bookmarks: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const statusIds = useAppSelector((state) => state.status_lists.getIn(['bookmarks', 'items'])); + const isLoading = useAppSelector((state) => state.status_lists.getIn(['bookmarks', 'isLoading'], true)); + const hasMore = useAppSelector((state) => !!state.status_lists.getIn(['bookmarks', 'next'])); + + React.useEffect(() => { + dispatch(fetchBookmarkedStatuses()); + }, []); + + const handleRefresh = () => { + return dispatch(fetchBookmarkedStatuses()); + }; + + const emptyMessage = ; + + return ( + + + handleLoadMore(dispatch)} + onRefresh={handleRefresh} + emptyMessage={emptyMessage} + divideType='space' + /> + + ); +}; + +export default Bookmarks; diff --git a/app/soapbox/features/chats/components/chat_list.js b/app/soapbox/features/chats/components/chat_list.js index b31219913..ffb0c1720 100644 --- a/app/soapbox/features/chats/components/chat_list.js +++ b/app/soapbox/features/chats/components/chat_list.js @@ -35,12 +35,12 @@ const chatDateComparator = (chatA, chatB) => { return 0; }; -const makeMapStateToProps = () => { - const sortedChatIdsSelector = createSelector( - [getSortedChatIds], - chats => chats, - ); +const sortedChatIdsSelector = createSelector( + [getSortedChatIds], + chats => chats, +); +const makeMapStateToProps = () => { const mapStateToProps = state => ({ chatIds: sortedChatIdsSelector(state.getIn(['chats', 'items'])), hasMore: !!state.getIn(['chats', 'next']), diff --git a/app/soapbox/features/domain_blocks/index.js b/app/soapbox/features/domain_blocks/index.js deleted file mode 100644 index 3ab084564..000000000 --- a/app/soapbox/features/domain_blocks/index.js +++ /dev/null @@ -1,75 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { Spinner } from 'soapbox/components/ui'; - -import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks'; -import ScrollableList from '../../components/scrollable_list'; -import DomainContainer from '../../containers/domain_container'; -import Column from '../ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'column.domain_blocks', defaultMessage: 'Hidden domains' }, - unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, -}); - -const mapStateToProps = state => ({ - domains: state.getIn(['domain_lists', 'blocks', 'items']), - hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Blocks extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - hasMore: PropTypes.bool, - domains: ImmutablePropTypes.orderedSet, - intl: PropTypes.object.isRequired, - }; - - componentDidMount() { - this.props.dispatch(fetchDomainBlocks()); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandDomainBlocks()); - }, 300, { leading: true }); - - render() { - const { intl, domains, hasMore } = this.props; - - if (!domains) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - - - {domains.map(domain => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/domain_blocks/index.tsx b/app/soapbox/features/domain_blocks/index.tsx new file mode 100644 index 000000000..712a6671f --- /dev/null +++ b/app/soapbox/features/domain_blocks/index.tsx @@ -0,0 +1,60 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { fetchDomainBlocks, expandDomainBlocks } from 'soapbox/actions/domain_blocks'; +import Domain from 'soapbox/components/domain'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import Column from '../ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.domain_blocks', defaultMessage: 'Hidden domains' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandDomainBlocks()); +}, 300, { leading: true }); + +const DomainBlocks: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const domains = useAppSelector((state) => state.domain_lists.getIn(['blocks', 'items'])) as string[]; + const hasMore = useAppSelector((state) => !!state.domain_lists.getIn(['blocks', 'next'])); + + React.useEffect(() => { + dispatch(fetchDomainBlocks()); + }, []); + + if (!domains) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + hasMore={hasMore} + emptyMessage={emptyMessage} + > + {domains.map((domain) => + , + )} + + + ); +}; + +export default DomainBlocks; diff --git a/app/soapbox/features/emoji/emoji_compressed.js b/app/soapbox/features/emoji/emoji_compressed.js index c95bccf08..542bb7feb 100644 --- a/app/soapbox/features/emoji/emoji_compressed.js +++ b/app/soapbox/features/emoji/emoji_compressed.js @@ -14,7 +14,7 @@ const { unicodeToFilename } = require('./unicode_to_filename'); const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); -if(data.compressed) { +if (data.compressed) { data = emojiMartUncompress(data); } diff --git a/app/soapbox/features/mutes/index.js b/app/soapbox/features/mutes/index.js deleted file mode 100644 index 69664c72f..000000000 --- a/app/soapbox/features/mutes/index.js +++ /dev/null @@ -1,74 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { Column, Spinner } from 'soapbox/components/ui'; - -import { fetchMutes, expandMutes } from '../../actions/mutes'; -import ScrollableList from '../../components/scrollable_list'; -import AccountContainer from '../../containers/account_container'; - -const messages = defineMessages({ - heading: { id: 'column.mutes', defaultMessage: 'Muted users' }, -}); - -const mapStateToProps = state => ({ - accountIds: state.getIn(['user_lists', 'mutes', 'items']), - hasMore: !!state.getIn(['user_lists', 'mutes', 'next']), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Mutes extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - hasMore: PropTypes.bool, - accountIds: ImmutablePropTypes.orderedSet, - intl: PropTypes.object.isRequired, - }; - - componentDidMount() { - this.props.dispatch(fetchMutes()); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandMutes()); - }, 300, { leading: true }); - - render() { - const { intl, hasMore, accountIds } = this.props; - - if (!accountIds) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - - - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/mutes/index.tsx b/app/soapbox/features/mutes/index.tsx new file mode 100644 index 000000000..8e27c06d2 --- /dev/null +++ b/app/soapbox/features/mutes/index.tsx @@ -0,0 +1,58 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { fetchMutes, expandMutes } from 'soapbox/actions/mutes'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Column, Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + heading: { id: 'column.mutes', defaultMessage: 'Muted users' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandMutes()); +}, 300, { leading: true }); + +const Mutes: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const accountIds = useAppSelector((state) => state.user_lists.getIn(['mutes', 'items'])); + const hasMore = useAppSelector((state) => !!state.user_lists.getIn(['mutes', 'next'])); + + React.useEffect(() => { + dispatch(fetchMutes()); + }, []); + + if (!accountIds) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + hasMore={hasMore} + emptyMessage={emptyMessage} + className='space-y-4' + > + {accountIds.map(id => + , + )} + + + ); +}; + +export default Mutes; diff --git a/app/soapbox/features/status/components/quoted_status.tsx b/app/soapbox/features/status/components/quoted_status.tsx index 25c8e850e..0b715a86f 100644 --- a/app/soapbox/features/status/components/quoted_status.tsx +++ b/app/soapbox/features/status/components/quoted_status.tsx @@ -8,7 +8,8 @@ import { withRouter } from 'react-router-dom'; import AttachmentThumbs from 'soapbox/components/attachment_thumbs'; import { Stack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; -import { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; + +import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; const messages = defineMessages({ cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, diff --git a/app/soapbox/features/ui/components/profile_info_panel.js b/app/soapbox/features/ui/components/profile_info_panel.js index 57dfcf48e..e4547e270 100644 --- a/app/soapbox/features/ui/components/profile_info_panel.js +++ b/app/soapbox/features/ui/components/profile_info_panel.js @@ -22,7 +22,7 @@ const isSafeUrl = text => { try { const url = new URL(text); return ['http:', 'https:'].includes(url.protocol); - } catch(e) { + } catch (e) { return false; } }; diff --git a/app/soapbox/features/video/index.js b/app/soapbox/features/video/index.js index 708d87149..869fcdfce 100644 --- a/app/soapbox/features/video/index.js +++ b/app/soapbox/features/video/index.js @@ -216,12 +216,12 @@ class Video extends React.PureComponent { handleMouseVolSlide = throttle(e => { const { x } = getPointerPosition(this.volume, e); - if(!isNaN(x)) { + if (!isNaN(x)) { let slideamt = x; - if(x > 1) { + if (x > 1) { slideamt = 1; - } else if(x < 0) { + } else if (x < 0) { slideamt = 0; } diff --git a/app/soapbox/reducers/auth.js b/app/soapbox/reducers/auth.js index 9bd3f6f33..26d3c8794 100644 --- a/app/soapbox/reducers/auth.js +++ b/app/soapbox/reducers/auth.js @@ -44,7 +44,7 @@ const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY))); const validUser = user => { try { return validId(user.get('id')) && validId(user.get('access_token')); - } catch(e) { + } catch (e) { return false; } }; diff --git a/app/soapbox/reducers/conversations.js b/app/soapbox/reducers/conversations.js index 29ea929f3..f15408e0c 100644 --- a/app/soapbox/reducers/conversations.js +++ b/app/soapbox/reducers/conversations.js @@ -58,7 +58,7 @@ const expandNormalizedConversations = (state, conversations, next, isLoadingRece list = list.concat(items); return list.sortBy(x => x.get('last_status'), (a, b) => { - if(a === null || b === null) { + if (a === null || b === null) { return -1; } diff --git a/app/soapbox/stream.js b/app/soapbox/stream.js index fb0474367..5d201e314 100644 --- a/app/soapbox/stream.js +++ b/app/soapbox/stream.js @@ -93,7 +93,7 @@ export default function getStream(streamingAPIBaseURL, accessToken, stream, { co if (!e.data) return; try { received(JSON.parse(e.data)); - } catch(error) { + } catch (error) { console.error(e); console.error(`Could not parse the above streaming event.\n${error}`); } diff --git a/app/soapbox/utils/auth.js b/app/soapbox/utils/auth.js deleted file mode 100644 index 0d0c6c321..000000000 --- a/app/soapbox/utils/auth.js +++ /dev/null @@ -1,64 +0,0 @@ -import { List as ImmutableList } from 'immutable'; - -export const validId = id => typeof id === 'string' && id !== 'null' && id !== 'undefined'; - -export const isURL = url => { - try { - new URL(url); - return true; - } catch { - return false; - } -}; - -export const parseBaseURL = url => { - try { - return new URL(url).origin; - } catch { - return ''; - } -}; - -export const getLoggedInAccount = state => { - const me = state.get('me'); - return state.getIn(['accounts', me]); -}; - -export const isLoggedIn = getState => { - return validId(getState().get('me')); -}; - -export const getAppToken = state => state.getIn(['auth', 'app', 'access_token']); - -export const getUserToken = (state, accountId) => { - const accountUrl = state.getIn(['accounts', accountId, 'url']); - return state.getIn(['auth', 'users', accountUrl, 'access_token']); -}; - -export const getAccessToken = state => { - const me = state.get('me'); - return getUserToken(state, me); -}; - -export const getAuthUserId = state => { - const me = state.getIn(['auth', 'me']); - - return ImmutableList([ - state.getIn(['auth', 'users', me, 'id']), - me, - ]).find(validId); -}; - -export const getAuthUserUrl = state => { - const me = state.getIn(['auth', 'me']); - - return ImmutableList([ - state.getIn(['auth', 'users', me, 'url']), - me, - ]).find(isURL); -}; - -/** Get the VAPID public key. */ -export const getVapidKey = state => { - return state.getIn(['auth', 'app', 'vapid_key']) || state.getIn(['instance', 'pleroma', 'vapid_public_key']); -}; diff --git a/app/soapbox/utils/auth.ts b/app/soapbox/utils/auth.ts new file mode 100644 index 000000000..1a5b1b38e --- /dev/null +++ b/app/soapbox/utils/auth.ts @@ -0,0 +1,66 @@ +import { List as ImmutableList } from 'immutable'; + +import type { RootState } from 'soapbox/store'; + +export const validId = (id: any) => typeof id === 'string' && id !== 'null' && id !== 'undefined'; + +export const isURL = (url: string) => { + try { + new URL(url); + return true; + } catch { + return false; + } +}; + +export const parseBaseURL = (url: any) => { + try { + return new URL(url).origin; + } catch { + return ''; + } +}; + +export const getLoggedInAccount = (state: RootState) => { + const me = state.me; + return state.accounts.get(me); +}; + +export const isLoggedIn = (getState: () => RootState) => { + return validId(getState().me); +}; + +export const getAppToken = (state: RootState) => state.auth.getIn(['app', 'access_token']); + +export const getUserToken = (state: RootState, accountId?: string | false | null) => { + const accountUrl = state.accounts.getIn([accountId, 'url']); + return state.auth.getIn(['users', accountUrl, 'access_token']); +}; + +export const getAccessToken = (state: RootState) => { + const me = state.me; + return getUserToken(state, me); +}; + +export const getAuthUserId = (state: RootState) => { + const me = state.auth.get('me'); + + return ImmutableList([ + state.auth.getIn(['users', me, 'id']), + me, + ]).find(validId); +}; + +export const getAuthUserUrl = (state: RootState) => { + const me = state.auth.get('me'); + + return ImmutableList([ + state.auth.getIn(['users', me, 'url']), + me, + ]).find(isURL); +}; + +/** Get the VAPID public key. */ +export const getVapidKey = (state: RootState) => { + return state.auth.getIn(['app', 'vapid_key']) || state.instance.getIn(['pleroma', 'vapid_public_key']); +}; diff --git a/app/soapbox/utils/base64.js b/app/soapbox/utils/base64.ts similarity index 82% rename from app/soapbox/utils/base64.js rename to app/soapbox/utils/base64.ts index 8226e2c54..c512a6594 100644 --- a/app/soapbox/utils/base64.js +++ b/app/soapbox/utils/base64.ts @@ -1,4 +1,4 @@ -export const decode = base64 => { +export const decode = (base64: string) => { const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); diff --git a/app/soapbox/utils/greentext.js b/app/soapbox/utils/greentext.js index 543bc2f23..f60ee7c3c 100644 --- a/app/soapbox/utils/greentext.js +++ b/app/soapbox/utils/greentext.js @@ -15,7 +15,7 @@ export const addGreentext = html => { } else { return string; } - } catch(e) { + } catch (e) { return string; } }); diff --git a/app/soapbox/utils/instance.js b/app/soapbox/utils/instance.js deleted file mode 100644 index af9d69665..000000000 --- a/app/soapbox/utils/instance.js +++ /dev/null @@ -1,11 +0,0 @@ -export const getHost = instance => { - try { - return new URL(instance.get('uri')).host; - } catch { - try { - return new URL(`https://${instance.get('uri')}`).host; - } catch { - return null; - } - } -}; diff --git a/app/soapbox/utils/media_aspect_ratio.js b/app/soapbox/utils/media_aspect_ratio.ts similarity index 70% rename from app/soapbox/utils/media_aspect_ratio.js rename to app/soapbox/utils/media_aspect_ratio.ts index 18e6fdc57..8821d9deb 100644 --- a/app/soapbox/utils/media_aspect_ratio.js +++ b/app/soapbox/utils/media_aspect_ratio.ts @@ -1,17 +1,17 @@ export const minimumAspectRatio = 9 / 16; // Portrait phone export const maximumAspectRatio = 10; // Generous min-height -export const isPanoramic = ar => { +export const isPanoramic = (ar: number) => { if (isNaN(ar)) return false; return ar >= maximumAspectRatio; }; -export const isPortrait = ar => { +export const isPortrait = (ar: number) => { if (isNaN(ar)) return false; return ar <= minimumAspectRatio; }; -export const isNonConformingRatio = ar => { +export const isNonConformingRatio = (ar: number) => { if (isNaN(ar)) return false; return !isPanoramic(ar) && !isPortrait(ar); }; diff --git a/app/soapbox/utils/phone.js b/app/soapbox/utils/phone.ts similarity index 100% rename from app/soapbox/utils/phone.js rename to app/soapbox/utils/phone.ts diff --git a/app/soapbox/utils/status.js b/app/soapbox/utils/status.ts similarity index 67% rename from app/soapbox/utils/status.js rename to app/soapbox/utils/status.ts index acd69dc5e..7f2bfe42f 100644 --- a/app/soapbox/utils/status.js +++ b/app/soapbox/utils/status.ts @@ -1,6 +1,8 @@ import { isIntegerId } from 'soapbox/utils/numbers'; -export const getFirstExternalLink = status => { +import type { Status as StatusEntity } from 'soapbox/types/entities'; + +export const getFirstExternalLink = (status: StatusEntity) => { try { // Pulled from Pleroma's media parser const selector = 'a:not(.mention,.hashtag,.attachment,[rel~="tag"])'; @@ -12,11 +14,11 @@ export const getFirstExternalLink = status => { } }; -export const shouldHaveCard = status => { +export const shouldHaveCard = (status: StatusEntity) => { return Boolean(getFirstExternalLink(status)); }; // https://gitlab.com/soapbox-pub/soapbox-fe/-/merge_requests/1087 -export const hasIntegerMediaIds = status => { +export const hasIntegerMediaIds = (status: StatusEntity) => { return status.media_attachments.some(({ id }) => isIntegerId(id)); }; diff --git a/app/soapbox/utils/timelines.js b/app/soapbox/utils/timelines.js deleted file mode 100644 index d15a4fa88..000000000 --- a/app/soapbox/utils/timelines.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; - -export const shouldFilter = (status, columnSettings) => { - const shows = ImmutableMap({ - reblog: status.get('reblog') !== null, - reply: status.get('in_reply_to_id') !== null, - direct: status.get('visibility') === 'direct', - }); - - return shows.some((value, key) => { - return columnSettings.getIn(['shows', key]) === false && value; - }); -}; diff --git a/app/soapbox/utils/timelines.ts b/app/soapbox/utils/timelines.ts new file mode 100644 index 000000000..03ba96044 --- /dev/null +++ b/app/soapbox/utils/timelines.ts @@ -0,0 +1,15 @@ +import { Map as ImmutableMap } from 'immutable'; + +import type { Status as StatusEntity } from 'soapbox/types/entities'; + +export const shouldFilter = (status: StatusEntity, columnSettings: any) => { + const shows = ImmutableMap({ + reblog: status.reblog !== null, + reply: status.in_reply_to_id !== null, + direct: status.visibility === 'direct', + }); + + return shows.some((value, key) => { + return columnSettings.getIn(['shows', key]) === false && value; + }); +}; diff --git a/package.json b/package.json index c14cd88ae..4caec0c8d 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@types/jest": "^27.4.1", "@types/lodash": "^4.14.180", "@types/qrcode.react": "^1.0.2", + "@types/react-datepicker": "^4.4.0", "@types/react-helmet": "^6.1.5", "@types/react-motion": "^0.0.32", "@types/react-router-dom": "^5.3.3", @@ -145,7 +146,7 @@ "qrcode.react": "^1.0.0", "react": "^16.13.1", "react-color": "^2.18.1", - "react-datepicker": "^4.6.0", + "react-datepicker": "^4.7.0", "react-dom": "^16.13.1", "react-helmet": "^6.0.0", "react-hotkeys": "^1.1.4", diff --git a/yarn.lock b/yarn.lock index d86daa869..9ed45677b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2171,6 +2171,16 @@ dependencies: "@types/react" "*" +"@types/react-datepicker@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-4.4.0.tgz#0072e18536ad305fd57786f9b6f9e499eed2b475" + integrity sha512-wzmevaO51rLFwSZd5HSqBU0aAvZlRRkj6QhHqj0jfRDSKnN3y5IKXyhgxPS8R0LOWOtjdpirI1DBryjnIp/7gA== + dependencies: + "@popperjs/core" "^2.9.2" + "@types/react" "*" + date-fns "^2.0.1" + react-popper "^2.2.5" + "@types/react-dom@*": version "17.0.14" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.14.tgz#c8f917156b652ddf807711f5becbd2ab018dea9f" @@ -4006,7 +4016,7 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" -date-fns@^2.24.0: +date-fns@^2.0.1, date-fns@^2.24.0: version "2.28.0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== @@ -8662,7 +8672,7 @@ react-color@^2.18.1: reactcss "^1.2.0" tinycolor2 "^1.4.1" -react-datepicker@^4.6.0: +react-datepicker@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.7.0.tgz#75e03b0a6718b97b84287933307faf2ed5f03cf4" integrity sha512-FS8KgbwqpxmJBv/bUdA42MYqYZa+fEYcpc746DZiHvVE2nhjrW/dg7c5B5fIUuI8gZET6FOzuDgezNcj568Czw== From ae396544a713dd27d684c3dd8eb93e1d91ef2057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Mon, 11 Apr 2022 23:02:37 +0200 Subject: [PATCH 13/17] Typescript, FC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../components/account_authorize.js | 50 ------------- .../components/account_authorize.tsx | 61 +++++++++++++++ .../containers/account_authorize_container.js | 27 ------- app/soapbox/features/follow_requests/index.js | 75 ------------------- .../features/follow_requests/index.tsx | 60 +++++++++++++++ app/soapbox/features/mutes/index.tsx | 2 +- app/soapbox/utils/timelines.ts | 6 +- 7 files changed, 125 insertions(+), 156 deletions(-) delete mode 100644 app/soapbox/features/follow_requests/components/account_authorize.js create mode 100644 app/soapbox/features/follow_requests/components/account_authorize.tsx delete mode 100644 app/soapbox/features/follow_requests/containers/account_authorize_container.js delete mode 100644 app/soapbox/features/follow_requests/index.js create mode 100644 app/soapbox/features/follow_requests/index.tsx diff --git a/app/soapbox/features/follow_requests/components/account_authorize.js b/app/soapbox/features/follow_requests/components/account_authorize.js deleted file mode 100644 index 56aac382a..000000000 --- a/app/soapbox/features/follow_requests/components/account_authorize.js +++ /dev/null @@ -1,50 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; - -import Avatar from '../../../components/avatar'; -import DisplayName from '../../../components/display_name'; -import IconButton from '../../../components/icon_button'; -import Permalink from '../../../components/permalink'; - -const messages = defineMessages({ - authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, - reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, -}); - -export default @injectIntl -class AccountAuthorize extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.record.isRequired, - onAuthorize: PropTypes.func.isRequired, - onReject: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - render() { - const { intl, account, onAuthorize, onReject } = this.props; - const content = { __html: account.get('note_emojified') }; - - return ( -
-
- -
- -
- -
-
- -
-
-
-
-
- ); - } - -} diff --git a/app/soapbox/features/follow_requests/components/account_authorize.tsx b/app/soapbox/features/follow_requests/components/account_authorize.tsx new file mode 100644 index 000000000..cd46f7817 --- /dev/null +++ b/app/soapbox/features/follow_requests/components/account_authorize.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts'; +import Avatar from 'soapbox/components/avatar'; +import DisplayName from 'soapbox/components/display_name'; +import IconButton from 'soapbox/components/icon_button'; +import Permalink from 'soapbox/components/permalink'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetAccount } from 'soapbox/selectors'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, +}); + +const getAccount = makeGetAccount(); + +interface IAccountAuthorize { + id: string, +} + +const AccountAuthorize: React.FC = ({ id }) => { + const intl = useIntl(); + const dispatch = useDispatch(); + + const account = useAppSelector((state) => getAccount(state, id)); + + const onAuthorize = () => { + dispatch(authorizeFollowRequest(id)); + }; + + const onReject = () => { + dispatch(rejectFollowRequest(id)); + }; + + if (!account) return null; + + const content = { __html: account.note_emojified }; + + return ( +
+
+ +
+ +
+ +
+
+ +
+
+
+
+
+ ); +}; + +export default AccountAuthorize; diff --git a/app/soapbox/features/follow_requests/containers/account_authorize_container.js b/app/soapbox/features/follow_requests/containers/account_authorize_container.js deleted file mode 100644 index cf38b3c69..000000000 --- a/app/soapbox/features/follow_requests/containers/account_authorize_container.js +++ /dev/null @@ -1,27 +0,0 @@ -import { connect } from 'react-redux'; - -import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts'; -import { makeGetAccount } from '../../../selectors'; -import AccountAuthorize from '../components/account_authorize'; - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, props) => ({ - account: getAccount(state, props.id), - }); - - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch, { id }) => ({ - onAuthorize() { - dispatch(authorizeFollowRequest(id)); - }, - - onReject() { - dispatch(rejectFollowRequest(id)); - }, -}); - -export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize); diff --git a/app/soapbox/features/follow_requests/index.js b/app/soapbox/features/follow_requests/index.js deleted file mode 100644 index 3c3f8f75e..000000000 --- a/app/soapbox/features/follow_requests/index.js +++ /dev/null @@ -1,75 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { Spinner } from 'soapbox/components/ui'; - -import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts'; -import ScrollableList from '../../components/scrollable_list'; -import Column from '../ui/components/column'; - -import AccountAuthorizeContainer from './containers/account_authorize_container'; - -const messages = defineMessages({ - heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }, -}); - -const mapStateToProps = state => ({ - accountIds: state.getIn(['user_lists', 'follow_requests', 'items']), - hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']), -}); - -export default @connect(mapStateToProps) -@injectIntl -class FollowRequests extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - hasMore: PropTypes.bool, - accountIds: ImmutablePropTypes.orderedSet, - intl: PropTypes.object.isRequired, - }; - - componentDidMount() { - this.props.dispatch(fetchFollowRequests()); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandFollowRequests()); - }, 300, { leading: true }); - - render() { - const { intl, accountIds, hasMore } = this.props; - - if (!accountIds) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - - - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/follow_requests/index.tsx b/app/soapbox/features/follow_requests/index.tsx new file mode 100644 index 000000000..ef82d1aef --- /dev/null +++ b/app/soapbox/features/follow_requests/index.tsx @@ -0,0 +1,60 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { fetchFollowRequests, expandFollowRequests } from 'soapbox/actions/accounts'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import Column from '../ui/components/column'; + +import AccountAuthorize from './components/account_authorize'; + +const messages = defineMessages({ + heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandFollowRequests()); +}, 300, { leading: true }); + +const FollowRequests: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const accountIds = useAppSelector((state) => state.user_lists.getIn(['follow_requests', 'items'])); + const hasMore = useAppSelector((state) => !!state.user_lists.getIn(['follow_requests', 'next'])); + + React.useEffect(() => { + dispatch(fetchFollowRequests()); + }, []); + + if (!accountIds) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + hasMore={hasMore} + emptyMessage={emptyMessage} + > + {accountIds.map(id => + , + )} + + + ); +}; + +export default FollowRequests; diff --git a/app/soapbox/features/mutes/index.tsx b/app/soapbox/features/mutes/index.tsx index 8e27c06d2..c1f306c24 100644 --- a/app/soapbox/features/mutes/index.tsx +++ b/app/soapbox/features/mutes/index.tsx @@ -47,7 +47,7 @@ const Mutes: React.FC = () => { emptyMessage={emptyMessage} className='space-y-4' > - {accountIds.map(id => + {accountIds.map((id: string) => , )} diff --git a/app/soapbox/utils/timelines.ts b/app/soapbox/utils/timelines.ts index 03ba96044..9f901b125 100644 --- a/app/soapbox/utils/timelines.ts +++ b/app/soapbox/utils/timelines.ts @@ -4,9 +4,9 @@ import type { Status as StatusEntity } from 'soapbox/types/entities'; export const shouldFilter = (status: StatusEntity, columnSettings: any) => { const shows = ImmutableMap({ - reblog: status.reblog !== null, - reply: status.in_reply_to_id !== null, - direct: status.visibility === 'direct', + reblog: status.get('reblog') !== null, + reply: status.get('in_reply_to_id') !== null, + direct: status.get('visibility') === 'direct', }); return shows.some((value, key) => { From b5ae9adf63f46908c4e6bee47455c8f1925f7c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 12 Apr 2022 18:52:56 +0200 Subject: [PATCH 14/17] Chats: typescript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../features/chats/components/audio_toggle.js | 61 ------------ .../chats/components/audio_toggle.tsx | 46 +++++++++ app/soapbox/features/chats/components/chat.js | 87 ---------------- .../features/chats/components/chat.tsx | 69 +++++++++++++ .../features/chats/components/chat_list.js | 99 ------------------- .../features/chats/components/chat_list.tsx | 84 ++++++++++++++++ app/soapbox/features/chats/index.js | 66 ------------- app/soapbox/features/chats/index.tsx | 55 +++++++++++ .../compose/containers/upload_container.js | 2 +- app/soapbox/normalizers/chat.ts | 18 ++++ app/soapbox/normalizers/chat_message.ts | 29 ++++++ app/soapbox/normalizers/index.ts | 2 + ...message_lists.js => chat_message_lists.ts} | 30 +++--- .../{chat_messages.js => chat_messages.ts} | 32 +++--- app/soapbox/reducers/chats.js | 58 ----------- app/soapbox/reducers/chats.ts | 76 ++++++++++++++ app/soapbox/selectors/index.ts | 11 ++- app/soapbox/types/entities.ts | 6 ++ 18 files changed, 431 insertions(+), 400 deletions(-) delete mode 100644 app/soapbox/features/chats/components/audio_toggle.js create mode 100644 app/soapbox/features/chats/components/audio_toggle.tsx delete mode 100644 app/soapbox/features/chats/components/chat.js create mode 100644 app/soapbox/features/chats/components/chat.tsx delete mode 100644 app/soapbox/features/chats/components/chat_list.js create mode 100644 app/soapbox/features/chats/components/chat_list.tsx delete mode 100644 app/soapbox/features/chats/index.js create mode 100644 app/soapbox/features/chats/index.tsx create mode 100644 app/soapbox/normalizers/chat.ts create mode 100644 app/soapbox/normalizers/chat_message.ts rename app/soapbox/reducers/{chat_message_lists.js => chat_message_lists.ts} (63%) rename app/soapbox/reducers/{chat_messages.js => chat_messages.ts} (60%) delete mode 100644 app/soapbox/reducers/chats.js create mode 100644 app/soapbox/reducers/chats.ts diff --git a/app/soapbox/features/chats/components/audio_toggle.js b/app/soapbox/features/chats/components/audio_toggle.js deleted file mode 100644 index f0068d1f3..000000000 --- a/app/soapbox/features/chats/components/audio_toggle.js +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { injectIntl, defineMessages } from 'react-intl'; -import { connect } from 'react-redux'; -import Toggle from 'react-toggle'; - -import { changeSetting, getSettings } from 'soapbox/actions/settings'; - -const messages = defineMessages({ - switchOn: { id: 'chats.audio_toggle_on', defaultMessage: 'Audio notification on' }, - switchOff: { id: 'chats.audio_toggle_off', defaultMessage: 'Audio notification off' }, -}); - -const mapStateToProps = state => { - return { - checked: getSettings(state).getIn(['chats', 'sound'], false), - }; -}; - -const mapDispatchToProps = (dispatch) => ({ - toggleAudio(setting) { - dispatch(changeSetting(['chats', 'sound'], setting)); - }, -}); - -export default @connect(mapStateToProps, mapDispatchToProps) -@injectIntl -class AudioToggle extends React.PureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - checked: PropTypes.bool.isRequired, - toggleAudio: PropTypes.func, - showLabel: PropTypes.bool, - }; - - handleToggleAudio = () => { - this.props.toggleAudio(!this.props.checked); - } - - render() { - const { intl, checked, showLabel } = this.props; - const id = 'chats-audio-toggle'; - const label = intl.formatMessage(checked ? messages.switchOff : messages.switchOn); - - return ( -
-
- - {showLabel && ()} -
-
- ); - } - -} diff --git a/app/soapbox/features/chats/components/audio_toggle.tsx b/app/soapbox/features/chats/components/audio_toggle.tsx new file mode 100644 index 000000000..96ddf6211 --- /dev/null +++ b/app/soapbox/features/chats/components/audio_toggle.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import Toggle from 'react-toggle'; + +import { changeSetting, getSettings } from 'soapbox/actions/settings'; +import { useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + switchOn: { id: 'chats.audio_toggle_on', defaultMessage: 'Audio notification on' }, + switchOff: { id: 'chats.audio_toggle_off', defaultMessage: 'Audio notification off' }, +}); + +interface IAudioToggle { + showLabel?: boolean +} + +const AudioToggle: React.FC = ({ showLabel }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const checked = useAppSelector(state => !!getSettings(state).getIn(['chats', 'sound'])); + + const handleToggleAudio = () => { + dispatch(changeSetting(['chats', 'sound'], !checked)); + }; + + const id = 'chats-audio-toggle'; + const label = intl.formatMessage(checked ? messages.switchOff : messages.switchOn); + + return ( +
+
+ + {showLabel && ()} +
+
+ ); +}; + +export default AudioToggle; diff --git a/app/soapbox/features/chats/components/chat.js b/app/soapbox/features/chats/components/chat.js deleted file mode 100644 index f19190bed..000000000 --- a/app/soapbox/features/chats/components/chat.js +++ /dev/null @@ -1,87 +0,0 @@ -import PropTypes from 'prop-types'; -import React 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 Icon from 'soapbox/components/icon'; -import emojify from 'soapbox/features/emoji/emoji'; -import { makeGetChat } from 'soapbox/selectors'; -import { shortNumberFormat } from 'soapbox/utils/numbers'; - -import Avatar from '../../../components/avatar'; -import DisplayName from '../../../components/display_name'; - -const makeMapStateToProps = () => { - const getChat = makeGetChat(); - - const mapStateToProps = (state, { chatId }) => { - const chat = state.getIn(['chats', 'items', chatId]); - - return { - chat: chat ? getChat(state, chat.toJS()) : undefined, - }; - }; - - return mapStateToProps; -}; - -export default @connect(makeMapStateToProps) -class Chat extends ImmutablePureComponent { - - static propTypes = { - chatId: PropTypes.string.isRequired, - chat: ImmutablePropTypes.map, - onClick: PropTypes.func, - }; - - handleClick = () => { - this.props.onClick(this.props.chat); - } - - render() { - const { chat } = this.props; - if (!chat) return null; - const account = chat.get('account'); - const unreadCount = chat.get('unread'); - const content = chat.getIn(['last_message', 'content']); - const attachment = chat.getIn(['last_message', 'attachment']); - const image = attachment && attachment.getIn(['pleroma', 'mime_type'], '').startsWith('image/'); - const parsedContent = content ? emojify(content) : ''; - - return ( -
-
- ); - } - -} diff --git a/app/soapbox/features/chats/components/chat.tsx b/app/soapbox/features/chats/components/chat.tsx new file mode 100644 index 000000000..afb1d9df8 --- /dev/null +++ b/app/soapbox/features/chats/components/chat.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Avatar from 'soapbox/components/avatar'; +import DisplayName from 'soapbox/components/display_name'; +import Icon from 'soapbox/components/icon'; +import emojify from 'soapbox/features/emoji/emoji'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetChat } from 'soapbox/selectors'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +import type { Account as AccountEntity, Chat as ChatEntity } from 'soapbox/types/entities'; + +const getChat = makeGetChat(); + +interface IChat { + chatId: string, + onClick: (chat: any) => void, +} + +const Chat: React.FC = ({ chatId, onClick }) => { + const chat = useAppSelector((state) => { + const chat = state.chats.getIn(['items', chatId]); + return chat ? getChat(state, (chat as any).toJS()) : undefined; + }) as ChatEntity; + + const account = chat.account as AccountEntity; + if (!chat || !account) return null; + const unreadCount = chat.unread; + const content = chat.getIn(['last_message', 'content']); + const attachment = chat.getIn(['last_message', 'attachment']); + const image = attachment && (attachment as any).getIn(['pleroma', 'mime_type'], '').startsWith('image/'); + const parsedContent = content ? emojify(content) : ''; + + return ( +
+
+ ); +}; + +export default Chat; diff --git a/app/soapbox/features/chats/components/chat_list.js b/app/soapbox/features/chats/components/chat_list.js deleted file mode 100644 index ffb0c1720..000000000 --- a/app/soapbox/features/chats/components/chat_list.js +++ /dev/null @@ -1,99 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; - -import { expandChats } from 'soapbox/actions/chats'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat'; - -import Chat from './chat'; - -const messages = defineMessages({ - emptyMessage: { id: 'chat_panels.main_window.empty', defaultMessage: 'No chats found. To start a chat, visit a user\'s profile' }, -}); - -const getSortedChatIds = chats => ( - chats - .toList() - .sort(chatDateComparator) - .map(chat => chat.get('id')) -); - -const chatDateComparator = (chatA, chatB) => { - // Sort most recently updated chats at the top - const a = new Date(chatA.get('updated_at')); - const b = new Date(chatB.get('updated_at')); - - if (a === b) return 0; - if (a > b) return -1; - if (a < b) return 1; - return 0; -}; - -const sortedChatIdsSelector = createSelector( - [getSortedChatIds], - chats => chats, -); - -const makeMapStateToProps = () => { - const mapStateToProps = state => ({ - chatIds: sortedChatIdsSelector(state.getIn(['chats', 'items'])), - hasMore: !!state.getIn(['chats', 'next']), - isLoading: state.getIn(['chats', 'loading']), - }); - - return mapStateToProps; -}; - -export default @connect(makeMapStateToProps) -@injectIntl -class ChatList extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - chatIds: ImmutablePropTypes.list, - onClickChat: PropTypes.func, - onRefresh: PropTypes.func, - hasMore: PropTypes.func, - isLoading: PropTypes.bool, - }; - - handleLoadMore = debounce(() => { - this.props.dispatch(expandChats()); - }, 300, { leading: true }); - - render() { - const { intl, chatIds, hasMore, isLoading } = this.props; - - return ( - - {chatIds.map(chatId => ( -
- -
- ))} -
- ); - } - -} diff --git a/app/soapbox/features/chats/components/chat_list.tsx b/app/soapbox/features/chats/components/chat_list.tsx new file mode 100644 index 000000000..874c71041 --- /dev/null +++ b/app/soapbox/features/chats/components/chat_list.tsx @@ -0,0 +1,84 @@ +import { Map as ImmutableMap } from 'immutable'; +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { expandChats } from 'soapbox/actions/chats'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat'; +import { useAppSelector } from 'soapbox/hooks'; + +import Chat from './chat'; + +const messages = defineMessages({ + emptyMessage: { id: 'chat_panels.main_window.empty', defaultMessage: 'No chats found. To start a chat, visit a user\'s profile' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandChats()); +}, 300, { leading: true }); + +const getSortedChatIds = (chats: ImmutableMap) => ( + chats + .toList() + .sort(chatDateComparator) + .map(chat => chat.id) +); + +const chatDateComparator = (chatA: { updated_at: string }, chatB: { updated_at: string }) => { + // Sort most recently updated chats at the top + const a = new Date(chatA.updated_at); + const b = new Date(chatB.updated_at); + + if (a === b) return 0; + if (a > b) return -1; + if (a < b) return 1; + return 0; +}; + +const sortedChatIdsSelector = createSelector( + [getSortedChatIds], + chats => chats, +); + +interface IChatList { + onClickChat: (chat: any) => void, + onRefresh: () => void, +} + +const ChatList: React.FC = ({ onClickChat, onRefresh }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const chatIds = useAppSelector(state => sortedChatIdsSelector(state.chats.get('items'))); + const hasMore = useAppSelector(state => !!state.chats.get('next')); + const isLoading = useAppSelector(state => state.chats.get('isLoading')); + + return ( + handleLoadMore(dispatch)} + onRefresh={onRefresh} + placeholderComponent={PlaceholderChat} + placeholderCount={20} + > + {chatIds.map((chatId: string) => ( +
+ +
+ ))} +
+ ); +}; + +export default ChatList; diff --git a/app/soapbox/features/chats/index.js b/app/soapbox/features/chats/index.js deleted file mode 100644 index ed8c35e60..000000000 --- a/app/soapbox/features/chats/index.js +++ /dev/null @@ -1,66 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; - -import { fetchChats, launchChat } from 'soapbox/actions/chats'; -import AccountSearch from 'soapbox/components/account_search'; -import AudioToggle from 'soapbox/features/chats/components/audio_toggle'; - -import { Column } from '../../components/ui'; - -import ChatList from './components/chat_list'; - -const messages = defineMessages({ - title: { id: 'column.chats', defaultMessage: 'Chats' }, - searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' }, -}); - -export default @connect() -@injectIntl -@withRouter -class ChatIndex extends React.PureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - history: PropTypes.object, - }; - - handleSuggestion = accountId => { - this.props.dispatch(launchChat(accountId, this.props.history, true)); - } - - handleClickChat = (chat) => { - this.props.history.push(`/chats/${chat.get('id')}`); - } - - handleRefresh = () => { - const { dispatch } = this.props; - return dispatch(fetchChats()); - } - - render() { - const { intl } = this.props; - - return ( - -
- -
- - - - -
- ); - } - -} diff --git a/app/soapbox/features/chats/index.tsx b/app/soapbox/features/chats/index.tsx new file mode 100644 index 000000000..c13335ff3 --- /dev/null +++ b/app/soapbox/features/chats/index.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; + +import { fetchChats, launchChat } from 'soapbox/actions/chats'; +import AccountSearch from 'soapbox/components/account_search'; +import AudioToggle from 'soapbox/features/chats/components/audio_toggle'; + +import { Column } from '../../components/ui'; + +import ChatList from './components/chat_list'; + +const messages = defineMessages({ + title: { id: 'column.chats', defaultMessage: 'Chats' }, + searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' }, +}); + +const ChatIndex: React.FC = () => { + const intl = useIntl(); + const dispatch = useDispatch(); + const history = useHistory(); + + const handleSuggestion = (accountId: string) => { + dispatch(launchChat(accountId, history, true)); + }; + + const handleClickChat = (chat: { id: string }) => { + history.push(`/chats/${chat.id}`); + }; + + const handleRefresh = () => { + return dispatch(fetchChats()); + }; + + return ( + +
+ +
+ + + + +
+ ); +}; + +export default ChatIndex; diff --git a/app/soapbox/features/compose/containers/upload_container.js b/app/soapbox/features/compose/containers/upload_container.js index 9c77c6df7..332f206ee 100644 --- a/app/soapbox/features/compose/containers/upload_container.js +++ b/app/soapbox/features/compose/containers/upload_container.js @@ -26,7 +26,7 @@ const mapDispatchToProps = dispatch => ({ }, onOpenModal: media => { - dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0, onClose: console.log })); + dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 })); }, onSubmit(router) { diff --git a/app/soapbox/normalizers/chat.ts b/app/soapbox/normalizers/chat.ts new file mode 100644 index 000000000..04149c682 --- /dev/null +++ b/app/soapbox/normalizers/chat.ts @@ -0,0 +1,18 @@ +import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; + +import type { ReducerAccount } from 'soapbox/reducers/accounts'; +import type { Account, EmbeddedEntity } from 'soapbox/types/entities'; + +export const ChatRecord = ImmutableRecord({ + account: null as EmbeddedEntity, + id: '', + unread: 0, + last_message: '' as string || null, + updated_at: new Date(), +}); + +export const normalizeChat = (chat: Record) => { + return ChatRecord( + ImmutableMap(fromJS(chat)), + ); +}; diff --git a/app/soapbox/normalizers/chat_message.ts b/app/soapbox/normalizers/chat_message.ts new file mode 100644 index 000000000..71536acb5 --- /dev/null +++ b/app/soapbox/normalizers/chat_message.ts @@ -0,0 +1,29 @@ +import { + List as ImmutableList, + Map as ImmutableMap, + Record as ImmutableRecord, + fromJS, +} from 'immutable'; + +import type { Attachment, Card, Emoji } from 'soapbox/types/entities'; + +export const ChatMessageRecord = ImmutableRecord({ + account_id: '', + attachment: null as Attachment | null, + card: null as Card | null, + chat_id: '', + content: '', + created_at: new Date(), + emojis: ImmutableList(), + id: '', + unread: false, + + deleting: false, + pending: false, +}); + +export const normalizeChatMessage = (chatMessage: Record) => { + return ChatMessageRecord( + ImmutableMap(fromJS(chatMessage)), + ); +}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 613de5331..3251669de 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -1,6 +1,8 @@ export { AccountRecord, FieldRecord, normalizeAccount } from './account'; export { AttachmentRecord, normalizeAttachment } from './attachment'; export { CardRecord, normalizeCard } from './card'; +export { ChatRecord, normalizeChat } from './chat'; +export { ChatMessageRecord, normalizeChatMessage } from './chat_message'; export { EmojiRecord, normalizeEmoji } from './emoji'; export { InstanceRecord, normalizeInstance } from './instance'; export { MentionRecord, normalizeMention } from './mention'; diff --git a/app/soapbox/reducers/chat_message_lists.js b/app/soapbox/reducers/chat_message_lists.ts similarity index 63% rename from app/soapbox/reducers/chat_message_lists.js rename to app/soapbox/reducers/chat_message_lists.ts index 9939885e6..79a595801 100644 --- a/app/soapbox/reducers/chat_message_lists.js +++ b/app/soapbox/reducers/chat_message_lists.ts @@ -1,4 +1,5 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; +import { AnyAction } from 'redux'; import { CHATS_FETCH_SUCCESS, @@ -10,41 +11,46 @@ import { } from 'soapbox/actions/chats'; import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; -const initialState = ImmutableMap(); +type APIEntity = Record; +type APIEntities = Array; -const idComparator = (a, b) => { +type State = ImmutableMap>; + +const initialState: State = ImmutableMap(); + +const idComparator = (a: string, b: string) => { if (a < b) return -1; if (a > b) return 1; return 0; }; -const updateList = (state, chatId, messageIds) => { +const updateList = (state: State, chatId: string, messageIds: string[]) => { const ids = state.get(chatId, ImmutableOrderedSet()); - const newIds = ids.union(messageIds).sort(idComparator); + const newIds = (ids.union(messageIds) as ImmutableOrderedSet).sort(idComparator); return state.set(chatId, newIds); }; -const importMessage = (state, chatMessage) => { +const importMessage = (state: State, chatMessage: APIEntity) => { return updateList(state, chatMessage.chat_id, [chatMessage.id]); }; -const importMessages = (state, chatMessages) => ( +const importMessages = (state: State, chatMessages: APIEntities) => ( state.withMutations(map => chatMessages.forEach(chatMessage => importMessage(map, chatMessage))) ); -const importLastMessages = (state, chats) => +const importLastMessages = (state: State, chats: APIEntities) => state.withMutations(mutable => chats.forEach(chat => { if (chat.last_message) importMessage(mutable, chat.last_message); })); -const replaceMessage = (state, chatId, oldId, newId) => { - return state.update(chatId, chat => chat.delete(oldId).add(newId).sort(idComparator)); +const replaceMessage = (state: State, chatId: string, oldId: string, newId: string) => { + return state.update(chatId, chat => chat!.delete(oldId).add(newId).sort(idComparator)); }; -export default function chatMessageLists(state = initialState, action) { +export default function chatMessageLists(state = initialState, action: AnyAction) { switch(action.type) { case CHAT_MESSAGE_SEND_REQUEST: return updateList(state, action.chatId, [action.uuid]); @@ -58,11 +64,11 @@ export default function chatMessageLists(state = initialState, action) { else return state; case CHAT_MESSAGES_FETCH_SUCCESS: - return updateList(state, action.chatId, action.chatMessages.map(chat => chat.id)); + return updateList(state, action.chatId, action.chatMessages.map((chat: APIEntity) => chat.id)); case CHAT_MESSAGE_SEND_SUCCESS: return replaceMessage(state, action.chatId, action.uuid, action.chatMessage.id); case CHAT_MESSAGE_DELETE_SUCCESS: - return state.update(action.chatId, chat => chat.delete(action.messageId)); + return state.update(action.chatId, chat => chat!.delete(action.messageId)); default: return state; } diff --git a/app/soapbox/reducers/chat_messages.js b/app/soapbox/reducers/chat_messages.ts similarity index 60% rename from app/soapbox/reducers/chat_messages.js rename to app/soapbox/reducers/chat_messages.ts index a0787d077..d6b4f4fb9 100644 --- a/app/soapbox/reducers/chat_messages.js +++ b/app/soapbox/reducers/chat_messages.ts @@ -1,4 +1,5 @@ import { Map as ImmutableMap, fromJS } from 'immutable'; +import { AnyAction } from 'redux'; import { CHATS_FETCH_SUCCESS, @@ -10,25 +11,32 @@ import { CHAT_MESSAGE_DELETE_SUCCESS, } from 'soapbox/actions/chats'; import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; +import { normalizeChatMessage } from 'soapbox/normalizers'; -const initialState = ImmutableMap(); +type ChatMessageRecord = ReturnType; +type APIEntity = Record; +type APIEntities = Array; -const importMessage = (state, message) => { - return state.set(message.get('id'), message); +type State = ImmutableMap; + +const importMessage = (state: State, message: APIEntity) => { + return state.set(message.id, normalizeChatMessage(message)); }; -const importMessages = (state, messages) => +const importMessages = (state: State, messages: APIEntities) => state.withMutations(mutable => messages.forEach(message => importMessage(mutable, message))); -const importLastMessages = (state, chats) => +const importLastMessages = (state: State, chats: APIEntities) => state.withMutations(mutable => chats.forEach(chat => { - if (chat.get('last_message')) - importMessage(mutable, chat.get('last_message')); + if (chat.last_message) + importMessage(mutable, chat.last_message); })); -export default function chatMessages(state = initialState, action) { +const initialState: State = ImmutableMap(); + +export default function chatMessages(state = initialState, action: AnyAction) { switch(action.type) { case CHAT_MESSAGE_SEND_REQUEST: return importMessage(state, fromJS({ @@ -41,16 +49,16 @@ export default function chatMessages(state = initialState, action) { })); case CHATS_FETCH_SUCCESS: case CHATS_EXPAND_SUCCESS: - return importLastMessages(state, fromJS(action.chats)); + return importLastMessages(state, action.chats); case CHAT_MESSAGES_FETCH_SUCCESS: - return importMessages(state, fromJS(action.chatMessages)); + return importMessages(state, action.chatMessages); case CHAT_MESSAGE_SEND_SUCCESS: return importMessage(state, fromJS(action.chatMessage)).delete(action.uuid); case STREAMING_CHAT_UPDATE: - return importLastMessages(state, fromJS([action.chat])); + return importLastMessages(state, [action.chat]); case CHAT_MESSAGE_DELETE_REQUEST: return state.update(action.messageId, chatMessage => - chatMessage.set('pending', true).set('deleting', true)); + chatMessage!.set('pending', true).set('deleting', true)); case CHAT_MESSAGE_DELETE_SUCCESS: return state.delete(action.messageId); default: diff --git a/app/soapbox/reducers/chats.js b/app/soapbox/reducers/chats.js deleted file mode 100644 index 430251210..000000000 --- a/app/soapbox/reducers/chats.js +++ /dev/null @@ -1,58 +0,0 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; - -import { - CHATS_FETCH_SUCCESS, - CHATS_FETCH_REQUEST, - CHATS_EXPAND_SUCCESS, - CHATS_EXPAND_REQUEST, - CHAT_FETCH_SUCCESS, - CHAT_READ_SUCCESS, - CHAT_READ_REQUEST, -} from 'soapbox/actions/chats'; -import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; - -const normalizeChat = (chat, normalOldChat) => { - const normalChat = { ...chat }; - const { account, last_message: lastMessage } = chat; - - if (account) normalChat.account = account.id; - if (lastMessage) normalChat.last_message = lastMessage.id; - - return normalChat; -}; - -const importChat = (state, chat) => state.setIn(['items', chat.id], fromJS(normalizeChat(chat))); - -const importChats = (state, chats, next) => - state.withMutations(mutable => { - if (next !== undefined) mutable.set('next', next); - chats.forEach(chat => importChat(mutable, chat)); - mutable.set('loading', false); - }); - -const initialState = ImmutableMap({ - next: null, - isLoading: false, - items: ImmutableMap({}), -}); - -export default function chats(state = initialState, action) { - switch(action.type) { - case CHATS_FETCH_REQUEST: - case CHATS_EXPAND_REQUEST: - return state.set('loading', true); - case CHATS_FETCH_SUCCESS: - case CHATS_EXPAND_SUCCESS: - return importChats(state, action.chats, action.next); - case STREAMING_CHAT_UPDATE: - return importChats(state, [action.chat]); - case CHAT_FETCH_SUCCESS: - return importChats(state, [action.chat]); - case CHAT_READ_REQUEST: - return state.setIn([action.chatId, 'unread'], 0); - case CHAT_READ_SUCCESS: - return importChats(state, [action.chat]); - default: - return state; - } -} diff --git a/app/soapbox/reducers/chats.ts b/app/soapbox/reducers/chats.ts new file mode 100644 index 000000000..956da3c39 --- /dev/null +++ b/app/soapbox/reducers/chats.ts @@ -0,0 +1,76 @@ +import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable'; + +import { + CHATS_FETCH_SUCCESS, + CHATS_FETCH_REQUEST, + CHATS_EXPAND_SUCCESS, + CHATS_EXPAND_REQUEST, + CHAT_FETCH_SUCCESS, + CHAT_READ_SUCCESS, + CHAT_READ_REQUEST, +} from 'soapbox/actions/chats'; +import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; +import { normalizeChat } from 'soapbox/normalizers'; +import { normalizeId } from 'soapbox/utils/normalizers'; + +import type { AnyAction } from 'redux'; + +type ChatRecord = ReturnType; +type APIEntity = Record; +type APIEntities = Array; + +export interface ReducerChat extends ChatRecord { + account: string | null, + last_message: string | null, +} + +const ReducerRecord = ImmutableRecord({ + next: null as string | null, + isLoading: false, + items: ImmutableMap({}), +}); + +type State = ReturnType; + +const minifyChat = (chat: ChatRecord): ReducerChat => { + return chat.mergeWith((o, n) => n || o, { + account: normalizeId(chat.getIn(['account', 'id'])), + last_message: normalizeId(chat.getIn(['last_message', 'id'])), + }) as ReducerChat; +}; + +const fixChat = (chat: APIEntity): ReducerChat => { + return normalizeChat(chat).withMutations(chat => { + minifyChat(chat); + }) as ReducerChat; +}; + +const importChat = (state: State, chat: APIEntity) => state.setIn(['items', chat.id], fixChat(chat)); + +const importChats = (state: State, chats: APIEntities, next?: string) => + state.withMutations(mutable => { + if (next !== undefined) mutable.set('next', next); + chats.forEach(chat => importChat(mutable, chat)); + mutable.set('isLoading', false); + }); + +export default function chats(state: State = ReducerRecord(), action: AnyAction): State { + switch(action.type) { + case CHATS_FETCH_REQUEST: + case CHATS_EXPAND_REQUEST: + return state.set('isLoading', true); + case CHATS_FETCH_SUCCESS: + case CHATS_EXPAND_SUCCESS: + return importChats(state, action.chats, action.next); + case STREAMING_CHAT_UPDATE: + return importChats(state, [action.chat]); + case CHAT_FETCH_SUCCESS: + return importChats(state, [action.chat]); + case CHAT_READ_REQUEST: + return state.setIn([action.chatId, 'unread'], 0); + case CHAT_READ_SUCCESS: + return importChats(state, [action.chat]); + default: + return state; + } +} diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts index ddac93b24..40f8105e3 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -12,6 +12,7 @@ import { validId } from 'soapbox/utils/auth'; import ConfigDB from 'soapbox/utils/config_db'; import { shouldFilter } from 'soapbox/utils/timelines'; +import type { ReducerChat } from 'soapbox/reducers/chats'; import type { RootState } from 'soapbox/store'; import type { Notification } from 'soapbox/types/entities'; @@ -241,16 +242,18 @@ type APIChat = { id: string, last_message: string }; export const makeGetChat = () => { return createSelector( [ - (state: RootState, { id }: APIChat) => state.chats.getIn(['items', id]), + (state: RootState, { id }: APIChat) => state.chats.getIn(['items', id]) as ReducerChat, (state: RootState, { id }: APIChat) => state.accounts.get(state.chats.getIn(['items', id, 'account'])), (state: RootState, { last_message }: APIChat) => state.chat_messages.get(last_message), ], - (chat, account, lastMessage: string) => { - if (!chat) return null; + (chat, account, lastMessage) => { + if (!chat || !account) return null; - return chat.withMutations((map: ImmutableMap) => { + return chat.withMutations((map) => { + // @ts-ignore map.set('account', account); + // @ts-ignore map.set('last_message', lastMessage); }); }, diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index 942e5f4f8..80caf1ea1 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -2,6 +2,8 @@ import { AccountRecord, AttachmentRecord, CardRecord, + ChatRecord, + ChatMessageRecord, EmojiRecord, FieldRecord, InstanceRecord, @@ -16,6 +18,8 @@ import type { Record as ImmutableRecord } from 'immutable'; type Attachment = ReturnType; type Card = ReturnType; +type Chat = ReturnType; +type ChatMessage = ReturnType; type Emoji = ReturnType; type Field = ReturnType; type Instance = ReturnType; @@ -44,6 +48,8 @@ export { Account, Attachment, Card, + Chat, + ChatMessage, Emoji, Field, Instance, From 9b7f8b38165db863363b9b9212900a02072ce3aa Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 12 Apr 2022 12:13:51 -0500 Subject: [PATCH 15/17] Fix API mock in verification test --- app/soapbox/__mocks__/api.ts | 2 +- app/soapbox/features/verification/__tests__/index.test.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/soapbox/__mocks__/api.ts b/app/soapbox/__mocks__/api.ts index 2f3a0d7b8..99797009e 100644 --- a/app/soapbox/__mocks__/api.ts +++ b/app/soapbox/__mocks__/api.ts @@ -9,7 +9,7 @@ export const __stub = (func: Function) => mocks.push(func); export const __clear = (): Function[] => mocks = []; const setupMock = (axios: AxiosInstance) => { - const mock = new MockAdapter(axios); + const mock = new MockAdapter(axios, { onNoMatch: 'throwException' }); mocks.map(func => func(mock)); }; diff --git a/app/soapbox/features/verification/__tests__/index.test.tsx b/app/soapbox/features/verification/__tests__/index.test.tsx index b4c28509e..27a88957e 100644 --- a/app/soapbox/features/verification/__tests__/index.test.tsx +++ b/app/soapbox/features/verification/__tests__/index.test.tsx @@ -2,7 +2,8 @@ import { Map as ImmutableMap } from 'immutable'; import React from 'react'; import { Route, Switch } from 'react-router-dom'; -import { __stub } from '../../../__mocks__/api'; +import { __stub } from 'soapbox/api'; + import { render, screen } from '../../../jest/test-helpers'; import Verification from '../index'; From 7394452ad913df03a36699342f1e9f19c4f3d516 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 12 Apr 2022 12:38:19 -0500 Subject: [PATCH 16/17] ForkTsCheckerWebpackPlugin: increase typescript memory limit to 8GB, fixes #865 --- webpack/shared.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpack/shared.js b/webpack/shared.js index 2daba677a..1fcdef898 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -75,7 +75,7 @@ module.exports = { new webpack.ProvidePlugin({ process: 'process/browser', }), - new ForkTsCheckerWebpackPlugin(), + new ForkTsCheckerWebpackPlugin({ typescript: { memoryLimit: 8192 } }), new MiniCssExtractPlugin({ filename: 'packs/css/[name]-[contenthash:8].css', chunkFilename: 'packs/css/[name]-[contenthash:8].chunk.css', From 93a6945b7fcb1e861f3b21853cd580b2456d51f4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 12 Apr 2022 15:23:18 -0500 Subject: [PATCH 17/17] Let a custom auth app be embedded in the build --- app/soapbox/actions/auth.js | 22 ++++++++++++++++++---- app/soapbox/{custom.js => custom.ts} | 7 ++++--- docs/development/build-config.md | 28 ++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) rename app/soapbox/{custom.js => custom.ts} (57%) diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index 1a700b9ec..5c0fc467a 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -14,6 +14,7 @@ import { createApp } from 'soapbox/actions/apps'; import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me'; import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth'; import snackbar from 'soapbox/actions/snackbar'; +import { custom } from 'soapbox/custom'; import KVStore from 'soapbox/storage/kv_store'; import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth'; import sourceCode from 'soapbox/utils/code'; @@ -39,12 +40,14 @@ export const AUTH_ACCOUNT_REMEMBER_REQUEST = 'AUTH_ACCOUNT_REMEMBER_REQUEST'; export const AUTH_ACCOUNT_REMEMBER_SUCCESS = 'AUTH_ACCOUNT_REMEMBER_SUCCESS'; export const AUTH_ACCOUNT_REMEMBER_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL'; +const customApp = custom('app'); + export const messages = defineMessages({ loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' }, invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' }, }); -const noOp = () => () => new Promise(f => f()); +const noOp = () => new Promise(f => f()); const getScopes = state => { const instance = state.get('instance'); @@ -54,12 +57,23 @@ const getScopes = state => { function createAppAndToken() { return (dispatch, getState) => { - return dispatch(createAuthApp()).then(() => { + return dispatch(getAuthApp()).then(() => { return dispatch(createAppToken()); }); }; } +/** Create an auth app, or use it from build config */ +function getAuthApp() { + return (dispatch, getState) => { + if (customApp?.client_secret) { + return noOp().then(() => dispatch({ type: AUTH_APP_CREATED, app: customApp })); + } else { + return dispatch(createAuthApp()); + } + }; +} + function createAuthApp() { return (dispatch, getState) => { const params = { @@ -117,7 +131,7 @@ export function refreshUserToken() { const refreshToken = getState().getIn(['auth', 'user', 'refresh_token']); const app = getState().getIn(['auth', 'app']); - if (!refreshToken) return dispatch(noOp()); + if (!refreshToken) return dispatch(noOp); const params = { client_id: app.get('client_id'), @@ -200,7 +214,7 @@ export function loadCredentials(token, accountUrl) { export function logIn(intl, username, password) { return (dispatch, getState) => { - return dispatch(createAuthApp()).then(() => { + return dispatch(getAuthApp()).then(() => { return dispatch(createUserToken(username, password)); }).catch(error => { if (error.response.data.error === 'mfa_required') { diff --git a/app/soapbox/custom.js b/app/soapbox/custom.ts similarity index 57% rename from app/soapbox/custom.js rename to app/soapbox/custom.ts index 623cb22b3..4bccb386d 100644 --- a/app/soapbox/custom.js +++ b/app/soapbox/custom.ts @@ -1,12 +1,13 @@ /** * Functions for dealing with custom build configuration. */ -import { NODE_ENV } from 'soapbox/build_config'; +import * as BuildConfig from 'soapbox/build_config'; /** Require a custom JSON file if it exists */ -export const custom = (filename, fallback = {}) => { - if (NODE_ENV === 'test') return fallback; +export const custom = (filename: string, fallback: any = {}): any => { + if (BuildConfig.NODE_ENV === 'test') return fallback; + // @ts-ignore: yes it does const context = require.context('custom', false, /\.json$/); const path = `./${filename}.json`; diff --git a/docs/development/build-config.md b/docs/development/build-config.md index 0d7f44b99..4467dbfe0 100644 --- a/docs/development/build-config.md +++ b/docs/development/build-config.md @@ -38,6 +38,34 @@ For example: See `app/soapbox/utils/features.js` for the full list of features. +### Embedded app (`custom/app.json`) + +By default, Soapbox will create a new OAuth app every time a user tries to register or log in. +This is usually the desired behavior, as it works "out of the box" without any additional configuration, and it is resistant to tampering and subtle client bugs. +However, some larger servers may wish to skip this step for performance reasons. + +If an app is supplied in `custom/app.json`, it will be used for authorization. +The full app entity must be provided, for example: + +```json +{ + "client_id": "cf5yI6ffXH1UcDkEApEIrtHpwCi5Tv9xmju8IKdMAkE", + "client_secret": "vHmSDpm6BJGUvR4_qWzmqWjfHcSYlZumxpFfohRwNNQ", + "id": "7132", + "name": "Soapbox FE", + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "website": "https://soapbox.pub/", + "vapid_key": "BLElLQVJVmY_e4F5JoYxI5jXiVOYNsJ9p-amkykc9NcI-jwa9T1Y2GIbDqbY-HqC6ayPkfW4K4o9vgBFKYmkuS4" +} +``` + +It is crucial that the app has the expected scopes. +You can obtain one with the following curl command (replace `MY_DOMAIN`): + +```sh +curl -X POST -H "Content-Type: application/json" -d '{"client_name": "Soapbox FE", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob", "scopes": "read write follow push admin", "website": "https://soapbox.pub/"}' "https://MY_DOMAIN.com/api/v1/apps" +``` + ### Custom files (`custom/instance/*`) You can place arbitrary files of any type in the `custom/instance/` directory.