diff --git a/app/soapbox/__fixtures__/announcements.json b/app/soapbox/__fixtures__/announcements.json new file mode 100644 index 000000000..20e1960d0 --- /dev/null +++ b/app/soapbox/__fixtures__/announcements.json @@ -0,0 +1,44 @@ +[ + { + "id": "1", + "content": "

Updated to Soapbox v3.

", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-06-15T18:47:14.190Z", + "updated_at": "2022-06-15T18:47:18.339Z", + "read": true, + "mentions": [], + "statuses": [], + "tags": [], + "emojis": [], + "reactions": [ + { + "name": "📈", + "count": 476, + "me": true + } + ] + }, + { + "id": "2", + "content": "

Rolled back to Soapbox v2 for now.

", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-07-13T11:11:50.628Z", + "updated_at": "2022-07-13T11:11:50.628Z", + "read": true, + "mentions": [], + "statuses": [], + "tags": [], + "emojis": [], + "reactions": [ + { + "name": "📉", + "count": 420, + "me": false + } + ] + } +] \ No newline at end of file diff --git a/app/soapbox/actions/__tests__/announcements.test.ts b/app/soapbox/actions/__tests__/announcements.test.ts new file mode 100644 index 000000000..978311585 --- /dev/null +++ b/app/soapbox/actions/__tests__/announcements.test.ts @@ -0,0 +1,113 @@ +import { List as ImmutableList } from 'immutable'; + +import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'soapbox/actions/announcements'; +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { normalizeAnnouncement, normalizeInstance } from 'soapbox/normalizers'; + +import type { APIEntity } from 'soapbox/types/entities'; + +const announcements = require('soapbox/__fixtures__/announcements.json'); + +describe('fetchAnnouncements()', () => { + describe('with a successful API request', () => { + it('should fetch announcements from the API', async() => { + const state = rootState + .set('instance', normalizeInstance({ version: '3.5.3' })); + const store = mockStore(state); + + __stub((mock) => { + mock.onGet('/api/v1/announcements').reply(200, announcements); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_FETCH_REQUEST', skipLoading: true }, + { type: 'ANNOUNCEMENTS_FETCH_SUCCESS', announcements, skipLoading: true }, + { type: 'POLLS_IMPORT', polls: [] }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { type: 'STATUSES_IMPORT', statuses: [], expandSpoilers: false }, + ]; + await store.dispatch(fetchAnnouncements()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('dismissAnnouncement', () => { + describe('with a successful API request', () => { + it('should mark announcement as dismissed', async() => { + const store = mockStore(rootState); + + __stub((mock) => { + mock.onPost('/api/v1/announcements/1/dismiss').reply(200); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_DISMISS_REQUEST', id: '1' }, + { type: 'ANNOUNCEMENTS_DISMISS_SUCCESS', id: '1' }, + ]; + await store.dispatch(dismissAnnouncement('1')); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('addReaction', () => { + let store: ReturnType; + + beforeEach(() => { + const state = rootState + .setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement)))) + .setIn(['announcements', 'isLoading'], false); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + it('should add reaction to a post', async() => { + __stub((mock) => { + mock.onPut('/api/v1/announcements/2/reactions/📉').reply(200); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_REACTION_ADD_REQUEST', id: '2', name: '📉', skipLoading: true }, + { type: 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS', id: '2', name: '📉', skipLoading: true }, + ]; + await store.dispatch(addReaction('2', '📉')); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('removeReaction', () => { + let store: ReturnType; + + beforeEach(() => { + const state = rootState + .setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement)))) + .setIn(['announcements', 'isLoading'], false); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + it('should remove reaction from a post', async() => { + __stub((mock) => { + mock.onDelete('/api/v1/announcements/2/reactions/📉').reply(200); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST', id: '2', name: '📉', skipLoading: true }, + { type: 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS', id: '2', name: '📉', skipLoading: true }, + ]; + await store.dispatch(removeReaction('2', '📉')); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); diff --git a/app/soapbox/actions/__tests__/suggestions.test.ts b/app/soapbox/actions/__tests__/suggestions.test.ts new file mode 100644 index 000000000..3c8d0c95a --- /dev/null +++ b/app/soapbox/actions/__tests__/suggestions.test.ts @@ -0,0 +1,108 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { normalizeInstance } from 'soapbox/normalizers'; + +import { + fetchSuggestions, +} from '../suggestions'; + +let store: ReturnType; +let state; + +describe('fetchSuggestions()', () => { + describe('with Truth Social software', () => { + beforeEach(() => { + state = rootState + .set('instance', normalizeInstance({ + version: '3.4.1 (compatible; TruthSocial 1.0.0)', + pleroma: ImmutableMap({ + metadata: ImmutableMap({ + features: [], + }), + }), + })) + .set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + const response = [ + { + account_id: '1', + acct: 'jl', + account_avatar: 'https://example.com/some.jpg', + display_name: 'justin', + note: '

note

', + verified: true, + }, + ]; + + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/suggestions').reply(200, response, { + link: '; rel=\'prev\'', + }); + }); + }); + + it('dispatches the correct actions', async() => { + const expectedActions = [ + { type: 'SUGGESTIONS_V2_FETCH_REQUEST', skipLoading: true }, + { + type: 'ACCOUNTS_IMPORT', accounts: [{ + acct: response[0].acct, + avatar: response[0].account_avatar, + avatar_static: response[0].account_avatar, + id: response[0].account_id, + note: response[0].note, + verified: response[0].verified, + display_name: response[0].display_name, + }], + }, + { + type: 'SUGGESTIONS_TRUTH_FETCH_SUCCESS', + suggestions: response, + next: undefined, + skipLoading: true, + }, + { + type: 'RELATIONSHIPS_FETCH_REQUEST', + skipLoading: true, + ids: [response[0].account_id], + }, + ]; + await store.dispatch(fetchSuggestions()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/suggestions').networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'SUGGESTIONS_V2_FETCH_REQUEST', skipLoading: true }, + { + type: 'SUGGESTIONS_V2_FETCH_FAIL', + error: new Error('Network Error'), + skipLoading: true, + skipAlert: true, + }, + ]; + + await store.dispatch(fetchSuggestions()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); diff --git a/app/soapbox/actions/announcements.ts b/app/soapbox/actions/announcements.ts new file mode 100644 index 000000000..410de3cd9 --- /dev/null +++ b/app/soapbox/actions/announcements.ts @@ -0,0 +1,197 @@ +import api from 'soapbox/api'; +import { getFeatures } from 'soapbox/utils/features'; + +import { importFetchedStatuses } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST'; +export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS'; +export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL'; +export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; +export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE'; + +export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST'; +export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS'; +export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL'; + +export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST'; +export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; + +export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST'; +export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL'; + +export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE'; + +export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW'; + +const noOp = () => {}; + +export const fetchAnnouncements = (done = noOp) => + (dispatch: AppDispatch, getState: () => RootState) => { + const { instance } = getState(); + const features = getFeatures(instance); + + if (!features.announcements) return null; + + dispatch(fetchAnnouncementsRequest()); + + return api(getState).get('/api/v1/announcements').then(response => { + dispatch(fetchAnnouncementsSuccess(response.data)); + dispatch(importFetchedStatuses(response.data.map(({ statuses }: APIEntity) => statuses))); + }).catch(error => { + dispatch(fetchAnnouncementsFail(error)); + }).finally(() => { + done(); + }); + }; + +export const fetchAnnouncementsRequest = () => ({ + type: ANNOUNCEMENTS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchAnnouncementsSuccess = (announcements: APIEntity) => ({ + type: ANNOUNCEMENTS_FETCH_SUCCESS, + announcements, + skipLoading: true, +}); + +export const fetchAnnouncementsFail = (error: AxiosError) => ({ + type: ANNOUNCEMENTS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export const updateAnnouncements = (announcement: APIEntity) => ({ + type: ANNOUNCEMENTS_UPDATE, + announcement: announcement, +}); + +export const dismissAnnouncement = (announcementId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(dismissAnnouncementRequest(announcementId)); + + return api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => { + dispatch(dismissAnnouncementSuccess(announcementId)); + }).catch(error => { + dispatch(dismissAnnouncementFail(announcementId, error)); + }); + }; + +export const dismissAnnouncementRequest = (announcementId: string) => ({ + type: ANNOUNCEMENTS_DISMISS_REQUEST, + id: announcementId, +}); + +export const dismissAnnouncementSuccess = (announcementId: string) => ({ + type: ANNOUNCEMENTS_DISMISS_SUCCESS, + id: announcementId, +}); + +export const dismissAnnouncementFail = (announcementId: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_DISMISS_FAIL, + id: announcementId, + error, +}); + +export const addReaction = (announcementId: string, name: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const announcement = getState().announcements.items.find(x => x.get('id') === announcementId); + + let alreadyAdded = false; + + if (announcement) { + const reaction = announcement.reactions.find(x => x.name === name); + + if (reaction && reaction.me) { + alreadyAdded = true; + } + } + + if (!alreadyAdded) { + dispatch(addReactionRequest(announcementId, name, alreadyAdded)); + } + + return api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(addReactionSuccess(announcementId, name, alreadyAdded)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(addReactionFail(announcementId, name, err)); + } + }); + }; + +export const addReactionRequest = (announcementId: string, name: string, alreadyAdded?: boolean) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionSuccess = (announcementId: string, name: string, alreadyAdded?: boolean) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionFail = (announcementId: string, name: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const removeReaction = (announcementId: string, name: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(removeReactionRequest(announcementId, name)); + + return api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(removeReactionSuccess(announcementId, name)); + }).catch(err => { + dispatch(removeReactionFail(announcementId, name, err)); + }); + }; + +export const removeReactionRequest = (announcementId: string, name: string) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionSuccess = (announcementId: string, name: string) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionFail = (announcementId: string, name: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const updateReaction = (reaction: APIEntity) => ({ + type: ANNOUNCEMENTS_REACTION_UPDATE, + reaction, +}); + +export const toggleShowAnnouncements = () => ({ + type: ANNOUNCEMENTS_TOGGLE_SHOW, +}); + +export const deleteAnnouncement = (id: string) => ({ + type: ANNOUNCEMENTS_DELETE, + id, +}); diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts index 61b4e9b63..c0f79c6b8 100644 --- a/app/soapbox/actions/filters.ts +++ b/app/soapbox/actions/filters.ts @@ -2,6 +2,7 @@ import { defineMessages } from 'react-intl'; import snackbar from 'soapbox/actions/snackbar'; import { isLoggedIn } from 'soapbox/utils/auth'; +import { getFeatures } from 'soapbox/utils/features'; import api from '../api'; @@ -28,6 +29,12 @@ const fetchFilters = () => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.filters) return; + dispatch({ type: FILTERS_FETCH_REQUEST, skipLoading: true, diff --git a/app/soapbox/actions/notifications.ts b/app/soapbox/actions/notifications.ts index adeb46bf9..64311a15a 100644 --- a/app/soapbox/actions/notifications.ts +++ b/app/soapbox/actions/notifications.ts @@ -174,9 +174,9 @@ const excludeTypesFromFilter = (filter: string) => { return allTypes.filterNot(item => item === filter).toJS(); }; -const noOp = () => {}; +const noOp = () => new Promise(f => f(undefined)); -const expandNotifications = ({ maxId }: Record = {}, done = noOp) => +const expandNotifications = ({ maxId }: Record = {}, done: () => any = noOp) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return dispatch(noOp); diff --git a/app/soapbox/actions/oauth.ts b/app/soapbox/actions/oauth.ts index aefef7b6f..55df6f1ae 100644 --- a/app/soapbox/actions/oauth.ts +++ b/app/soapbox/actions/oauth.ts @@ -18,7 +18,7 @@ export const OAUTH_TOKEN_REVOKE_REQUEST = 'OAUTH_TOKEN_REVOKE_REQUEST'; export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS'; export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL'; -export const obtainOAuthToken = (params: Record, baseURL?: string) => +export const obtainOAuthToken = (params: Record, baseURL?: string) => (dispatch: AppDispatch) => { dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params }); return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => { diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index c47667197..c77daa6ac 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -3,6 +3,12 @@ import messages from 'soapbox/locales/messages'; import { connectStream } from '../stream'; +import { + deleteAnnouncement, + fetchAnnouncements, + updateAnnouncements, + updateReaction as updateAnnouncementsReaction, +} from './announcements'; import { updateConversations } from './conversations'; import { fetchFilters } from './filters'; import { updateNotificationsQueue, expandNotifications } from './notifications'; @@ -100,13 +106,24 @@ const connectTimelineStream = ( case 'pleroma:follow_relationships_update': dispatch(updateFollowRelationships(JSON.parse(data.payload))); break; + case 'announcement': + dispatch(updateAnnouncements(JSON.parse(data.payload))); + break; + case 'announcement.reaction': + dispatch(updateAnnouncementsReaction(JSON.parse(data.payload))); + break; + case 'announcement.delete': + dispatch(deleteAnnouncement(data.payload)); + break; } }, }; }); const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () => void) => - dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({} as any, done)))); + dispatch(expandHomeTimeline({}, () => + dispatch(expandNotifications({}, () => + dispatch(fetchAnnouncements(done)))))); const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); diff --git a/app/soapbox/actions/suggestions.ts b/app/soapbox/actions/suggestions.ts index d82f81f40..86743f5aa 100644 --- a/app/soapbox/actions/suggestions.ts +++ b/app/soapbox/actions/suggestions.ts @@ -1,3 +1,5 @@ +import { AxiosResponse } from 'axios'; + import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; @@ -5,6 +7,7 @@ import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts } from './importer'; +import { insertSuggestionsIntoTimeline } from './timelines'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity } from 'soapbox/types/entities'; @@ -19,6 +22,10 @@ const SUGGESTIONS_V2_FETCH_REQUEST = 'SUGGESTIONS_V2_FETCH_REQUEST'; const SUGGESTIONS_V2_FETCH_SUCCESS = 'SUGGESTIONS_V2_FETCH_SUCCESS'; const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL'; +const SUGGESTIONS_TRUTH_FETCH_REQUEST = 'SUGGESTIONS_TRUTH_FETCH_REQUEST'; +const SUGGESTIONS_TRUTH_FETCH_SUCCESS = 'SUGGESTIONS_TRUTH_FETCH_SUCCESS'; +const SUGGESTIONS_TRUTH_FETCH_FAIL = 'SUGGESTIONS_TRUTH_FETCH_FAIL'; + const fetchSuggestionsV1 = (params: Record = {}) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: SUGGESTIONS_FETCH_REQUEST, skipLoading: true }); @@ -52,6 +59,48 @@ const fetchSuggestionsV2 = (params: Record = {}) => }); }; +export type SuggestedProfile = { + account_avatar: string + account_id: string + acct: string + display_name: string + note: string + verified: boolean +} + +const mapSuggestedProfileToAccount = (suggestedProfile: SuggestedProfile) => ({ + id: suggestedProfile.account_id, + avatar: suggestedProfile.account_avatar, + avatar_static: suggestedProfile.account_avatar, + acct: suggestedProfile.acct, + display_name: suggestedProfile.display_name, + note: suggestedProfile.note, + verified: suggestedProfile.verified, +}); + +const fetchTruthSuggestions = (params: Record = {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + const next = getState().suggestions.next; + + dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true }); + + return api(getState) + .get(next ? next : '/api/v1/truth/carousels/suggestions', next ? {} : { params }) + .then((response: AxiosResponse) => { + const suggestedProfiles = response.data; + const next = getLinks(response).refs.find(link => link.rel === 'next')?.uri; + + const accounts = suggestedProfiles.map(mapSuggestedProfileToAccount); + dispatch(importFetchedAccounts(accounts)); + dispatch({ type: SUGGESTIONS_TRUTH_FETCH_SUCCESS, suggestions: suggestedProfiles, next, skipLoading: true }); + return suggestedProfiles; + }) + .catch(error => { + dispatch({ type: SUGGESTIONS_V2_FETCH_FAIL, error, skipLoading: true, skipAlert: true }); + throw error; + }); + }; + const fetchSuggestions = (params: Record = { limit: 50 }) => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); @@ -59,17 +108,24 @@ const fetchSuggestions = (params: Record = { limit: 50 }) => const instance = state.instance; const features = getFeatures(instance); - if (!me) return; + if (!me) return null; - if (features.suggestionsV2) { - dispatch(fetchSuggestionsV2(params)) + if (features.truthSuggestions) { + return dispatch(fetchTruthSuggestions(params)) + .then((suggestions: APIEntity[]) => { + const accountIds = suggestions.map((account) => account.account_id); + dispatch(fetchRelationships(accountIds)); + }) + .catch(() => { }); + } else if (features.suggestionsV2) { + return dispatch(fetchSuggestionsV2(params)) .then((suggestions: APIEntity[]) => { const accountIds = suggestions.map(({ account }) => account.id); dispatch(fetchRelationships(accountIds)); }) .catch(() => { }); } else if (features.suggestions) { - dispatch(fetchSuggestionsV1(params)) + return dispatch(fetchSuggestionsV1(params)) .then((accounts: APIEntity[]) => { const accountIds = accounts.map(({ id }) => id); dispatch(fetchRelationships(accountIds)); @@ -77,9 +133,14 @@ const fetchSuggestions = (params: Record = { limit: 50 }) => .catch(() => { }); } else { // Do nothing + return null; } }; +const fetchSuggestionsForTimeline = () => (dispatch: AppDispatch, _getState: () => RootState) => { + dispatch(fetchSuggestions({ limit: 20 }))?.then(() => dispatch(insertSuggestionsIntoTimeline())); +}; + const dismissSuggestion = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; @@ -100,8 +161,12 @@ export { SUGGESTIONS_V2_FETCH_REQUEST, SUGGESTIONS_V2_FETCH_SUCCESS, SUGGESTIONS_V2_FETCH_FAIL, + SUGGESTIONS_TRUTH_FETCH_REQUEST, + SUGGESTIONS_TRUTH_FETCH_SUCCESS, + SUGGESTIONS_TRUTH_FETCH_FAIL, fetchSuggestionsV1, fetchSuggestionsV2, fetchSuggestions, + fetchSuggestionsForTimeline, dismissSuggestion, }; diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index a962b068c..f1e9f5634 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -12,21 +12,22 @@ import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity, Status } from 'soapbox/types/entities'; -const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; -const TIMELINE_DELETE = 'TIMELINE_DELETE'; -const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; +const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; +const TIMELINE_DELETE = 'TIMELINE_DELETE'; +const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE'; const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE'; const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; -const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; +const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; -const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; +const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; const TIMELINE_REPLACE = 'TIMELINE_REPLACE'; +const TIMELINE_INSERT = 'TIMELINE_INSERT'; const MAX_QUEUED_ITEMS = 40; @@ -110,9 +111,9 @@ const dequeueTimeline = (timelineId: string, expandFunc?: (lastStatusId: string) const deleteFromTimelines = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const accountId = getState().statuses.get(id)?.account; + const accountId = getState().statuses.get(id)?.account; const references = getState().statuses.filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); - const reblogOf = getState().statuses.getIn([id, 'reblog'], null); + const reblogOf = getState().statuses.getIn([id, 'reblog'], null); dispatch({ type: TIMELINE_DELETE, @@ -127,7 +128,7 @@ const clearTimeline = (timeline: string) => (dispatch: AppDispatch) => dispatch({ type: TIMELINE_CLEAR, timeline }); -const noOp = () => {}; +const noOp = () => { }; const noOpAsync = () => () => new Promise(f => f(undefined)); const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none') => { @@ -139,9 +140,15 @@ const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none const replaceHomeTimeline = ( accountId: string | null, { maxId }: Record = {}, + done?: () => void, ) => (dispatch: AppDispatch, _getState: () => RootState) => { dispatch({ type: TIMELINE_REPLACE, accountId }); - dispatch(expandHomeTimeline({ accountId, maxId })); + dispatch(expandHomeTimeline({ accountId, maxId }, () => { + dispatch(insertSuggestionsIntoTimeline()); + if (done) { + done(); + } + })); }; const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => @@ -214,9 +221,9 @@ const expandGroupTimeline = (id: string, { maxId }: Record = {}, do const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, - any: parseTags(tags, 'any'), - all: parseTags(tags, 'all'), - none: parseTags(tags, 'none'), + any: parseTags(tags, 'any'), + all: parseTags(tags, 'all'), + none: parseTags(tags, 'none'), }, done); }; @@ -259,6 +266,10 @@ const scrollTopTimeline = (timeline: string, top: boolean) => ({ top, }); +const insertSuggestionsIntoTimeline = () => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: TIMELINE_INSERT, timeline: 'home' }); +}; + export { TIMELINE_UPDATE, TIMELINE_DELETE, @@ -272,6 +283,7 @@ export { TIMELINE_CONNECT, TIMELINE_DISCONNECT, TIMELINE_REPLACE, + TIMELINE_INSERT, MAX_QUEUED_ITEMS, processTimelineUpdate, updateTimeline, @@ -298,4 +310,5 @@ export { connectTimeline, disconnectTimeline, scrollTopTimeline, + insertSuggestionsIntoTimeline, }; diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index efa8b423c..5456ff6fb 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -9,7 +9,7 @@ import { getAcct } from 'soapbox/utils/accounts'; import { displayFqn } from 'soapbox/utils/state'; import RelativeTimestamp from './relative_timestamp'; -import { Avatar, Emoji, HStack, Icon, IconButton, Text } from './ui'; +import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui'; import type { Account as AccountEntity } from 'soapbox/types/entities'; @@ -57,7 +57,9 @@ interface IAccount { timestamp?: string | Date, timestampUrl?: string, futureTimestamp?: boolean, + withAccountNote?: boolean, withDate?: boolean, + withLinkToProfile?: boolean, withRelationship?: boolean, showEdit?: boolean, emoji?: string, @@ -78,7 +80,9 @@ const Account = ({ timestamp, timestampUrl, futureTimestamp = false, + withAccountNote = false, withDate = false, + withLinkToProfile = true, withRelationship = true, showEdit = false, emoji, @@ -154,12 +158,12 @@ const Account = ({ if (withDate) timestamp = account.created_at; - const LinkEl: any = showProfileHoverCard ? Link : 'div'; + const LinkEl: any = withLinkToProfile ? Link : 'div'; return (
- + {children}} @@ -202,35 +206,45 @@ const Account = ({ - - @{username} + + + @{username} - {account.favicon && ( - - )} + {account.favicon && ( + + )} - {(timestamp) ? ( - <> - · + {(timestamp) ? ( + <> + · - {timestampUrl ? ( - + {timestampUrl ? ( + + + + ) : ( - - ) : ( - - )} - - ) : null} + )} + + ) : null} - {showEdit ? ( - <> - · + {showEdit ? ( + <> + · - - - ) : null} - + + + ) : null} + + + {withAccountNote && ( + + )} +
diff --git a/app/soapbox/components/animated-number.tsx b/app/soapbox/components/animated-number.tsx new file mode 100644 index 000000000..0f6908fde --- /dev/null +++ b/app/soapbox/components/animated-number.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react'; +import { FormattedNumber } from 'react-intl'; +import { TransitionMotion, spring } from 'react-motion'; + +import { useSettings } from 'soapbox/hooks'; + +const obfuscatedCount = (count: number) => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + +interface IAnimatedNumber { + value: number; + obfuscate?: boolean; +} + +const AnimatedNumber: React.FC = ({ value, obfuscate }) => { + const reduceMotion = useSettings().get('reduceMotion'); + + const [direction, setDirection] = useState(1); + const [displayedValue, setDisplayedValue] = useState(value); + + useEffect(() => { + if (displayedValue !== undefined) { + if (value > displayedValue) setDirection(1); + else if (value < displayedValue) setDirection(-1); + } + setDisplayedValue(value); + }, [value]); + + const willEnter = () => ({ y: -1 * direction }); + + const willLeave = () => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }); + + if (reduceMotion) { + return obfuscate ? <>{obfuscatedCount(displayedValue)} : ; + } + + const styles = [{ + key: `${displayedValue}`, + data: displayedValue, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + + {items => ( + + {items.map(({ key, data, style }) => ( + 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : } + ))} + + )} + + ); +}; + +export default AnimatedNumber; \ No newline at end of file diff --git a/app/soapbox/components/announcements/announcement-content.tsx b/app/soapbox/components/announcements/announcement-content.tsx new file mode 100644 index 000000000..f4265d1fd --- /dev/null +++ b/app/soapbox/components/announcements/announcement-content.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useRef } from 'react'; +import { useHistory } from 'react-router-dom'; + +import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities'; + +interface IAnnouncementContent { + announcement: AnnouncementEntity; +} + +const AnnouncementContent: React.FC = ({ announcement }) => { + const history = useHistory(); + + const node = useRef(null); + + useEffect(() => { + updateLinks(); + }); + + const onMentionClick = (mention: MentionEntity, e: MouseEvent) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/@${mention.acct}`); + } + }; + + const onHashtagClick = (hashtag: string, e: MouseEvent) => { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/tags/${hashtag}`); + } + }; + + const onStatusClick = (status: string, e: MouseEvent) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + history.push(status); + } + }; + + const updateLinks = () => { + if (!node.current) return; + + const links = node.current.querySelectorAll('a'); + + links.forEach(link => { + // Skip already processed + if (link.classList.contains('status-link')) return; + + // Add attributes + link.classList.add('status-link'); + link.setAttribute('rel', 'nofollow noopener'); + link.setAttribute('target', '_blank'); + + const mention = announcement.mentions.find(mention => link.href === `${mention.url}`); + + // Add event listeners on mentions, hashtags and statuses + if (mention) { + link.addEventListener('click', onMentionClick.bind(link, mention), false); + link.setAttribute('title', mention.acct); + } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) { + link.addEventListener('click', onHashtagClick.bind(link, link.text), false); + } else { + const status = announcement.statuses.get(link.href); + if (status) { + link.addEventListener('click', onStatusClick.bind(this, status), false); + } + link.setAttribute('title', link.href); + link.classList.add('unhandled-link'); + } + }); + }; + + return ( +
+ ); +}; + +export default AnnouncementContent; diff --git a/app/soapbox/components/announcements/announcement.tsx b/app/soapbox/components/announcements/announcement.tsx new file mode 100644 index 000000000..f6344f7b5 --- /dev/null +++ b/app/soapbox/components/announcements/announcement.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { FormattedDate } from 'react-intl'; + +import { Stack, Text } from 'soapbox/components/ui'; +import { useFeatures } from 'soapbox/hooks'; + +import AnnouncementContent from './announcement-content'; +import ReactionsBar from './reactions-bar'; + +import type { Map as ImmutableMap } from 'immutable'; +import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities'; + +interface IAnnouncement { + announcement: AnnouncementEntity; + addReaction: (id: string, name: string) => void; + removeReaction: (id: string, name: string) => void; + emojiMap: ImmutableMap>; +} + +const Announcement: React.FC = ({ announcement, addReaction, removeReaction, emojiMap }) => { + const features = useFeatures(); + + const startsAt = announcement.starts_at && new Date(announcement.starts_at); + const endsAt = announcement.ends_at && new Date(announcement.ends_at); + const now = new Date(); + const hasTimeRange = startsAt && endsAt; + const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear(); + const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear(); + const skipTime = announcement.all_day; + + return ( + + {hasTimeRange && ( + + + {' '} + - + {' '} + + + )} + + + + {features.announcementsReactions && ( + + )} + + ); +}; + +export default Announcement; diff --git a/app/soapbox/components/announcements/announcements-panel.tsx b/app/soapbox/components/announcements/announcements-panel.tsx new file mode 100644 index 000000000..200615dab --- /dev/null +++ b/app/soapbox/components/announcements/announcements-panel.tsx @@ -0,0 +1,69 @@ +import classNames from 'classnames'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import ReactSwipeableViews from 'react-swipeable-views'; +import { createSelector } from 'reselect'; + +import { addReaction as addReactionAction, removeReaction as removeReactionAction } from 'soapbox/actions/announcements'; +import { Card, HStack, Widget } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import Announcement from './announcement'; + +import type { RootState } from 'soapbox/store'; + +const customEmojiMap = createSelector([(state: RootState) => state.custom_emojis], items => (items as ImmutableList>).reduce((map, emoji) => map.set(emoji.get('shortcode')!, emoji), ImmutableMap>())); + +const AnnouncementsPanel = () => { + const dispatch = useAppDispatch(); + const emojiMap = useAppSelector(state => customEmojiMap(state)); + const [index, setIndex] = useState(0); + + const announcements = useAppSelector((state) => state.announcements.items); + + const addReaction = (id: string, name: string) => dispatch(addReactionAction(id, name)); + const removeReaction = (id: string, name: string) => dispatch(removeReactionAction(id, name)); + + if (announcements.size === 0) return null; + + const handleChangeIndex = (index: number) => { + setIndex(index % announcements.size); + }; + + return ( + }> + + + {announcements.map((announcement) => ( + + )).reverse()} + + {announcements.size > 1 && ( + + {announcements.map((_, i) => ( + + ); +}; + +export default Reaction; diff --git a/app/soapbox/components/announcements/reactions-bar.tsx b/app/soapbox/components/announcements/reactions-bar.tsx new file mode 100644 index 000000000..66b5f3f83 --- /dev/null +++ b/app/soapbox/components/announcements/reactions-bar.tsx @@ -0,0 +1,65 @@ +import classNames from 'classnames'; +import React from 'react'; +import { TransitionMotion, spring } from 'react-motion'; + +import { Icon } from 'soapbox/components/ui'; +import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container'; +import { useSettings } from 'soapbox/hooks'; + +import Reaction from './reaction'; + +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import type { Emoji } from 'soapbox/components/autosuggest_emoji'; +import type { AnnouncementReaction } from 'soapbox/types/entities'; + +interface IReactionsBar { + announcementId: string; + reactions: ImmutableList; + emojiMap: ImmutableMap>; + addReaction: (id: string, name: string) => void; + removeReaction: (id: string, name: string) => void; +} + +const ReactionsBar: React.FC = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => { + const reduceMotion = useSettings().get('reduceMotion'); + + const handleEmojiPick = (data: Emoji) => { + addReaction(announcementId, data.native.replace(/:/g, '')); + }; + + const willEnter = () => ({ scale: reduceMotion ? 1 : 0 }); + + const willLeave = () => ({ scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }); + + const visibleReactions = reactions.filter(x => x.count > 0); + + const styles = visibleReactions.map(reaction => ({ + key: reaction.name, + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} + + {visibleReactions.size < 8 && } />} +
+ )} +
+ ); +}; + +export default ReactionsBar; diff --git a/app/soapbox/components/autosuggest_textarea.js b/app/soapbox/components/autosuggest_textarea.tsx similarity index 79% rename from app/soapbox/components/autosuggest_textarea.js rename to app/soapbox/components/autosuggest_textarea.tsx index 9a7ff45dc..69d29261f 100644 --- a/app/soapbox/components/autosuggest_textarea.js +++ b/app/soapbox/components/autosuggest_textarea.tsx @@ -1,17 +1,17 @@ import Portal from '@reach/portal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; import AutosuggestAccount from '../features/compose/components/autosuggest_account'; import { isRtl } from '../rtl'; -import AutosuggestEmoji from './autosuggest_emoji'; +import AutosuggestEmoji, { Emoji } from './autosuggest_emoji'; -const textAtCursorMatchesToken = (str, caretPosition) => { +import type { List as ImmutableList } from 'immutable'; + +const textAtCursorMatchesToken = (str: string, caretPosition: number) => { let word; const left = str.slice(0, caretPosition).search(/\S+$/); @@ -36,25 +36,28 @@ const textAtCursorMatchesToken = (str, caretPosition) => { } }; -export default class AutosuggestTextarea extends ImmutablePureComponent { +interface IAutosuggesteTextarea { + id?: string, + value: string, + suggestions: ImmutableList, + disabled: boolean, + placeholder: string, + onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void, + onSuggestionsClearRequested: () => void, + onSuggestionsFetchRequested: (token: string | number) => void, + onChange: React.ChangeEventHandler, + onKeyUp: React.KeyboardEventHandler, + onKeyDown: React.KeyboardEventHandler, + onPaste: (files: FileList) => void, + autoFocus: boolean, + onFocus: () => void, + onBlur?: () => void, + condensed?: boolean, +} - static propTypes = { - value: PropTypes.string, - suggestions: ImmutablePropTypes.list, - disabled: PropTypes.bool, - placeholder: PropTypes.string, - onSuggestionSelected: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onKeyUp: PropTypes.func, - onKeyDown: PropTypes.func, - onPaste: PropTypes.func.isRequired, - autoFocus: PropTypes.bool, - onFocus: PropTypes.func, - onBlur: PropTypes.func, - condensed: PropTypes.bool, - }; +class AutosuggestTextarea extends ImmutablePureComponent { + + textarea: HTMLTextAreaElement | null = null; static defaultProps = { autoFocus: true, @@ -68,7 +71,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { tokenStart: 0, }; - onChange = (e) => { + onChange: React.ChangeEventHandler = (e) => { const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); if (token !== null && this.state.lastToken !== token) { @@ -82,7 +85,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { this.props.onChange(e); } - onKeyDown = (e) => { + onKeyDown: React.KeyboardEventHandler = (e) => { const { suggestions, disabled } = this.props; const { selectedSuggestion, suggestionsHidden } = this.state; @@ -91,7 +94,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { return; } - if (e.which === 229 || e.isComposing) { + if (e.which === 229 || (e as any).isComposing) { // Ignore key events during text composition // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) return; @@ -100,7 +103,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { switch (e.key) { case 'Escape': if (suggestions.size === 0 || suggestionsHidden) { - document.querySelector('.ui').parentElement.focus(); + document.querySelector('.ui')?.parentElement?.focus(); } else { e.preventDefault(); this.setState({ suggestionsHidden: true }); @@ -156,14 +159,14 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } } - onSuggestionClick = (e) => { - const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + onSuggestionClick: React.MouseEventHandler = (e) => { + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index') as any); e.preventDefault(); this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); - this.textarea.focus(); + this.textarea?.focus(); } - shouldComponentUpdate(nextProps, nextState) { + shouldComponentUpdate(nextProps: IAutosuggesteTextarea, nextState: any) { // Skip updating when only the lastToken changes so the // cursor doesn't jump around due to re-rendering unnecessarily const lastTokenUpdated = this.state.lastToken !== nextState.lastToken; @@ -172,29 +175,29 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { if (lastTokenUpdated && !valueUpdated) { return false; } else { - return super.shouldComponentUpdate(nextProps, nextState); + return super.shouldComponentUpdate!(nextProps, nextState, undefined); } } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: IAutosuggesteTextarea, prevState: any) { const { suggestions } = this.props; if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) { this.setState({ suggestionsHidden: false }); } } - setTextarea = (c) => { + setTextarea: React.Ref = (c) => { this.textarea = c; } - onPaste = (e) => { + onPaste: React.ClipboardEventHandler = (e) => { if (e.clipboardData && e.clipboardData.files.length === 1) { this.props.onPaste(e.clipboardData.files); e.preventDefault(); } } - renderSuggestion = (suggestion, i) => { + renderSuggestion = (suggestion: string | Emoji, i: number) => { const { selectedSuggestion } = this.state; let inner, key; @@ -212,7 +215,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { return (
@@ -297,3 +300,5 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } } + +export default AutosuggestTextarea; diff --git a/app/soapbox/components/dropdown_menu.tsx b/app/soapbox/components/dropdown_menu.tsx index ed8d87c16..3118b0d2d 100644 --- a/app/soapbox/components/dropdown_menu.tsx +++ b/app/soapbox/components/dropdown_menu.tsx @@ -18,7 +18,7 @@ let id = 0; export interface MenuItem { action?: React.EventHandler, middleClick?: React.EventHandler, - text: string | JSX.Element, + text: string, href?: string, to?: string, newTab?: boolean, diff --git a/app/soapbox/components/fork_awesome_icon.js b/app/soapbox/components/fork_awesome_icon.js deleted file mode 100644 index 1d85f1288..000000000 --- a/app/soapbox/components/fork_awesome_icon.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * ForkAwesomeIcon: renders a ForkAwesome icon. - * Full list: https://forkaweso.me/Fork-Awesome/icons/ - * @module soapbox/components/fork_awesome_icon - * @see soapbox/components/icon - */ - -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; - -export default class ForkAwesomeIcon extends React.PureComponent { - - static propTypes = { - id: PropTypes.string.isRequired, - className: PropTypes.string, - fixedWidth: PropTypes.bool, - }; - - render() { - const { id, className, fixedWidth, ...other } = this.props; - - // Use the Fork Awesome retweet icon, but change its alt - // tag. There is a common adblocker rule which hides elements with - // alt='retweet' unless the domain is twitter.com. This should - // change what screenreaders call it as well. - const alt = (id === 'retweet') ? 'repost' : id; - - return ( - - ); - } - -} diff --git a/app/soapbox/components/fork_awesome_icon.tsx b/app/soapbox/components/fork_awesome_icon.tsx new file mode 100644 index 000000000..45b146e08 --- /dev/null +++ b/app/soapbox/components/fork_awesome_icon.tsx @@ -0,0 +1,34 @@ +/** + * ForkAwesomeIcon: renders a ForkAwesome icon. + * Full list: https://forkaweso.me/Fork-Awesome/icons/ + * @module soapbox/components/fork_awesome_icon + * @see soapbox/components/icon + */ + +import classNames from 'classnames'; +import React from 'react'; + +export interface IForkAwesomeIcon extends React.HTMLAttributes { + id: string, + className?: string, + fixedWidth?: boolean, +} + +const ForkAwesomeIcon: React.FC = ({ id, className, fixedWidth, ...rest }) => { + // Use the Fork Awesome retweet icon, but change its alt + // tag. There is a common adblocker rule which hides elements with + // alt='retweet' unless the domain is twitter.com. This should + // change what screenreaders call it as well. + // const alt = (id === 'retweet') ? 'repost' : id; + + return ( + + ); +}; + +export default ForkAwesomeIcon; diff --git a/app/soapbox/components/icon.js b/app/soapbox/components/icon.js deleted file mode 100644 index 3a7059061..000000000 --- a/app/soapbox/components/icon.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Icon: abstract icon class that can render icons from multiple sets. - * @module soapbox/components/icon - * @see soapbox/components/fork_awesome_icon - * @see soapbox/components/svg_icon - */ - -import PropTypes from 'prop-types'; -import React from 'react'; - -import ForkAwesomeIcon from './fork_awesome_icon'; -import SvgIcon from './svg_icon'; - -export default class Icon extends React.PureComponent { - - static propTypes = { - id: PropTypes.string, - src: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - className: PropTypes.string, - fixedWidth: PropTypes.bool, - }; - - render() { - const { id, src, fixedWidth, ...rest } = this.props; - - if (src) { - return ; - } else { - return ; - } - } - -} diff --git a/app/soapbox/components/icon.tsx b/app/soapbox/components/icon.tsx new file mode 100644 index 000000000..cba7b5805 --- /dev/null +++ b/app/soapbox/components/icon.tsx @@ -0,0 +1,27 @@ +/** + * Icon: abstract icon class that can render icons from multiple sets. + * @module soapbox/components/icon + * @see soapbox/components/fork_awesome_icon + * @see soapbox/components/svg_icon + */ + +import React from 'react'; + +import ForkAwesomeIcon, { IForkAwesomeIcon } from './fork_awesome_icon'; +import SvgIcon, { ISvgIcon } from './svg_icon'; + +export type IIcon = IForkAwesomeIcon | ISvgIcon; + +const Icon: React.FC = (props) => { + if ((props as ISvgIcon).src) { + const { src, ...rest } = (props as ISvgIcon); + + return ; + } else { + const { id, fixedWidth, ...rest } = (props as IForkAwesomeIcon); + + return ; + } +}; + +export default Icon; diff --git a/app/soapbox/components/icon_with_counter.tsx b/app/soapbox/components/icon_with_counter.tsx index d0fd093a6..2d95cb9f9 100644 --- a/app/soapbox/components/icon_with_counter.tsx +++ b/app/soapbox/components/icon_with_counter.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import Icon from 'soapbox/components/icon'; +import Icon, { IIcon } from 'soapbox/components/icon'; import { Counter } from 'soapbox/components/ui'; interface IIconWithCounter extends React.HTMLAttributes { @@ -12,7 +12,7 @@ interface IIconWithCounter extends React.HTMLAttributes { const IconWithCounter: React.FC = ({ icon, count, ...rest }) => { return (
- + {count > 0 && ( diff --git a/app/soapbox/components/quoted-status.tsx b/app/soapbox/components/quoted-status.tsx index 4c48e85a1..e00ca10e5 100644 --- a/app/soapbox/components/quoted-status.tsx +++ b/app/soapbox/components/quoted-status.tsx @@ -137,6 +137,7 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => timestamp={status.created_at} withRelationship={false} showProfileHoverCard={!compose} + withLinkToProfile={!compose} /> {renderReplyMentions()} diff --git a/app/soapbox/components/scroll-top-button.tsx b/app/soapbox/components/scroll-top-button.tsx index 37be35ee6..64c7c4629 100644 --- a/app/soapbox/components/scroll-top-button.tsx +++ b/app/soapbox/components/scroll-top-button.tsx @@ -34,6 +34,12 @@ const ScrollTopButton: React.FC = ({ const [scrolled, setScrolled] = useState(false); const autoload = settings.get('autoloadTimelines') === true; + const visible = count > 0 && scrolled; + + const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', { + 'hidden': !visible, + }); + const getScrollTop = (): number => { return (document.scrollingElement || document.documentElement).scrollTop; }; @@ -75,12 +81,6 @@ const ScrollTopButton: React.FC = ({ maybeUnload(); }, [count]); - const visible = count > 0 && scrolled; - - const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', { - 'hidden': !visible, - }); - return (
diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index 92cf79348..0e2708dd8 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useRef, useMemo, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle, ListRange, IndexLocationWithAlign } from 'react-virtuoso'; -import PullToRefresh from 'soapbox/components/pull-to-refresh'; import { useSettings } from 'soapbox/hooks'; import LoadMore from './load_more'; @@ -63,7 +62,10 @@ interface IScrollableList extends VirtuosoProps { placeholderComponent?: React.ComponentType | React.NamedExoticComponent, /** Number of placeholders to render while loading. */ placeholderCount?: number, - /** Pull to refresh callback. */ + /** + * Pull to refresh callback. + * @deprecated Put a PTR around the component instead. + */ onRefresh?: () => Promise, /** Extra class names on the Virtuoso element. */ className?: string, @@ -244,20 +246,12 @@ const ScrollableList = React.forwardRef(({ /> ); - /** Conditionally render inner elements. */ - const renderBody = (): JSX.Element => { - if (isEmpty) { - return renderEmpty(); - } else { - return renderFeed(); - } - }; - - return ( - - {renderBody()} - - ); + // Conditionally render inner elements. + if (isEmpty) { + return renderEmpty(); + } else { + return renderFeed(); + } }); export default ScrollableList; diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index b2bbc941f..583006070 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { getSettings } from 'soapbox/actions/settings'; import DropdownMenu from 'soapbox/containers/dropdown_menu_container'; @@ -11,8 +11,20 @@ import SidebarNavigationLink from './sidebar-navigation-link'; import type { Menu } from 'soapbox/components/dropdown_menu'; +const messages = defineMessages({ + follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, + bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, + lists: { id: 'column.lists', defaultMessage: 'Lists' }, + developers: { id: 'navigation.developers', defaultMessage: 'Developers' }, + dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' }, + all: { id: 'tabs_bar.all', defaultMessage: 'All' }, + fediverse: { id: 'tabs_bar.fediverse', defaultMessage: 'Fediverse' }, +}); + /** Desktop sidebar with links to different views in the app. */ const SidebarNavigation = () => { + const intl = useIntl(); + const instance = useAppSelector((state) => state.instance); const settings = useAppSelector((state) => getSettings(state)); const account = useOwnAccount(); @@ -30,7 +42,7 @@ const SidebarNavigation = () => { if (account.locked || followRequestsCount > 0) { menu.push({ to: '/follow_requests', - text: , + text: intl.formatMessage(messages.follow_requests), icon: require('@tabler/icons/user-plus.svg'), count: followRequestsCount, }); @@ -39,7 +51,7 @@ const SidebarNavigation = () => { if (features.bookmarks) { menu.push({ to: '/bookmarks', - text: , + text: intl.formatMessage(messages.bookmarks), icon: require('@tabler/icons/bookmark.svg'), }); } @@ -47,7 +59,7 @@ const SidebarNavigation = () => { if (features.lists) { menu.push({ to: '/lists', - text: , + text: intl.formatMessage(messages.lists), icon: require('@tabler/icons/list.svg'), }); } @@ -56,7 +68,7 @@ const SidebarNavigation = () => { menu.push({ to: '/developers', icon: require('@tabler/icons/code.svg'), - text: , + text: intl.formatMessage(messages.developers), }); } @@ -64,7 +76,7 @@ const SidebarNavigation = () => { menu.push({ to: '/soapbox/admin', icon: require('@tabler/icons/dashboard.svg'), - text: , + text: intl.formatMessage(messages.dashboard), count: dashboardCount, }); } @@ -78,7 +90,7 @@ const SidebarNavigation = () => { menu.push({ to: '/timeline/local', icon: features.federating ? require('@tabler/icons/users.svg') : require('@tabler/icons/world.svg'), - text: features.federating ? instance.title : , + text: features.federating ? instance.title : intl.formatMessage(messages.all), }); } @@ -86,7 +98,7 @@ const SidebarNavigation = () => { menu.push({ to: '/timeline/fediverse', icon: require('icons/fediverse.svg'), - text: , + text: intl.formatMessage(messages.fediverse), }); } diff --git a/app/soapbox/components/sidebar_menu.tsx b/app/soapbox/components/sidebar_menu.tsx index 787c8ed76..ee7ee1adc 100644 --- a/app/soapbox/components/sidebar_menu.tsx +++ b/app/soapbox/components/sidebar_menu.tsx @@ -84,7 +84,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const getAccount = makeGetAccount(); const instance = useAppSelector((state) => state.instance); const me = useAppSelector((state) => state.me); - const account = useAppSelector((state) => me ? getAccount(state, me) : null); + const account = useAppSelector((state) => me ? getAccount(state, me) : null); const otherAccounts: ImmutableList = useAppSelector((state) => getOtherAccounts(state)); const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const settings = useAppSelector((state) => getSettings(state)); @@ -121,7 +121,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const renderAccount = (account: AccountEntity) => (
- +
); @@ -166,7 +166,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { - + diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index a7a775427..1f1be8b74 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -132,11 +132,11 @@ class Status extends ImmutablePureComponent { this.didShowCard = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card); } - getSnapshotBeforeUpdate(): ScrollPosition | undefined { + getSnapshotBeforeUpdate(): ScrollPosition | null { if (this.props.getScrollPosition) { - return this.props.getScrollPosition(); + return this.props.getScrollPosition() || null; } else { - return undefined; + return null; } } @@ -481,6 +481,7 @@ class Status extends ImmutablePureComponent { hideActions={!reblogElement} showEdit={!!status.edited_at} showProfileHoverCard={this.props.hoverable} + withLinkToProfile={this.props.hoverable} />
diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 7a86778eb..408506997 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -6,6 +6,7 @@ import { FormattedMessage } from 'react-intl'; import LoadGap from 'soapbox/components/load_gap'; import ScrollableList from 'soapbox/components/scrollable_list'; import StatusContainer from 'soapbox/containers/status_container'; +import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; import PendingStatus from 'soapbox/features/ui/components/pending_status'; @@ -35,7 +36,7 @@ interface IStatusList extends Omit { /** ID of the timeline in Redux. */ timelineId?: string, /** Whether to display a gap or border between statuses in the list. */ - divideType: 'space' | 'border', + divideType?: 'space' | 'border', } /** Feed of statuses, built atop ScrollableList. */ @@ -77,7 +78,7 @@ const StatusList: React.FC = ({ const handleLoadOlder = useCallback(debounce(() => { const maxId = lastStatusId || statusIds.last(); if (onLoadMore && maxId) { - onLoadMore(maxId); + onLoadMore(maxId.replace('末suggestions-', '')); } }, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds.last()]); @@ -149,11 +150,17 @@ const StatusList: React.FC = ({ )); }; + const renderFeedSuggestions = (): React.ReactNode => { + return ; + }; + const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.size > 0) { return statusIds.toArray().map((statusId, index) => { if (statusId === null) { return renderLoadGap(index); + } else if (statusId.startsWith('末suggestions-')) { + return renderFeedSuggestions(); } else if (statusId.startsWith('末pending-')) { return renderPendingStatus(statusId); } else { diff --git a/app/soapbox/components/sub_navigation.js b/app/soapbox/components/sub_navigation.js deleted file mode 100644 index f75ca802f..000000000 --- a/app/soapbox/components/sub_navigation.js +++ /dev/null @@ -1,105 +0,0 @@ -import throttle from 'lodash/throttle'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { injectIntl, defineMessages } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; - -import { openModal } from 'soapbox/actions/modals'; - -import { CardHeader, CardTitle } from './ui'; - -const messages = defineMessages({ - back: { id: 'column_back_button.label', defaultMessage: 'Back' }, - settings: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, -}); - -const mapDispatchToProps = (dispatch, { settings: Settings }) => { - return { - onOpenSettings() { - dispatch(openModal('COMPONENT', { component: Settings })); - }, - }; -}; - -export default @connect(undefined, mapDispatchToProps) -@injectIntl -@withRouter -class SubNavigation extends React.PureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - message: PropTypes.string, - settings: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - onOpenSettings: PropTypes.func.isRequired, - history: PropTypes.object, - } - - state = { - scrolled: false, - } - - handleBackClick = () => { - if (window.history && window.history.length === 1) { - this.props.history.push('/'); - } else { - this.props.history.goBack(); - } - } - - handleBackKeyUp = (e) => { - if (e.key === 'Enter') { - this.handleClick(); - } - } - - componentDidMount() { - this.attachScrollListener(); - } - - componentWillUnmount() { - this.detachScrollListener(); - } - - attachScrollListener() { - window.addEventListener('scroll', this.handleScroll); - } - - detachScrollListener() { - window.removeEventListener('scroll', this.handleScroll); - } - - handleScroll = throttle(() => { - if (this.node) { - const { offsetTop } = this.node; - - if (offsetTop > 0) { - this.setState({ scrolled: true }); - } else { - this.setState({ scrolled: false }); - } - } - }, 150, { trailing: true }); - - handleOpenSettings = () => { - this.props.onOpenSettings(); - } - - setRef = c => { - this.node = c; - } - - render() { - const { intl, message } = this.props; - - return ( - - - - ); - } - -} diff --git a/app/soapbox/components/sub_navigation.tsx b/app/soapbox/components/sub_navigation.tsx new file mode 100644 index 000000000..b8e2b310d --- /dev/null +++ b/app/soapbox/components/sub_navigation.tsx @@ -0,0 +1,83 @@ +// import throttle from 'lodash/throttle'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +// import { connect } from 'react-redux'; +import { useHistory } from 'react-router-dom'; + +// import { openModal } from 'soapbox/actions/modals'; +// import { useAppDispatch } from 'soapbox/hooks'; + +import { CardHeader, CardTitle } from './ui'; + +const messages = defineMessages({ + back: { id: 'column_back_button.label', defaultMessage: 'Back' }, + settings: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, +}); + +interface ISubNavigation { + message: String, + settings?: React.ComponentType, +} + +const SubNavigation: React.FC = ({ message }) => { + const intl = useIntl(); + // const dispatch = useAppDispatch(); + const history = useHistory(); + + // const ref = useRef(null); + + // const [scrolled, setScrolled] = useState(false); + + // const onOpenSettings = () => { + // dispatch(openModal('COMPONENT', { component: Settings })); + // }; + + const handleBackClick = () => { + if (window.history && window.history.length === 1) { + history.push('/'); + } else { + history.goBack(); + } + }; + + // const handleBackKeyUp = (e) => { + // if (e.key === 'Enter') { + // handleClick(); + // } + // } + + // const handleOpenSettings = () => { + // onOpenSettings(); + // } + + // useEffect(() => { + // const handleScroll = throttle(() => { + // if (this.node) { + // const { offsetTop } = this.node; + + // if (offsetTop > 0) { + // setScrolled(true); + // } else { + // setScrolled(false); + // } + // } + // }, 150, { trailing: true }); + + // window.addEventListener('scroll', handleScroll); + + // return () => { + // window.removeEventListener('scroll', handleScroll); + // }; + // }, []); + + return ( + + + + ); +}; + +export default SubNavigation; diff --git a/app/soapbox/components/svg_icon.js b/app/soapbox/components/svg_icon.js deleted file mode 100644 index 04f0cd526..000000000 --- a/app/soapbox/components/svg_icon.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * SvgIcon: abstact component to render SVG icons. - * @module soapbox/components/svg_icon - * @see soapbox/components/icon - */ - -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports - -export default class SvgIcon extends React.PureComponent { - - static propTypes = { - src: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, - alt: PropTypes.string, - className: PropTypes.string, - }; - - render() { - const { src, className, alt, ...other } = this.props; - - return ( -
- } /> -
- ); - } - -} diff --git a/app/soapbox/components/svg_icon.tsx b/app/soapbox/components/svg_icon.tsx new file mode 100644 index 000000000..a81979d0d --- /dev/null +++ b/app/soapbox/components/svg_icon.tsx @@ -0,0 +1,29 @@ +/** + * SvgIcon: abstact component to render SVG icons. + * @module soapbox/components/svg_icon + * @see soapbox/components/icon + */ + +import classNames from 'classnames'; +import React from 'react'; +import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports + +export interface ISvgIcon extends React.HTMLAttributes { + src: string, + id?: string, + alt?: string, + className?: string, +} + +const SvgIcon: React.FC = ({ src, alt, className, ...rest }) => { + return ( +
+ } /> +
+ ); +}; + +export default SvgIcon; diff --git a/app/soapbox/components/tombstone.tsx b/app/soapbox/components/tombstone.tsx index e90125b39..c35da4b17 100644 --- a/app/soapbox/components/tombstone.tsx +++ b/app/soapbox/components/tombstone.tsx @@ -21,7 +21,7 @@ const Tombstone: React.FC = ({ id, onMoveUp, onMoveDown }) => {
- +
diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index 5ac4f350e..81b7fca1e 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -18,6 +18,8 @@ export interface IColumn { withHeader?: boolean, /** Extra class name for top
element. */ className?: string, + /** Ref forwarded to column. */ + ref?: React.Ref } /** A backdrop for the main section of the UI. */ diff --git a/app/soapbox/components/ui/hstack/hstack.tsx b/app/soapbox/components/ui/hstack/hstack.tsx index 9769ebc60..2a021d903 100644 --- a/app/soapbox/components/ui/hstack/hstack.tsx +++ b/app/soapbox/components/ui/hstack/hstack.tsx @@ -6,6 +6,7 @@ const justifyContentOptions = { center: 'justify-center', start: 'justify-start', end: 'justify-end', + around: 'justify-around', }; const alignItemsOptions = { @@ -32,7 +33,7 @@ interface IHStack { /** Extra class names on the
element. */ className?: string, /** Horizontal alignment of children. */ - justifyContent?: 'between' | 'center' | 'start' | 'end', + justifyContent?: 'between' | 'center' | 'start' | 'end' | 'around', /** Size of the gap between elements. */ space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | 8, /** Whether to let the flexbox grow. */ diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index cd48426b6..042b26838 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -27,6 +27,7 @@ export { MenuList, } from './menu/menu'; export { default as Modal } from './modal/modal'; +export { default as PhoneInput } from './phone-input/phone-input'; export { default as ProgressBar } from './progress-bar/progress-bar'; export { default as Select } from './select/select'; export { default as Spinner } from './spinner/spinner'; diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 488f2733e..9a4785c7b 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -20,7 +20,7 @@ interface IInput extends Pick, 'maxL className?: string, /** Extra class names for the outer
element. */ outerClassName?: string, - /** URL to the svg icon. */ + /** URL to the svg icon. Cannot be used with addon. */ icon?: string, /** Internal input name. */ name?: string, @@ -31,9 +31,11 @@ interface IInput extends Pick, 'maxL /** Change event handler for the input. */ onChange?: (event: React.ChangeEvent) => void, /** HTML input type. */ - type: 'text' | 'number' | 'email' | 'tel' | 'password', + type?: 'text' | 'number' | 'email' | 'tel' | 'password', /** Whether to display the input in red. */ hasError?: boolean, + /** An element to display as prefix to input. Cannot be used with icon. */ + addon?: React.ReactElement, } /** Form input element. */ @@ -41,7 +43,7 @@ const Input = React.forwardRef( (props, ref) => { const intl = useIntl(); - const { type = 'text', icon, className, outerClassName, hasError, ...filteredProps } = props; + const { type = 'text', icon, className, outerClassName, hasError, addon, ...filteredProps } = props; const [revealed, setRevealed] = React.useState(false); @@ -59,6 +61,12 @@ const Input = React.forwardRef(
) : null} + {addon ? ( +
+ {addon} +
+ ) : null} + ( 'pr-7': isPassword, 'text-red-600 border-red-600': hasError, 'pl-8': typeof icon !== 'undefined', + 'pl-16': typeof addon !== 'undefined', }, className)} /> diff --git a/app/soapbox/components/ui/phone-input/country-code-dropdown.tsx b/app/soapbox/components/ui/phone-input/country-code-dropdown.tsx new file mode 100644 index 000000000..aaf2b4ac5 --- /dev/null +++ b/app/soapbox/components/ui/phone-input/country-code-dropdown.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { COUNTRY_CODES, CountryCode } from 'soapbox/utils/phone'; + +interface ICountryCodeDropdown { + countryCode: CountryCode, + onChange(countryCode: CountryCode): void, +} + +/** Dropdown menu to select a country code. */ +const CountryCodeDropdown: React.FC = ({ countryCode, onChange }) => { + return ( + + ); +}; + +export default CountryCodeDropdown; diff --git a/app/soapbox/components/ui/phone-input/phone-input.tsx b/app/soapbox/components/ui/phone-input/phone-input.tsx new file mode 100644 index 000000000..81d2723c4 --- /dev/null +++ b/app/soapbox/components/ui/phone-input/phone-input.tsx @@ -0,0 +1,81 @@ +import { parsePhoneNumber, AsYouType } from 'libphonenumber-js'; +import React, { useState, useEffect } from 'react'; + +import { CountryCode } from 'soapbox/utils/phone'; + +import Input from '../input/input'; + +import CountryCodeDropdown from './country-code-dropdown'; + +interface IPhoneInput extends Pick, 'required' | 'autoFocus'> { + /** E164 phone number. */ + value?: string, + /** Change handler which receives the E164 phone string. */ + onChange?: (phone: string | undefined) => void, + /** Country code that's selected on mount. */ + defaultCountryCode?: CountryCode, +} + +/** Internationalized phone input with country code picker. */ +const PhoneInput: React.FC = (props) => { + const { value, onChange, defaultCountryCode = '1', ...rest } = props; + + const [countryCode, setCountryCode] = useState(defaultCountryCode); + const [nationalNumber, setNationalNumber] = useState(''); + + const handleChange: React.ChangeEventHandler = ({ target }) => { + // HACK: AsYouType is not meant to be used this way. But it works! + const asYouType = new AsYouType({ defaultCallingCode: countryCode }); + const formatted = asYouType.input(target.value); + + // If the new value is the same as before, we might be backspacing, + // so use the actual event value instead of the formatted value. + if (formatted === nationalNumber && target.value !== nationalNumber) { + setNationalNumber(target.value); + } else { + setNationalNumber(formatted); + } + }; + + // When the internal state changes, update the external state. + useEffect(() => { + if (onChange) { + try { + const opts = { defaultCallingCode: countryCode, extract: false } as any; + const result = parsePhoneNumber(nationalNumber, opts); + + // Throw if the number is invalid, but catch it below. + // We'll only ever call `onChange` with a valid E164 string or `undefined`. + if (!result.isPossible()) { + throw result; + } + + onChange(result.format('E.164')); + } catch (e) { + // The value returned is always a valid E164 string. + // If it's not valid, it'll return undefined. + onChange(undefined); + } + } + }, [countryCode, nationalNumber]); + + useEffect(() => { + handleChange({ target: { value: nationalNumber } } as any); + }, [countryCode, nationalNumber]); + + return ( + + } + {...rest} + /> + ); +}; + +export default PhoneInput; diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 3bb96d276..9ecb4a104 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -1,9 +1,10 @@ import classNames from 'classnames'; import React from 'react'; -type SIZES = 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10 +type SIZES = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10 const spaces = { + 0: 'space-y-0', '0.5': 'space-y-0.5', 1: 'space-y-1', '1.5': 'space-y-1.5', diff --git a/app/soapbox/components/ui/textarea/textarea.tsx b/app/soapbox/components/ui/textarea/textarea.tsx index b181fc848..aef6804f6 100644 --- a/app/soapbox/components/ui/textarea/textarea.tsx +++ b/app/soapbox/components/ui/textarea/textarea.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React from 'react'; -interface ITextarea extends Pick, 'maxLength' | 'onChange' | 'required' | 'disabled' | 'rows'> { +interface ITextarea extends Pick, 'maxLength' | 'onChange' | 'required' | 'disabled' | 'rows' | 'readOnly'> { /** Put the cursor into the input on mount. */ autoFocus?: boolean, /** The initial text in the input. */ diff --git a/app/soapbox/features/aliases/index.tsx b/app/soapbox/features/aliases/index.tsx index 0e765339e..9dfb52c44 100644 --- a/app/soapbox/features/aliases/index.tsx +++ b/app/soapbox/features/aliases/index.tsx @@ -84,7 +84,7 @@ const Aliases = () => { {alias}
- +
diff --git a/app/soapbox/features/bookmarks/index.tsx b/app/soapbox/features/bookmarks/index.tsx index c9e3e5de3..269aa8d1f 100644 --- a/app/soapbox/features/bookmarks/index.tsx +++ b/app/soapbox/features/bookmarks/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks'; +import PullToRefresh from 'soapbox/components/pull-to-refresh'; import StatusList from 'soapbox/components/status_list'; import SubNavigation from 'soapbox/components/sub_navigation'; import { Column } from 'soapbox/components/ui'; @@ -39,16 +40,17 @@ const Bookmarks: React.FC = () => {
- handleLoadMore(dispatch)} - onRefresh={handleRefresh} - emptyMessage={emptyMessage} - divideType='space' - /> + + handleLoadMore(dispatch)} + emptyMessage={emptyMessage} + divideType='space' + /> + ); }; diff --git a/app/soapbox/features/community_timeline/index.tsx b/app/soapbox/features/community_timeline/index.tsx index 4cc9ad65e..60670a160 100644 --- a/app/soapbox/features/community_timeline/index.tsx +++ b/app/soapbox/features/community_timeline/index.tsx @@ -3,6 +3,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { connectCommunityStream } from 'soapbox/actions/streaming'; import { expandCommunityTimeline } from 'soapbox/actions/timelines'; +import PullToRefresh from 'soapbox/components/pull-to-refresh'; import SubNavigation from 'soapbox/components/sub_navigation'; import { Column } from 'soapbox/components/ui'; import { useAppDispatch, useSettings } from 'soapbox/hooks'; @@ -44,14 +45,15 @@ const CommunityTimeline = () => { return ( - } - divideType='space' - /> + + } + divideType='space' + /> + ); }; diff --git a/app/soapbox/features/compose/components/reply_indicator.tsx b/app/soapbox/features/compose/components/reply_indicator.tsx index 1e76e616e..b9c90e98f 100644 --- a/app/soapbox/features/compose/components/reply_indicator.tsx +++ b/app/soapbox/features/compose/components/reply_indicator.tsx @@ -39,6 +39,7 @@ const ReplyIndicator: React.FC = ({ status, hideActions, onCanc id={status.getIn(['account', 'id']) as string} timestamp={status.created_at} showProfileHoverCard={false} + withLinkToProfile={false} /> { - const me = state.get('me'); - const account = state.getIn(['accounts', me]); - - const instance = state.get('instance'); - const features = getFeatures(instance); - - return { - account, - defaultScopes: features.scopes, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class CreateApp extends ImmutablePureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - account: ImmutablePropTypes.record.isRequired, - defaultScopes: PropTypes.string, - } - - initialState = () => { - return { - params: ImmutableMap({ - client_name: '', - redirect_uris: 'urn:ietf:wg:oauth:2.0:oob', - scopes: '', - website: '', - }), - app: null, - token: null, - isLoading: false, - }; - } - - state = this.initialState() - - handleCreateApp = () => { - const { dispatch, account } = this.props; - const { params } = this.state; - const baseURL = getBaseURL(account); - - return dispatch(createApp(params.toJS(), baseURL)) - .then(app => this.setState({ app })); - } - - handleCreateToken = () => { - const { dispatch, account } = this.props; - const { app, params: appParams } = this.state; - const baseURL = getBaseURL(account); - - const tokenParams = { - client_id: app.client_id, - client_secret: app.client_secret, - redirect_uri: appParams.get('redirect_uri'), - grant_type: 'client_credentials', - scope: appParams.get('scopes'), - }; - - return dispatch(obtainOAuthToken(tokenParams, baseURL)) - .then(token => this.setState({ token })); - } - - handleSubmit = e => { - this.setState({ isLoading: true }); - - this.handleCreateApp() - .then(this.handleCreateToken) - .then(() => { - this.scrollToTop(); - this.setState({ isLoading: false }); - }).catch(error => { - console.error(error); - this.setState({ isLoading: false }); - }); - } - - setParam = (key, value) => { - const { params } = this.state; - const newParams = params.set(key, value); - - this.setState({ params: newParams }); - } - - handleParamChange = key => { - return e => { - this.setParam(key, e.target.value); - }; - } - - resetState = () => { - this.setState(this.initialState()); - } - - handleReset = e => { - this.resetState(); - this.scrollToTop(); - } - - scrollToTop = () => { - window.scrollTo({ top: 0, behavior: 'smooth' }); - } - - renderResults = () => { - const { intl } = this.props; - const { app, token } = this.state; - - return ( - -
- - - - - - - - - - }> -